feat: implement offline queue, background sync and AI robustness
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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} />
|
||||
|
||||
125
src/components/UploadQueue.tsx
Normal file
125
src/components/UploadQueue.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
66
src/lib/offline-db.ts
Normal 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);
|
||||
});
|
||||
};
|
||||
@@ -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();
|
||||
|
||||
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user