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 { 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,163 +271,165 @@ 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">
|
||||||
{/* Header */}
|
{!isCollapsed ? (
|
||||||
<div
|
<motion.div
|
||||||
className="p-4 bg-zinc-800/50 flex items-center justify-between cursor-pointer"
|
key="expanded"
|
||||||
onClick={() => setIsCollapsed(!isCollapsed)}
|
initial={{ opacity: 0, scale: 0.9, y: 20 }}
|
||||||
>
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||||
<div className="flex items-center gap-3">
|
exit={{ opacity: 0, scale: 0.9, y: 20 }}
|
||||||
<div className="relative">
|
className="w-[calc(100vw-3rem)] md:w-96 bg-zinc-900 border border-white/10 rounded-[2.5rem] shadow-2xl overflow-hidden"
|
||||||
<RefreshCw size={18} className={isSyncing ? 'animate-spin text-amber-500' : 'text-zinc-500'} />
|
>
|
||||||
{totalInQueue > 0 && !isSyncing && (
|
{/* Header */}
|
||||||
<div className="absolute -top-1 -right-1 w-2 h-2 bg-amber-500 rounded-full animate-pulse" />
|
<div
|
||||||
)}
|
className="p-5 bg-zinc-800/50 flex items-center justify-between cursor-pointer"
|
||||||
</div>
|
onClick={() => setIsCollapsed(true)}
|
||||||
<div className="flex flex-col">
|
>
|
||||||
<span className="text-[10px] font-black uppercase tracking-[0.2em] text-zinc-400">Sync Warteschlange</span>
|
<div className="flex items-center gap-3">
|
||||||
<span className="text-xs font-bold text-white">
|
<div className="relative">
|
||||||
{isSyncing ? 'Synchronisiere...' : navigator.onLine ? 'Warten auf Upload' : 'Offline - Lokal gespeichert'}
|
<RefreshCw size={18} className={isSyncing ? 'animate-spin text-orange-500' : 'text-zinc-500'} />
|
||||||
</span>
|
{totalInQueue > 0 && !isSyncing && (
|
||||||
</div>
|
<div className="absolute -top-1 -right-1 w-2 h-2 bg-orange-500 rounded-full animate-pulse" />
|
||||||
</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>
|
</div>
|
||||||
))}
|
<div className="flex flex-col">
|
||||||
|
<span className="text-[10px] font-black uppercase tracking-[0.2em] text-zinc-400">Sync status</span>
|
||||||
{/* Tastings */}
|
<span className="text-xs font-bold text-white">
|
||||||
{pendingTastings.map((item) => (
|
{isSyncing ? 'Synchronisiere...' : navigator.onLine ? 'Warten auf Upload' : 'Offline'}
|
||||||
<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">
|
</span>
|
||||||
<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>
|
</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>
|
</div>
|
||||||
|
|
||||||
{navigator.onLine && !isSyncing && (
|
{/* Content */}
|
||||||
<button
|
<div className="p-5 space-y-4 max-h-[60vh] md:max-h-[400px] overflow-y-auto custom-scrollbar">
|
||||||
onClick={() => syncQueue()}
|
<div className="space-y-3">
|
||||||
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"
|
{/* Completed Items */}
|
||||||
>
|
{completedItems.length > 0 && (
|
||||||
<RefreshCw size={12} />
|
<div className="space-y-2 pb-2 border-b border-white/5">
|
||||||
Synchronisierung erzwingen
|
<div className="flex items-center gap-2 text-[9px] font-black uppercase tracking-[0.2em] text-green-500 mb-2">
|
||||||
</button>
|
<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 && (
|
{/* Scans */}
|
||||||
<div className="flex items-center justify-center gap-2 py-2 text-[10px] text-zinc-500 font-bold italic">
|
{pendingScans.map((item) => (
|
||||||
<AlertCircle size={12} />
|
<div key={`scan-${item.id}`} className="flex flex-col gap-2.5 p-3 rounded-2xl bg-white/5 border border-white/5">
|
||||||
Keine Internetverbindung
|
<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>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user