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:
104
.offline
Normal file
104
.offline
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
Code-Snippet
|
||||||
|
|
||||||
|
sequenceDiagram
|
||||||
|
autonumber
|
||||||
|
participant User
|
||||||
|
participant UI as Next.js UI (Optimistic)
|
||||||
|
participant IDB as Local DB (Dexie/IndexedDB)
|
||||||
|
participant Sync as Sync Worker / Service
|
||||||
|
participant SupabaseStorage as Supabase Storage (Images)
|
||||||
|
participant SupabaseDB as Supabase Database (Tables)
|
||||||
|
|
||||||
|
box rgb(33, 37, 41) Offline Phase (Messekeller)
|
||||||
|
Note over User, IDB: User hat kein Internet
|
||||||
|
User->>UI: Macht Foto & trägt Whisky ein
|
||||||
|
UI->>UI: Konvertiert Foto zu Blob/Base64
|
||||||
|
UI->>IDB: Speichert "Draft" (Blob + Daten + tasted_at)
|
||||||
|
UI->>UI: Generiert lokale Blob-URL
|
||||||
|
UI-->>User: Zeigt Eintrag sofort an (Optimistic UI)
|
||||||
|
Note right of UI: UI zeigt Status "Wartet auf Sync..."
|
||||||
|
end
|
||||||
|
|
||||||
|
box rgb(13, 17, 23) Online / Sync Phase
|
||||||
|
Sync->>Sync: Event: 'online' detected
|
||||||
|
Sync->>IDB: Hole alle Items aus 'sync_queue'
|
||||||
|
|
||||||
|
loop Für jeden Eintrag in Queue
|
||||||
|
alt Hat Bild?
|
||||||
|
Sync->>SupabaseStorage: Upload Blob (Bucket: 'tastings')
|
||||||
|
activate SupabaseStorage
|
||||||
|
SupabaseStorage-->>Sync: Return Public Public URL
|
||||||
|
deactivate SupabaseStorage
|
||||||
|
else Kein Bild
|
||||||
|
Sync->>Sync: Nutze Placeholder URL / Null
|
||||||
|
end
|
||||||
|
|
||||||
|
Sync->>SupabaseDB: INSERT row (mit neuer Image URL & tasted_at)
|
||||||
|
activate SupabaseDB
|
||||||
|
SupabaseDB-->>Sync: Success (New ID)
|
||||||
|
deactivate SupabaseDB
|
||||||
|
|
||||||
|
Sync->>IDB: Lösche Eintrag aus 'sync_queue'
|
||||||
|
Sync->>UI: Trigger Re-Fetch / Update State
|
||||||
|
UI-->>User: Status ändert sich zu "Gespeichert ✔"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
Der Prompt für das LLM (Copy & Paste)
|
||||||
|
|
||||||
|
Wenn du das nun programmieren lassen willst, kopiere alles zwischen den Trennlinien und gib es dem LLM. Es enthält das Diagramm und die technischen Instruktionen.
|
||||||
|
|
||||||
|
START PROMPT
|
||||||
|
|
||||||
|
Ich entwickle eine Offline-First Whisky Tasting App mit Next.js, Supabase und Dexie.js (IndexedDB). Bitte implementiere die Logik für den Datensync basierend auf dem folgenden Architektur-Diagramm.
|
||||||
|
|
||||||
|
Technologie-Stack:
|
||||||
|
|
||||||
|
Frontend: Next.js (React)
|
||||||
|
|
||||||
|
Local DB: Dexie.js (für IndexedDB Wrapper)
|
||||||
|
|
||||||
|
Remote DB: Supabase (PostgreSQL + Storage)
|
||||||
|
|
||||||
|
State: React Query (TanStack Query)
|
||||||
|
|
||||||
|
Wichtige Anforderungen:
|
||||||
|
|
||||||
|
Zwei Zeitstempel: Beachte den Unterschied zwischen created_at (wann es in Supabase ankommt) und tasted_at (wann der User den Button gedrückt hat). tasted_at muss lokal erfasst und hochgeladen werden.
|
||||||
|
|
||||||
|
Image Handling: Bilder werden lokal als Blob gespeichert. Beim Sync muss ZUERST das Bild in den Supabase Storage hochgeladen werden, um den public_url String zu erhalten. ERST DANN darf der Datenbank-Eintrag (Insert) erfolgen.
|
||||||
|
|
||||||
|
Optimistic UI: Die UI muss Daten aus Dexie (useLiveQuery von Dexie oder ähnlich) und Supabase gemischt anzeigen, damit der User keinen Unterschied merkt.
|
||||||
|
|
||||||
|
Hier ist das Ablaufdiagramm (Mermaid):
|
||||||
|
Code-Snippet
|
||||||
|
|
||||||
|
sequenceDiagram
|
||||||
|
participant User
|
||||||
|
participant UI as Next.js UI
|
||||||
|
participant IDB as Local DB (Dexie)
|
||||||
|
participant Sync as Sync Function
|
||||||
|
participant Supabase as Supabase Cloud
|
||||||
|
|
||||||
|
Note over User, IDB: Offline Action
|
||||||
|
User->>UI: Save Dram (Photo + Notes)
|
||||||
|
UI->>IDB: Add to table 'tasting_queue' (Blob, Data, tasted_at)
|
||||||
|
UI-->>User: Show entry immediately (via Blob URL)
|
||||||
|
|
||||||
|
Note over Sync, Supabase: Sync Process (Background)
|
||||||
|
Sync->>IDB: Check for items in 'tasting_queue'
|
||||||
|
loop For each Item
|
||||||
|
Sync->>Supabase: Upload Image Blob -> Get URL
|
||||||
|
Sync->>Supabase: Insert Database Row (with Image URL)
|
||||||
|
Sync->>IDB: Delete from 'tasting_queue'
|
||||||
|
end
|
||||||
|
|
||||||
|
Bitte erstelle mir:
|
||||||
|
|
||||||
|
Das Dexie Schema (db.ts).
|
||||||
|
|
||||||
|
Die Sync-Funktion, die prüft ob wir online sind und die Queue abarbeitet.
|
||||||
|
|
||||||
|
Einen React Hook, der diesen Sync automatisch triggert, sobald die Verbindung wieder da ist.
|
||||||
|
|
||||||
|
END PROMPT
|
||||||
@@ -9,6 +9,7 @@ import { SessionProvider } from "@/context/SessionContext";
|
|||||||
import ActiveSessionBanner from "@/components/ActiveSessionBanner";
|
import ActiveSessionBanner from "@/components/ActiveSessionBanner";
|
||||||
import MainContentWrapper from "@/components/MainContentWrapper";
|
import MainContentWrapper from "@/components/MainContentWrapper";
|
||||||
import AuthListener from "@/components/AuthListener";
|
import AuthListener from "@/components/AuthListener";
|
||||||
|
import SyncHandler from "@/components/SyncHandler";
|
||||||
|
|
||||||
const inter = Inter({ subsets: ["latin"] });
|
const inter = Inter({ subsets: ["latin"] });
|
||||||
|
|
||||||
@@ -50,6 +51,7 @@ export default function RootLayout({
|
|||||||
<AuthListener />
|
<AuthListener />
|
||||||
<ActiveSessionBanner />
|
<ActiveSessionBanner />
|
||||||
<MainContentWrapper>
|
<MainContentWrapper>
|
||||||
|
<SyncHandler />
|
||||||
<PWARegistration />
|
<PWARegistration />
|
||||||
<OfflineIndicator />
|
<OfflineIndicator />
|
||||||
<UploadQueue />
|
<UploadQueue />
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { useRouter, useSearchParams } from 'next/navigation';
|
|||||||
import { analyzeBottle } from '@/services/analyze-bottle';
|
import { analyzeBottle } from '@/services/analyze-bottle';
|
||||||
import { saveBottle } from '@/services/save-bottle';
|
import { saveBottle } from '@/services/save-bottle';
|
||||||
import { BottleMetadata } from '@/types/whisky';
|
import { BottleMetadata } from '@/types/whisky';
|
||||||
import { savePendingBottle } from '@/lib/offline-db';
|
import { db } from '@/lib/db';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import { findMatchingBottle } from '@/services/find-matching-bottle';
|
import { findMatchingBottle } from '@/services/find-matching-bottle';
|
||||||
import { validateSession } from '@/services/validate-session';
|
import { validateSession } from '@/services/validate-session';
|
||||||
@@ -125,10 +125,10 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
|
|||||||
// Check if Offline
|
// Check if Offline
|
||||||
if (!navigator.onLine) {
|
if (!navigator.onLine) {
|
||||||
console.log('Offline detected. Queuing image...');
|
console.log('Offline detected. Queuing image...');
|
||||||
await savePendingBottle({
|
await db.pending_scans.add({
|
||||||
id: uuidv4(),
|
|
||||||
imageBase64: compressedBase64,
|
imageBase64: compressedBase64,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
|
provider: aiProvider
|
||||||
});
|
});
|
||||||
setIsQueued(true);
|
setIsQueued(true);
|
||||||
return;
|
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';
|
'use client';
|
||||||
|
|
||||||
import React, { useState, useEffect, useMemo } from 'react';
|
import React, { useState, useMemo } from 'react';
|
||||||
import { Tag, TagCategory, getTagsByCategory, createCustomTag } from '@/services/tags';
|
import { TagCategory, createCustomTag } from '@/services/tags';
|
||||||
import { X, Plus, Search, Check, Loader2, Sparkles } from 'lucide-react';
|
import { X, Plus, Search, Check, Loader2, Sparkles } from 'lucide-react';
|
||||||
import { useI18n } from '@/i18n/I18nContext';
|
import { useI18n } from '@/i18n/I18nContext';
|
||||||
|
import { useLiveQuery } from 'dexie-react-hooks';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
|
||||||
interface TagSelectorProps {
|
interface TagSelectorProps {
|
||||||
category: TagCategory;
|
category: TagCategory;
|
||||||
@@ -16,26 +18,23 @@ interface TagSelectorProps {
|
|||||||
|
|
||||||
export default function TagSelector({ category, selectedTagIds, onToggleTag, label, suggestedTagNames, suggestedCustomTagNames }: TagSelectorProps) {
|
export default function TagSelector({ category, selectedTagIds, onToggleTag, label, suggestedTagNames, suggestedCustomTagNames }: TagSelectorProps) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [tags, setTags] = useState<Tag[]>([]);
|
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
const [isCreating, setIsCreating] = useState(false);
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
const [creatingSuggestion, setCreatingSuggestion] = useState<string | null>(null);
|
const [creatingSuggestion, setCreatingSuggestion] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
const tags = useLiveQuery(
|
||||||
const fetchTags = async () => {
|
() => db.cache_tags.where('category').equals(category).sortBy('popularity_score'),
|
||||||
setIsLoading(true);
|
[category],
|
||||||
const data = await getTagsByCategory(category);
|
[]
|
||||||
setTags(data);
|
);
|
||||||
setIsLoading(false);
|
|
||||||
};
|
const isLoading = tags === undefined;
|
||||||
fetchTags();
|
|
||||||
}, [category]);
|
|
||||||
|
|
||||||
const filteredTags = useMemo(() => {
|
const filteredTags = useMemo(() => {
|
||||||
if (!search) return tags;
|
const tagList = tags || [];
|
||||||
|
if (!search) return tagList;
|
||||||
const s = search.toLowerCase();
|
const s = search.toLowerCase();
|
||||||
return tags.filter(tag => {
|
return tagList.filter((tag: any) => {
|
||||||
const rawMatch = tag.name.toLowerCase().includes(s);
|
const rawMatch = tag.name.toLowerCase().includes(s);
|
||||||
const translatedMatch = tag.is_system_default && t(`aroma.${tag.name}`).toLowerCase().includes(s);
|
const translatedMatch = tag.is_system_default && t(`aroma.${tag.name}`).toLowerCase().includes(s);
|
||||||
return rawMatch || translatedMatch;
|
return rawMatch || translatedMatch;
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import Link from 'next/link';
|
|||||||
import AvatarStack from './AvatarStack';
|
import AvatarStack from './AvatarStack';
|
||||||
import { useI18n } from '@/i18n/I18nContext';
|
import { useI18n } from '@/i18n/I18nContext';
|
||||||
import { deleteTasting } from '@/services/delete-tasting';
|
import { deleteTasting } from '@/services/delete-tasting';
|
||||||
|
import { useLiveQuery } from 'dexie-react-hooks';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
|
||||||
interface Tasting {
|
interface Tasting {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -40,14 +42,16 @@ interface Tasting {
|
|||||||
interface TastingListProps {
|
interface TastingListProps {
|
||||||
initialTastings: Tasting[];
|
initialTastings: Tasting[];
|
||||||
currentUserId?: string;
|
currentUserId?: string;
|
||||||
|
bottleId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TastingList({ initialTastings, currentUserId }: TastingListProps) {
|
export default function TastingList({ initialTastings, currentUserId, bottleId }: TastingListProps) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [sortBy, setSortBy] = useState<'date-desc' | 'date-asc' | 'rating-desc' | 'rating-asc'>('date-desc');
|
const [sortBy, setSortBy] = useState<'date-desc' | 'date-asc' | 'rating-desc' | 'rating-asc'>('date-desc');
|
||||||
const [isDeleting, setIsDeleting] = useState<string | null>(null);
|
const [isDeleting, setIsDeleting] = useState<string | null>(null);
|
||||||
|
|
||||||
const handleDelete = async (tastingId: string, bottleId: string) => {
|
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;
|
if (!confirm('Bist du sicher, dass du diese Notiz löschen möchtest?')) return;
|
||||||
|
|
||||||
setIsDeleting(tastingId);
|
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 sortedTastings = useMemo(() => {
|
||||||
const result = [...initialTastings];
|
const merged = [
|
||||||
return result.sort((a, b) => {
|
...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) {
|
switch (sortBy) {
|
||||||
case 'date-desc':
|
case 'date-desc': return timeB - timeA;
|
||||||
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime();
|
case 'date-asc': return timeA - timeB;
|
||||||
case 'date-asc':
|
case 'rating-desc': return b.rating - a.rating;
|
||||||
return new Date(a.created_at).getTime() - new Date(b.created_at).getTime();
|
case 'rating-asc': return a.rating - b.rating;
|
||||||
case 'rating-desc':
|
default: return 0;
|
||||||
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) {
|
if (!initialTastings || initialTastings.length === 0) {
|
||||||
return (
|
return (
|
||||||
@@ -125,6 +149,12 @@ export default function TastingList({ initialTastings, currentUserId }: TastingL
|
|||||||
}`}>
|
}`}>
|
||||||
{note.is_sample ? 'Sample' : 'Bottle'}
|
{note.is_sample ? 'Sample' : 'Bottle'}
|
||||||
</span>
|
</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">
|
<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} />
|
<Clock size={10} />
|
||||||
{new Date(note.created_at).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}
|
{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} />
|
<Calendar size={12} />
|
||||||
{new Date(note.created_at).toLocaleDateString('de-DE')}
|
{new Date(note.created_at).toLocaleDateString('de-DE')}
|
||||||
</div>
|
</div>
|
||||||
{(!currentUserId || note.user_id === currentUserId) && (
|
{(!currentUserId || note.user_id === currentUserId) && !(note as any).isPending && (
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDelete(note.id, note.bottle_id)}
|
onClick={() => handleDelete(note.id, note.bottle_id)}
|
||||||
disabled={!!isDeleting}
|
disabled={!!isDeleting}
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import { createClientComponentClient } from '@supabase/auth-helpers-nextjs';
|
|||||||
import { useI18n } from '@/i18n/I18nContext';
|
import { useI18n } from '@/i18n/I18nContext';
|
||||||
import { useSession } from '@/context/SessionContext';
|
import { useSession } from '@/context/SessionContext';
|
||||||
import TagSelector from './TagSelector';
|
import TagSelector from './TagSelector';
|
||||||
|
import { useLiveQuery } from 'dexie-react-hooks';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
|
||||||
interface Buddy {
|
interface Buddy {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -28,7 +30,7 @@ export default function TastingNoteForm({ bottleId, sessionId }: TastingNoteForm
|
|||||||
const [isSample, setIsSample] = useState(false);
|
const [isSample, setIsSample] = useState(false);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
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 [selectedBuddyIds, setSelectedBuddyIds] = useState<string[]>([]);
|
||||||
const [noseTagIds, setNoseTagIds] = useState<string[]>([]);
|
const [noseTagIds, setNoseTagIds] = useState<string[]>([]);
|
||||||
const [palateTagIds, setPalateTagIds] = useState<string[]>([]);
|
const [palateTagIds, setPalateTagIds] = useState<string[]>([]);
|
||||||
@@ -41,10 +43,6 @@ export default function TastingNoteForm({ bottleId, sessionId }: TastingNoteForm
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
// Fetch All Buddies
|
|
||||||
const { data: buddiesData } = await supabase.from('buddies').select('id, name').order('name');
|
|
||||||
setBuddies(buddiesData || []);
|
|
||||||
|
|
||||||
// Fetch Bottle Suggestions
|
// Fetch Bottle Suggestions
|
||||||
const { data: bottleData } = await supabase
|
const { data: bottleData } = await supabase
|
||||||
.from('bottles')
|
.from('bottles')
|
||||||
@@ -74,7 +72,7 @@ export default function TastingNoteForm({ bottleId, sessionId }: TastingNoteForm
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
fetchData();
|
fetchData();
|
||||||
}, [effectiveSessionId, bottleId]);
|
}, [supabase, effectiveSessionId, bottleId]);
|
||||||
|
|
||||||
const toggleBuddy = (id: string) => {
|
const toggleBuddy = (id: string) => {
|
||||||
setSelectedBuddyIds(prev =>
|
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]);
|
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) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
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 {
|
try {
|
||||||
const result = await saveTasting({
|
if (!navigator.onLine) {
|
||||||
bottle_id: bottleId,
|
// Save to Offline DB
|
||||||
session_id: effectiveSessionId,
|
await db.pending_tastings.add({
|
||||||
rating,
|
bottle_id: bottleId,
|
||||||
nose_notes: nose,
|
data,
|
||||||
palate_notes: palate,
|
tasted_at: new Date().toISOString()
|
||||||
finish_notes: finish,
|
});
|
||||||
is_sample: isSample,
|
clearForm();
|
||||||
buddy_ids: selectedBuddyIds,
|
setLoading(false);
|
||||||
tag_ids: [...noseTagIds, ...palateTagIds, ...finishTagIds],
|
return;
|
||||||
});
|
}
|
||||||
|
|
||||||
|
const result = await saveTasting(data);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
setNose('');
|
clearForm();
|
||||||
setPalate('');
|
|
||||||
setFinish('');
|
|
||||||
setSelectedBuddyIds([]);
|
|
||||||
setNoseTagIds([]);
|
|
||||||
setPalateTagIds([]);
|
|
||||||
setFinishTagIds([]);
|
|
||||||
// We don't need to manually refresh because of revalidatePath in the server action
|
|
||||||
} else {
|
} else {
|
||||||
setError(result.error || t('common.error'));
|
setError(result.error || t('common.error'));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +1,26 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useEffect, useState, useCallback } from 'react';
|
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 { analyzeBottle } from '@/services/analyze-bottle';
|
||||||
import { saveBottle } from '@/services/save-bottle';
|
import { saveBottle } from '@/services/save-bottle';
|
||||||
|
import { saveTasting } from '@/services/save-tasting';
|
||||||
import { createClientComponentClient } from '@supabase/auth-helpers-nextjs';
|
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() {
|
export default function UploadQueue() {
|
||||||
const supabase = createClientComponentClient();
|
const supabase = createClientComponentClient();
|
||||||
const [queue, setQueue] = useState<PendingBottle[]>([]);
|
|
||||||
const [isSyncing, setIsSyncing] = useState(false);
|
const [isSyncing, setIsSyncing] = useState(false);
|
||||||
const [currentProgress, setCurrentProgress] = useState<{ id: string, status: string } | null>(null);
|
const [currentProgress, setCurrentProgress] = useState<{ id: string, status: string } | null>(null);
|
||||||
|
|
||||||
const loadQueue = useCallback(async () => {
|
const pendingScans = useLiveQuery(() => db.pending_scans.toArray(), [], [] as PendingScan[]);
|
||||||
const pending = await getAllPendingBottles();
|
const pendingTastings = useLiveQuery(() => db.pending_tastings.toArray(), [], [] as PendingTasting[]);
|
||||||
setQueue(pending);
|
|
||||||
}, []);
|
const totalInQueue = pendingScans.length + pendingTastings.length;
|
||||||
|
|
||||||
const syncQueue = useCallback(async () => {
|
const syncQueue = useCallback(async () => {
|
||||||
if (isSyncing || !navigator.onLine || queue.length === 0) return;
|
if (isSyncing || !navigator.onLine || totalInQueue === 0) return;
|
||||||
|
|
||||||
setIsSyncing(true);
|
setIsSyncing(true);
|
||||||
const { data: { user } } = await supabase.auth.getUser();
|
const { data: { user } } = await supabase.auth.getUser();
|
||||||
@@ -30,93 +31,132 @@ export default function UploadQueue() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const item of queue) {
|
try {
|
||||||
setCurrentProgress({ id: item.id, status: 'Analysiere...' });
|
// 1. Sync Scans (Magic Shots)
|
||||||
try {
|
for (const item of pendingScans) {
|
||||||
// 1. Analyze
|
const itemId = `scan-${item.id}`;
|
||||||
const analysis = await analyzeBottle(item.imageBase64);
|
setCurrentProgress({ id: itemId, status: 'Analysiere Scan...' });
|
||||||
if (analysis.success && analysis.data) {
|
try {
|
||||||
setCurrentProgress({ id: item.id, status: 'Speichere...' });
|
const analysis = await analyzeBottle(item.imageBase64);
|
||||||
// 2. Save
|
if (analysis.success && analysis.data) {
|
||||||
const save = await saveBottle(analysis.data, item.imageBase64, user.id);
|
setCurrentProgress({ id: itemId, status: 'Speichere Flasche...' });
|
||||||
if (save.success) {
|
const save = await saveBottle(analysis.data, item.imageBase64, user.id);
|
||||||
await deletePendingBottle(item.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);
|
// 2. Sync Tastings
|
||||||
setCurrentProgress(null);
|
for (const item of pendingTastings) {
|
||||||
loadQueue();
|
const itemId = `tasting-${item.id}`;
|
||||||
}, [isSyncing, queue, supabase, loadQueue]);
|
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(() => {
|
useEffect(() => {
|
||||||
loadQueue();
|
|
||||||
|
|
||||||
// Listen for storage changes (e.g. from CameraCapture)
|
|
||||||
const interval = setInterval(loadQueue, 5000);
|
|
||||||
|
|
||||||
const handleOnline = () => {
|
const handleOnline = () => {
|
||||||
console.log('Back online! Triggering sync...');
|
console.log('Online! Triggering background sync...');
|
||||||
syncQueue();
|
syncQueue();
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener('online', handleOnline);
|
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 (
|
return (
|
||||||
<div className="fixed bottom-6 right-6 z-50 animate-in slide-in-from-right-10">
|
<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 justify-between border-b border-white/10 pb-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<RefreshCw size={16} className={isSyncing ? 'animate-spin text-amber-500' : 'text-zinc-400'} />
|
<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>
|
</div>
|
||||||
<span className="bg-amber-600 text-[10px] font-black px-1.5 py-0.5 rounded-md">
|
<span className="bg-amber-600 text-[10px] font-black px-1.5 py-0.5 rounded-md">
|
||||||
{queue.length}
|
{totalInQueue} Items
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{queue.slice(0, 3).map((item) => (
|
{/* Scans */}
|
||||||
<div key={item.id} className="flex items-center justify-between text-[11px] font-medium text-zinc-400">
|
{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="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" />
|
<img src={item.imageBase64} className="w-full h-full object-cover opacity-50" />
|
||||||
</div>
|
</div>
|
||||||
<span className="truncate max-w-[120px]">
|
<span className="truncate max-w-[150px]">
|
||||||
{currentProgress?.id === item.id ? currentProgress.status : 'Wartet auf Netz...'}
|
{currentProgress?.id === `scan-${item.id}` ? currentProgress.status : 'Scan wartet...'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{currentProgress?.id === item.id ? (
|
{currentProgress?.id === `scan-${item.id}` ? (
|
||||||
<Loader2 size={12} className="animate-spin text-amber-500" />
|
<Loader2 size={12} className="animate-spin text-amber-500" />
|
||||||
) : (
|
) : <Info size={12} className="text-zinc-600" />}
|
||||||
<AlertCircle size={12} className="text-zinc-600" />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{queue.length > 3 && (
|
|
||||||
<div className="text-[10px] text-zinc-500 text-center font-bold italic pt-1">
|
{/* Tastings */}
|
||||||
+ {queue.length - 3} weitere Flaschen
|
{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>
|
||||||
)}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{navigator.onLine && !isSyncing && (
|
{navigator.onLine && !isSyncing && (
|
||||||
<button
|
<button
|
||||||
onClick={syncQueue}
|
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"
|
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>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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;
|
is_sample?: boolean;
|
||||||
buddy_ids?: string[];
|
buddy_ids?: string[];
|
||||||
tag_ids?: string[];
|
tag_ids?: string[];
|
||||||
|
tasted_at?: string;
|
||||||
}) {
|
}) {
|
||||||
const supabase = createServerActionClient({ cookies });
|
const supabase = createServerActionClient({ cookies });
|
||||||
|
|
||||||
@@ -41,6 +42,7 @@ export async function saveTasting(data: {
|
|||||||
palate_notes: data.palate_notes,
|
palate_notes: data.palate_notes,
|
||||||
finish_notes: data.finish_notes,
|
finish_notes: data.finish_notes,
|
||||||
is_sample: data.is_sample || false,
|
is_sample: data.is_sample || false,
|
||||||
|
created_at: data.tasted_at || undefined,
|
||||||
})
|
})
|
||||||
.select()
|
.select()
|
||||||
.single();
|
.single();
|
||||||
|
|||||||
Reference in New Issue
Block a user