feat: implement robust offline-first sync with Dexie.js
- Migrated to Dexie.js for IndexedDB management - Added optimistic UI for tasting notes with 'Wartet auf Sync' badge - Implemented background caching for tags and buddies - Unified scanning and tasting sync in a global UploadQueue
This commit is contained in:
@@ -9,6 +9,7 @@ import { SessionProvider } from "@/context/SessionContext";
|
||||
import ActiveSessionBanner from "@/components/ActiveSessionBanner";
|
||||
import MainContentWrapper from "@/components/MainContentWrapper";
|
||||
import AuthListener from "@/components/AuthListener";
|
||||
import SyncHandler from "@/components/SyncHandler";
|
||||
|
||||
const inter = Inter({ subsets: ["latin"] });
|
||||
|
||||
@@ -50,6 +51,7 @@ export default function RootLayout({
|
||||
<AuthListener />
|
||||
<ActiveSessionBanner />
|
||||
<MainContentWrapper>
|
||||
<SyncHandler />
|
||||
<PWARegistration />
|
||||
<OfflineIndicator />
|
||||
<UploadQueue />
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { analyzeBottle } from '@/services/analyze-bottle';
|
||||
import { saveBottle } from '@/services/save-bottle';
|
||||
import { BottleMetadata } from '@/types/whisky';
|
||||
import { savePendingBottle } from '@/lib/offline-db';
|
||||
import { db } from '@/lib/db';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { findMatchingBottle } from '@/services/find-matching-bottle';
|
||||
import { validateSession } from '@/services/validate-session';
|
||||
@@ -125,10 +125,10 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
|
||||
// Check if Offline
|
||||
if (!navigator.onLine) {
|
||||
console.log('Offline detected. Queuing image...');
|
||||
await savePendingBottle({
|
||||
id: uuidv4(),
|
||||
await db.pending_scans.add({
|
||||
imageBase64: compressedBase64,
|
||||
timestamp: Date.now(),
|
||||
provider: aiProvider
|
||||
});
|
||||
setIsQueued(true);
|
||||
return;
|
||||
|
||||
8
src/components/SyncHandler.tsx
Normal file
8
src/components/SyncHandler.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { useCacheSync } from '@/hooks/useCacheSync';
|
||||
|
||||
export default function SyncHandler() {
|
||||
useCacheSync();
|
||||
return null;
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { Tag, TagCategory, getTagsByCategory, createCustomTag } from '@/services/tags';
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { TagCategory, createCustomTag } from '@/services/tags';
|
||||
import { X, Plus, Search, Check, Loader2, Sparkles } from 'lucide-react';
|
||||
import { useI18n } from '@/i18n/I18nContext';
|
||||
import { useLiveQuery } from 'dexie-react-hooks';
|
||||
import { db } from '@/lib/db';
|
||||
|
||||
interface TagSelectorProps {
|
||||
category: TagCategory;
|
||||
@@ -16,26 +18,23 @@ interface TagSelectorProps {
|
||||
|
||||
export default function TagSelector({ category, selectedTagIds, onToggleTag, label, suggestedTagNames, suggestedCustomTagNames }: TagSelectorProps) {
|
||||
const { t } = useI18n();
|
||||
const [tags, setTags] = useState<Tag[]>([]);
|
||||
const [search, setSearch] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [creatingSuggestion, setCreatingSuggestion] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchTags = async () => {
|
||||
setIsLoading(true);
|
||||
const data = await getTagsByCategory(category);
|
||||
setTags(data);
|
||||
setIsLoading(false);
|
||||
};
|
||||
fetchTags();
|
||||
}, [category]);
|
||||
const tags = useLiveQuery(
|
||||
() => db.cache_tags.where('category').equals(category).sortBy('popularity_score'),
|
||||
[category],
|
||||
[]
|
||||
);
|
||||
|
||||
const isLoading = tags === undefined;
|
||||
|
||||
const filteredTags = useMemo(() => {
|
||||
if (!search) return tags;
|
||||
const tagList = tags || [];
|
||||
if (!search) return tagList;
|
||||
const s = search.toLowerCase();
|
||||
return tags.filter(tag => {
|
||||
return tagList.filter((tag: any) => {
|
||||
const rawMatch = tag.name.toLowerCase().includes(s);
|
||||
const translatedMatch = tag.is_system_default && t(`aroma.${tag.name}`).toLowerCase().includes(s);
|
||||
return rawMatch || translatedMatch;
|
||||
|
||||
@@ -6,6 +6,8 @@ import Link from 'next/link';
|
||||
import AvatarStack from './AvatarStack';
|
||||
import { useI18n } from '@/i18n/I18nContext';
|
||||
import { deleteTasting } from '@/services/delete-tasting';
|
||||
import { useLiveQuery } from 'dexie-react-hooks';
|
||||
import { db } from '@/lib/db';
|
||||
|
||||
interface Tasting {
|
||||
id: string;
|
||||
@@ -40,14 +42,16 @@ interface Tasting {
|
||||
interface TastingListProps {
|
||||
initialTastings: Tasting[];
|
||||
currentUserId?: string;
|
||||
bottleId?: string;
|
||||
}
|
||||
|
||||
export default function TastingList({ initialTastings, currentUserId }: TastingListProps) {
|
||||
export default function TastingList({ initialTastings, currentUserId, bottleId }: TastingListProps) {
|
||||
const { t } = useI18n();
|
||||
const [sortBy, setSortBy] = useState<'date-desc' | 'date-asc' | 'rating-desc' | 'rating-asc'>('date-desc');
|
||||
const [isDeleting, setIsDeleting] = useState<string | null>(null);
|
||||
|
||||
const handleDelete = async (tastingId: string, bottleId: string) => {
|
||||
if (tastingId.startsWith('pending-')) return;
|
||||
if (!confirm('Bist du sicher, dass du diese Notiz löschen möchtest?')) return;
|
||||
|
||||
setIsDeleting(tastingId);
|
||||
@@ -63,23 +67,43 @@ export default function TastingList({ initialTastings, currentUserId }: TastingL
|
||||
}
|
||||
};
|
||||
|
||||
const pendingTastings = useLiveQuery(
|
||||
() => bottleId
|
||||
? db.pending_tastings.where('bottle_id').equals(bottleId).toArray()
|
||||
: db.pending_tastings.toArray(),
|
||||
[bottleId],
|
||||
[]
|
||||
);
|
||||
|
||||
const sortedTastings = useMemo(() => {
|
||||
const result = [...initialTastings];
|
||||
return result.sort((a, b) => {
|
||||
const merged = [
|
||||
...initialTastings,
|
||||
...(pendingTastings || []).map(p => ({
|
||||
id: `pending-${p.id}`,
|
||||
rating: p.data.rating,
|
||||
nose_notes: p.data.nose_notes,
|
||||
palate_notes: p.data.palate_notes,
|
||||
finish_notes: p.data.finish_notes,
|
||||
is_sample: p.data.is_sample,
|
||||
bottle_id: p.bottle_id,
|
||||
created_at: p.tasted_at,
|
||||
user_id: currentUserId || '',
|
||||
isPending: true
|
||||
}))
|
||||
];
|
||||
|
||||
return merged.sort((a, b) => {
|
||||
const timeA = new Date(a.created_at).getTime();
|
||||
const timeB = new Date(b.created_at).getTime();
|
||||
switch (sortBy) {
|
||||
case 'date-desc':
|
||||
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime();
|
||||
case 'date-asc':
|
||||
return new Date(a.created_at).getTime() - new Date(b.created_at).getTime();
|
||||
case 'rating-desc':
|
||||
return b.rating - a.rating;
|
||||
case 'rating-asc':
|
||||
return a.rating - b.rating;
|
||||
default:
|
||||
return 0;
|
||||
case 'date-desc': return timeB - timeA;
|
||||
case 'date-asc': return timeA - timeB;
|
||||
case 'rating-desc': return b.rating - a.rating;
|
||||
case 'rating-asc': return a.rating - b.rating;
|
||||
default: return 0;
|
||||
}
|
||||
});
|
||||
}, [initialTastings, sortBy]);
|
||||
}, [initialTastings, pendingTastings, sortBy, currentUserId]);
|
||||
|
||||
if (!initialTastings || initialTastings.length === 0) {
|
||||
return (
|
||||
@@ -125,6 +149,12 @@ export default function TastingList({ initialTastings, currentUserId }: TastingL
|
||||
}`}>
|
||||
{note.is_sample ? 'Sample' : 'Bottle'}
|
||||
</span>
|
||||
{(note as any).isPending && (
|
||||
<div className="bg-amber-100 dark:bg-amber-900/30 text-amber-600 dark:text-amber-400 px-2 py-0.5 rounded-lg text-[10px] font-black uppercase tracking-tighter flex items-center gap-1.5 animate-pulse">
|
||||
<Clock size={10} />
|
||||
Wartet auf Sync...
|
||||
</div>
|
||||
)}
|
||||
<div className="text-[10px] text-zinc-500 font-bold bg-zinc-100 dark:bg-zinc-800 px-2 py-1 rounded-lg flex items-center gap-1">
|
||||
<Clock size={10} />
|
||||
{new Date(note.created_at).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}
|
||||
@@ -144,7 +174,7 @@ export default function TastingList({ initialTastings, currentUserId }: TastingL
|
||||
<Calendar size={12} />
|
||||
{new Date(note.created_at).toLocaleDateString('de-DE')}
|
||||
</div>
|
||||
{(!currentUserId || note.user_id === currentUserId) && (
|
||||
{(!currentUserId || note.user_id === currentUserId) && !(note as any).isPending && (
|
||||
<button
|
||||
onClick={() => handleDelete(note.id, note.bottle_id)}
|
||||
disabled={!!isDeleting}
|
||||
|
||||
@@ -7,6 +7,8 @@ import { createClientComponentClient } from '@supabase/auth-helpers-nextjs';
|
||||
import { useI18n } from '@/i18n/I18nContext';
|
||||
import { useSession } from '@/context/SessionContext';
|
||||
import TagSelector from './TagSelector';
|
||||
import { useLiveQuery } from 'dexie-react-hooks';
|
||||
import { db } from '@/lib/db';
|
||||
|
||||
interface Buddy {
|
||||
id: string;
|
||||
@@ -28,7 +30,7 @@ export default function TastingNoteForm({ bottleId, sessionId }: TastingNoteForm
|
||||
const [isSample, setIsSample] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [buddies, setBuddies] = useState<Buddy[]>([]);
|
||||
const buddies = useLiveQuery(() => db.cache_buddies.toArray(), [], [] as Buddy[]);
|
||||
const [selectedBuddyIds, setSelectedBuddyIds] = useState<string[]>([]);
|
||||
const [noseTagIds, setNoseTagIds] = useState<string[]>([]);
|
||||
const [palateTagIds, setPalateTagIds] = useState<string[]>([]);
|
||||
@@ -41,10 +43,6 @@ export default function TastingNoteForm({ bottleId, sessionId }: TastingNoteForm
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
// Fetch All Buddies
|
||||
const { data: buddiesData } = await supabase.from('buddies').select('id, name').order('name');
|
||||
setBuddies(buddiesData || []);
|
||||
|
||||
// Fetch Bottle Suggestions
|
||||
const { data: bottleData } = await supabase
|
||||
.from('bottles')
|
||||
@@ -74,7 +72,7 @@ export default function TastingNoteForm({ bottleId, sessionId }: TastingNoteForm
|
||||
}
|
||||
};
|
||||
fetchData();
|
||||
}, [effectiveSessionId, bottleId]);
|
||||
}, [supabase, effectiveSessionId, bottleId]);
|
||||
|
||||
const toggleBuddy = (id: string) => {
|
||||
setSelectedBuddyIds(prev =>
|
||||
@@ -94,33 +92,51 @@ export default function TastingNoteForm({ bottleId, sessionId }: TastingNoteForm
|
||||
setFinishTagIds(prev => prev.includes(id) ? prev.filter(tid => tid !== id) : [...prev, id]);
|
||||
};
|
||||
|
||||
const clearForm = () => {
|
||||
setNose('');
|
||||
setPalate('');
|
||||
setFinish('');
|
||||
setSelectedBuddyIds([]);
|
||||
setNoseTagIds([]);
|
||||
setPalateTagIds([]);
|
||||
setFinishTagIds([]);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const data = {
|
||||
bottle_id: bottleId,
|
||||
session_id: effectiveSessionId,
|
||||
rating,
|
||||
nose_notes: nose,
|
||||
palate_notes: palate,
|
||||
finish_notes: finish,
|
||||
is_sample: isSample,
|
||||
buddy_ids: selectedBuddyIds,
|
||||
tag_ids: [...noseTagIds, ...palateTagIds, ...finishTagIds],
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await saveTasting({
|
||||
bottle_id: bottleId,
|
||||
session_id: effectiveSessionId,
|
||||
rating,
|
||||
nose_notes: nose,
|
||||
palate_notes: palate,
|
||||
finish_notes: finish,
|
||||
is_sample: isSample,
|
||||
buddy_ids: selectedBuddyIds,
|
||||
tag_ids: [...noseTagIds, ...palateTagIds, ...finishTagIds],
|
||||
});
|
||||
if (!navigator.onLine) {
|
||||
// Save to Offline DB
|
||||
await db.pending_tastings.add({
|
||||
bottle_id: bottleId,
|
||||
data,
|
||||
tasted_at: new Date().toISOString()
|
||||
});
|
||||
clearForm();
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await saveTasting(data);
|
||||
|
||||
if (result.success) {
|
||||
setNose('');
|
||||
setPalate('');
|
||||
setFinish('');
|
||||
setSelectedBuddyIds([]);
|
||||
setNoseTagIds([]);
|
||||
setPalateTagIds([]);
|
||||
setFinishTagIds([]);
|
||||
// We don't need to manually refresh because of revalidatePath in the server action
|
||||
clearForm();
|
||||
} else {
|
||||
setError(result.error || t('common.error'));
|
||||
}
|
||||
|
||||
@@ -1,25 +1,26 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import { getAllPendingBottles, deletePendingBottle, PendingBottle } from '@/lib/offline-db';
|
||||
import { useLiveQuery } from 'dexie-react-hooks';
|
||||
import { db, PendingScan, PendingTasting } from '@/lib/db';
|
||||
import { analyzeBottle } from '@/services/analyze-bottle';
|
||||
import { saveBottle } from '@/services/save-bottle';
|
||||
import { saveTasting } from '@/services/save-tasting';
|
||||
import { createClientComponentClient } from '@supabase/auth-helpers-nextjs';
|
||||
import { RefreshCw, CheckCircle2, AlertCircle, Loader2 } from 'lucide-react';
|
||||
import { RefreshCw, CheckCircle2, AlertCircle, Loader2, Info } 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 pendingScans = useLiveQuery(() => db.pending_scans.toArray(), [], [] as PendingScan[]);
|
||||
const pendingTastings = useLiveQuery(() => db.pending_tastings.toArray(), [], [] as PendingTasting[]);
|
||||
|
||||
const totalInQueue = pendingScans.length + pendingTastings.length;
|
||||
|
||||
const syncQueue = useCallback(async () => {
|
||||
if (isSyncing || !navigator.onLine || queue.length === 0) return;
|
||||
if (isSyncing || !navigator.onLine || totalInQueue === 0) return;
|
||||
|
||||
setIsSyncing(true);
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
@@ -30,93 +31,132 @@ export default function UploadQueue() {
|
||||
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);
|
||||
try {
|
||||
// 1. Sync Scans (Magic Shots)
|
||||
for (const item of pendingScans) {
|
||||
const itemId = `scan-${item.id}`;
|
||||
setCurrentProgress({ id: itemId, status: 'Analysiere Scan...' });
|
||||
try {
|
||||
const analysis = await analyzeBottle(item.imageBase64);
|
||||
if (analysis.success && analysis.data) {
|
||||
setCurrentProgress({ id: itemId, status: 'Speichere Flasche...' });
|
||||
const save = await saveBottle(analysis.data, item.imageBase64, user.id);
|
||||
if (save.success) {
|
||||
await db.pending_scans.delete(item.id!);
|
||||
}
|
||||
} else {
|
||||
throw new Error(analysis.error || 'Analyse fehlgeschlagen');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Scan sync failed:', err);
|
||||
setCurrentProgress({ id: itemId, status: 'Fehler bei Scan' });
|
||||
// Wait a bit before next
|
||||
await new Promise(r => setTimeout(r, 2000));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Sync failed for item', item.id, err);
|
||||
}
|
||||
}
|
||||
|
||||
setIsSyncing(false);
|
||||
setCurrentProgress(null);
|
||||
loadQueue();
|
||||
}, [isSyncing, queue, supabase, loadQueue]);
|
||||
// 2. Sync Tastings
|
||||
for (const item of pendingTastings) {
|
||||
const itemId = `tasting-${item.id}`;
|
||||
setCurrentProgress({ id: itemId, status: 'Synchronisiere Tasting...' });
|
||||
try {
|
||||
const result = await saveTasting({
|
||||
...item.data,
|
||||
tasted_at: item.tasted_at
|
||||
});
|
||||
if (result.success) {
|
||||
await db.pending_tastings.delete(item.id!);
|
||||
} else {
|
||||
throw new Error(result.error);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Tasting sync failed:', err);
|
||||
setCurrentProgress({ id: itemId, status: 'Fehler bei Tasting' });
|
||||
await new Promise(r => setTimeout(r, 2000));
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Global Sync Error:', err);
|
||||
} finally {
|
||||
setIsSyncing(false);
|
||||
setCurrentProgress(null);
|
||||
}
|
||||
}, [isSyncing, pendingScans, pendingTastings, totalInQueue, supabase]);
|
||||
|
||||
useEffect(() => {
|
||||
loadQueue();
|
||||
|
||||
// Listen for storage changes (e.g. from CameraCapture)
|
||||
const interval = setInterval(loadQueue, 5000);
|
||||
|
||||
const handleOnline = () => {
|
||||
console.log('Back online! Triggering sync...');
|
||||
console.log('Online! Triggering background sync...');
|
||||
syncQueue();
|
||||
};
|
||||
|
||||
window.addEventListener('online', handleOnline);
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
window.removeEventListener('online', handleOnline);
|
||||
};
|
||||
}, [loadQueue, syncQueue]);
|
||||
|
||||
if (queue.length === 0) return null;
|
||||
// Initial check if we are online and have items
|
||||
if (navigator.onLine && totalInQueue > 0 && !isSyncing) {
|
||||
syncQueue();
|
||||
}
|
||||
|
||||
return () => window.removeEventListener('online', handleOnline);
|
||||
}, [totalInQueue, syncQueue, isSyncing]);
|
||||
|
||||
if (totalInQueue === 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="bg-zinc-900 text-white p-4 rounded-2xl shadow-2xl border border-white/10 flex flex-col gap-3 min-w-[300px]">
|
||||
<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>
|
||||
<span className="text-xs font-black uppercase tracking-widest">Global Sync Queue</span>
|
||||
</div>
|
||||
<span className="bg-amber-600 text-[10px] font-black px-1.5 py-0.5 rounded-md">
|
||||
{queue.length}
|
||||
{totalInQueue} Items
|
||||
</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">
|
||||
{/* Scans */}
|
||||
{pendingScans.map((item) => (
|
||||
<div key={`scan-${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">
|
||||
<div className="w-6 h-6 rounded bg-zinc-800 overflow-hidden ring-1 ring-white/10">
|
||||
<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 className="truncate max-w-[150px]">
|
||||
{currentProgress?.id === `scan-${item.id}` ? currentProgress.status : 'Scan wartet...'}
|
||||
</span>
|
||||
</div>
|
||||
{currentProgress?.id === item.id ? (
|
||||
{currentProgress?.id === `scan-${item.id}` ? (
|
||||
<Loader2 size={12} className="animate-spin text-amber-500" />
|
||||
) : (
|
||||
<AlertCircle size={12} className="text-zinc-600" />
|
||||
)}
|
||||
) : <Info 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
|
||||
|
||||
{/* Tastings */}
|
||||
{pendingTastings.map((item) => (
|
||||
<div key={`tasting-${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-full bg-amber-900/40 flex items-center justify-center text-[8px] font-bold text-amber-500 ring-1 ring-amber-500/20">
|
||||
{item.data.rating}
|
||||
</div>
|
||||
<span className="truncate max-w-[150px]">
|
||||
{currentProgress?.id === `tasting-${item.id}` ? currentProgress.status : 'Tasting wartet...'}
|
||||
</span>
|
||||
</div>
|
||||
{currentProgress?.id === `tasting-${item.id}` ? (
|
||||
<Loader2 size={12} className="animate-spin text-amber-500" />
|
||||
) : <Info size={12} className="text-zinc-600" />}
|
||||
</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"
|
||||
onClick={() => syncQueue()}
|
||||
className="w-full py-2.5 bg-zinc-800 hover:bg-zinc-700 text-amber-500 text-[10px] font-black uppercase rounded-xl transition-all border border-white/5 active:scale-95 flex items-center justify-center gap-2 mt-1"
|
||||
>
|
||||
Jetzt Synchronisieren
|
||||
<RefreshCw size={12} />
|
||||
Sync Erzwingen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
44
src/hooks/useCacheSync.ts
Normal file
44
src/hooks/useCacheSync.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { useEffect } from 'react';
|
||||
import { createClientComponentClient } from '@supabase/auth-helpers-nextjs';
|
||||
import { db } from '@/lib/db';
|
||||
import { getAllSystemTags } from '@/services/tags';
|
||||
|
||||
export function useCacheSync() {
|
||||
const supabase = createClientComponentClient();
|
||||
|
||||
useEffect(() => {
|
||||
const syncCache = async () => {
|
||||
if (!navigator.onLine) return;
|
||||
|
||||
try {
|
||||
// 1. Sync Tags
|
||||
const tags = await getAllSystemTags();
|
||||
if (tags.length > 0) {
|
||||
await db.cache_tags.bulkPut(tags.map(t => ({
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
category: t.category,
|
||||
is_system_default: t.is_system_default,
|
||||
popularity_score: t.popularity_score || 0
|
||||
})));
|
||||
}
|
||||
|
||||
// 2. Sync Buddies
|
||||
const { data: buddies } = await supabase
|
||||
.from('buddies')
|
||||
.select('id, name')
|
||||
.order('name');
|
||||
|
||||
if (buddies && buddies.length > 0) {
|
||||
await db.cache_buddies.bulkPut(buddies);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to sync offline cache:', err);
|
||||
}
|
||||
};
|
||||
|
||||
syncCache();
|
||||
window.addEventListener('online', syncCache);
|
||||
return () => window.removeEventListener('online', syncCache);
|
||||
}, [supabase]);
|
||||
}
|
||||
57
src/lib/db.ts
Normal file
57
src/lib/db.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import Dexie, { type Table } from 'dexie';
|
||||
|
||||
export interface PendingScan {
|
||||
id?: number;
|
||||
imageBase64: string;
|
||||
timestamp: number;
|
||||
provider?: 'gemini' | 'nebius';
|
||||
}
|
||||
|
||||
export interface PendingTasting {
|
||||
id?: number;
|
||||
bottle_id: string;
|
||||
data: {
|
||||
session_id?: string;
|
||||
rating: number;
|
||||
nose_notes?: string;
|
||||
palate_notes?: string;
|
||||
finish_notes?: string;
|
||||
is_sample?: boolean;
|
||||
buddy_ids?: string[];
|
||||
tag_ids?: string[];
|
||||
};
|
||||
photo?: string; // Optional photo if taken during tasting
|
||||
tasted_at: string;
|
||||
}
|
||||
|
||||
export interface CachedTag {
|
||||
id: string;
|
||||
name: string;
|
||||
category: string;
|
||||
is_system_default: boolean;
|
||||
popularity_score: number;
|
||||
}
|
||||
|
||||
export interface CachedBuddy {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export class WhiskyDexie extends Dexie {
|
||||
pending_scans!: Table<PendingScan>;
|
||||
pending_tastings!: Table<PendingTasting>;
|
||||
cache_tags!: Table<CachedTag>;
|
||||
cache_buddies!: Table<CachedBuddy>;
|
||||
|
||||
constructor() {
|
||||
super('WhiskyVault');
|
||||
this.version(1).stores({
|
||||
pending_scans: '++id, timestamp',
|
||||
pending_tastings: '++id, bottle_id, tasted_at',
|
||||
cache_tags: 'id, category, name',
|
||||
cache_buddies: 'id, name'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const db = new WhiskyDexie();
|
||||
@@ -1,66 +0,0 @@
|
||||
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);
|
||||
});
|
||||
};
|
||||
@@ -15,6 +15,7 @@ export async function saveTasting(data: {
|
||||
is_sample?: boolean;
|
||||
buddy_ids?: string[];
|
||||
tag_ids?: string[];
|
||||
tasted_at?: string;
|
||||
}) {
|
||||
const supabase = createServerActionClient({ cookies });
|
||||
|
||||
@@ -41,6 +42,7 @@ export async function saveTasting(data: {
|
||||
palate_notes: data.palate_notes,
|
||||
finish_notes: data.finish_notes,
|
||||
is_sample: data.is_sample || false,
|
||||
created_at: data.tasted_at || undefined,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
Reference in New Issue
Block a user