feat: refactor UploadQueue to floating bubble UI for mobile
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user