feat: refine session workflow with global state, quick tasting, and statistics

This commit is contained in:
2025-12-18 17:19:38 +01:00
parent 7f600698e4
commit ca1621e765
14 changed files with 399 additions and 116 deletions

View File

@@ -43,6 +43,10 @@ export default async function BottlePage({
.from('tastings') .from('tastings')
.select(` .select(`
*, *,
tasting_sessions (
id,
name
),
tasting_tags ( tasting_tags (
buddies ( buddies (
id, id,
@@ -65,17 +69,6 @@ export default async function BottlePage({
Zurück zur Sammlung Zurück zur Sammlung
</Link> </Link>
{sessionId && (
<div className="bg-amber-600/10 border border-amber-600/20 p-4 rounded-2xl flex items-center gap-4 text-amber-700 dark:text-amber-400 animate-in slide-in-from-top-4 duration-500">
<div className="bg-amber-600 text-white p-2 rounded-xl">
<PlusCircle size={20} />
</div>
<div>
<p className="font-bold">Session Aktiv!</p>
<p className="text-sm opacity-80">Jede Tasting Note, die du jetzt speicherst, wird automatisch deiner Session zugeordnet. 🥃</p>
</div>
</div>
)}
{/* Hero Section */} {/* Hero Section */}
<section className="grid grid-cols-1 md:grid-cols-2 gap-8 items-start"> <section className="grid grid-cols-1 md:grid-cols-2 gap-8 items-start">
@@ -165,7 +158,7 @@ export default async function BottlePage({
{/* Form */} {/* Form */}
<div className="lg:col-span-1 border border-zinc-200 dark:border-zinc-800 rounded-3xl p-6 bg-white dark:bg-zinc-900/50 md:sticky md:top-24"> <div className="lg:col-span-1 border border-zinc-200 dark:border-zinc-800 rounded-3xl p-6 bg-white dark:bg-zinc-900/50 md:sticky md:top-24">
<h3 className="text-lg font-bold mb-6 flex items-center gap-2 text-amber-600"> <h3 className="text-lg font-bold mb-6 flex items-center gap-2 text-amber-600">
<Droplets size={20} /> {sessionId ? 'Session-Notiz' : 'Neu Verkosten'} <Droplets size={20} /> Dram bewerten
</h3> </h3>
<TastingNoteForm bottleId={bottle.id} sessionId={sessionId} /> <TastingNoteForm bottleId={bottle.id} sessionId={sessionId} />
</div> </div>

View File

@@ -5,6 +5,9 @@ import PWARegistration from "@/components/PWARegistration";
import OfflineIndicator from "@/components/OfflineIndicator"; import OfflineIndicator from "@/components/OfflineIndicator";
import UploadQueue from "@/components/UploadQueue"; import UploadQueue from "@/components/UploadQueue";
import { I18nProvider } from "@/i18n/I18nContext"; 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"] }); const inter = Inter({ subsets: ["latin"] });
@@ -42,10 +45,15 @@ export default function RootLayout({
<html lang="de"> <html lang="de">
<body className={inter.className}> <body className={inter.className}>
<I18nProvider> <I18nProvider>
<SessionProvider>
<ActiveSessionBanner />
<MainContentWrapper>
<PWARegistration /> <PWARegistration />
<OfflineIndicator /> <OfflineIndicator />
<UploadQueue /> <UploadQueue />
{children} {children}
</MainContentWrapper>
</SessionProvider>
</I18nProvider> </I18nProvider>
</body> </body>
</html> </html>

View File

@@ -2,9 +2,11 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { createClientComponentClient } from '@supabase/auth-helpers-nextjs'; 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 Link from 'next/link';
import { useSession } from '@/context/SessionContext';
import { useParams, useRouter } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
import { useI18n } from '@/i18n/I18nContext';
interface Buddy { interface Buddy {
id: string; id: string;
@@ -35,6 +37,7 @@ interface SessionTasting {
} }
export default function SessionDetailPage() { export default function SessionDetailPage() {
const { t } = useI18n();
const { id } = useParams(); const { id } = useParams();
const router = useRouter(); const router = useRouter();
const supabase = createClientComponentClient(); const supabase = createClientComponentClient();
@@ -43,6 +46,7 @@ export default function SessionDetailPage() {
const [tastings, setTastings] = useState<SessionTasting[]>([]); const [tastings, setTastings] = useState<SessionTasting[]>([]);
const [allBuddies, setAllBuddies] = useState<Buddy[]>([]); const [allBuddies, setAllBuddies] = useState<Buddy[]>([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const { activeSession, setActiveSession } = useSession();
const [isAddingParticipant, setIsAddingParticipant] = useState(false); const [isAddingParticipant, setIsAddingParticipant] = useState(false);
useEffect(() => { useEffect(() => {
@@ -175,8 +179,31 @@ export default function SessionDetailPage() {
<Calendar size={16} className="text-zinc-400" /> <Calendar size={16} className="text-zinc-400" />
{new Date(session.scheduled_at).toLocaleDateString('de-DE')} {new Date(session.scheduled_at).toLocaleDateString('de-DE')}
</span> </span>
{tastings.length > 0 && (
<span className="flex items-center gap-1.5 transition-all animate-in fade-in slide-in-from-left-2">
<GlassWater size={16} className="text-zinc-400" />
{tastings.length} {tastings.length === 1 ? 'Whisky' : 'Whiskys'}
</span>
)}
</div> </div>
</div> </div>
<div className="flex gap-2">
{activeSession?.id !== session.id ? (
<button
onClick={() => setActiveSession({ id: session.id, name: session.name })}
className="px-6 py-3 bg-amber-600 hover:bg-amber-700 text-white rounded-2xl text-sm font-black uppercase tracking-widest flex items-center gap-2 transition-all shadow-xl shadow-amber-600/20"
>
<Play size={18} fill="currentColor" />
Session Starten
</button>
) : (
<div className="px-6 py-3 bg-zinc-900 dark:bg-zinc-100 text-white dark:text-zinc-900 rounded-2xl text-sm font-black uppercase tracking-widest flex items-center gap-2 border border-zinc-200 dark:border-zinc-800">
<Sparkles size={18} className="text-amber-500" />
Aktiv
</div>
)}
</div>
</div> </div>
</header> </header>

View File

@@ -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 (
<div className="fixed top-0 left-0 right-0 z-[100] animate-in slide-in-from-top duration-500">
<div className="bg-amber-600 text-white px-4 py-2 flex items-center justify-between shadow-lg">
<Link
href={`/sessions/${activeSession.id}`}
className="flex items-center gap-3 flex-1 min-w-0"
>
<div className="bg-white/20 p-1.5 rounded-lg shrink-0">
<Sparkles size={16} className="text-white animate-pulse" />
</div>
<div className="min-w-0">
<p className="text-[10px] font-black uppercase tracking-wider opacity-90 leading-none mb-1">{t('session.activeSession')}</p>
<p className="text-sm font-bold truncate leading-none">{activeSession.name}</p>
</div>
<ArrowRight size={14} className="opacity-50 ml-2" />
</Link>
<button
onClick={() => setActiveSession(null)}
className="ml-4 p-2 hover:bg-white/10 rounded-full transition-colors"
title="End Session"
>
<X size={20} />
</button>
</div>
</div>
);
}

View File

@@ -7,6 +7,7 @@ import { getStorageUrl } from '@/lib/supabase';
import { useSearchParams } from 'next/navigation'; import { useSearchParams } from 'next/navigation';
import { validateSession } from '@/services/validate-session'; import { validateSession } from '@/services/validate-session';
import { useI18n } from '@/i18n/I18nContext'; import { useI18n } from '@/i18n/I18nContext';
import { useSession } from '@/context/SessionContext';
import { shortenCategory } from '@/lib/format'; import { shortenCategory } from '@/lib/format';
interface Bottle { interface Bottle {
@@ -121,21 +122,24 @@ interface BottleGridProps {
export default function BottleGrid({ bottles }: BottleGridProps) { export default function BottleGrid({ bottles }: BottleGridProps) {
const { t } = useI18n(); const { t } = useI18n();
const { activeSession } = useSession();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const sessionId = searchParams.get('session_id'); const sessionIdFromUrl = searchParams.get('session_id');
const effectiveSessionId = activeSession?.id || sessionIdFromUrl;
const [validatedSessionId, setValidatedSessionId] = useState<string | null>(null); const [validatedSessionId, setValidatedSessionId] = useState<string | null>(null);
React.useEffect(() => { React.useEffect(() => {
const checkSession = async () => { const checkSession = async () => {
if (sessionId) { if (effectiveSessionId) {
const isValid = await validateSession(sessionId); const isValid = await validateSession(effectiveSessionId);
setValidatedSessionId(isValid ? sessionId : null); setValidatedSessionId(isValid ? effectiveSessionId : null);
} else { } else {
setValidatedSessionId(null); setValidatedSessionId(null);
} }
}; };
checkSession(); checkSession();
}, [sessionId]); }, [effectiveSessionId]);
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [selectedCategory, setSelectedCategory] = useState<string | null>(null); const [selectedCategory, setSelectedCategory] = useState<string | null>(null);

View File

@@ -1,7 +1,7 @@
'use client'; 'use client';
import React, { useRef, useState } from 'react'; 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 { createClientComponentClient } from '@supabase/auth-helpers-nextjs';
import { useRouter, useSearchParams } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
import { analyzeBottle } from '@/services/analyze-bottle'; import { analyzeBottle } from '@/services/analyze-bottle';
@@ -15,6 +15,7 @@ import { discoverWhiskybaseId } from '@/services/discover-whiskybase';
import { updateBottle } from '@/services/update-bottle'; import { updateBottle } from '@/services/update-bottle';
import Link from 'next/link'; import Link from 'next/link';
import { useI18n } from '@/i18n/I18nContext'; import { useI18n } from '@/i18n/I18nContext';
import { useSession } from '@/context/SessionContext';
import { shortenCategory } from '@/lib/format'; import { shortenCategory } from '@/lib/format';
interface CameraCaptureProps { interface CameraCaptureProps {
@@ -28,20 +29,26 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
const supabase = createClientComponentClient(); const supabase = createClientComponentClient();
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); 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<string | null>(null); const [validatedSessionId, setValidatedSessionId] = React.useState<string | null>(null);
React.useEffect(() => { React.useEffect(() => {
const checkSession = async () => { const checkSession = async () => {
if (sessionId) { if (effectiveSessionId) {
const isValid = await validateSession(sessionId); const isValid = await validateSession(effectiveSessionId);
setValidatedSessionId(isValid ? sessionId : null); setValidatedSessionId(isValid ? effectiveSessionId : null);
} else { } else {
setValidatedSessionId(null); setValidatedSessionId(null);
} }
}; };
checkSession(); checkSession();
}, [sessionId]); }, [effectiveSessionId]);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const galleryInputRef = useRef<HTMLInputElement>(null); const galleryInputRef = useRef<HTMLInputElement>(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 () => { const handleSave = async () => {
if (!analysisResult || !previewUrl) return; if (!analysisResult || !previewUrl) return;
@@ -348,6 +383,7 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
</button> </button>
</div> </div>
) : matchingBottle ? ( ) : matchingBottle ? (
<div className="flex flex-col gap-3 w-full animate-in zoom-in-95 duration-300">
<Link <Link
href={`/bottles/${matchingBottle.id}${validatedSessionId ? `?session_id=${validatedSessionId}` : ''}`} href={`/bottles/${matchingBottle.id}${validatedSessionId ? `?session_id=${validatedSessionId}` : ''}`}
className="w-full py-4 px-6 bg-amber-600 hover:bg-amber-700 text-white rounded-xl font-semibold flex items-center justify-center gap-2 transition-all active:scale-[0.98] shadow-lg shadow-amber-600/20" className="w-full py-4 px-6 bg-amber-600 hover:bg-amber-700 text-white rounded-xl font-semibold flex items-center justify-center gap-2 transition-all active:scale-[0.98] shadow-lg shadow-amber-600/20"
@@ -355,16 +391,35 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
<ExternalLink size={20} /> <ExternalLink size={20} />
{t('camera.toVault')} {t('camera.toVault')}
</Link> </Link>
<button
onClick={() => setMatchingBottle(null)}
className="w-full py-3 px-6 text-zinc-500 hover:text-zinc-800 dark:hover:text-zinc-200 font-bold transition-colors"
>
{t('camera.saveAnyway')}
</button>
</div>
) : ( ) : (
<div className="flex flex-col gap-3 w-full"> <div className="flex flex-col gap-3 w-full">
<button <button
onClick={isQueued ? () => setPreviewUrl(null) : (previewUrl && analysisResult ? handleSave : triggerUpload)} onClick={() => {
if (isQueued) {
setPreviewUrl(null);
} else if (previewUrl && analysisResult) {
if (validatedSessionId) {
handleQuickSave();
} else {
handleSave();
}
} else {
triggerUpload();
}
}}
disabled={isProcessing || isSaving} disabled={isProcessing || isSaving}
className="w-full py-4 px-6 bg-amber-600 hover:bg-amber-700 text-white rounded-xl font-semibold flex items-center justify-center gap-2 transition-all active:scale-[0.98] shadow-lg shadow-amber-600/20 disabled:opacity-50" className={`w-full py-4 px-6 rounded-xl font-semibold flex items-center justify-center gap-2 transition-all active:scale-[0.98] shadow-lg disabled:opacity-50 ${validatedSessionId && previewUrl && analysisResult ? 'bg-zinc-900 dark:bg-zinc-100 text-white dark:text-zinc-900 shadow-black/10' : 'bg-amber-600 hover:bg-amber-700 text-white shadow-amber-600/20'}`}
> >
{isSaving ? ( {isSaving ? (
<> <>
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white"></div> <div className="animate-spin rounded-full h-5 w-5 border-b-2 border-current"></div>
{t('camera.saving')} {t('camera.saving')}
</> </>
) : isQueued ? ( ) : isQueued ? (
@@ -373,10 +428,17 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
{t('camera.nextBottle')} {t('camera.nextBottle')}
</> </>
) : previewUrl && analysisResult ? ( ) : previewUrl && analysisResult ? (
validatedSessionId ? (
<>
<Droplets size={20} className="text-amber-500" />
{t('camera.quickTasting')}
</>
) : (
<> <>
<CheckCircle2 size={20} /> <CheckCircle2 size={20} />
{t('camera.inVault')} {t('camera.inVault')}
</> </>
)
) : previewUrl ? ( ) : previewUrl ? (
<> <>
<Upload size={20} /> <Upload size={20} />
@@ -402,6 +464,7 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
</div> </div>
)} )}
{/* Status Messages */}
{error && ( {error && (
<div className="flex items-center gap-2 text-red-500 text-sm bg-red-50 dark:bg-red-900/10 p-3 rounded-lg w-full"> <div className="flex items-center gap-2 text-red-500 text-sm bg-red-50 dark:bg-red-900/10 p-3 rounded-lg w-full">
<AlertCircle size={16} /> <AlertCircle size={16} />
@@ -416,7 +479,7 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
</div> </div>
)} )}
{matchingBottle && ( {matchingBottle && !lastSavedId && (
<div className="flex flex-col gap-2 p-4 bg-blue-50 dark:bg-blue-900/10 border border-blue-200 dark:border-blue-900/30 rounded-xl w-full"> <div className="flex flex-col gap-2 p-4 bg-blue-50 dark:bg-blue-900/10 border border-blue-200 dark:border-blue-900/30 rounded-xl w-full">
<div className="flex items-center gap-2 text-blue-600 dark:text-blue-400 font-bold text-sm"> <div className="flex items-center gap-2 text-blue-600 dark:text-blue-400 font-bold text-sm">
<AlertCircle size={16} /> <AlertCircle size={16} />
@@ -425,45 +488,45 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
<p className="text-xs text-blue-500/80"> <p className="text-xs text-blue-500/80">
{t('camera.alreadyInVaultDesc')} {t('camera.alreadyInVaultDesc')}
</p> </p>
<button
onClick={() => setMatchingBottle(null)}
className="text-[10px] text-zinc-400 font-black uppercase text-left hover:text-zinc-600"
>
{t('camera.saveAnyway')}
</button>
</div> </div>
)} )}
{previewUrl && !isProcessing && !error && !isQueued && !matchingBottle && ( {/* Analysis Results Display */}
{previewUrl && !isProcessing && !error && !isQueued && !matchingBottle && analysisResult && (
<div className="flex flex-col gap-3 w-full animate-in fade-in slide-in-from-top-4 duration-500"> <div className="flex flex-col gap-3 w-full animate-in fade-in slide-in-from-top-4 duration-500">
<div className="flex items-center gap-2 text-green-500 text-sm bg-green-50 dark:bg-green-900/10 p-3 rounded-lg w-full"> <div className="flex items-center gap-2 text-green-500 text-sm bg-green-50 dark:bg-green-900/10 p-3 rounded-lg w-full">
<CheckCircle2 size={16} /> <CheckCircle2 size={16} />
{t('camera.analysisSuccess')} {t('camera.analysisSuccess')}
</div> </div>
{analysisResult && (
<div className="p-3 md:p-4 bg-zinc-50 dark:bg-zinc-800/50 rounded-2xl border border-zinc-200 dark:border-zinc-700"> <div className="p-3 md:p-4 bg-zinc-50 dark:bg-zinc-800/50 rounded-2xl border border-zinc-200 dark:border-zinc-700">
<div className="flex items-center gap-2 mb-2 md:mb-3 text-amber-600 dark:text-amber-500"> <div className="flex items-center gap-2 mb-2 md:mb-3 text-amber-600 dark:text-amber-500">
<Sparkles size={18} /> <Sparkles size={18} />
<span className="font-bold text-[10px] md:text-sm uppercase tracking-wider">{t('camera.results')}</span> <span className="font-bold text-[10px] md:text-sm uppercase tracking-wider">{t('camera.results')}</span>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<div className="flex justify-between text-sm"> <div className="flex justify-between items-center text-sm">
<span className="text-zinc-500">{t('bottle.nameLabel')}:</span> <span className="text-zinc-500">{t('bottle.nameLabel')}:</span>
<span className="font-semibold">{analysisResult.name || '-'}</span> <span className="font-semibold text-right">{analysisResult.name || '-'}</span>
</div> </div>
<div className="flex justify-between text-sm"> <div className="flex justify-between items-center text-sm">
<span className="text-zinc-500">{t('bottle.distilleryLabel')}:</span> <span className="text-zinc-500">{t('bottle.distilleryLabel')}:</span>
<span className="font-semibold">{analysisResult.distillery || '-'}</span> <span className="font-semibold text-right">{analysisResult.distillery || '-'}</span>
</div> </div>
<div className="flex justify-between text-sm"> <div className="flex justify-between items-center text-sm">
<span className="text-zinc-500">{t('bottle.categoryLabel')}:</span> <span className="text-zinc-500">{t('bottle.categoryLabel')}:</span>
<span className="font-semibold">{shortenCategory(analysisResult.category || '-')}</span> <span className="font-semibold text-right">{shortenCategory(analysisResult.category || '-')}</span>
</div> </div>
<div className="flex justify-between text-sm"> <div className="flex justify-between items-center text-sm">
<span className="text-zinc-500">{t('bottle.abvLabel')}:</span> <span className="text-zinc-500">{t('bottle.abvLabel')}:</span>
<span className="font-semibold">{analysisResult.abv ? `${analysisResult.abv}%` : '-'}</span> <span className="font-semibold text-right">{analysisResult.abv ? `${analysisResult.abv}%` : '-'}</span>
</div> </div>
{analysisResult.age && (
<div className="flex justify-between items-center text-sm">
<span className="text-zinc-500">{t('bottle.ageLabel')}:</span>
<span className="font-semibold text-right">{analysisResult.age} {t('bottle.years')}</span>
</div>
)}
{analysisResult.distilled_at && ( {analysisResult.distilled_at && (
<div className="flex justify-between text-sm"> <div className="flex justify-between text-sm">
<span className="text-zinc-500">{t('bottle.distilledLabel')}:</span> <span className="text-zinc-500">{t('bottle.distilledLabel')}:</span>
@@ -484,7 +547,6 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
)} )}
</div> </div>
</div> </div>
)}
</div> </div>
)} )}
</div> </div>

View File

@@ -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 (
<div className={`transition-all duration-500 ${activeSession ? 'pt-[52px]' : 'pt-0'}`}>
{children}
</div>
);
}

View File

@@ -2,15 +2,17 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { createClientComponentClient } from '@supabase/auth-helpers-nextjs'; 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 Link from 'next/link';
import { useI18n } from '@/i18n/I18nContext'; import { useI18n } from '@/i18n/I18nContext';
import { useSession } from '@/context/SessionContext';
interface Session { interface Session {
id: string; id: string;
name: string; name: string;
scheduled_at: string; scheduled_at: string;
participant_count?: number; participant_count?: number;
whisky_count?: number;
} }
export default function SessionList() { export default function SessionList() {
@@ -20,6 +22,7 @@ export default function SessionList() {
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [isCreating, setIsCreating] = useState(false); const [isCreating, setIsCreating] = useState(false);
const [newName, setNewName] = useState(''); const [newName, setNewName] = useState('');
const { activeSession, setActiveSession } = useSession();
useEffect(() => { useEffect(() => {
fetchSessions(); fetchSessions();
@@ -30,7 +33,8 @@ export default function SessionList() {
.from('tasting_sessions') .from('tasting_sessions')
.select(` .select(`
*, *,
session_participants (count) session_participants (count),
tastings (count)
`) `)
.order('scheduled_at', { ascending: false }); .order('scheduled_at', { ascending: false });
@@ -39,7 +43,8 @@ export default function SessionList() {
} else { } else {
setSessions(data.map(s => ({ setSessions(data.map(s => ({
...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); setIsLoading(false);
@@ -64,6 +69,7 @@ export default function SessionList() {
} else { } else {
setSessions(prev => [data, ...prev]); setSessions(prev => [data, ...prev]);
setNewName(''); setNewName('');
setActiveSession({ id: data.id, name: data.name });
} }
setIsCreating(false); setIsCreating(false);
}; };
@@ -103,14 +109,18 @@ export default function SessionList() {
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
{sessions.map((session) => ( {sessions.map((session) => (
<Link <div
key={session.id} key={session.id}
href={`/sessions/${session.id}`} className={`flex items-center justify-between p-4 rounded-2xl border group transition-all ${activeSession?.id === session.id
className="flex items-center justify-between p-4 bg-zinc-50 dark:bg-zinc-800/50 rounded-2xl border border-zinc-100 dark:border-zinc-800 group hover:border-amber-500/30 transition-all" ? 'bg-amber-600 border-amber-600 shadow-lg shadow-amber-600/20'
: 'bg-zinc-50 dark:bg-zinc-800/50 border-zinc-100 dark:border-zinc-800 hover:border-amber-500/30'
}`}
> >
<div className="space-y-1"> <Link href={`/sessions/${session.id}`} className="flex-1 space-y-1 min-w-0">
<div className="font-bold text-zinc-800 dark:text-zinc-100">{session.name}</div> <div className={`font-bold truncate ${activeSession?.id === session.id ? 'text-white' : 'text-zinc-800 dark:text-zinc-100'}`}>
<div className="flex items-center gap-4 text-[10px] font-black uppercase tracking-widest text-zinc-400"> {session.name}
</div>
<div className={`flex items-center gap-4 text-[10px] font-black uppercase tracking-widest ${activeSession?.id === session.id ? 'text-white/80' : 'text-zinc-400'}`}>
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<Calendar size={12} /> <Calendar size={12} />
{new Date(session.scheduled_at).toLocaleDateString(locale === 'de' ? 'de-DE' : 'en-US')} {new Date(session.scheduled_at).toLocaleDateString(locale === 'de' ? 'de-DE' : 'en-US')}
@@ -121,11 +131,33 @@ export default function SessionList() {
{session.participant_count} {t('tasting.participants')} {session.participant_count} {t('tasting.participants')}
</span> </span>
)} )}
{session.whisky_count! > 0 && (
<span className="flex items-center gap-1">
<GlassWater size={12} />
{session.whisky_count} Whiskys
</span>
)}
</div> </div>
</div>
<ChevronRight size={20} className="text-zinc-300 group-hover:text-amber-500 transition-colors" />
</Link> </Link>
))} <div className="flex items-center gap-2">
{activeSession?.id !== session.id ? (
<button
onClick={() => setActiveSession({ id: session.id, name: session.name })}
className="p-2 bg-white dark:bg-zinc-700 text-amber-600 rounded-xl shadow-sm border border-zinc-200 dark:border-zinc-600 hover:scale-110 transition-transform"
title="Start Session"
>
<GlassWater size={18} />
</button>
) : (
<div className="p-2 bg-white/20 text-white rounded-xl">
<Check size={18} />
</div>
)}
<ChevronRight size={20} className={activeSession?.id === session.id ? 'text-white/50' : 'text-zinc-300'} />
</div>
</div>
))
}
</div > </div >
)} )}
</div > </div >

View File

@@ -1,7 +1,8 @@
'use client'; 'use client';
import React, { useState, useMemo } from 'react'; 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'; import { deleteTasting } from '@/services/delete-tasting';
interface Tasting { interface Tasting {
@@ -19,6 +20,10 @@ interface Tasting {
name: string; name: string;
} }
}[]; }[];
tasting_sessions?: {
id: string;
name: string;
};
} }
interface TastingListProps { interface TastingListProps {
@@ -108,9 +113,17 @@ export default function TastingList({ initialTastings }: TastingListProps) {
{note.is_sample ? 'Sample' : 'Bottle'} {note.is_sample ? 'Sample' : 'Bottle'}
</span> </span>
<div className="text-[10px] text-zinc-500 font-bold bg-zinc-100 dark:bg-zinc-800 px-2 py-1 rounded-lg flex items-center gap-1"> <div className="text-[10px] text-zinc-500 font-bold bg-zinc-100 dark:bg-zinc-800 px-2 py-1 rounded-lg flex items-center gap-1">
<Clock size={10} />
{new Date(note.created_at).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })} {new Date(note.created_at).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}
</div> </div>
{note.tasting_sessions && (
<Link
href={`/sessions/${note.tasting_sessions.id}`}
className="text-[10px] text-zinc-500 font-bold bg-amber-50 dark:bg-amber-900/20 px-2 py-1 rounded-lg flex items-center gap-1 border border-amber-200/50 dark:border-amber-800/50 transition-all hover:bg-amber-100 dark:hover:bg-amber-900/40"
>
<GlassWater size={10} className="text-amber-600" />
{note.tasting_sessions.name}
</Link>
)}
</div> </div>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className="text-[10px] text-zinc-400 font-black tracking-widest uppercase flex items-center gap-1"> <div className="text-[10px] text-zinc-400 font-black tracking-widest uppercase flex items-center gap-1">

View File

@@ -2,9 +2,10 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { saveTasting } from '@/services/save-tasting'; 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 { createClientComponentClient } from '@supabase/auth-helpers-nextjs';
import { useI18n } from '@/i18n/I18nContext'; import { useI18n } from '@/i18n/I18nContext';
import { useSession } from '@/context/SessionContext';
interface Buddy { interface Buddy {
id: string; id: string;
@@ -28,6 +29,9 @@ export default function TastingNoteForm({ bottleId, sessionId }: TastingNoteForm
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [buddies, setBuddies] = useState<Buddy[]>([]); const [buddies, setBuddies] = useState<Buddy[]>([]);
const [selectedBuddyIds, setSelectedBuddyIds] = useState<string[]>([]); const [selectedBuddyIds, setSelectedBuddyIds] = useState<string[]>([]);
const { activeSession } = useSession();
const effectiveSessionId = sessionId || activeSession?.id;
useEffect(() => { useEffect(() => {
const fetchData = async () => { const fetchData = async () => {
@@ -36,19 +40,21 @@ export default function TastingNoteForm({ bottleId, sessionId }: TastingNoteForm
setBuddies(buddiesData || []); setBuddies(buddiesData || []);
// If Session ID, fetch session participants and pre-select them // If Session ID, fetch session participants and pre-select them
if (sessionId) { if (effectiveSessionId) {
const { data: participants } = await supabase const { data: participants } = await supabase
.from('session_participants') .from('session_participants')
.select('buddy_id') .select('buddy_id')
.eq('session_id', sessionId); .eq('session_id', effectiveSessionId);
if (participants) { if (participants) {
setSelectedBuddyIds(participants.map(p => p.buddy_id)); setSelectedBuddyIds(participants.map(p => p.buddy_id));
} }
} else {
setSelectedBuddyIds([]);
} }
}; };
fetchData(); fetchData();
}, [sessionId]); }, [effectiveSessionId]);
const toggleBuddy = (id: string) => { const toggleBuddy = (id: string) => {
setSelectedBuddyIds(prev => setSelectedBuddyIds(prev =>
@@ -64,7 +70,7 @@ export default function TastingNoteForm({ bottleId, sessionId }: TastingNoteForm
try { try {
const result = await saveTasting({ const result = await saveTasting({
bottle_id: bottleId, bottle_id: bottleId,
session_id: sessionId, session_id: effectiveSessionId,
rating, rating,
nose_notes: nose, nose_notes: nose,
palate_notes: palate, palate_notes: palate,
@@ -91,6 +97,18 @@ export default function TastingNoteForm({ bottleId, sessionId }: TastingNoteForm
return ( return (
<form onSubmit={handleSubmit} className="space-y-6"> <form onSubmit={handleSubmit} className="space-y-6">
{activeSession && (
<div className="p-3 bg-amber-50 dark:bg-amber-900/10 border border-amber-200 dark:border-amber-900/30 rounded-2xl flex items-center gap-3 animate-in fade-in slide-in-from-top-2">
<div className="bg-amber-600 text-white p-2 rounded-xl">
<Sparkles size={16} />
</div>
<div className="min-w-0">
<p className="text-[10px] font-black uppercase tracking-wider text-amber-700 dark:text-amber-400">Recording for Session</p>
<p className="text-xs font-bold text-amber-900 dark:text-amber-200 truncate">{activeSession.name}</p>
</div>
</div>
)}
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<label className="text-[11px] font-black text-zinc-400 uppercase tracking-widest flex items-center gap-2"> <label className="text-[11px] font-black text-zinc-400 uppercase tracking-widest flex items-center gap-2">

View File

@@ -0,0 +1,67 @@
'use client';
import React, { createContext, useContext, useState, useEffect } from 'react';
import { validateSession } from '@/services/validate-session';
interface SessionData {
id: string;
name: string;
}
interface SessionContextType {
activeSession: SessionData | null;
setActiveSession: (session: SessionData | null) => void;
isLoading: boolean;
}
const SessionContext = createContext<SessionContextType | undefined>(undefined);
export const SessionProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [activeSession, setActiveSessionState] = useState<SessionData | null>(null);
const [isLoading, setIsLoading] = useState(true);
const setActiveSession = (session: SessionData | null) => {
setActiveSessionState(session);
if (session) {
localStorage.setItem('active_tasting_session', JSON.stringify(session));
} else {
localStorage.removeItem('active_tasting_session');
}
};
useEffect(() => {
const loadSession = async () => {
const stored = localStorage.getItem('active_tasting_session');
if (stored) {
try {
const session = JSON.parse(stored) as SessionData;
// Validate on load
const isValid = await validateSession(session.id);
if (isValid) {
setActiveSessionState(session);
} else {
localStorage.removeItem('active_tasting_session');
}
} catch (e) {
localStorage.removeItem('active_tasting_session');
}
}
setIsLoading(false);
};
loadSession();
}, []);
return (
<SessionContext.Provider value={{ activeSession, setActiveSession, isLoading }}>
{children}
</SessionContext.Provider>
);
};
export const useSession = () => {
const context = useContext(SessionContext);
if (context === undefined) {
throw new Error('useSession must be used within a SessionProvider');
}
return context;
};

View File

@@ -112,6 +112,7 @@ export const de: TranslationKeys = {
lowConfidence: 'Ich bin mir unsicher. Bitte Details prüfen.', lowConfidence: 'Ich bin mir unsicher. Bitte Details prüfen.',
saveToVault: 'In den Vault legen', saveToVault: 'In den Vault legen',
tastingNow: 'Jetzt verkosten', tastingNow: 'Jetzt verkosten',
quickTasting: 'Direkt verkosten!',
backToList: 'Zurück zur Liste', backToList: 'Zurück zur Liste',
whiskybaseSearch: 'Whiskybase-Link suchen', whiskybaseSearch: 'Whiskybase-Link suchen',
searchingWb: 'Suche auf Whiskybase...', searchingWb: 'Suche auf Whiskybase...',

View File

@@ -112,6 +112,7 @@ export const en: TranslationKeys = {
lowConfidence: 'Unsure about details. Please check.', lowConfidence: 'Unsure about details. Please check.',
saveToVault: 'Save to Vault', saveToVault: 'Save to Vault',
tastingNow: 'Tasting Now', tastingNow: 'Tasting Now',
quickTasting: 'Taste Now!',
backToList: 'Back to List', backToList: 'Back to List',
whiskybaseSearch: 'Search Whiskybase', whiskybaseSearch: 'Search Whiskybase',
searchingWb: 'Searching Whiskybase...', searchingWb: 'Searching Whiskybase...',

View File

@@ -110,6 +110,7 @@ export type TranslationKeys = {
lowConfidence: string; lowConfidence: string;
saveToVault: string; saveToVault: string;
tastingNow: string; tastingNow: string;
quickTasting: string;
backToList: string; backToList: string;
whiskybaseSearch: string; whiskybaseSearch: string;
searchingWb: string; searchingWb: string;