feat: Add Flight Recorder, Timeline, ABV Curve and Offline Bottle Caching with Draft Notes support
This commit is contained in:
@@ -1,232 +1,51 @@
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import { notFound } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { ChevronLeft, Calendar, Award, Droplets, MapPin, Tag, ExternalLink, Package, PlusCircle, Info } from 'lucide-react';
|
||||
import { getStorageUrl } from '@/lib/supabase';
|
||||
import TastingNoteForm from '@/components/TastingNoteForm';
|
||||
import TastingList from '@/components/TastingList';
|
||||
import DeleteBottleButton from '@/components/DeleteBottleButton';
|
||||
import EditBottleForm from '@/components/EditBottleForm';
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import BottleDetails from '@/components/BottleDetails';
|
||||
import { createClient } from '@/lib/supabase/client';
|
||||
import { validateSession } from '@/services/validate-session';
|
||||
import { useParams, useSearchParams } from 'next/navigation';
|
||||
|
||||
export default async function BottlePage(props: {
|
||||
params: Promise<{ id: string }>,
|
||||
searchParams: Promise<{ session_id?: string }>
|
||||
}) {
|
||||
const params = await props.params;
|
||||
const searchParams = await props.searchParams;
|
||||
let sessionId = searchParams.session_id;
|
||||
export default function BottlePage() {
|
||||
const params = useParams();
|
||||
const searchParams = useSearchParams();
|
||||
const [sessionId, setSessionId] = useState<string | undefined>(undefined);
|
||||
const [userId, setUserId] = useState<string | undefined>(undefined);
|
||||
const supabase = createClient();
|
||||
|
||||
// Validate Session Age (12 hour limit)
|
||||
if (sessionId) {
|
||||
const isValid = await validateSession(sessionId);
|
||||
if (!isValid) {
|
||||
sessionId = undefined;
|
||||
}
|
||||
}
|
||||
const supabase = await createClient();
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
const bottleId = params?.id as string;
|
||||
const rawSessionId = searchParams?.get('session_id');
|
||||
|
||||
const { data: bottle } = await supabase
|
||||
.from('bottles')
|
||||
.select('*')
|
||||
.eq('id', params.id)
|
||||
.single();
|
||||
useEffect(() => {
|
||||
const checkSession = async () => {
|
||||
if (rawSessionId) {
|
||||
const isValid = await validateSession(rawSessionId);
|
||||
if (isValid) {
|
||||
setSessionId(rawSessionId);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (!bottle) {
|
||||
notFound();
|
||||
}
|
||||
const getAuth = async () => {
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (user) {
|
||||
setUserId(user.id);
|
||||
}
|
||||
};
|
||||
|
||||
const tastingsResult = await supabase
|
||||
.from('tastings')
|
||||
.select(`
|
||||
*,
|
||||
tasting_sessions (
|
||||
id,
|
||||
name
|
||||
),
|
||||
tasting_buddies (
|
||||
buddies (
|
||||
id,
|
||||
name
|
||||
)
|
||||
),
|
||||
tasting_tags (
|
||||
tags (
|
||||
id,
|
||||
name,
|
||||
category,
|
||||
is_system_default
|
||||
)
|
||||
)
|
||||
`)
|
||||
.eq('bottle_id', params.id)
|
||||
.order('created_at', { ascending: false });
|
||||
checkSession();
|
||||
getAuth();
|
||||
}, [rawSessionId, supabase]);
|
||||
|
||||
let tastings = tastingsResult.data;
|
||||
|
||||
if (tastingsResult.error) {
|
||||
console.error('Error fetching tastings with sessions:', tastingsResult.error);
|
||||
// Fallback: try without session join if relationship is missing
|
||||
const fallbackResult = await supabase
|
||||
.from('tastings')
|
||||
.select(`
|
||||
*,
|
||||
tasting_tags (
|
||||
tags (
|
||||
id,
|
||||
name,
|
||||
category,
|
||||
is_system_default
|
||||
)
|
||||
)
|
||||
`)
|
||||
.eq('bottle_id', params.id)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
tastings = fallbackResult.data;
|
||||
}
|
||||
if (!bottleId) return null;
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-zinc-50 dark:bg-black p-4 md:p-12 lg:p-24">
|
||||
<div className="max-w-4xl mx-auto space-y-6 md:space-y-12">
|
||||
{/* Back Button */}
|
||||
<Link
|
||||
href={`/${sessionId ? `?session_id=${sessionId}` : ''}`}
|
||||
className="inline-flex items-center gap-2 text-zinc-500 hover:text-amber-600 transition-colors font-medium mb-4"
|
||||
>
|
||||
<ChevronLeft size={20} />
|
||||
Zurück zur Sammlung
|
||||
</Link>
|
||||
|
||||
|
||||
{/* Hero Section */}
|
||||
<section className="grid grid-cols-1 md:grid-cols-2 gap-8 items-start">
|
||||
<div className="aspect-[4/5] rounded-3xl overflow-hidden shadow-2xl border border-zinc-200 dark:border-zinc-800">
|
||||
<img
|
||||
src={getStorageUrl(bottle.image_url)}
|
||||
alt={bottle.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl md:text-4xl font-black text-zinc-900 dark:text-white tracking-tighter leading-tight">
|
||||
{bottle.name}
|
||||
</h1>
|
||||
<p className="text-sm md:text-xl text-amber-600 font-bold mt-1 uppercase tracking-widest">{bottle.distillery}</p>
|
||||
|
||||
{bottle.whiskybase_id && (
|
||||
<div className="mt-4">
|
||||
<a
|
||||
href={`https://www.whiskybase.com/whiskies/whisky/${bottle.whiskybase_id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-[#db0000] text-white rounded-xl text-sm font-bold shadow-lg shadow-red-600/20 hover:scale-[1.05] transition-transform"
|
||||
>
|
||||
<ExternalLink size={16} />
|
||||
Whiskybase ID: {bottle.whiskybase_id}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||
<div className="p-4 bg-white dark:bg-zinc-900 rounded-2xl border border-zinc-100 dark:border-zinc-800 shadow-sm flex flex-col justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-zinc-400 text-[10px] font-black uppercase mb-1">
|
||||
<Tag size={12} /> Kategorie
|
||||
</div>
|
||||
<div className="font-bold text-sm dark:text-zinc-200">{bottle.category || '-'}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 bg-white dark:bg-zinc-900 rounded-2xl border border-zinc-100 dark:border-zinc-800 shadow-sm">
|
||||
<div className="flex items-center gap-2 text-zinc-400 text-[10px] font-black uppercase mb-1">
|
||||
<Droplets size={12} /> Alkoholgehalt
|
||||
</div>
|
||||
<div className="font-bold text-sm dark:text-zinc-200">{bottle.abv}% Vol.</div>
|
||||
</div>
|
||||
<div className="p-4 bg-white dark:bg-zinc-900 rounded-2xl border border-zinc-100 dark:border-zinc-800 shadow-sm">
|
||||
<div className="flex items-center gap-2 text-zinc-400 text-[10px] font-black uppercase mb-1">
|
||||
<Award size={12} /> Alter
|
||||
</div>
|
||||
<div className="font-bold text-sm dark:text-zinc-200">{bottle.age ? `${bottle.age} J.` : '-'}</div>
|
||||
</div>
|
||||
|
||||
{bottle.distilled_at && (
|
||||
<div className="p-4 bg-white dark:bg-zinc-900 rounded-2xl border border-zinc-100 dark:border-zinc-800 shadow-sm">
|
||||
<div className="flex items-center gap-2 text-zinc-400 text-[10px] font-black uppercase mb-1">
|
||||
<Calendar size={12} /> Destilliert
|
||||
</div>
|
||||
<div className="font-bold text-sm dark:text-zinc-200">{bottle.distilled_at}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{bottle.bottled_at && (
|
||||
<div className="p-4 bg-white dark:bg-zinc-900 rounded-2xl border border-zinc-100 dark:border-zinc-800 shadow-sm">
|
||||
<div className="flex items-center gap-2 text-zinc-400 text-[10px] font-black uppercase mb-1">
|
||||
<Package size={12} /> Abgefüllt
|
||||
</div>
|
||||
<div className="font-bold text-sm dark:text-zinc-200">{bottle.bottled_at}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{bottle.batch_info && (
|
||||
<div className="p-4 bg-zinc-50 dark:bg-zinc-800/30 rounded-2xl border border-dashed border-zinc-200 dark:border-zinc-700/50 md:col-span-1">
|
||||
<div className="flex items-center gap-2 text-zinc-400 text-[10px] font-black uppercase mb-1">
|
||||
<Info size={12} /> Batch / Code
|
||||
</div>
|
||||
<div className="font-mono text-xs dark:text-zinc-300 truncate" title={bottle.batch_info}>{bottle.batch_info}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="p-4 bg-white dark:bg-zinc-900 rounded-2xl border border-zinc-100 dark:border-zinc-800 shadow-sm">
|
||||
<div className="flex items-center gap-2 text-zinc-400 text-[10px] font-black uppercase mb-1">
|
||||
<Calendar size={12} /> Letzter Dram
|
||||
</div>
|
||||
<div className="font-bold text-sm dark:text-zinc-200">
|
||||
{tastings && tastings.length > 0
|
||||
? new Date(tastings[0].created_at).toLocaleDateString('de-DE')
|
||||
: 'Noch nie'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-2 flex flex-wrap gap-4">
|
||||
<EditBottleForm bottle={bottle} />
|
||||
<DeleteBottleButton bottleId={bottle.id} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<hr className="border-zinc-200 dark:border-zinc-800" />
|
||||
|
||||
{/* Tasting Notes Section */}
|
||||
<section className="space-y-8">
|
||||
<div className="flex flex-col md:flex-row justify-between items-start md:items-end gap-4">
|
||||
<div>
|
||||
<h2 className="text-3xl font-black text-zinc-900 dark:text-white tracking-tight">Tasting Notes</h2>
|
||||
<p className="text-zinc-500 mt-1">Hier findest du deine bisherigen Eindrücke.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 md:gap-8 items-start">
|
||||
{/* 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} /> Dram bewerten
|
||||
</h3>
|
||||
<TastingNoteForm bottleId={bottle.id} sessionId={sessionId} />
|
||||
</div>
|
||||
|
||||
{/* List */}
|
||||
<div className="lg:col-span-2">
|
||||
<TastingList initialTastings={tastings || []} currentUserId={user?.id} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<BottleDetails
|
||||
bottleId={bottleId}
|
||||
sessionId={sessionId}
|
||||
userId={userId}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ import { deleteSession } from '@/services/delete-session';
|
||||
import { useSession } from '@/context/SessionContext';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { useI18n } from '@/i18n/I18nContext';
|
||||
import SessionTimeline from '@/components/SessionTimeline';
|
||||
import SessionABVCurve from '@/components/SessionABVCurve';
|
||||
|
||||
interface Buddy {
|
||||
id: string;
|
||||
@@ -31,12 +33,20 @@ interface Session {
|
||||
interface SessionTasting {
|
||||
id: string;
|
||||
rating: number;
|
||||
tasted_at: string;
|
||||
bottles: {
|
||||
id: string;
|
||||
name: string;
|
||||
distillery: string;
|
||||
image_url?: string | null;
|
||||
abv: number;
|
||||
category?: string;
|
||||
};
|
||||
tasting_tags: {
|
||||
tags: {
|
||||
name: string;
|
||||
};
|
||||
}[];
|
||||
}
|
||||
|
||||
export default function SessionDetailPage() {
|
||||
@@ -84,15 +94,17 @@ export default function SessionDetailPage() {
|
||||
// Fetch Tastings in this session
|
||||
const { data: tastingData } = await supabase
|
||||
.from('tastings')
|
||||
.select('id, rating, bottles(id, name, distillery, image_url)')
|
||||
.select(`
|
||||
id,
|
||||
rating,
|
||||
tasted_at,
|
||||
bottles(id, name, distillery, image_url, abv, category),
|
||||
tasting_tags(tags(name))
|
||||
`)
|
||||
.eq('session_id', id)
|
||||
.order('created_at', { ascending: false });
|
||||
.order('tasted_at', { ascending: true });
|
||||
|
||||
setTastings((tastingData as any)?.map((t: any) => ({
|
||||
id: t.id,
|
||||
rating: t.rating,
|
||||
bottles: t.bottles
|
||||
})) || []);
|
||||
setTastings((tastingData as any) || []);
|
||||
|
||||
// Fetch all buddies for the picker
|
||||
const { data: buddies } = await supabase
|
||||
@@ -323,6 +335,17 @@ export default function SessionDetailPage() {
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ABV Curve */}
|
||||
{tastings.length > 0 && (
|
||||
<SessionABVCurve
|
||||
tastings={tastings.map(t => ({
|
||||
id: t.id,
|
||||
abv: t.bottles.abv || 40,
|
||||
tasted_at: t.tasted_at
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
</aside>
|
||||
|
||||
{/* Main Content: Bottle List */}
|
||||
@@ -342,33 +365,18 @@ export default function SessionDetailPage() {
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{tastings.length === 0 ? (
|
||||
<div className="text-center py-12 text-zinc-500 italic text-sm">
|
||||
Noch keine Flaschen in dieser Session verkostet. 🥃
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{tastings.map((t) => (
|
||||
<div key={t.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">
|
||||
<div>
|
||||
<div className="text-[9px] font-black text-amber-600 uppercase tracking-widest">{t.bottles.distillery}</div>
|
||||
<div className="font-bold text-zinc-800 dark:text-zinc-100">{t.bottles.name}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="bg-amber-100 dark:bg-amber-900/30 text-amber-700 px-3 py-1 rounded-xl text-xs font-black">
|
||||
{t.rating}/100
|
||||
</div>
|
||||
<Link
|
||||
href={`/bottles/${t.bottles.id}`}
|
||||
className="p-2 text-zinc-300 hover:text-amber-600 transition-colors"
|
||||
>
|
||||
<ChevronRight size={20} />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<SessionTimeline
|
||||
tastings={tastings.map(t => ({
|
||||
id: t.id,
|
||||
bottle_id: t.bottles.id,
|
||||
bottle_name: t.bottles.name,
|
||||
tasted_at: t.tasted_at,
|
||||
rating: t.rating,
|
||||
tags: t.tasting_tags?.map((tg: any) => tg.tags.name) || [],
|
||||
category: t.bottles.category
|
||||
}))}
|
||||
sessionStart={session.scheduled_at} // Fallback to scheduled time if no started_at
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user