feat: move offline indicator to header, rename to Offline-Modus, and redesign tasting form

This commit is contained in:
2025-12-21 00:13:33 +01:00
parent 74a10b193c
commit 716afce2ae
7 changed files with 148 additions and 122 deletions

View File

@@ -62,16 +62,17 @@ A critical feature is the ability to link tasting notes to a bottle that hasn't
## 📱 PWA Features ## 📱 PWA Features
### Service Worker (`public/sw.js`) - "Bunker v7 + SWR" ### Service Worker (`public/sw.js`) - "Offline-Modus v10 + SWR"
The Service Worker implements a robust "Cache-First, Network-Background" strategy: The Service Worker implements a robust "Cache-First, Network-Background" strategy:
- **Pre-Caching**: The landing page (`/`) and core static assets are cached individually during installation to prevent total failure on single-file 404s. - **Pre-Caching**: The landing page (`/`) and core static assets are cached individually (sequentially) during installation to prevent total failure on single-file 404s.
- **Manifest Path**: Corrected to `/manifest.webmanifest` to match Next.js defaults. - **Manifest Path**: Corrected to `/manifest.webmanifest` to match Next.js defaults.
- **SWR Navigation & Assets**: Both load instantly from cache. Updates happen in the background via `fetchWithTimeout` and `AbortController`. - **SWR Navigation & Assets**: Both load instantly from cache. Updates happen in the background via `fetchWithTimeout` and `AbortController`.
- **Universal Root Fallback**: Deep links (like `/bottles/[id]`) fallback to `/` if not cached, allowing Next.js to take over. - **Universal Root Fallback**: Deep links (like `/bottles/[id]`) fallback to `/` if not cached, allowing Next.js to take over.
- **Network Stability**: Added a 2-second stabilization delay in `UploadQueue.tsx` before background sync starts after a network change. - **Network Stability**: Added a 2-second stabilization delay in `UploadQueue.tsx` before background sync starts after a network change.
- **RSC Data Resiliency**: Requests to `/_next/data/` return an empty JSON object if they fail. - **RSC Data Resiliency**: Requests to `/_next/data/` return an empty JSON object if they fail.
- **Stale-While-Revalidate**: Applied to static assets to ensure immediate UI response. - **Indicator**: Located in the application header for constant status visibility.
- **Offline-Modus**: Formerly called "Bunker", this system ensures all core assets are ready for a zero-connectivity environment.
### Manifest (`src/app/manifest.ts`) ### Manifest (`src/app/manifest.ts`)
Defines the app's appearance when installed: Defines the app's appearance when installed:

View File

@@ -4,6 +4,7 @@ import React, { useEffect, useState } from 'react';
import BottleDetails from '@/components/BottleDetails'; import BottleDetails from '@/components/BottleDetails';
import { createClient } from '@/lib/supabase/client'; import { createClient } from '@/lib/supabase/client';
import { validateSession } from '@/services/validate-session'; import { validateSession } from '@/services/validate-session';
import OfflineIndicator from '@/components/OfflineIndicator';
import { useParams, useSearchParams } from 'next/navigation'; import { useParams, useSearchParams } from 'next/navigation';
export default function BottlePage() { export default function BottlePage() {
@@ -40,7 +41,10 @@ export default function BottlePage() {
if (!bottleId) return null; if (!bottleId) return null;
return ( return (
<main className="min-h-screen bg-zinc-50 dark:bg-black p-4 md:p-12 lg:p-24"> <main className="min-h-screen bg-zinc-50 dark:bg-black p-4 md:p-12 lg:p-24 space-y-6">
<div className="max-w-4xl mx-auto flex justify-end">
<OfflineIndicator />
</div>
<BottleDetails <BottleDetails
bottleId={bottleId} bottleId={bottleId}
sessionId={sessionId} sessionId={sessionId}

View File

@@ -53,7 +53,6 @@ export default function RootLayout({
<MainContentWrapper> <MainContentWrapper>
<SyncHandler /> <SyncHandler />
<PWARegistration /> <PWARegistration />
<OfflineIndicator />
<UploadQueue /> <UploadQueue />
{children} {children}
</MainContentWrapper> </MainContentWrapper>

View File

@@ -10,6 +10,7 @@ import SessionList from "@/components/SessionList";
import StatsDashboard from "@/components/StatsDashboard"; import StatsDashboard from "@/components/StatsDashboard";
import DramOfTheDay from "@/components/DramOfTheDay"; import DramOfTheDay from "@/components/DramOfTheDay";
import LanguageSwitcher from "@/components/LanguageSwitcher"; import LanguageSwitcher from "@/components/LanguageSwitcher";
import OfflineIndicator from "@/components/OfflineIndicator";
import { useI18n } from "@/i18n/I18nContext"; import { useI18n } from "@/i18n/I18nContext";
import { useSession } from "@/context/SessionContext"; import { useSession } from "@/context/SessionContext";
import { Sparkles } from "lucide-react"; import { Sparkles } from "lucide-react";
@@ -181,6 +182,7 @@ export default function Home() {
)} )}
</div> </div>
<div className="flex flex-wrap items-center justify-center sm:justify-end gap-3 md:gap-4"> <div className="flex flex-wrap items-center justify-center sm:justify-end gap-3 md:gap-4">
<OfflineIndicator />
<LanguageSwitcher /> <LanguageSwitcher />
<DramOfTheDay bottles={bottles} /> <DramOfTheDay bottles={bottles} />
<button <button

View File

@@ -12,6 +12,7 @@ import { useParams, useRouter } from 'next/navigation';
import { useI18n } from '@/i18n/I18nContext'; import { useI18n } from '@/i18n/I18nContext';
import SessionTimeline from '@/components/SessionTimeline'; import SessionTimeline from '@/components/SessionTimeline';
import SessionABVCurve from '@/components/SessionABVCurve'; import SessionABVCurve from '@/components/SessionABVCurve';
import OfflineIndicator from '@/components/OfflineIndicator';
interface Buddy { interface Buddy {
id: string; id: string;
@@ -206,13 +207,16 @@ export default function SessionDetailPage() {
<main className="min-h-screen bg-zinc-50 dark:bg-black p-4 md:p-12 lg:p-24"> <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-8"> <div className="max-w-4xl mx-auto space-y-8">
{/* Back Button */} {/* Back Button */}
<Link <div className="flex justify-between items-center">
href="/" <Link
className="inline-flex items-center gap-2 text-zinc-400 hover:text-amber-600 transition-colors font-bold uppercase text-[10px] tracking-[0.2em]" href="/"
> className="inline-flex items-center gap-2 text-zinc-400 hover:text-amber-600 transition-colors font-bold uppercase text-[10px] tracking-[0.2em]"
<ChevronLeft size={16} /> >
Alle Sessions <ChevronLeft size={16} />
</Link> Alle Sessions
</Link>
<OfflineIndicator />
</div>
{/* Hero */} {/* Hero */}
<header className="bg-white dark:bg-zinc-900 rounded-3xl p-8 border border-zinc-200 dark:border-zinc-800 shadow-xl relative overflow-hidden group"> <header className="bg-white dark:bg-zinc-900 rounded-3xl p-8 border border-zinc-200 dark:border-zinc-800 shadow-xl relative overflow-hidden group">

View File

@@ -1,17 +1,17 @@
'use client'; 'use client';
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { WifiOff, ShieldCheck } from 'lucide-react'; import { WifiOff, ShieldCheck, Loader2 } from 'lucide-react';
export default function OfflineIndicator() { export default function OfflineIndicator() {
const [isOffline, setIsOffline] = useState(false); const [isOffline, setIsOffline] = useState(false);
const [isBunkerReady, setIsBunkerReady] = useState(false); const [isReady, setIsReady] = useState(false);
const [progress, setProgress] = useState(0); const [progress, setProgress] = useState(0);
useEffect(() => { useEffect(() => {
setIsOffline(!navigator.onLine); setIsOffline(!navigator.onLine);
const savedReady = localStorage.getItem('whisky_bunker_ready') === 'true'; const savedReady = localStorage.getItem('whisky_bunker_ready') === 'true';
setIsBunkerReady(savedReady); setIsReady(savedReady);
const handleOnline = () => setIsOffline(false); const handleOnline = () => setIsOffline(false);
const handleOffline = () => setIsOffline(true); const handleOffline = () => setIsOffline(true);
@@ -21,8 +21,7 @@ export default function OfflineIndicator() {
setProgress(event.data.progress); setProgress(event.data.progress);
} }
if (event.data?.type === 'PRECACHE_COMPLETE' || event.data?.type === 'BUNKER_STATUS') { if (event.data?.type === 'PRECACHE_COMPLETE' || event.data?.type === 'BUNKER_STATUS') {
console.log('🛡️ PWA: Bunker is ready!'); setIsReady(true);
setIsBunkerReady(true);
localStorage.setItem('whisky_bunker_ready', 'true'); localStorage.setItem('whisky_bunker_ready', 'true');
} }
}; };
@@ -32,8 +31,6 @@ export default function OfflineIndicator() {
if ('serviceWorker' in navigator) { if ('serviceWorker' in navigator) {
navigator.serviceWorker.addEventListener('message', handleMessage); navigator.serviceWorker.addEventListener('message', handleMessage);
// Initial check: if already active, ask for status
if (navigator.serviceWorker.controller) { if (navigator.serviceWorker.controller) {
navigator.serviceWorker.controller.postMessage({ type: 'CHECK_BUNKER_STATUS' }); navigator.serviceWorker.controller.postMessage({ type: 'CHECK_BUNKER_STATUS' });
} }
@@ -48,61 +45,35 @@ export default function OfflineIndicator() {
}; };
}, []); }, []);
// 1. OFFLINE BAR (TOP)
if (isOffline) { if (isOffline) {
return ( return (
<div className="fixed top-0 left-0 w-full bg-red-600 text-white text-[11px] font-black uppercase tracking-[0.2em] py-2 flex items-center justify-center gap-2 z-[10001] shadow-xl animate-in slide-in-from-top duration-300"> <div className="flex items-center gap-1.5 px-2.5 py-1 bg-red-600/10 border border-red-600/20 rounded-full animate-pulse">
<WifiOff size={14} className="animate-pulse" /> <WifiOff size={10} className="text-red-600" />
Offline-Modus: Bunker aktiv 🛡 <span className="text-[9px] font-black uppercase tracking-widest text-red-600">Offline</span>
</div> </div>
); );
} }
// 2. READY STATUS (BOTTOM RIGHT) if (isReady) {
if (isBunkerReady) {
return ( return (
<div className="fixed bottom-24 right-4 z-[10000] animate-in fade-in slide-in-from-right-10 duration-700 pointer-events-auto"> <div className="flex items-center gap-1.5 px-2.5 py-1 bg-green-600/10 border border-green-600/20 rounded-full group cursor-help relative">
<div className="bg-zinc-900/95 backdrop-blur-xl border border-green-500/40 px-4 py-2.5 rounded-2xl flex items-center gap-2.5 shadow-[0_20px_50px_rgba(0,0,0,0.5),0_0_20px_rgba(34,197,94,0.1)] group hover:scale-105 transition-all cursor-help ring-1 ring-white/10"> <ShieldCheck size={10} className="text-green-600" />
<div className="relative"> <span className="text-[9px] font-black uppercase tracking-widest text-green-600">Offline-Modus aktiv</span>
<ShieldCheck size={18} className="text-green-500" />
<div className="absolute -top-1 -right-1 w-2 h-2 bg-green-500 rounded-full blur-[2px] animate-pulse" />
</div>
<div className="flex flex-col">
<span className="text-[10px] font-black uppercase tracking-[0.2em] text-white">Bunker Aktiv</span>
<span className="text-[8px] font-bold text-zinc-500 uppercase tracking-widest">Vollständig Offline fähig</span>
</div>
{/* Tooltip */} {/* Tooltip for desktop */}
<div className="absolute bottom-full right-0 mb-3 w-56 p-3 bg-zinc-950 text-[10px] text-zinc-400 rounded-2xl border border-white/10 opacity-0 group-hover:opacity-100 transition-all pointer-events-none shadow-2xl translate-y-2 group-hover:translate-y-0"> <div className="absolute top-full left-1/2 -translate-x-1/2 mt-2 w-48 p-2 bg-zinc-900 text-[10px] text-zinc-400 rounded-xl border border-white/10 opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none z-50 shadow-2xl text-center">
<p className="leading-relaxed"> Alle Funktionen sind vollständig offline verfügbar.
<strong className="text-white block mb-1">Status: Gesichert</strong>
Alle wichtigen Bestandteile der App sind lokal gespeichert. Du kannst die App jederzeit im Funkloch nutzen.
</p>
</div>
</div> </div>
</div> </div>
); );
} }
// 3. LOADING STATUS (BOTTOM RIGHT)
return ( return (
<div className="fixed bottom-24 right-4 z-[10000] animate-in fade-in slide-in-from-right-10 duration-500 pointer-events-auto"> <div className="flex items-center gap-1.5 px-2.5 py-1 bg-amber-600/10 border border-amber-600/20 rounded-full">
<div className="bg-zinc-900/95 backdrop-blur-xl border border-amber-500/40 px-4 py-2.5 rounded-2xl flex items-center gap-3 shadow-[0_20px_50px_rgba(0,0,0,0.5),0_0_20px_rgba(245,158,11,0.1)] ring-1 ring-white/10"> <Loader2 size={10} className="text-amber-600 animate-spin" />
<div className="relative w-5 h-5 flex items-center justify-center"> <span className="text-[9px] font-black uppercase tracking-widest text-amber-600">
<div className="absolute inset-0 border-2 border-amber-500/20 rounded-full" /> {progress > 0 ? `Lade Offline-Daten... ${progress}%` : 'Initialisiere...'}
<div </span>
className="absolute inset-0 border-2 border-amber-500 rounded-full border-t-transparent animate-spin"
style={{ borderRightColor: 'transparent' }}
/>
<div className="w-1.5 h-1.5 bg-amber-500 rounded-full animate-pulse" />
</div>
<div className="flex flex-col">
<span className="text-[10px] font-black uppercase tracking-[0.2em] text-white">Bunker lädt...</span>
<span className="text-[10px] font-black text-amber-500 tabular-nums">
{progress > 0 ? `${progress}%` : 'Initialisiere'}
</span>
</div>
</div>
</div> </div>
); );
} }

View File

@@ -288,77 +288,122 @@ export default function TastingNoteForm({ bottleId, pendingBottleId, sessionId,
</div> </div>
</div> </div>
<div className="space-y-4"> <div className="space-y-6">
<div className="bg-zinc-50 dark:bg-zinc-800/50 p-4 rounded-2xl border border-zinc-200 dark:border-zinc-700/50 space-y-4"> {/* Nose Section */}
<TagSelector <div className="bg-white dark:bg-zinc-900 rounded-3xl border border-zinc-200 dark:border-zinc-800 shadow-sm overflow-hidden transition-all">
category="nose" <div className="bg-zinc-50 dark:bg-zinc-800/50 px-5 py-4 border-b border-zinc-200 dark:border-zinc-800 flex items-center gap-3">
selectedTagIds={noseTagIds} <div className="bg-amber-100 dark:bg-amber-900/30 p-2 rounded-xl text-amber-600">
onToggleTag={toggleNoseTag} <Wind size={18} />
label={t('tasting.nose')} </div>
suggestedTagNames={suggestedTags} <div>
suggestedCustomTagNames={suggestedCustomTags} <h3 className="text-sm font-black uppercase tracking-widest text-zinc-900 dark:text-white leading-none">
/> {t('tasting.nose')}
<div className="space-y-2"> </h3>
<label className="text-[10px] font-black text-zinc-400 uppercase tracking-widest block opacity-50">Zusätzliche Notizen</label> <p className="text-[10px] text-zinc-500 font-bold uppercase mt-1">Aroma & Eindruck</p>
<textarea </div>
value={nose} </div>
onChange={(e) => setNose(e.target.value)} <div className="p-5 space-y-5">
placeholder={t('tasting.notesPlaceholder')} <TagSelector
rows={2} category="nose"
className="w-full p-3 bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-xl text-sm focus:ring-2 focus:ring-amber-500 outline-none resize-none transition-all dark:text-zinc-200" selectedTagIds={noseTagIds}
onToggleTag={toggleNoseTag}
label=""
suggestedTagNames={suggestedTags}
suggestedCustomTagNames={suggestedCustomTags}
/> />
<div className="space-y-2">
<label className="text-[10px] font-black text-zinc-400 uppercase tracking-widest block px-1">Eigene Notizen</label>
<textarea
value={nose}
onChange={(e) => setNose(e.target.value)}
placeholder={t('tasting.notesPlaceholder')}
rows={2}
className="w-full p-4 bg-zinc-50 dark:bg-zinc-800 border-none rounded-2xl text-sm focus:ring-2 focus:ring-amber-500 outline-none resize-none transition-all dark:text-zinc-200 placeholder:text-zinc-400"
/>
</div>
</div> </div>
</div> </div>
<div className="bg-zinc-50 dark:bg-zinc-800/50 p-4 rounded-2xl border border-zinc-200 dark:border-zinc-700/50 space-y-4"> {/* Palate Section */}
<TagSelector <div className="bg-white dark:bg-zinc-900 rounded-3xl border border-zinc-200 dark:border-zinc-800 shadow-sm overflow-hidden transition-all">
category="taste" <div className="bg-zinc-50 dark:bg-zinc-800/50 px-5 py-4 border-b border-zinc-200 dark:border-zinc-800 flex items-center gap-3">
selectedTagIds={palateTagIds} <div className="bg-amber-100 dark:bg-amber-900/30 p-2 rounded-xl text-amber-600">
onToggleTag={togglePalateTag} <Utensils size={18} />
label={t('tasting.palate')} </div>
suggestedTagNames={suggestedTags} <div>
suggestedCustomTagNames={suggestedCustomTags} <h3 className="text-sm font-black uppercase tracking-widest text-zinc-900 dark:text-white leading-none">
/> {t('tasting.palate')}
<div className="space-y-2"> </h3>
<label className="text-[10px] font-black text-zinc-400 uppercase tracking-widest block opacity-50">Zusätzliche Notizen</label> <p className="text-[10px] text-zinc-500 font-bold uppercase mt-1">Geschmack & Textur</p>
<textarea </div>
value={palate} </div>
onChange={(e) => setPalate(e.target.value)} <div className="p-5 space-y-5">
placeholder={t('tasting.notesPlaceholder')} <TagSelector
rows={2} category="taste"
className="w-full p-3 bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-xl text-sm focus:ring-2 focus:ring-amber-500 outline-none resize-none transition-all dark:text-zinc-200" selectedTagIds={palateTagIds}
onToggleTag={togglePalateTag}
label=""
suggestedTagNames={suggestedTags}
suggestedCustomTagNames={suggestedCustomTags}
/> />
<div className="space-y-2">
<label className="text-[10px] font-black text-zinc-400 uppercase tracking-widest block px-1">Eigene Notizen</label>
<textarea
value={palate}
onChange={(e) => setPalate(e.target.value)}
placeholder={t('tasting.notesPlaceholder')}
rows={2}
className="w-full p-4 bg-zinc-50 dark:bg-zinc-800 border-none rounded-2xl text-sm focus:ring-2 focus:ring-amber-500 outline-none resize-none transition-all dark:text-zinc-200 placeholder:text-zinc-400"
/>
</div>
</div> </div>
</div> </div>
<div className="bg-zinc-50 dark:bg-zinc-800/50 p-4 rounded-2xl border border-zinc-200 dark:border-zinc-700/50 space-y-6"> {/* Finish Section */}
<TagSelector <div className="bg-white dark:bg-zinc-900 rounded-3xl border border-zinc-200 dark:border-zinc-800 shadow-sm overflow-hidden transition-all">
category="finish" <div className="bg-zinc-50 dark:bg-zinc-800/50 px-5 py-4 border-b border-zinc-200 dark:border-zinc-800 flex items-center gap-3">
selectedTagIds={finishTagIds} <div className="bg-amber-100 dark:bg-amber-900/30 p-2 rounded-xl text-amber-600">
onToggleTag={toggleFinishTag} <Droplets size={18} />
label={t('tasting.finish')} </div>
suggestedTagNames={suggestedTags} <div>
suggestedCustomTagNames={suggestedCustomTags} <h3 className="text-sm font-black uppercase tracking-widest text-zinc-900 dark:text-white leading-none">
/> {t('tasting.finish')}
</h3>
<TagSelector <p className="text-[10px] text-zinc-500 font-bold uppercase mt-1">Abgang & Nachhall</p>
category="texture" </div>
selectedTagIds={finishTagIds} // Using finish state for texture for now, or separate if needed </div>
onToggleTag={toggleFinishTag} <div className="p-5 space-y-5">
label="Textur & Mundgefühl" <TagSelector
suggestedTagNames={suggestedTags} category="finish"
suggestedCustomTagNames={suggestedCustomTags} selectedTagIds={finishTagIds}
/> onToggleTag={toggleFinishTag}
label=""
<div className="space-y-2"> suggestedTagNames={suggestedTags}
<label className="text-[10px] font-black text-zinc-400 uppercase tracking-widest block opacity-50">Zusätzliche Notizen</label> suggestedCustomTagNames={suggestedCustomTags}
<textarea
value={finish}
onChange={(e) => setFinish(e.target.value)}
placeholder={t('tasting.notesPlaceholder')}
rows={2}
className="w-full p-3 bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-xl text-sm focus:ring-2 focus:ring-amber-500 outline-none resize-none transition-all dark:text-zinc-200"
/> />
<div className="space-y-2 pt-2 border-t border-zinc-100 dark:border-zinc-800">
<label className="text-[10px] font-black text-zinc-400 uppercase tracking-widest block px-1 opacity-50">Gefühl & Textur</label>
<TagSelector
category="texture"
selectedTagIds={finishTagIds}
onToggleTag={toggleFinishTag}
label=""
suggestedTagNames={suggestedTags}
suggestedCustomTagNames={suggestedCustomTags}
/>
</div>
<div className="space-y-2">
<label className="text-[10px] font-black text-zinc-400 uppercase tracking-widest block px-1">Eigene Notizen</label>
<textarea
value={finish}
onChange={(e) => setFinish(e.target.value)}
placeholder={t('tasting.notesPlaceholder')}
rows={2}
className="w-full p-4 bg-zinc-50 dark:bg-zinc-800 border-none rounded-2xl text-sm focus:ring-2 focus:ring-amber-500 outline-none resize-none transition-all dark:text-zinc-200 placeholder:text-zinc-400"
/>
</div>
</div> </div>
</div> </div>
</div> </div>