feat: refine session workflow with global state, quick tasting, and statistics
This commit is contained in:
@@ -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
|
||||
</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 */}
|
||||
<section className="grid grid-cols-1 md:grid-cols-2 gap-8 items-start">
|
||||
@@ -165,7 +158,7 @@ export default async function BottlePage({
|
||||
{/* 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">
|
||||
<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>
|
||||
<TastingNoteForm bottleId={bottle.id} sessionId={sessionId} />
|
||||
</div>
|
||||
|
||||
@@ -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({
|
||||
<html lang="de">
|
||||
<body className={inter.className}>
|
||||
<I18nProvider>
|
||||
<PWARegistration />
|
||||
<OfflineIndicator />
|
||||
<UploadQueue />
|
||||
{children}
|
||||
<SessionProvider>
|
||||
<ActiveSessionBanner />
|
||||
<MainContentWrapper>
|
||||
<PWARegistration />
|
||||
<OfflineIndicator />
|
||||
<UploadQueue />
|
||||
{children}
|
||||
</MainContentWrapper>
|
||||
</SessionProvider>
|
||||
</I18nProvider>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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<SessionTasting[]>([]);
|
||||
const [allBuddies, setAllBuddies] = useState<Buddy[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const { activeSession, setActiveSession } = useSession();
|
||||
const [isAddingParticipant, setIsAddingParticipant] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -175,8 +179,31 @@ export default function SessionDetailPage() {
|
||||
<Calendar size={16} className="text-zinc-400" />
|
||||
{new Date(session.scheduled_at).toLocaleDateString('de-DE')}
|
||||
</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 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>
|
||||
</header>
|
||||
|
||||
@@ -275,8 +302,8 @@ export default function SessionDetailPage() {
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div >
|
||||
</main >
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
42
src/components/ActiveSessionBanner.tsx
Normal file
42
src/components/ActiveSessionBanner.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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<string | null>(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<string | null>(null);
|
||||
|
||||
@@ -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<string | null>(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<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 () => {
|
||||
if (!analysisResult || !previewUrl) return;
|
||||
|
||||
@@ -348,23 +383,43 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
|
||||
</button>
|
||||
</div>
|
||||
) : matchingBottle ? (
|
||||
<Link
|
||||
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"
|
||||
>
|
||||
<ExternalLink size={20} />
|
||||
{t('camera.toVault')}
|
||||
</Link>
|
||||
<div className="flex flex-col gap-3 w-full animate-in zoom-in-95 duration-300">
|
||||
<Link
|
||||
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"
|
||||
>
|
||||
<ExternalLink size={20} />
|
||||
{t('camera.toVault')}
|
||||
</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">
|
||||
<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}
|
||||
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 ? (
|
||||
<>
|
||||
<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')}
|
||||
</>
|
||||
) : isQueued ? (
|
||||
@@ -373,10 +428,17 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
|
||||
{t('camera.nextBottle')}
|
||||
</>
|
||||
) : previewUrl && analysisResult ? (
|
||||
<>
|
||||
<CheckCircle2 size={20} />
|
||||
{t('camera.inVault')}
|
||||
</>
|
||||
validatedSessionId ? (
|
||||
<>
|
||||
<Droplets size={20} className="text-amber-500" />
|
||||
{t('camera.quickTasting')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle2 size={20} />
|
||||
{t('camera.inVault')}
|
||||
</>
|
||||
)
|
||||
) : previewUrl ? (
|
||||
<>
|
||||
<Upload size={20} />
|
||||
@@ -402,6 +464,7 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status Messages */}
|
||||
{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">
|
||||
<AlertCircle size={16} />
|
||||
@@ -416,7 +479,7 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
|
||||
</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 items-center gap-2 text-blue-600 dark:text-blue-400 font-bold text-sm">
|
||||
<AlertCircle size={16} />
|
||||
@@ -425,66 +488,65 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
|
||||
<p className="text-xs text-blue-500/80">
|
||||
{t('camera.alreadyInVaultDesc')}
|
||||
</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>
|
||||
)}
|
||||
|
||||
{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 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} />
|
||||
{t('camera.analysisSuccess')}
|
||||
</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="flex items-center gap-2 mb-2 md:mb-3 text-amber-600 dark:text-amber-500">
|
||||
<Sparkles size={18} />
|
||||
<span className="font-bold text-[10px] md:text-sm uppercase tracking-wider">{t('camera.results')}</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-zinc-500">{t('bottle.nameLabel')}:</span>
|
||||
<span className="font-semibold">{analysisResult.name || '-'}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-zinc-500">{t('bottle.distilleryLabel')}:</span>
|
||||
<span className="font-semibold">{analysisResult.distillery || '-'}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-zinc-500">{t('bottle.categoryLabel')}:</span>
|
||||
<span className="font-semibold">{shortenCategory(analysisResult.category || '-')}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-zinc-500">{t('bottle.abvLabel')}:</span>
|
||||
<span className="font-semibold">{analysisResult.abv ? `${analysisResult.abv}%` : '-'}</span>
|
||||
</div>
|
||||
{analysisResult.distilled_at && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-zinc-500">{t('bottle.distilledLabel')}:</span>
|
||||
<span className="font-semibold">{analysisResult.distilled_at}</span>
|
||||
</div>
|
||||
)}
|
||||
{analysisResult.bottled_at && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-zinc-500">{t('bottle.bottledLabel')}:</span>
|
||||
<span className="font-semibold">{analysisResult.bottled_at}</span>
|
||||
</div>
|
||||
)}
|
||||
{analysisResult.batch_info && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-zinc-500">{t('bottle.batchLabel')}:</span>
|
||||
<span className="font-semibold">{analysisResult.batch_info}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<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">
|
||||
<Sparkles size={18} />
|
||||
<span className="font-bold text-[10px] md:text-sm uppercase tracking-wider">{t('camera.results')}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center text-sm">
|
||||
<span className="text-zinc-500">{t('bottle.nameLabel')}:</span>
|
||||
<span className="font-semibold text-right">{analysisResult.name || '-'}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center text-sm">
|
||||
<span className="text-zinc-500">{t('bottle.distilleryLabel')}:</span>
|
||||
<span className="font-semibold text-right">{analysisResult.distillery || '-'}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center text-sm">
|
||||
<span className="text-zinc-500">{t('bottle.categoryLabel')}:</span>
|
||||
<span className="font-semibold text-right">{shortenCategory(analysisResult.category || '-')}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center text-sm">
|
||||
<span className="text-zinc-500">{t('bottle.abvLabel')}:</span>
|
||||
<span className="font-semibold text-right">{analysisResult.abv ? `${analysisResult.abv}%` : '-'}</span>
|
||||
</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 && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-zinc-500">{t('bottle.distilledLabel')}:</span>
|
||||
<span className="font-semibold">{analysisResult.distilled_at}</span>
|
||||
</div>
|
||||
)}
|
||||
{analysisResult.bottled_at && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-zinc-500">{t('bottle.bottledLabel')}:</span>
|
||||
<span className="font-semibold">{analysisResult.bottled_at}</span>
|
||||
</div>
|
||||
)}
|
||||
{analysisResult.batch_info && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-zinc-500">{t('bottle.batchLabel')}:</span>
|
||||
<span className="font-semibold">{analysisResult.batch_info}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
14
src/components/MainContentWrapper.tsx
Normal file
14
src/components/MainContentWrapper.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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() {
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{sessions.map((session) => (
|
||||
<Link
|
||||
<div
|
||||
key={session.id}
|
||||
href={`/sessions/${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"
|
||||
className={`flex items-center justify-between p-4 rounded-2xl border group transition-all ${activeSession?.id === session.id
|
||||
? '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">
|
||||
<div className="font-bold text-zinc-800 dark:text-zinc-100">{session.name}</div>
|
||||
<div className="flex items-center gap-4 text-[10px] font-black uppercase tracking-widest text-zinc-400">
|
||||
<Link href={`/sessions/${session.id}`} className="flex-1 space-y-1 min-w-0">
|
||||
<div className={`font-bold truncate ${activeSession?.id === session.id ? 'text-white' : 'text-zinc-800 dark:text-zinc-100'}`}>
|
||||
{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">
|
||||
<Calendar size={12} />
|
||||
{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')}
|
||||
</span>
|
||||
)}
|
||||
{session.whisky_count! > 0 && (
|
||||
<span className="flex items-center gap-1">
|
||||
<GlassWater size={12} />
|
||||
{session.whisky_count} Whiskys
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</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>
|
||||
<ChevronRight size={20} className="text-zinc-300 group-hover:text-amber-500 transition-colors" />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div >
|
||||
)}
|
||||
</div>
|
||||
</div >
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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'}
|
||||
</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">
|
||||
<Clock size={10} />
|
||||
{new Date(note.created_at).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}
|
||||
</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 className="flex items-center gap-4">
|
||||
<div className="text-[10px] text-zinc-400 font-black tracking-widest uppercase flex items-center gap-1">
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
const [buddies, setBuddies] = useState<Buddy[]>([]);
|
||||
const [selectedBuddyIds, setSelectedBuddyIds] = useState<string[]>([]);
|
||||
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 (
|
||||
<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="flex items-center justify-between">
|
||||
<label className="text-[11px] font-black text-zinc-400 uppercase tracking-widest flex items-center gap-2">
|
||||
|
||||
67
src/context/SessionContext.tsx
Normal file
67
src/context/SessionContext.tsx
Normal 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;
|
||||
};
|
||||
@@ -112,6 +112,7 @@ export const de: TranslationKeys = {
|
||||
lowConfidence: 'Ich bin mir unsicher. Bitte Details prüfen.',
|
||||
saveToVault: 'In den Vault legen',
|
||||
tastingNow: 'Jetzt verkosten',
|
||||
quickTasting: 'Direkt verkosten!',
|
||||
backToList: 'Zurück zur Liste',
|
||||
whiskybaseSearch: 'Whiskybase-Link suchen',
|
||||
searchingWb: 'Suche auf Whiskybase...',
|
||||
|
||||
@@ -112,6 +112,7 @@ export const en: TranslationKeys = {
|
||||
lowConfidence: 'Unsure about details. Please check.',
|
||||
saveToVault: 'Save to Vault',
|
||||
tastingNow: 'Tasting Now',
|
||||
quickTasting: 'Taste Now!',
|
||||
backToList: 'Back to List',
|
||||
whiskybaseSearch: 'Search Whiskybase',
|
||||
searchingWb: 'Searching Whiskybase...',
|
||||
|
||||
@@ -110,6 +110,7 @@ export type TranslationKeys = {
|
||||
lowConfidence: string;
|
||||
saveToVault: string;
|
||||
tastingNow: string;
|
||||
quickTasting: string;
|
||||
backToList: string;
|
||||
whiskybaseSearch: string;
|
||||
searchingWb: string;
|
||||
|
||||
Reference in New Issue
Block a user