Compare commits

..

4 Commits

Author SHA1 Message Date
06fa208dd8 feat: Auto browser language detection, remove LanguageSwitcher
- Update I18nContext to auto-detect browser language
- Default to English, switch to German if browser is German
- Remove LanguageSwitcher from guest and authenticated views
- Remove DramOfTheDay from header
- Cleaner, mobile-friendly header layout
2026-01-19 23:26:55 +01:00
883f76e488 fix: Restore logout button, hide DramOfTheDay on mobile
- Keep logout button in header (user feedback)
- Hide DramOfTheDay on mobile to save space (hidden sm:block)
- Keep responsive flex-wrap and reduced gaps
2026-01-19 23:23:28 +01:00
d8a9e9fd0a fix: Make header responsive on mobile
- Add flex-wrap to header right section
- Hide LanguageSwitcher on small screens (hidden sm:block)
- Replace logout text with Settings icon button
- Add shrink-0 to prevent logo compression
- Reduce gaps on mobile (gap-1 sm:gap-2)
2026-01-19 23:21:32 +01:00
5c00be59f1 feat: Add UX optimizations - skeletons and optimistic hooks
- Add Skeletons.tsx with TastingListSkeleton, ChartSkeleton, etc.
- Add useOptimistic.ts hooks for React 19 optimistic updates
- Update stats page to use skeleton loading instead of spinner
- Remove force-dynamic exports (12 files) for SSG compatibility
- Note: PPR (cacheComponents) tested but reverted - requires RSC-first refactor
2026-01-19 23:01:00 +01:00
20 changed files with 208 additions and 34 deletions

2
next-env.d.ts vendored
View File

@@ -1,6 +1,6 @@
/// <reference types="next" /> /// <reference types="next" />
/// <reference types="next/image-types/global" /> /// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts"; import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited // NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@@ -8,6 +8,8 @@ const nextConfig = {
// React Compiler for automatic memoization (React 19+) // React Compiler for automatic memoization (React 19+)
reactCompiler: true, reactCompiler: true,
experimental: { experimental: {
// Note: cacheComponents (PPR) disabled - requires Suspense boundaries for all auth contexts
// Can be enabled later after refactoring to RSC-first architecture
serverActions: { serverActions: {
bodySizeLimit: '10mb', bodySizeLimit: '10mb',
}, },

View File

@@ -1,4 +1,3 @@
export const dynamic = 'force-dynamic';
import { createClient } from '@/lib/supabase/server'; import { createClient } from '@/lib/supabase/server';
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';

View File

@@ -1,4 +1,3 @@
export const dynamic = 'force-dynamic';
import { createClient } from '@/lib/supabase/server'; import { createClient } from '@/lib/supabase/server';
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';

View File

@@ -1,4 +1,3 @@
export const dynamic = 'force-dynamic';
import { createClient } from '@/lib/supabase/server'; import { createClient } from '@/lib/supabase/server';
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import { checkIsAdmin } from '@/services/track-api-usage'; import { checkIsAdmin } from '@/services/track-api-usage';

View File

@@ -1,4 +1,3 @@
export const dynamic = 'force-dynamic';
import { createClient } from '@/lib/supabase/server'; import { createClient } from '@/lib/supabase/server';
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import { checkIsAdmin, getGlobalApiStats } from '@/services/track-api-usage'; import { checkIsAdmin, getGlobalApiStats } from '@/services/track-api-usage';

View File

@@ -1,4 +1,3 @@
export const dynamic = 'force-dynamic';
import { createClient } from '@/lib/supabase/server'; import { createClient } from '@/lib/supabase/server';
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import { checkIsAdmin } from '@/services/track-api-usage'; import { checkIsAdmin } from '@/services/track-api-usage';

View File

@@ -1,4 +1,3 @@
export const dynamic = 'force-dynamic';
import { createClient } from '@/lib/supabase/server'; import { createClient } from '@/lib/supabase/server';
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';

View File

@@ -1,4 +1,3 @@
export const dynamic = 'force-dynamic';
import { createClient } from '@/lib/supabase/server'; import { createClient } from '@/lib/supabase/server';
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';

View File

@@ -1,4 +1,3 @@
export const dynamic = 'force-dynamic';
import { createClient } from '@/lib/supabase/server'; import { createClient } from '@/lib/supabase/server';
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';

View File

@@ -1,4 +1,3 @@
export const dynamic = 'force-dynamic';
import { createClient } from '@/lib/supabase/server'; import { createClient } from '@/lib/supabase/server';
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import { checkIsAdmin } from '@/services/track-api-usage'; import { checkIsAdmin } from '@/services/track-api-usage';

View File

@@ -1,4 +1,3 @@
export const dynamic = 'force-dynamic';
import { createClient } from '@/lib/supabase/server'; import { createClient } from '@/lib/supabase/server';
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';

View File

@@ -1,4 +1,3 @@
export const dynamic = 'force-dynamic';
import sharp from 'sharp'; import sharp from 'sharp';
import { createClient } from '@/lib/supabase/server'; import { createClient } from '@/lib/supabase/server';
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';

View File

@@ -1,4 +1,3 @@
export const dynamic = 'force-dynamic';
import { createClient } from '@/lib/supabase/server'; import { createClient } from '@/lib/supabase/server';
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';

View File

@@ -2,8 +2,8 @@ import { MetadataRoute } from 'next'
export default function manifest(): MetadataRoute.Manifest { export default function manifest(): MetadataRoute.Manifest {
return { return {
name: 'Whisky Vault', name: 'Dramlog.eu',
short_name: 'WhiskyVault', short_name: 'Dramlog',
description: 'Dein persönlicher Whisky-Begleiter zum Scannen und Verkosten.', description: 'Dein persönlicher Whisky-Begleiter zum Scannen und Verkosten.',
start_url: '/', start_url: '/',
display: 'standalone', display: 'standalone',

View File

@@ -5,13 +5,12 @@ import { useRouter, useSearchParams } from 'next/navigation';
import { createClient } from '@/lib/supabase/client'; import { createClient } from '@/lib/supabase/client';
import BottleGrid from "@/components/BottleGrid"; import BottleGrid from "@/components/BottleGrid";
import AuthForm from "@/components/AuthForm"; import AuthForm from "@/components/AuthForm";
import LanguageSwitcher from "@/components/LanguageSwitcher";
import OfflineIndicator from "@/components/OfflineIndicator"; import OfflineIndicator from "@/components/OfflineIndicator";
import { useI18n } from "@/i18n/I18nContext"; import { useI18n } from "@/i18n/I18nContext";
import { useAuth } from "@/context/AuthContext"; import { useAuth } from "@/context/AuthContext";
import { useSession } from "@/context/SessionContext"; import { useSession } from "@/context/SessionContext";
import TastingHub from "@/components/TastingHub"; import TastingHub from "@/components/TastingHub";
import { Sparkles, Loader2, Search, SlidersHorizontal, Settings, CircleUser } from "lucide-react"; import { Sparkles, Loader2, Search, SlidersHorizontal } from "lucide-react";
import { BottomNavigation } from '@/components/BottomNavigation'; import { BottomNavigation } from '@/components/BottomNavigation';
import ScanAndTasteFlow from '@/components/ScanAndTasteFlow'; import ScanAndTasteFlow from '@/components/ScanAndTasteFlow';
import UserStatusBadge from '@/components/UserStatusBadge'; import UserStatusBadge from '@/components/UserStatusBadge';
@@ -19,7 +18,6 @@ import { getActiveSplits } from '@/services/split-actions';
import SplitCard from '@/components/SplitCard'; import SplitCard from '@/components/SplitCard';
import HeroBanner from '@/components/HeroBanner'; import HeroBanner from '@/components/HeroBanner';
import QuickActionsGrid from '@/components/QuickActionsGrid'; import QuickActionsGrid from '@/components/QuickActionsGrid';
import DramOfTheDay from '@/components/DramOfTheDay';
import { checkIsAdmin } from '@/services/track-api-usage'; import { checkIsAdmin } from '@/services/track-api-usage';
export default function Home() { export default function Home() {
@@ -152,9 +150,6 @@ export default function Home() {
<p className="text-zinc-500 max-w-sm mx-auto font-bold tracking-wide"> <p className="text-zinc-500 max-w-sm mx-auto font-bold tracking-wide">
{t('home.tagline')} {t('home.tagline')}
</p> </p>
<div className="mt-8">
<LanguageSwitcher />
</div>
</div> </div>
<AuthForm /> <AuthForm />
@@ -190,8 +185,8 @@ export default function Home() {
<div className="flex-1 overflow-y-auto pb-24"> <div className="flex-1 overflow-y-auto pb-24">
{/* 1. Header */} {/* 1. Header */}
<header className="px-4 pt-4 pb-2"> <header className="px-4 pt-4 pb-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between gap-2">
<div className="flex flex-col"> <div className="flex flex-col shrink-0">
<h1 className="text-2xl font-bold text-zinc-50 tracking-tighter"> <h1 className="text-2xl font-bold text-zinc-50 tracking-tighter">
DRAM<span className="text-orange-600">LOG</span> DRAM<span className="text-orange-600">LOG</span>
</h1> </h1>
@@ -203,7 +198,7 @@ export default function Home() {
</div> </div>
<span className="text-[9px] font-bold uppercase tracking-widest text-orange-600 flex items-center gap-1"> <span className="text-[9px] font-bold uppercase tracking-widest text-orange-600 flex items-center gap-1">
<Sparkles size={10} className="animate-pulse" /> <Sparkles size={10} className="animate-pulse" />
Live: {activeSession.name} <span className="hidden sm:inline">Live:</span> {activeSession.name}
</span> </span>
</div> </div>
)} )}
@@ -211,11 +206,9 @@ export default function Home() {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<UserStatusBadge /> <UserStatusBadge />
<OfflineIndicator /> <OfflineIndicator />
<LanguageSwitcher />
<DramOfTheDay bottles={bottles} />
<button <button
onClick={handleLogout} onClick={handleLogout}
className="text-[9px] font-bold uppercase tracking-widest text-zinc-600 hover:text-white transition-colors" className="text-[9px] font-bold uppercase tracking-widest text-zinc-600 hover:text-white transition-colors whitespace-nowrap"
> >
{t('home.logout')} {t('home.logout')}
</button> </button>

View File

@@ -6,6 +6,7 @@ import AnalyticsDashboard from '@/components/AnalyticsDashboard';
import { useI18n } from '@/i18n/I18nContext'; import { useI18n } from '@/i18n/I18nContext';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { createClient } from '@/lib/supabase/client'; import { createClient } from '@/lib/supabase/client';
import { ChartSkeleton, StatsCardSkeleton } from '@/components/Skeletons';
export default function StatsPage() { export default function StatsPage() {
const router = useRouter(); const router = useRouter();
@@ -59,8 +60,20 @@ export default function StatsPage() {
{/* Dashboard */} {/* Dashboard */}
{isLoading ? ( {isLoading ? (
<div className="flex items-center justify-center py-20"> <div className="space-y-6">
<div className="w-8 h-8 border-2 border-orange-500 border-t-transparent rounded-full animate-spin"></div> {/* KPI Cards Skeleton */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<StatsCardSkeleton />
<StatsCardSkeleton />
<StatsCardSkeleton />
<StatsCardSkeleton />
</div>
{/* Charts Skeleton */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<ChartSkeleton height={300} />
<ChartSkeleton height={300} />
</div>
<ChartSkeleton height={400} />
</div> </div>
) : ( ) : (
<AnalyticsDashboard bottles={bottles} /> <AnalyticsDashboard bottles={bottles} />

View File

@@ -0,0 +1,102 @@
'use client';
import React from 'react';
// Generic Skeleton components for Suspense fallbacks
export function TastingListSkeleton({ count = 3 }: { count?: number }) {
return (
<div className="space-y-4">
{Array.from({ length: count }).map((_, i) => (
<div
key={i}
className="bg-zinc-900 border border-zinc-800 rounded-2xl p-4 animate-pulse"
>
<div className="flex items-start gap-4">
<div className="w-12 h-12 bg-zinc-800 rounded-xl shrink-0" />
<div className="flex-1 space-y-2">
<div className="h-4 bg-zinc-800 rounded w-3/4" />
<div className="h-3 bg-zinc-800 rounded w-1/2" />
<div className="h-3 bg-zinc-800 rounded w-1/4" />
</div>
<div className="w-10 h-10 bg-zinc-800 rounded-xl" />
</div>
</div>
))}
</div>
);
}
export function BottleDetailsSkeleton() {
return (
<div className="space-y-6 animate-pulse">
{/* Image skeleton */}
<div className="aspect-square bg-zinc-900 rounded-3xl" />
{/* Title skeleton */}
<div className="space-y-2">
<div className="h-8 bg-zinc-800 rounded w-3/4" />
<div className="h-5 bg-zinc-800 rounded w-1/2" />
</div>
{/* Badges skeleton */}
<div className="flex gap-2">
<div className="h-8 w-20 bg-zinc-800 rounded-xl" />
<div className="h-8 w-16 bg-zinc-800 rounded-xl" />
<div className="h-8 w-24 bg-zinc-800 rounded-xl" />
</div>
{/* Stats skeleton */}
<div className="grid grid-cols-2 gap-4">
<div className="h-20 bg-zinc-900 rounded-2xl" />
<div className="h-20 bg-zinc-900 rounded-2xl" />
</div>
</div>
);
}
export function ChartSkeleton({ height = 200 }: { height?: number }) {
return (
<div
className="bg-zinc-900 border border-zinc-800 rounded-2xl animate-pulse flex items-center justify-center"
style={{ height }}
>
<div className="text-zinc-700 text-sm">Loading chart...</div>
</div>
);
}
export function StatsCardSkeleton() {
return (
<div className="bg-zinc-900 border border-zinc-800 rounded-2xl p-4 animate-pulse">
<div className="h-3 bg-zinc-800 rounded w-1/2 mb-2" />
<div className="h-8 bg-zinc-800 rounded w-1/3" />
</div>
);
}
export function SessionTimelineSkeleton() {
return (
<div className="space-y-4 animate-pulse">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="flex items-center gap-4">
<div className="w-3 h-3 bg-zinc-700 rounded-full" />
<div className="flex-1 h-16 bg-zinc-900 border border-zinc-800 rounded-xl" />
</div>
))}
</div>
);
}
export function GridSkeleton({ count = 6 }: { count?: number }) {
return (
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
{Array.from({ length: count }).map((_, i) => (
<div
key={i}
className="aspect-[3/4] bg-zinc-900 border border-zinc-800 rounded-2xl animate-pulse"
/>
))}
</div>
);
}

View File

@@ -0,0 +1,70 @@
'use client';
import { useOptimistic, useTransition } from 'react';
/**
* Hook for optimistic updates with automatic rollback on error.
* Uses React 19's useOptimistic + startTransition pattern.
*
* @example
* const { optimisticData, isPending, mutate } = useOptimisticMutation(
* tastings,
* async (newTasting) => await saveTasting(newTasting)
* );
*/
export function useOptimisticMutation<T, TInput>(
initialData: T[],
mutationFn: (input: TInput) => Promise<{ success: boolean; error?: string }>
) {
const [isPending, startTransition] = useTransition();
const [optimisticData, addOptimistic] = useOptimistic<T[], TInput>(
initialData,
(currentData, newItem) => [...currentData, newItem as unknown as T]
);
const mutate = async (input: TInput, optimisticValue: T) => {
startTransition(async () => {
// Immediately show optimistic update
addOptimistic(input);
// Perform actual mutation
const result = await mutationFn(input);
if (!result.success) {
console.error('[OptimisticMutation] Failed:', result.error);
// Note: React will automatically rollback on error
}
});
};
return {
optimisticData,
isPending,
mutate,
};
}
/**
* Simple optimistic state for single values (like ratings).
*/
export function useOptimisticValue<T>(
serverValue: T,
updateFn: (value: T) => Promise<{ success: boolean }>
) {
const [isPending, startTransition] = useTransition();
const [optimisticValue, setOptimistic] = useOptimistic(serverValue);
const setValue = (value: T) => {
startTransition(async () => {
setOptimistic(value);
await updateFn(value);
});
};
return {
value: optimisticValue,
isPending,
setValue,
};
}

View File

@@ -18,19 +18,25 @@ const translations: Record<Locale, TranslationKeys> = { de, en };
const I18nContext = createContext<I18nContextType | undefined>(undefined); const I18nContext = createContext<I18nContextType | undefined>(undefined);
export const I18nProvider = ({ children }: { children: ReactNode }) => { export const I18nProvider = ({ children }: { children: ReactNode }) => {
const [locale, setLocaleState] = useState<Locale>('de'); const [locale, setLocaleState] = useState<Locale>('en');
const [isInitialized, setIsInitialized] = useState(false);
useEffect(() => { useEffect(() => {
// Check for saved preference first
const savedLocale = localStorage.getItem('locale') as Locale; const savedLocale = localStorage.getItem('locale') as Locale;
if (savedLocale && (savedLocale === 'de' || savedLocale === 'en')) { if (savedLocale && (savedLocale === 'de' || savedLocale === 'en')) {
setLocaleState(savedLocale); setLocaleState(savedLocale);
} else { } else {
// Try to detect browser language // Auto-detect from browser: default to English, switch to German if detected
const browserLang = navigator.language.split('-')[0]; const browserLang = navigator.language.toLowerCase();
if (browserLang === 'en') { if (browserLang.startsWith('de')) {
setLocaleState('en'); setLocaleState('de');
localStorage.setItem('locale', 'de');
} else {
localStorage.setItem('locale', 'en');
} }
} }
setIsInitialized(true);
}, []); }, []);
const setLocale = (newLocale: Locale) => { const setLocale = (newLocale: Locale) => {