feat: implement offline queue, background sync and AI robustness

This commit is contained in:
2025-12-17 23:25:12 +01:00
parent fe82d52a85
commit 6f08bb3c4c
70 changed files with 3132 additions and 55 deletions

View File

@@ -3,6 +3,7 @@ import { Inter } from "next/font/google";
import "./globals.css";
import PWARegistration from "@/components/PWARegistration";
import OfflineIndicator from "@/components/OfflineIndicator";
import UploadQueue from "@/components/UploadQueue";
const inter = Inter({ subsets: ["latin"] });
@@ -41,6 +42,7 @@ export default function RootLayout({
<body className={inter.className}>
<PWARegistration />
<OfflineIndicator />
<UploadQueue />
{children}
</body>
</html>

View File

@@ -2,7 +2,7 @@
import React, { useState, useMemo } from 'react';
import Link from 'next/link';
import { Search, Filter, X, Calendar, Clock, Package, Lock, Unlock, Ghost, FlaskConical } from 'lucide-react';
import { Search, Filter, X, Calendar, Clock, Package, Lock, Unlock, Ghost, FlaskConical, AlertCircle } from 'lucide-react';
interface BottleCardProps {
bottle: any;
@@ -45,9 +45,18 @@ function BottleCard({ bottle }: BottleCardProps) {
<div className="p-5 space-y-4">
<div>
<p className="text-[10px] font-black text-amber-600 uppercase tracking-[0.2em] mb-1 leading-none">{bottle.distillery}</p>
<h3 className="font-black text-xl text-zinc-900 dark:text-zinc-100 leading-tight group-hover:text-amber-600 transition-colors line-clamp-2 min-h-[3.5rem] flex items-center">
{bottle.name}
<div className="flex justify-between items-start mb-1">
<p className="text-[10px] font-black text-amber-600 uppercase tracking-[0.2em] leading-none">{bottle.distillery}</p>
{(bottle.is_whisky === false || (bottle.confidence && bottle.confidence < 70)) && (
<div className="flex items-center gap-1 text-[8px] font-black bg-red-500 text-white px-1.5 py-0.5 rounded-full animate-pulse">
<AlertCircle size={8} />
REVIEW
</div>
)}
</div>
<h3 className={`font-black text-xl leading-tight group-hover:text-amber-600 transition-colors line-clamp-2 min-h-[3.5rem] flex items-center ${bottle.is_whisky === false ? 'text-red-600 dark:text-red-400' : 'text-zinc-900 dark:text-zinc-100'
}`}>
{bottle.name || 'Unbekannte Flasche'}
</h3>
</div>

View File

@@ -6,6 +6,8 @@ import { createClientComponentClient } from '@supabase/auth-helpers-nextjs';
import { analyzeBottle } from '@/services/analyze-bottle';
import { saveBottle } from '@/services/save-bottle';
import { BottleMetadata } from '@/types/whisky';
import { savePendingBottle } from '@/lib/offline-db';
import { v4 as uuidv4 } from 'uuid';
interface CameraCaptureProps {
onImageCaptured?: (base64Image: string) => void;
@@ -21,6 +23,7 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [analysisResult, setAnalysisResult] = useState<BottleMetadata | null>(null);
const [isQueued, setIsQueued] = useState(false);
const handleCapture = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
@@ -29,6 +32,7 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
setIsProcessing(true);
setError(null);
setAnalysisResult(null);
setIsQueued(false);
try {
const compressedBase64 = await compressImage(file);
@@ -38,6 +42,18 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
onImageCaptured(compressedBase64);
}
// Check if Offline
if (!navigator.onLine) {
console.log('Offline detected. Queuing image...');
await savePendingBottle({
id: uuidv4(),
imageBase64: compressedBase64,
timestamp: Date.now(),
});
setIsQueued(true);
return;
}
const response = await analyzeBottle(compressedBase64);
if (response.success && response.data) {
@@ -162,7 +178,7 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
/>
<button
onClick={previewUrl && analysisResult ? handleSave : triggerUpload}
onClick={isQueued ? () => setPreviewUrl(null) : (previewUrl && analysisResult ? handleSave : triggerUpload)}
disabled={isProcessing || isSaving}
className="w-full py-4 px-6 bg-amber-600 hover:bg-amber-700 text-white rounded-xl font-semibold flex items-center justify-center gap-2 transition-all active:scale-[0.98] shadow-lg shadow-amber-600/20 disabled:opacity-50"
>
@@ -171,6 +187,11 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white"></div>
Wird gespeichert...
</>
) : isQueued ? (
<>
<CheckCircle2 size={20} />
Nächste Flasche
</>
) : previewUrl && analysisResult ? (
<>
<CheckCircle2 size={20} />
@@ -196,7 +217,14 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
</div>
)}
{previewUrl && !isProcessing && !error && (
{isQueued && (
<div className="flex items-center gap-2 text-purple-500 text-sm bg-purple-50 dark:bg-purple-900/10 p-4 rounded-xl w-full border border-purple-100 dark:border-purple-800/30 font-medium">
<Sparkles size={16} />
Offline! Foto wurde gemerkt wird automatisch analysiert, sobald du wieder Netz hast. 📡
</div>
)}
{previewUrl && !isProcessing && !error && !isQueued && (
<div className="flex flex-col gap-3 w-full animate-in fade-in slide-in-from-top-4 duration-500">
<div className="flex items-center gap-2 text-green-500 text-sm bg-green-50 dark:bg-green-900/10 p-3 rounded-lg w-full">
<CheckCircle2 size={16} />

View File

@@ -0,0 +1,125 @@
'use client';
import React, { useEffect, useState, useCallback } from 'react';
import { getAllPendingBottles, deletePendingBottle, PendingBottle } from '@/lib/offline-db';
import { analyzeBottle } from '@/services/analyze-bottle';
import { saveBottle } from '@/services/save-bottle';
import { createClientComponentClient } from '@supabase/auth-helpers-nextjs';
import { RefreshCw, CheckCircle2, AlertCircle, Loader2 } from 'lucide-react';
export default function UploadQueue() {
const supabase = createClientComponentClient();
const [queue, setQueue] = useState<PendingBottle[]>([]);
const [isSyncing, setIsSyncing] = useState(false);
const [currentProgress, setCurrentProgress] = useState<{ id: string, status: string } | null>(null);
const loadQueue = useCallback(async () => {
const pending = await getAllPendingBottles();
setQueue(pending);
}, []);
const syncQueue = useCallback(async () => {
if (isSyncing || !navigator.onLine || queue.length === 0) return;
setIsSyncing(true);
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
console.error('No user found for background sync');
setIsSyncing(false);
return;
}
for (const item of queue) {
setCurrentProgress({ id: item.id, status: 'Analysiere...' });
try {
// 1. Analyze
const analysis = await analyzeBottle(item.imageBase64);
if (analysis.success && analysis.data) {
setCurrentProgress({ id: item.id, status: 'Speichere...' });
// 2. Save
const save = await saveBottle(analysis.data, item.imageBase64, user.id);
if (save.success) {
await deletePendingBottle(item.id);
}
}
} catch (err) {
console.error('Sync failed for item', item.id, err);
}
}
setIsSyncing(false);
setCurrentProgress(null);
loadQueue();
}, [isSyncing, queue, supabase, loadQueue]);
useEffect(() => {
loadQueue();
// Listen for storage changes (e.g. from CameraCapture)
const interval = setInterval(loadQueue, 5000);
const handleOnline = () => {
console.log('Back online! Triggering sync...');
syncQueue();
};
window.addEventListener('online', handleOnline);
return () => {
clearInterval(interval);
window.removeEventListener('online', handleOnline);
};
}, [loadQueue, syncQueue]);
if (queue.length === 0) return null;
return (
<div className="fixed bottom-6 right-6 z-50 animate-in slide-in-from-right-10">
<div className="bg-zinc-900 text-white p-4 rounded-2xl shadow-2xl border border-white/10 flex flex-col gap-3 min-w-[280px]">
<div className="flex items-center justify-between border-b border-white/10 pb-2">
<div className="flex items-center gap-2">
<RefreshCw size={16} className={isSyncing ? 'animate-spin text-amber-500' : 'text-zinc-400'} />
<span className="text-xs font-black uppercase tracking-widest">Upload Queue</span>
</div>
<span className="bg-amber-600 text-[10px] font-black px-1.5 py-0.5 rounded-md">
{queue.length}
</span>
</div>
<div className="space-y-2">
{queue.slice(0, 3).map((item) => (
<div key={item.id} className="flex items-center justify-between text-[11px] font-medium text-zinc-400">
<div className="flex items-center gap-2">
<div className="w-6 h-6 rounded bg-zinc-800 overflow-hidden">
<img src={item.imageBase64} className="w-full h-full object-cover opacity-50" />
</div>
<span className="truncate max-w-[120px]">
{currentProgress?.id === item.id ? currentProgress.status : 'Wartet auf Netz...'}
</span>
</div>
{currentProgress?.id === item.id ? (
<Loader2 size={12} className="animate-spin text-amber-500" />
) : (
<AlertCircle size={12} className="text-zinc-600" />
)}
</div>
))}
{queue.length > 3 && (
<div className="text-[10px] text-zinc-500 text-center font-bold italic pt-1">
+ {queue.length - 3} weitere Flaschen
</div>
)}
</div>
{navigator.onLine && !isSyncing && (
<button
onClick={syncQueue}
className="w-full py-2 bg-amber-600 hover:bg-amber-500 text-[10px] font-black uppercase rounded-lg transition-colors cursor-pointer"
>
Jetzt Synchronisieren
</button>
)}
</div>
</div>
);
}

View File

@@ -12,9 +12,10 @@ export const geminiModel = genAI.getGenerativeModel({
});
export const SYSTEM_INSTRUCTION = `
You are a sommelier and database clerk. Analyze the whisky bottle image. Extract precise metadata.
If a value is not visible, use null.
Infer the 'Category' (e.g., Islay Single Malt) based on the Distillery if possible.
You are a sommelier and database clerk. Analyze the whisky bottle image. Extract precise metadata.
If the image is NOT a whisky bottle or if you are very unsure, set "is_whisky" to false and provide a low "confidence" score.
If a value is not visible, use null.
Infer the 'Category' (e.g., Islay Single Malt) based on the Distillery if possible.
Search specifically for a "Whiskybase ID" or "WB ID" on the label.
Output raw JSON matching the following schema:
{
@@ -25,6 +26,8 @@ Output raw JSON matching the following schema:
"age": number | null,
"vintage": string | null,
"bottleCode": string | null,
"whiskybaseId": string | null
"whiskybaseId": string | null,
"is_whisky": boolean,
"confidence": number (0-100)
}
`;

66
src/lib/offline-db.ts Normal file
View File

@@ -0,0 +1,66 @@
export interface PendingBottle {
id: string;
imageBase64: string;
timestamp: number;
}
const DB_NAME = 'WhiskyVaultOffline';
const STORE_NAME = 'pendingCaptures';
const DB_VERSION = 1;
export const openDB = (): Promise<IDBDatabase> => {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME, { keyPath: 'id' });
}
};
request.onsuccess = (event) => {
resolve((event.target as IDBOpenDBRequest).result);
};
request.onerror = (event) => {
reject((event.target as IDBOpenDBRequest).error);
};
});
};
export const savePendingBottle = async (bottle: PendingBottle): Promise<void> => {
const db = await openDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction(STORE_NAME, 'readwrite');
const store = transaction.objectStore(STORE_NAME);
const request = store.put(bottle);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
};
export const getAllPendingBottles = async (): Promise<PendingBottle[]> => {
const db = await openDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction(STORE_NAME, 'readonly');
const store = transaction.objectStore(STORE_NAME);
const request = store.getAll();
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
};
export const deletePendingBottle = async (id: string): Promise<void> => {
const db = await openDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction(STORE_NAME, 'readwrite');
const store = transaction.objectStore(STORE_NAME);
const request = store.delete(id);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
};

View File

@@ -53,6 +53,8 @@ export async function saveBottle(
whiskybase_id: metadata.whiskybaseId,
image_url: publicUrl,
status: 'sealed', // Default status
is_whisky: metadata.is_whisky ?? true,
confidence: metadata.confidence ?? 100,
})
.select()
.single();

View File

@@ -9,6 +9,8 @@ export const BottleMetadataSchema = z.object({
vintage: z.string().nullable(),
bottleCode: z.string().nullable(),
whiskybaseId: z.string().nullable(),
is_whisky: z.boolean().default(true),
confidence: z.number().min(0).max(100).default(100),
});
export type BottleMetadata = z.infer<typeof BottleMetadataSchema>;