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 { saveTasting } from '@/services/save-tasting';
import { createClient } from '@/lib/supabase/client'; 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'; import TastingNoteForm from './TastingNoteForm';
// Helper to convert base64 to FormData // Helper to convert base64 to FormData
@@ -32,7 +33,7 @@ export default function UploadQueue() {
const supabase = createClient(); const supabase = createClient();
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 [isCollapsed, setIsCollapsed] = useState(false); const [isCollapsed, setIsCollapsed] = useState(true);
const [completedItems, setCompletedItems] = useState<{ id: string; name: string; bottleId?: string; type: 'scan' | 'tasting' }[]>([]); const [completedItems, setCompletedItems] = useState<{ id: string; name: string; bottleId?: string; type: 'scan' | 'tasting' }[]>([]);
const [activeNoteScanId, setActiveNoteScanId] = useState<string | null>(null); const [activeNoteScanId, setActiveNoteScanId] = useState<string | null>(null);
@@ -270,50 +271,58 @@ export default function UploadQueue() {
if (totalInQueue === 0) return null; if (totalInQueue === 0) return null;
return ( 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="fixed bottom-24 right-6 md:bottom-6 md:right-6 z-[100] flex flex-col items-end gap-3 translate-y-0">
<div className="bg-zinc-900 border border-white/10 rounded-3xl shadow-2xl overflow-hidden transition-all duration-500"> <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 */} {/* Header */}
<div <div
className="p-4 bg-zinc-800/50 flex items-center justify-between cursor-pointer" className="p-5 bg-zinc-800/50 flex items-center justify-between cursor-pointer"
onClick={() => setIsCollapsed(!isCollapsed)} onClick={() => setIsCollapsed(true)}
> >
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="relative"> <div className="relative">
<RefreshCw size={18} className={isSyncing ? 'animate-spin text-amber-500' : 'text-zinc-500'} /> <RefreshCw size={18} className={isSyncing ? 'animate-spin text-orange-500' : 'text-zinc-500'} />
{totalInQueue > 0 && !isSyncing && ( {totalInQueue > 0 && !isSyncing && (
<div className="absolute -top-1 -right-1 w-2 h-2 bg-amber-500 rounded-full animate-pulse" /> <div className="absolute -top-1 -right-1 w-2 h-2 bg-orange-500 rounded-full animate-pulse" />
)} )}
</div> </div>
<div className="flex flex-col"> <div className="flex flex-col">
<span className="text-[10px] font-black uppercase tracking-[0.2em] text-zinc-400">Sync Warteschlange</span> <span className="text-[10px] font-black uppercase tracking-[0.2em] text-zinc-400">Sync status</span>
<span className="text-xs font-bold text-white"> <span className="text-xs font-bold text-white">
{isSyncing ? 'Synchronisiere...' : navigator.onLine ? 'Warten auf Upload' : 'Offline - Lokal gespeichert'} {isSyncing ? 'Synchronisiere...' : navigator.onLine ? 'Warten auf Upload' : 'Offline'}
</span> </span>
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> <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"> <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} {totalInQueue}
</span> </span>
<X size={16} className="text-zinc-500" />
</div> </div>
</div> </div>
{/* Content */} {/* Content */}
{!isCollapsed && ( <div className="p-5 space-y-4 max-h-[60vh] md:max-h-[400px] overflow-y-auto custom-scrollbar">
<div className="p-4 space-y-4 max-h-[400px] overflow-y-auto custom-scrollbar">
<div className="space-y-3"> <div className="space-y-3">
{/* Completed Items (The "Results") */} {/* Completed Items */}
{completedItems.length > 0 && ( {completedItems.length > 0 && (
<div className="space-y-2 pb-2 border-b border-white/5"> <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"> <div className="flex items-center gap-2 text-[9px] font-black uppercase tracking-[0.2em] text-green-500 mb-2">
<CheckCircle2 size={10} /> <CheckCircle2 size={10} />
Synchronisierte Items Fertig
</div> </div>
{completedItems.map((item) => ( {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 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"> <div className="flex flex-col gap-0.5 min-w-0">
<span className="text-[10px] font-black text-green-500 uppercase tracking-widest"> <span className="text-[10px] font-black text-green-500 uppercase tracking-widest">
{item.type === 'scan' ? 'Neu im Vault' : 'Tasting gespeichert'} {item.type === 'scan' ? 'Scan bereit' : 'Tasting bereit'}
</span> </span>
<span className="text-[11px] font-bold text-white truncate pr-2"> <span className="text-[11px] font-bold text-white truncate pr-2">
{item.name} {item.name}
@@ -322,10 +331,9 @@ export default function UploadQueue() {
{item.bottleId && ( {item.bottleId && (
<a <a
href={`/bottles/${item.bottleId}`} 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" 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"
> >
Ansehen Details
<CheckCircle2 size={10} />
</a> </a>
)} )}
</div> </div>
@@ -335,40 +343,36 @@ export default function UploadQueue() {
{/* Scans */} {/* Scans */}
{pendingScans.map((item) => ( {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 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 justify-between">
<div className="flex items-center gap-3"> <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"> <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" /> <img src={item.imageBase64} className="w-full h-full object-cover opacity-60" />
</div> </div>
<div className="flex flex-col gap-0.5"> <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-[10px] font-black text-orange-500/80 uppercase tracking-widest">Flaschen-Scan</span>
<span className="text-[11px] font-medium text-zinc-300"> <span className="text-[11px] font-medium text-zinc-300">
{currentProgress?.id === `scan-${item.id}` ? ( {currentProgress?.id === `scan-${item.id}` ? (
<span className="flex items-center gap-1.5"> <span className="flex items-center gap-1.5">
<Loader2 size={10} className="animate-spin" /> <Loader2 size={10} className="animate-spin" />
{currentProgress.status} {currentProgress.status}
</span> </span>
) : 'Wartet auf Verbindung...'} ) : 'In Warteschlange...'}
</span> </span>
</div> </div>
</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> </div>
{/* Link for adding tasting notes to pending scan */}
<button <button
onClick={() => setActiveNoteScanId(activeNoteScanId === item.temp_id ? null : item.temp_id)} 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" 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={10} /> <Send size={12} />
{activeNoteScanId === item.temp_id ? 'Abbrechen' : 'Notiz hinzufügen'} {activeNoteScanId === item.temp_id ? 'Abbrechen' : 'Notiz hinzufügen'}
</button> </button>
{activeNoteScanId === item.temp_id && ( {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"> <div className="mt-2">
<TastingNoteForm <TastingNoteForm
pendingBottleId={item.temp_id} pendingBottleId={item.temp_id}
onSuccess={() => setActiveNoteScanId(null)} onSuccess={() => setActiveNoteScanId(null)}
@@ -380,30 +384,18 @@ export default function UploadQueue() {
{/* Tastings */} {/* Tastings */}
{pendingTastings.map((item) => ( {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 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="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="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-sm font-black text-amber-500">{item.data.rating}</div> <div className="text-xs font-black text-orange-500">{item.data.rating}</div>
</div> </div>
<div className="flex flex-col gap-0.5"> <div className="flex flex-col gap-0.5">
<span className="text-[10px] font-black text-amber-500/80 uppercase tracking-widest"> <span className="text-[10px] font-black text-orange-500/80 uppercase tracking-widest">Tasting Note</span>
{item.pending_bottle_id ? 'Draft Notiz' : 'Tasting Note'} <span className="text-[11px] font-medium text-zinc-300 line-clamp-1">
</span> {item.pending_bottle_id ? 'Wartet auf Scan...' : currentProgress?.id === `tasting-${item.id}` ? currentProgress.status : 'Wartet auf Sync...'}
<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> </span>
</div> </div>
</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> </div>
))} ))}
</div> </div>
@@ -411,22 +403,33 @@ export default function UploadQueue() {
{navigator.onLine && !isSyncing && ( {navigator.onLine && !isSyncing && (
<button <button
onClick={() => syncQueue()} 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" 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={12} /> <RefreshCw size={14} />
Synchronisierung erzwingen Sync jetzt
</button> </button>
)} )}
{!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
</div> </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> </div>
); );
} }