feat: refactor UploadQueue to floating bubble UI for mobile

This commit is contained in:
2025-12-23 16:20:14 +01:00
parent c134c78a2c
commit 6a41667f9c

View File

@@ -9,7 +9,8 @@ import { saveBottle } from '@/services/save-bottle';
import { saveTasting } from '@/services/save-tasting';
import { createClient } from '@/lib/supabase/client';
import { RefreshCw, CheckCircle2, AlertCircle, Loader2, Info, Send } from 'lucide-react';
import { RefreshCw, CheckCircle2, AlertCircle, Loader2, Info, Send, X } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import TastingNoteForm from './TastingNoteForm';
// Helper to convert base64 to FormData
@@ -32,7 +33,7 @@ export default function UploadQueue() {
const supabase = createClient();
const [isSyncing, setIsSyncing] = useState(false);
const [currentProgress, setCurrentProgress] = useState<{ id: string, status: string } | null>(null);
const [isCollapsed, setIsCollapsed] = useState(false);
const [isCollapsed, setIsCollapsed] = useState(true);
const [completedItems, setCompletedItems] = useState<{ id: string; name: string; bottleId?: string; type: 'scan' | 'tasting' }[]>([]);
const [activeNoteScanId, setActiveNoteScanId] = useState<string | null>(null);
@@ -270,163 +271,165 @@ export default function UploadQueue() {
if (totalInQueue === 0) return null;
return (
<div className={`fixed bottom-6 left-1/2 -translate-x-1/2 md:left-auto md:translate-x-0 md:right-6 z-[100] animate-in slide-in-from-bottom-10 md:slide-in-from-right-10 w-[calc(100%-2rem)] md:w-auto`}>
<div className="bg-zinc-900 border border-white/10 rounded-3xl shadow-2xl overflow-hidden transition-all duration-500">
{/* Header */}
<div
className="p-4 bg-zinc-800/50 flex items-center justify-between cursor-pointer"
onClick={() => setIsCollapsed(!isCollapsed)}
>
<div className="flex items-center gap-3">
<div className="relative">
<RefreshCw size={18} className={isSyncing ? 'animate-spin text-amber-500' : 'text-zinc-500'} />
{totalInQueue > 0 && !isSyncing && (
<div className="absolute -top-1 -right-1 w-2 h-2 bg-amber-500 rounded-full animate-pulse" />
)}
</div>
<div className="flex flex-col">
<span className="text-[10px] font-black uppercase tracking-[0.2em] text-zinc-400">Sync Warteschlange</span>
<span className="text-xs font-bold text-white">
{isSyncing ? 'Synchronisiere...' : navigator.onLine ? 'Warten auf Upload' : 'Offline - Lokal gespeichert'}
</span>
</div>
</div>
<div className="flex items-center gap-2">
<span className="bg-amber-600 text-[10px] font-black px-2 py-0.5 rounded-full text-white">
{totalInQueue}
</span>
</div>
</div>
{/* Content */}
{!isCollapsed && (
<div className="p-4 space-y-4 max-h-[400px] overflow-y-auto custom-scrollbar">
<div className="space-y-3">
{/* Completed Items (The "Results") */}
{completedItems.length > 0 && (
<div className="space-y-2 pb-2 border-b border-white/5">
<div className="flex items-center gap-2 text-[9px] font-black uppercase tracking-[0.2em] text-green-500 mb-2">
<CheckCircle2 size={10} />
Synchronisierte Items
</div>
{completedItems.map((item) => (
<div key={`done-${item.id}`} className="flex items-center justify-between p-2 rounded-xl bg-green-500/10 border border-green-500/20 animate-in zoom-in-95">
<div className="flex flex-col gap-0.5 min-w-0">
<span className="text-[10px] font-black text-green-500 uppercase tracking-widest">
{item.type === 'scan' ? 'Neu im Vault' : 'Tasting gespeichert'}
</span>
<span className="text-[11px] font-bold text-white truncate pr-2">
{item.name}
</span>
</div>
{item.bottleId && (
<a
href={`/bottles/${item.bottleId}`}
className="shrink-0 px-3 py-1.5 bg-green-500 hover:bg-green-600 text-white text-[10px] font-black uppercase rounded-lg transition-all shadow-lg shadow-green-500/20 flex items-center gap-1"
>
Ansehen
<CheckCircle2 size={10} />
</a>
)}
</div>
))}
</div>
)}
{/* Scans */}
{pendingScans.map((item) => (
<div key={`scan-${item.id}`} className="flex flex-col gap-2 p-2 rounded-xl bg-white/5 border border-white/5 transition-colors">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-zinc-800 overflow-hidden ring-1 ring-white/10 shrink-0">
<img src={item.imageBase64} className="w-full h-full object-cover opacity-60" />
</div>
<div className="flex flex-col gap-0.5">
<span className="text-[10px] font-black text-amber-500/80 uppercase tracking-widest">Magic Shot</span>
<span className="text-[11px] font-medium text-zinc-300">
{currentProgress?.id === `scan-${item.id}` ? (
<span className="flex items-center gap-1.5">
<Loader2 size={10} className="animate-spin" />
{currentProgress.status}
</span>
) : 'Wartet auf Verbindung...'}
</span>
</div>
</div>
<div className="text-[9px] text-zinc-500 font-bold whitespace-nowrap ml-4">
{new Date(item.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</div>
</div>
{/* Link for adding tasting notes to pending scan */}
<button
onClick={() => setActiveNoteScanId(activeNoteScanId === item.temp_id ? null : item.temp_id)}
className="w-full py-2 bg-amber-600/10 hover:bg-amber-600/20 text-amber-500 text-[10px] font-black uppercase rounded-lg transition-all flex items-center justify-center gap-2 border border-amber-500/10"
>
<Send size={10} />
{activeNoteScanId === item.temp_id ? 'Abbrechen' : 'Notiz hinzufügen'}
</button>
{activeNoteScanId === item.temp_id && (
<div className="mt-2 p-4 bg-zinc-800 rounded-2xl border border-white/5 animate-in slide-in-from-top-2">
<TastingNoteForm
pendingBottleId={item.temp_id}
onSuccess={() => setActiveNoteScanId(null)}
/>
</div>
<div className="fixed bottom-24 right-6 md:bottom-6 md:right-6 z-[100] flex flex-col items-end gap-3 translate-y-0">
<AnimatePresence mode="wait">
{!isCollapsed ? (
<motion.div
key="expanded"
initial={{ opacity: 0, scale: 0.9, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.9, y: 20 }}
className="w-[calc(100vw-3rem)] md:w-96 bg-zinc-900 border border-white/10 rounded-[2.5rem] shadow-2xl overflow-hidden"
>
{/* Header */}
<div
className="p-5 bg-zinc-800/50 flex items-center justify-between cursor-pointer"
onClick={() => setIsCollapsed(true)}
>
<div className="flex items-center gap-3">
<div className="relative">
<RefreshCw size={18} className={isSyncing ? 'animate-spin text-orange-500' : 'text-zinc-500'} />
{totalInQueue > 0 && !isSyncing && (
<div className="absolute -top-1 -right-1 w-2 h-2 bg-orange-500 rounded-full animate-pulse" />
)}
</div>
))}
{/* Tastings */}
{pendingTastings.map((item) => (
<div key={`tasting-${item.id}`} className="group flex items-center justify-between p-2 rounded-xl bg-white/5 border border-white/5 hover:bg-white/10 transition-colors">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-amber-900/20 flex items-center justify-center ring-1 ring-amber-500/20 shrink-0">
<div className="text-sm font-black text-amber-500">{item.data.rating}</div>
</div>
<div className="flex flex-col gap-0.5">
<span className="text-[10px] font-black text-amber-500/80 uppercase tracking-widest">
{item.pending_bottle_id ? 'Draft Notiz' : 'Tasting Note'}
</span>
<span className="text-[11px] font-medium text-zinc-300">
{item.pending_bottle_id ? (
<span className="text-amber-500/60 italic">Wartet auf Scan...</span>
) : currentProgress?.id === `tasting-${item.id}` ? (
<span className="flex items-center gap-1.5">
<Loader2 size={10} className="animate-spin" />
{currentProgress.status}
</span>
) : 'Wartet auf Sync...'}
</span>
</div>
</div>
<div className="text-[9px] text-zinc-500 font-bold whitespace-nowrap ml-4">
{new Date(item.tasted_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</div>
<div className="flex flex-col">
<span className="text-[10px] font-black uppercase tracking-[0.2em] text-zinc-400">Sync status</span>
<span className="text-xs font-bold text-white">
{isSyncing ? 'Synchronisiere...' : navigator.onLine ? 'Warten auf Upload' : 'Offline'}
</span>
</div>
))}
</div>
<div className="flex items-center gap-2">
<span className="bg-orange-600 text-[10px] font-black px-2.5 py-1 rounded-full text-white shadow-lg shadow-orange-950/40">
{totalInQueue}
</span>
<X size={16} className="text-zinc-500" />
</div>
</div>
{navigator.onLine && !isSyncing && (
<button
onClick={() => syncQueue()}
className="w-full py-3 bg-white/5 hover:bg-white/10 text-amber-500 text-[10px] font-black uppercase rounded-2xl transition-all border border-white/5 active:scale-95 flex items-center justify-center gap-2 mt-2"
>
<RefreshCw size={12} />
Synchronisierung erzwingen
</button>
)}
{/* Content */}
<div className="p-5 space-y-4 max-h-[60vh] md:max-h-[400px] overflow-y-auto custom-scrollbar">
<div className="space-y-3">
{/* Completed Items */}
{completedItems.length > 0 && (
<div className="space-y-2 pb-2 border-b border-white/5">
<div className="flex items-center gap-2 text-[9px] font-black uppercase tracking-[0.2em] text-green-500 mb-2">
<CheckCircle2 size={10} />
Fertig
</div>
{completedItems.map((item) => (
<div key={`done-${item.id}`} className="flex items-center justify-between p-2.5 rounded-2xl bg-green-500/10 border border-green-500/20">
<div className="flex flex-col gap-0.5 min-w-0">
<span className="text-[10px] font-black text-green-500 uppercase tracking-widest">
{item.type === 'scan' ? 'Scan bereit' : 'Tasting bereit'}
</span>
<span className="text-[11px] font-bold text-white truncate pr-2">
{item.name}
</span>
</div>
{item.bottleId && (
<a
href={`/bottles/${item.bottleId}`}
className="shrink-0 px-3 py-1.5 bg-green-500 hover:bg-green-600 text-white text-[10px] font-black uppercase rounded-lg transition-all"
>
Details
</a>
)}
</div>
))}
</div>
)}
{!navigator.onLine && (
<div className="flex items-center justify-center gap-2 py-2 text-[10px] text-zinc-500 font-bold italic">
<AlertCircle size={12} />
Keine Internetverbindung
{/* Scans */}
{pendingScans.map((item) => (
<div key={`scan-${item.id}`} className="flex flex-col gap-2.5 p-3 rounded-2xl bg-white/5 border border-white/5">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-zinc-800 overflow-hidden ring-1 ring-white/10 shrink-0">
<img src={item.imageBase64} className="w-full h-full object-cover opacity-60" />
</div>
<div className="flex flex-col gap-0.5">
<span className="text-[10px] font-black text-orange-500/80 uppercase tracking-widest">Flaschen-Scan</span>
<span className="text-[11px] font-medium text-zinc-300">
{currentProgress?.id === `scan-${item.id}` ? (
<span className="flex items-center gap-1.5">
<Loader2 size={10} className="animate-spin" />
{currentProgress.status}
</span>
) : 'In Warteschlange...'}
</span>
</div>
</div>
</div>
<button
onClick={() => setActiveNoteScanId(activeNoteScanId === item.temp_id ? null : item.temp_id)}
className="w-full py-2.5 bg-orange-600/10 hover:bg-orange-600/20 text-orange-500 text-[10px] font-black uppercase rounded-xl transition-all flex items-center justify-center gap-2 border border-orange-500/10"
>
<Send size={12} />
{activeNoteScanId === item.temp_id ? 'Abbrechen' : 'Notiz hinzufügen'}
</button>
{activeNoteScanId === item.temp_id && (
<div className="mt-2">
<TastingNoteForm
pendingBottleId={item.temp_id}
onSuccess={() => setActiveNoteScanId(null)}
/>
</div>
)}
</div>
))}
{/* Tastings */}
{pendingTastings.map((item) => (
<div key={`tasting-${item.id}`} className="flex items-center justify-between p-3 rounded-2xl bg-white/5 border border-white/5">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-orange-900/20 flex items-center justify-center ring-1 ring-orange-500/20 shrink-0">
<div className="text-xs font-black text-orange-500">{item.data.rating}</div>
</div>
<div className="flex flex-col gap-0.5">
<span className="text-[10px] font-black text-orange-500/80 uppercase tracking-widest">Tasting Note</span>
<span className="text-[11px] font-medium text-zinc-300 line-clamp-1">
{item.pending_bottle_id ? 'Wartet auf Scan...' : currentProgress?.id === `tasting-${item.id}` ? currentProgress.status : 'Wartet auf Sync...'}
</span>
</div>
</div>
</div>
))}
</div>
{navigator.onLine && !isSyncing && (
<button
onClick={() => syncQueue()}
className="w-full py-3.5 bg-white/5 hover:bg-white/10 text-orange-500 text-[10px] font-black uppercase rounded-2xl transition-all border border-white/5 flex items-center justify-center gap-2"
>
<RefreshCw size={14} />
Sync jetzt
</button>
)}
</div>
</motion.div>
) : (
<motion.button
key="bubble"
initial={{ opacity: 0, scale: 0.5, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.5, y: 20 }}
onClick={() => setIsCollapsed(false)}
className="relative w-14 h-14 bg-zinc-900 border border-white/10 rounded-full shadow-2xl flex items-center justify-center text-white hover:bg-zinc-800 transition-all group"
>
<RefreshCw size={20} className={isSyncing ? 'animate-spin text-orange-500' : 'text-zinc-500 group-hover:text-white transition-colors'} />
<div className="absolute -top-1 -right-1 bg-orange-600 text-[9px] font-black w-5 h-5 rounded-full flex items-center justify-center shadow-lg shadow-orange-950/40 ring-2 ring-zinc-900">
{totalInQueue}
</div>
{isSyncing && (
<div className="absolute inset-0 rounded-full border-2 border-orange-500/50 border-t-transparent animate-spin" />
)}
</div>
</motion.button>
)}
</div>
</AnimatePresence>
</div>
);
}