diff --git a/src/app/bottles/[id]/page.tsx b/src/app/bottles/[id]/page.tsx index c61b98a..bc7132e 100644 --- a/src/app/bottles/[id]/page.tsx +++ b/src/app/bottles/[id]/page.tsx @@ -43,6 +43,10 @@ export default async function BottlePage({ .from('tastings') .select(` *, + tasting_sessions ( + id, + name + ), tasting_tags ( buddies ( id, @@ -65,17 +69,6 @@ export default async function BottlePage({ Zurück zur Sammlung - {sessionId && ( -
-
- -
-
-

Session Aktiv!

-

Jede Tasting Note, die du jetzt speicherst, wird automatisch deiner Session zugeordnet. 🥃

-
-
- )} {/* Hero Section */}
@@ -165,7 +158,7 @@ export default async function BottlePage({ {/* Form */}

- {sessionId ? 'Session-Notiz' : 'Neu Verkosten'} + Dram bewerten

diff --git a/src/app/layout.tsx b/src/app/layout.tsx index a616043..b22fb4f 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -5,6 +5,9 @@ import PWARegistration from "@/components/PWARegistration"; import OfflineIndicator from "@/components/OfflineIndicator"; import UploadQueue from "@/components/UploadQueue"; import { I18nProvider } from "@/i18n/I18nContext"; +import { SessionProvider } from "@/context/SessionContext"; +import ActiveSessionBanner from "@/components/ActiveSessionBanner"; +import MainContentWrapper from "@/components/MainContentWrapper"; const inter = Inter({ subsets: ["latin"] }); @@ -42,10 +45,15 @@ export default function RootLayout({ - - - - {children} + + + + + + + {children} + + diff --git a/src/app/sessions/[id]/page.tsx b/src/app/sessions/[id]/page.tsx index b06be7e..b6655be 100644 --- a/src/app/sessions/[id]/page.tsx +++ b/src/app/sessions/[id]/page.tsx @@ -2,9 +2,11 @@ import React, { useState, useEffect } from 'react'; import { createClientComponentClient } from '@supabase/auth-helpers-nextjs'; -import { ChevronLeft, Users, Calendar, GlassWater, Plus, Trash2, Loader2, Sparkles, ChevronRight } from 'lucide-react'; +import { ChevronLeft, Users, Calendar, GlassWater, Plus, Trash2, Loader2, Sparkles, ChevronRight, Play } from 'lucide-react'; import Link from 'next/link'; +import { useSession } from '@/context/SessionContext'; import { useParams, useRouter } from 'next/navigation'; +import { useI18n } from '@/i18n/I18nContext'; interface Buddy { id: string; @@ -35,6 +37,7 @@ interface SessionTasting { } export default function SessionDetailPage() { + const { t } = useI18n(); const { id } = useParams(); const router = useRouter(); const supabase = createClientComponentClient(); @@ -43,6 +46,7 @@ export default function SessionDetailPage() { const [tastings, setTastings] = useState([]); const [allBuddies, setAllBuddies] = useState([]); const [isLoading, setIsLoading] = useState(true); + const { activeSession, setActiveSession } = useSession(); const [isAddingParticipant, setIsAddingParticipant] = useState(false); useEffect(() => { @@ -175,8 +179,31 @@ export default function SessionDetailPage() { {new Date(session.scheduled_at).toLocaleDateString('de-DE')} + {tastings.length > 0 && ( + + + {tastings.length} {tastings.length === 1 ? 'Whisky' : 'Whiskys'} + + )} + +
+ {activeSession?.id !== session.id ? ( + + ) : ( +
+ + Aktiv +
+ )} +
@@ -275,8 +302,8 @@ export default function SessionDetailPage() {
- - + + ); } diff --git a/src/components/ActiveSessionBanner.tsx b/src/components/ActiveSessionBanner.tsx new file mode 100644 index 0000000..d1a6e02 --- /dev/null +++ b/src/components/ActiveSessionBanner.tsx @@ -0,0 +1,42 @@ +'use client'; + +import React from 'react'; +import { useSession } from '@/context/SessionContext'; +import { GlassWater, X, ArrowRight, Sparkles } from 'lucide-react'; +import Link from 'next/link'; +import { useI18n } from '@/i18n/I18nContext'; + +export default function ActiveSessionBanner() { + const { activeSession, setActiveSession } = useSession(); + const { t } = useI18n(); + + if (!activeSession) return null; + + return ( +
+
+ +
+ +
+
+

{t('session.activeSession')}

+

{activeSession.name}

+
+ + + + +
+
+ ); +} diff --git a/src/components/BottleGrid.tsx b/src/components/BottleGrid.tsx index c0ade91..ed121ce 100644 --- a/src/components/BottleGrid.tsx +++ b/src/components/BottleGrid.tsx @@ -7,6 +7,7 @@ import { getStorageUrl } from '@/lib/supabase'; import { useSearchParams } from 'next/navigation'; import { validateSession } from '@/services/validate-session'; import { useI18n } from '@/i18n/I18nContext'; +import { useSession } from '@/context/SessionContext'; import { shortenCategory } from '@/lib/format'; interface Bottle { @@ -121,21 +122,24 @@ interface BottleGridProps { export default function BottleGrid({ bottles }: BottleGridProps) { const { t } = useI18n(); + const { activeSession } = useSession(); const searchParams = useSearchParams(); - const sessionId = searchParams.get('session_id'); + const sessionIdFromUrl = searchParams.get('session_id'); + const effectiveSessionId = activeSession?.id || sessionIdFromUrl; + const [validatedSessionId, setValidatedSessionId] = useState(null); React.useEffect(() => { const checkSession = async () => { - if (sessionId) { - const isValid = await validateSession(sessionId); - setValidatedSessionId(isValid ? sessionId : null); + if (effectiveSessionId) { + const isValid = await validateSession(effectiveSessionId); + setValidatedSessionId(isValid ? effectiveSessionId : null); } else { setValidatedSessionId(null); } }; checkSession(); - }, [sessionId]); + }, [effectiveSessionId]); const [searchQuery, setSearchQuery] = useState(''); const [selectedCategory, setSelectedCategory] = useState(null); diff --git a/src/components/CameraCapture.tsx b/src/components/CameraCapture.tsx index 9c347bf..495e133 100644 --- a/src/components/CameraCapture.tsx +++ b/src/components/CameraCapture.tsx @@ -1,7 +1,7 @@ 'use client'; import React, { useRef, useState } from 'react'; -import { Camera, Upload, CheckCircle2, AlertCircle, Sparkles, ExternalLink, ChevronRight, Search, Loader2 } from 'lucide-react'; +import { Camera, Upload, CheckCircle2, AlertCircle, X, Search, ExternalLink, ArrowRight, Loader2, Wand2, Plus, Sparkles, Droplets, ChevronRight } from 'lucide-react'; import { createClientComponentClient } from '@supabase/auth-helpers-nextjs'; import { useRouter, useSearchParams } from 'next/navigation'; import { analyzeBottle } from '@/services/analyze-bottle'; @@ -15,6 +15,7 @@ import { discoverWhiskybaseId } from '@/services/discover-whiskybase'; import { updateBottle } from '@/services/update-bottle'; import Link from 'next/link'; import { useI18n } from '@/i18n/I18nContext'; +import { useSession } from '@/context/SessionContext'; import { shortenCategory } from '@/lib/format'; interface CameraCaptureProps { @@ -28,20 +29,26 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS const supabase = createClientComponentClient(); const router = useRouter(); const searchParams = useSearchParams(); - const sessionId = searchParams.get('session_id'); + const { activeSession } = useSession(); + + // Maintain sessionId from query param for backwards compatibility, + // but prefer global activeSession + const sessionIdFromUrl = searchParams.get('session_id'); + const effectiveSessionId = activeSession?.id || sessionIdFromUrl; + const [validatedSessionId, setValidatedSessionId] = React.useState(null); React.useEffect(() => { const checkSession = async () => { - if (sessionId) { - const isValid = await validateSession(sessionId); - setValidatedSessionId(isValid ? sessionId : null); + if (effectiveSessionId) { + const isValid = await validateSession(effectiveSessionId); + setValidatedSessionId(isValid ? effectiveSessionId : null); } else { setValidatedSessionId(null); } }; checkSession(); - }, [sessionId]); + }, [effectiveSessionId]); const fileInputRef = useRef(null); const galleryInputRef = useRef(null); @@ -132,6 +139,34 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS } }; + const handleQuickSave = async () => { + if (!analysisResult || !previewUrl) return; + + setIsSaving(true); + setError(null); + + try { + const { data: { user } } = await supabase.auth.getUser(); + if (!user) { + throw new Error(t('camera.authRequired')); + } + + const response = await saveBottle(analysisResult, previewUrl, user.id); + + if (response.success && response.data) { + const url = `/bottles/${response.data.id}${validatedSessionId ? `?session_id=${validatedSessionId}` : ''}`; + router.push(url); + } else { + setError(response.error || t('common.error')); + } + } catch (err) { + console.error('Quick save failed:', err); + setError(err instanceof Error ? err.message : t('common.error')); + } finally { + setIsSaving(false); + } + }; + const handleSave = async () => { if (!analysisResult || !previewUrl) return; @@ -348,23 +383,43 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS ) : matchingBottle ? ( - - - {t('camera.toVault')} - +
+ + + {t('camera.toVault')} + + +
) : (
)} + {/* Status Messages */} {error && (
@@ -416,7 +479,7 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
)} - {matchingBottle && ( + {matchingBottle && !lastSavedId && (
@@ -425,66 +488,65 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS

{t('camera.alreadyInVaultDesc')}

-
)} - {previewUrl && !isProcessing && !error && !isQueued && !matchingBottle && ( + {/* Analysis Results Display */} + {previewUrl && !isProcessing && !error && !isQueued && !matchingBottle && analysisResult && (
{t('camera.analysisSuccess')}
- {analysisResult && ( -
-
- - {t('camera.results')} -
-
-
- {t('bottle.nameLabel')}: - {analysisResult.name || '-'} -
-
- {t('bottle.distilleryLabel')}: - {analysisResult.distillery || '-'} -
-
- {t('bottle.categoryLabel')}: - {shortenCategory(analysisResult.category || '-')} -
-
- {t('bottle.abvLabel')}: - {analysisResult.abv ? `${analysisResult.abv}%` : '-'} -
- {analysisResult.distilled_at && ( -
- {t('bottle.distilledLabel')}: - {analysisResult.distilled_at} -
- )} - {analysisResult.bottled_at && ( -
- {t('bottle.bottledLabel')}: - {analysisResult.bottled_at} -
- )} - {analysisResult.batch_info && ( -
- {t('bottle.batchLabel')}: - {analysisResult.batch_info} -
- )} -
+
+
+ + {t('camera.results')}
- )} +
+
+ {t('bottle.nameLabel')}: + {analysisResult.name || '-'} +
+
+ {t('bottle.distilleryLabel')}: + {analysisResult.distillery || '-'} +
+
+ {t('bottle.categoryLabel')}: + {shortenCategory(analysisResult.category || '-')} +
+
+ {t('bottle.abvLabel')}: + {analysisResult.abv ? `${analysisResult.abv}%` : '-'} +
+ {analysisResult.age && ( +
+ {t('bottle.ageLabel')}: + {analysisResult.age} {t('bottle.years')} +
+ )} + {analysisResult.distilled_at && ( +
+ {t('bottle.distilledLabel')}: + {analysisResult.distilled_at} +
+ )} + {analysisResult.bottled_at && ( +
+ {t('bottle.bottledLabel')}: + {analysisResult.bottled_at} +
+ )} + {analysisResult.batch_info && ( +
+ {t('bottle.batchLabel')}: + {analysisResult.batch_info} +
+ )} +
+
)}
diff --git a/src/components/MainContentWrapper.tsx b/src/components/MainContentWrapper.tsx new file mode 100644 index 0000000..e46c389 --- /dev/null +++ b/src/components/MainContentWrapper.tsx @@ -0,0 +1,14 @@ +'use client'; + +import React from 'react'; +import { useSession } from '@/context/SessionContext'; + +export default function MainContentWrapper({ children }: { children: React.ReactNode }) { + const { activeSession } = useSession(); + + return ( +
+ {children} +
+ ); +} diff --git a/src/components/SessionList.tsx b/src/components/SessionList.tsx index e608569..aab205e 100644 --- a/src/components/SessionList.tsx +++ b/src/components/SessionList.tsx @@ -2,15 +2,17 @@ import React, { useState, useEffect } from 'react'; import { createClientComponentClient } from '@supabase/auth-helpers-nextjs'; -import { Calendar, Plus, GlassWater, Loader2, ChevronRight, Users } from 'lucide-react'; +import { Calendar, Plus, GlassWater, Loader2, ChevronRight, Users, Check } from 'lucide-react'; import Link from 'next/link'; import { useI18n } from '@/i18n/I18nContext'; +import { useSession } from '@/context/SessionContext'; interface Session { id: string; name: string; scheduled_at: string; participant_count?: number; + whisky_count?: number; } export default function SessionList() { @@ -20,6 +22,7 @@ export default function SessionList() { const [isLoading, setIsLoading] = useState(true); const [isCreating, setIsCreating] = useState(false); const [newName, setNewName] = useState(''); + const { activeSession, setActiveSession } = useSession(); useEffect(() => { fetchSessions(); @@ -30,7 +33,8 @@ export default function SessionList() { .from('tasting_sessions') .select(` *, - session_participants (count) + session_participants (count), + tastings (count) `) .order('scheduled_at', { ascending: false }); @@ -39,7 +43,8 @@ export default function SessionList() { } else { setSessions(data.map(s => ({ ...s, - participant_count: s.session_participants[0]?.count || 0 + participant_count: s.session_participants[0]?.count || 0, + whisky_count: s.tastings[0]?.count || 0 })) || []); } setIsLoading(false); @@ -64,6 +69,7 @@ export default function SessionList() { } else { setSessions(prev => [data, ...prev]); setNewName(''); + setActiveSession({ id: data.id, name: data.name }); } setIsCreating(false); }; @@ -103,14 +109,18 @@ export default function SessionList() { ) : (
{sessions.map((session) => ( - -
-
{session.name}
-
+ +
+ {session.name} +
+
{new Date(session.scheduled_at).toLocaleDateString(locale === 'de' ? 'de-DE' : 'en-US')} @@ -121,13 +131,35 @@ export default function SessionList() { {session.participant_count} {t('tasting.participants')} )} + {session.whisky_count! > 0 && ( + + + {session.whisky_count} Whiskys + + )}
+ +
+ {activeSession?.id !== session.id ? ( + + ) : ( +
+ +
+ )} +
- - - ))} -
+
+ )) + } +
)} -
+ ); } diff --git a/src/components/TastingList.tsx b/src/components/TastingList.tsx index 78209f4..4d4ae48 100644 --- a/src/components/TastingList.tsx +++ b/src/components/TastingList.tsx @@ -1,7 +1,8 @@ 'use client'; import React, { useState, useMemo } from 'react'; -import { Calendar, Star, ArrowUpDown, Clock, Trash2, Loader2, Users } from 'lucide-react'; +import { Calendar, Star, ArrowUpDown, Clock, Trash2, Loader2, Users, GlassWater } from 'lucide-react'; +import Link from 'next/link'; import { deleteTasting } from '@/services/delete-tasting'; interface Tasting { @@ -19,6 +20,10 @@ interface Tasting { name: string; } }[]; + tasting_sessions?: { + id: string; + name: string; + }; } interface TastingListProps { @@ -108,9 +113,17 @@ export default function TastingList({ initialTastings }: TastingListProps) { {note.is_sample ? 'Sample' : 'Bottle'}
- {new Date(note.created_at).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}
+ {note.tasting_sessions && ( + + + {note.tasting_sessions.name} + + )}
diff --git a/src/components/TastingNoteForm.tsx b/src/components/TastingNoteForm.tsx index 79dd42e..50c2692 100644 --- a/src/components/TastingNoteForm.tsx +++ b/src/components/TastingNoteForm.tsx @@ -2,9 +2,10 @@ import React, { useState, useEffect } from 'react'; import { saveTasting } from '@/services/save-tasting'; -import { Loader2, Send, Star, Users, Check } from 'lucide-react'; +import { Loader2, Send, Star, Users, Check, Sparkles, Droplets } from 'lucide-react'; import { createClientComponentClient } from '@supabase/auth-helpers-nextjs'; import { useI18n } from '@/i18n/I18nContext'; +import { useSession } from '@/context/SessionContext'; interface Buddy { id: string; @@ -28,6 +29,9 @@ export default function TastingNoteForm({ bottleId, sessionId }: TastingNoteForm const [error, setError] = useState(null); const [buddies, setBuddies] = useState([]); const [selectedBuddyIds, setSelectedBuddyIds] = useState([]); + const { activeSession } = useSession(); + + const effectiveSessionId = sessionId || activeSession?.id; useEffect(() => { const fetchData = async () => { @@ -36,19 +40,21 @@ export default function TastingNoteForm({ bottleId, sessionId }: TastingNoteForm setBuddies(buddiesData || []); // If Session ID, fetch session participants and pre-select them - if (sessionId) { + if (effectiveSessionId) { const { data: participants } = await supabase .from('session_participants') .select('buddy_id') - .eq('session_id', sessionId); + .eq('session_id', effectiveSessionId); if (participants) { setSelectedBuddyIds(participants.map(p => p.buddy_id)); } + } else { + setSelectedBuddyIds([]); } }; fetchData(); - }, [sessionId]); + }, [effectiveSessionId]); const toggleBuddy = (id: string) => { setSelectedBuddyIds(prev => @@ -64,7 +70,7 @@ export default function TastingNoteForm({ bottleId, sessionId }: TastingNoteForm try { const result = await saveTasting({ bottle_id: bottleId, - session_id: sessionId, + session_id: effectiveSessionId, rating, nose_notes: nose, palate_notes: palate, @@ -91,6 +97,18 @@ export default function TastingNoteForm({ bottleId, sessionId }: TastingNoteForm return (
+ {activeSession && ( +
+
+ +
+
+

Recording for Session

+

{activeSession.name}

+
+
+ )} +