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')
|
.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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
@@ -275,8 +302,8 @@ export default function SessionDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div >
|
||||||
</main>
|
</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 { 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);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
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 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,13 +131,35 @@ export default function SessionList() {
|
|||||||
{session.participant_count} {t('tasting.participants')}
|
{session.participant_count} {t('tasting.participants')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
{session.whisky_count! > 0 && (
|
||||||
</div>
|
<span className="flex items-center gap-1">
|
||||||
<ChevronRight size={20} className="text-zinc-300 group-hover:text-amber-500 transition-colors" />
|
<GlassWater size={12} />
|
||||||
</Link>
|
{session.whisky_count} Whiskys
|
||||||
))}
|
</span>
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</div >
|
||||||
|
)}
|
||||||
|
</div >
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
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.',
|
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...',
|
||||||
|
|||||||
@@ -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...',
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user