feat: Upgrade to Next.js 16.1 & React 19.2, migrate to Supabase SSR with async client handling

This commit is contained in:
2025-12-19 20:31:46 +01:00
parent d9b44a0ec5
commit 24e243fff8
49 changed files with 942 additions and 852 deletions

View File

@@ -1,11 +1,11 @@
'use client';
import React, { useState } from 'react';
import { createClientComponentClient } from '@supabase/auth-helpers-nextjs';
import { createClient } from '@/lib/supabase/client';
import { LogIn, UserPlus, Mail, Lock, Loader2, AlertCircle } from 'lucide-react';
export default function AuthForm() {
const supabase = createClientComponentClient();
const supabase = createClient();
const [isLogin, setIsLogin] = useState(true);
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');

View File

@@ -1,10 +1,10 @@
'use client';
import { useEffect } from 'react';
import { createClientComponentClient } from '@supabase/auth-helpers-nextjs';
import { createClient } from '@/lib/supabase/client';
export default function AuthListener() {
const supabase = createClientComponentClient();
const supabase = createClient();
useEffect(() => {
// Listener für Auth-Status Änderungen

View File

@@ -1,9 +1,10 @@
'use client';
import React, { useState, useEffect } from 'react';
import { createClientComponentClient } from '@supabase/auth-helpers-nextjs';
import { createClient } from '@/lib/supabase/client';
import { Users, UserPlus, Trash2, User, Loader2, ChevronDown, ChevronUp } from 'lucide-react';
import { useI18n } from '@/i18n/I18nContext';
import { addBuddy, deleteBuddy } from '@/services/buddy';
interface Buddy {
id: string;
@@ -13,7 +14,7 @@ interface Buddy {
export default function BuddyList() {
const { t } = useI18n();
const supabase = createClientComponentClient();
const supabase = createClient();
const [buddies, setBuddies] = useState<Buddy[]>([]);
const [newName, setNewName] = useState('');
const [isLoading, setIsLoading] = useState(true);
@@ -57,33 +58,24 @@ export default function BuddyList() {
if (!newName.trim()) return;
setIsAdding(true);
const { data: { user } } = await supabase.auth.getUser();
if (!user) return;
const result = await addBuddy({ name: newName.trim() });
const { data, error } = await supabase
.from('buddies')
.insert([{ name: newName.trim(), user_id: user.id }])
.select();
if (error) {
console.error('Error adding buddy:', error);
} else {
setBuddies(prev => [...(data || []), ...prev].sort((a, b) => a.name.localeCompare(b.name)));
if (result.success && result.data) {
setBuddies(prev => [...[result.data], ...prev].sort((a, b) => a.name.localeCompare(b.name)));
setNewName('');
} else {
console.error('Error adding buddy:', result.error);
}
setIsAdding(false);
};
const handleDeleteBuddy = async (id: string) => {
const { error } = await supabase
.from('buddies')
.delete()
.eq('id', id);
const result = await deleteBuddy(id);
if (error) {
console.error('Error deleting buddy:', error);
} else {
if (result.success) {
setBuddies(prev => prev.filter(b => b.id !== id));
} else {
console.error('Error deleting buddy:', result.error);
}
};

View File

@@ -3,7 +3,7 @@
import React, { useRef, useState } from 'react';
import { Camera, Upload, CheckCircle2, AlertCircle, X, Search, ExternalLink, ArrowRight, Loader2, Wand2, Plus, Sparkles, Droplets, ChevronRight, User } from 'lucide-react';
import { createClientComponentClient } from '@supabase/auth-helpers-nextjs';
import { createClient } from '@/lib/supabase/client';
import { useRouter, useSearchParams } from 'next/navigation';
import { analyzeBottle } from '@/services/analyze-bottle';
import { saveBottle } from '@/services/save-bottle';
@@ -27,7 +27,7 @@ interface CameraCaptureProps {
export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onSaveComplete }: CameraCaptureProps) {
const { t, locale } = useI18n();
const supabase = createClientComponentClient();
const supabase = createClient();
const router = useRouter();
const searchParams = useSearchParams();
const { activeSession } = useSession();
@@ -158,11 +158,34 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
onAnalysisComplete(response.data);
}
} else {
setError(response.error || t('camera.analysisError'));
// If scan fails but it looks like a network issue, offer to queue
const isNetworkError = !navigator.onLine ||
response.error?.toLowerCase().includes('fetch') ||
response.error?.toLowerCase().includes('network') ||
response.error?.toLowerCase().includes('timeout');
if (isNetworkError) {
console.log('Network issue detected during scan. Queuing...');
await db.pending_scans.add({
imageBase64: compressedBase64,
timestamp: Date.now(),
provider: aiProvider,
locale: locale
});
setIsQueued(true);
setError(null); // Clear error as we are queuing
} else {
setError(response.error || t('camera.analysisError'));
}
}
} catch (err) {
console.error('Processing failed:', err);
setError(t('camera.processingError'));
// Even on generic error, if we have a compressed image, consider queuing if it looks like connection
if (previewUrl && !analysisResult) {
setError(t('camera.processingError') + " - " + t('camera.offlineNotice'));
} else {
setError(t('camera.processingError'));
}
} finally {
setIsProcessing(false);
}
@@ -361,8 +384,20 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
)}
{isProcessing && (
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-white"></div>
<div className="absolute inset-0 bg-black/60 backdrop-blur-md flex flex-col items-center justify-center gap-4 text-white p-6 text-center animate-in fade-in duration-300">
<div className="relative">
<Loader2 size={48} className="animate-spin text-amber-500" />
<Wand2 size={20} className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 text-white" />
</div>
<div className="space-y-1">
<p className="font-black uppercase tracking-[0.2em] text-[10px] text-amber-500">Magic Analysis</p>
<p className="text-sm font-bold">
{!navigator.onLine ? 'Offline: Speichere lokal...' : 'Analysiere Flasche...'}
</p>
<p className="text-[10px] text-zinc-400 max-w-[200px] mx-auto leading-relaxed">
{!navigator.onLine ? 'Dein Scan wird in der Warteschlange gespeichert und synchronisiert, sobald du wieder Empfang hast.' : 'Wir suchen in der Datenbank nach Details zu deinem Whisky...'}
</p>
</div>
</div>
)}
</div>
@@ -548,9 +583,19 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
)}
{isQueued && (
<div className="flex items-center gap-2 text-purple-500 text-sm bg-purple-50 dark:bg-purple-900/10 p-4 rounded-xl w-full border border-purple-100 dark:border-purple-800/30 font-medium">
<Sparkles size={16} />
{t('camera.offlineNotice')}
<div className="flex flex-col gap-3 p-5 bg-gradient-to-br from-purple-500/10 to-amber-500/10 dark:from-purple-900/20 dark:to-amber-900/20 border border-purple-500/20 rounded-3xl w-full animate-in zoom-in-95 duration-500">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-2xl bg-purple-500 flex items-center justify-center text-white shadow-lg shadow-purple-500/30">
<Sparkles size={20} />
</div>
<div className="flex flex-col">
<span className="text-sm font-black text-zinc-800 dark:text-zinc-100 italic">Lokal gespeichert!</span>
<span className="text-[10px] font-bold text-purple-600 dark:text-purple-400 uppercase tracking-widest">Warteschlange aktiv</span>
</div>
</div>
<p className="text-xs text-zinc-500 dark:text-zinc-400 leading-relaxed">
Keine Sorge, dein Scan wurde sicher im Vault gespeichert. Sobald du wieder Empfang hast, wird die Analyse automatisch im Hintergrund gestartet.
</p>
</div>
)}

View File

@@ -1,7 +1,7 @@
'use client';
import React, { useState, useEffect } from 'react';
import { createClientComponentClient } from '@supabase/auth-helpers-nextjs';
import { createClient } from '@/lib/supabase/client';
import { Calendar, Plus, GlassWater, Loader2, ChevronRight, Users, Check, Trash2, ChevronDown, ChevronUp } from 'lucide-react';
import Link from 'next/link';
import AvatarStack from './AvatarStack';
@@ -20,7 +20,7 @@ interface Session {
export default function SessionList() {
const { t, locale } = useI18n();
const supabase = createClientComponentClient();
const supabase = createClient();
const [sessions, setSessions] = useState<Session[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isCreating, setIsCreating] = useState(false);

View File

@@ -3,7 +3,7 @@
import React, { useState, useEffect } from 'react';
import { saveTasting } from '@/services/save-tasting';
import { Loader2, Send, Star, Users, Check, Sparkles, Droplets, Wind, Utensils, Zap } from 'lucide-react';
import { createClientComponentClient } from '@supabase/auth-helpers-nextjs';
import { createClient } from '@/lib/supabase/client';
import { useI18n } from '@/i18n/I18nContext';
import { useSession } from '@/context/SessionContext';
import TagSelector from './TagSelector';
@@ -22,7 +22,7 @@ interface TastingNoteFormProps {
export default function TastingNoteForm({ bottleId, sessionId }: TastingNoteFormProps) {
const { t } = useI18n();
const supabase = createClientComponentClient();
const supabase = createClient();
const [rating, setRating] = useState(85);
const [nose, setNose] = useState('');
const [palate, setPalate] = useState('');

View File

@@ -6,13 +6,15 @@ import { db, PendingScan, PendingTasting } from '@/lib/db';
import { analyzeBottle } from '@/services/analyze-bottle';
import { saveBottle } from '@/services/save-bottle';
import { saveTasting } from '@/services/save-tasting';
import { createClientComponentClient } from '@supabase/auth-helpers-nextjs';
import { createClient } from '@/lib/supabase/client';
import { RefreshCw, CheckCircle2, AlertCircle, Loader2, Info } from 'lucide-react';
export default function UploadQueue() {
const supabase = createClientComponentClient();
const supabase = createClient();
const [isSyncing, setIsSyncing] = useState(false);
const [currentProgress, setCurrentProgress] = useState<{ id: string, status: string } | null>(null);
const [isCollapsed, setIsCollapsed] = useState(false);
const [completedItems, setCompletedItems] = useState<{ id: string; name: string; bottleId?: string; type: 'scan' | 'tasting' }[]>([]);
const pendingScans = useLiveQuery(() => db.pending_scans.toArray(), [], [] as PendingScan[]);
const pendingTastings = useLiveQuery(() => db.pending_tastings.toArray(), [], [] as PendingTasting[]);
@@ -39,9 +41,16 @@ export default function UploadQueue() {
try {
const analysis = await analyzeBottle(item.imageBase64, undefined, item.locale);
if (analysis.success && analysis.data) {
const bottleData = analysis.data;
setCurrentProgress({ id: itemId, status: 'Speichere Flasche...' });
const save = await saveBottle(analysis.data, item.imageBase64, user.id);
if (save.success) {
const save = await saveBottle(bottleData, item.imageBase64, user.id);
if (save.success && save.data) {
setCompletedItems(prev => [...prev.slice(-4), {
id: itemId,
name: bottleData.name || 'Unbekannter Whisky',
bottleId: save.data.id,
type: 'scan'
}]);
await db.pending_scans.delete(item.id!);
}
} else {
@@ -62,10 +71,17 @@ export default function UploadQueue() {
try {
const result = await saveTasting({
...item.data,
is_sample: item.data.is_sample ?? false,
bottle_id: item.bottle_id,
tasted_at: item.tasted_at
});
if (result.success) {
setCompletedItems(prev => [...prev.slice(-4), {
id: itemId,
name: 'Tasting Note',
bottleId: item.bottle_id,
type: 'tasting'
}]);
await db.pending_tastings.delete(item.id!);
} else {
throw new Error(result.error);
@@ -103,62 +119,137 @@ export default function UploadQueue() {
if (totalInQueue === 0) return null;
return (
<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-[300px]">
<div className="flex items-center justify-between border-b border-white/10 pb-2">
<div className="flex items-center gap-2">
<RefreshCw size={16} className={isSyncing ? 'animate-spin text-amber-500' : 'text-zinc-400'} />
<span className="text-xs font-black uppercase tracking-widest">Global Sync Queue</span>
<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>
<span className="bg-amber-600 text-[10px] font-black px-1.5 py-0.5 rounded-md">
{totalInQueue} Items
</span>
</div>
<div className="space-y-2">
{/* Scans */}
{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="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" />
{/* 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>
<span className="truncate max-w-[150px]">
{currentProgress?.id === `scan-${item.id}` ? currentProgress.status : 'Scan wartet...'}
</span>
</div>
{currentProgress?.id === `scan-${item.id}` ? (
<Loader2 size={12} className="animate-spin text-amber-500" />
) : <Info size={12} className="text-zinc-600" />}
</div>
))}
)}
{/* Tastings */}
{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}
{/* Scans */}
{pendingScans.map((item) => (
<div key={`scan-${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-zinc-800 overflow-hidden ring-1 ring-white/10 shrink-0">
<img src={item.imageBase64} className="w-full h-full object-cover opacity-60 group-hover:opacity-100 transition-opacity" />
</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>
<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>
))}
{navigator.onLine && !isSyncing && (
<button
onClick={() => syncQueue()}
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"
>
<RefreshCw size={12} />
Sync Erzwingen
</button>
{/* 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">Tasting Node</span>
<span className="text-[11px] font-medium text-zinc-300">
{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>
{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>
)}
{!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>
)}
</div>
</div>