Compare commits
4 Commits
004698b604
...
06fa208dd8
| Author | SHA1 | Date | |
|---|---|---|---|
| 06fa208dd8 | |||
| 883f76e488 | |||
| d8a9e9fd0a | |||
| 5c00be59f1 |
2
next-env.d.ts
vendored
2
next-env.d.ts
vendored
@@ -1,6 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <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
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
@@ -8,6 +8,8 @@ const nextConfig = {
|
||||
// React Compiler for automatic memoization (React 19+)
|
||||
reactCompiler: true,
|
||||
experimental: {
|
||||
// Note: cacheComponents (PPR) disabled - requires Suspense boundaries for all auth contexts
|
||||
// Can be enabled later after refactoring to RSC-first architecture
|
||||
serverActions: {
|
||||
bodySizeLimit: '10mb',
|
||||
},
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
export const dynamic = 'force-dynamic';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { checkIsAdmin } from '@/services/track-api-usage';
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
export const dynamic = 'force-dynamic';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { checkIsAdmin, getGlobalApiStats } from '@/services/track-api-usage';
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
export const dynamic = 'force-dynamic';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { checkIsAdmin } from '@/services/track-api-usage';
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
export const dynamic = 'force-dynamic';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { checkIsAdmin } from '@/services/track-api-usage';
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
export const dynamic = 'force-dynamic';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
export const dynamic = 'force-dynamic';
|
||||
import sharp from 'sharp';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
export const dynamic = 'force-dynamic';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ import { MetadataRoute } from 'next'
|
||||
|
||||
export default function manifest(): MetadataRoute.Manifest {
|
||||
return {
|
||||
name: 'Whisky Vault',
|
||||
short_name: 'WhiskyVault',
|
||||
name: 'Dramlog.eu',
|
||||
short_name: 'Dramlog',
|
||||
description: 'Dein persönlicher Whisky-Begleiter zum Scannen und Verkosten.',
|
||||
start_url: '/',
|
||||
display: 'standalone',
|
||||
|
||||
@@ -5,13 +5,12 @@ import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { createClient } from '@/lib/supabase/client';
|
||||
import BottleGrid from "@/components/BottleGrid";
|
||||
import AuthForm from "@/components/AuthForm";
|
||||
import LanguageSwitcher from "@/components/LanguageSwitcher";
|
||||
import OfflineIndicator from "@/components/OfflineIndicator";
|
||||
import { useI18n } from "@/i18n/I18nContext";
|
||||
import { useAuth } from "@/context/AuthContext";
|
||||
import { useSession } from "@/context/SessionContext";
|
||||
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 ScanAndTasteFlow from '@/components/ScanAndTasteFlow';
|
||||
import UserStatusBadge from '@/components/UserStatusBadge';
|
||||
@@ -19,7 +18,6 @@ import { getActiveSplits } from '@/services/split-actions';
|
||||
import SplitCard from '@/components/SplitCard';
|
||||
import HeroBanner from '@/components/HeroBanner';
|
||||
import QuickActionsGrid from '@/components/QuickActionsGrid';
|
||||
import DramOfTheDay from '@/components/DramOfTheDay';
|
||||
import { checkIsAdmin } from '@/services/track-api-usage';
|
||||
|
||||
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">
|
||||
{t('home.tagline')}
|
||||
</p>
|
||||
<div className="mt-8">
|
||||
<LanguageSwitcher />
|
||||
</div>
|
||||
</div>
|
||||
<AuthForm />
|
||||
|
||||
@@ -190,8 +185,8 @@ export default function Home() {
|
||||
<div className="flex-1 overflow-y-auto pb-24">
|
||||
{/* 1. Header */}
|
||||
<header className="px-4 pt-4 pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex flex-col shrink-0">
|
||||
<h1 className="text-2xl font-bold text-zinc-50 tracking-tighter">
|
||||
DRAM<span className="text-orange-600">LOG</span>
|
||||
</h1>
|
||||
@@ -203,7 +198,7 @@ export default function Home() {
|
||||
</div>
|
||||
<span className="text-[9px] font-bold uppercase tracking-widest text-orange-600 flex items-center gap-1">
|
||||
<Sparkles size={10} className="animate-pulse" />
|
||||
Live: {activeSession.name}
|
||||
<span className="hidden sm:inline">Live:</span> {activeSession.name}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -211,11 +206,9 @@ export default function Home() {
|
||||
<div className="flex items-center gap-2">
|
||||
<UserStatusBadge />
|
||||
<OfflineIndicator />
|
||||
<LanguageSwitcher />
|
||||
<DramOfTheDay bottles={bottles} />
|
||||
<button
|
||||
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')}
|
||||
</button>
|
||||
|
||||
@@ -6,6 +6,7 @@ import AnalyticsDashboard from '@/components/AnalyticsDashboard';
|
||||
import { useI18n } from '@/i18n/I18nContext';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { createClient } from '@/lib/supabase/client';
|
||||
import { ChartSkeleton, StatsCardSkeleton } from '@/components/Skeletons';
|
||||
|
||||
export default function StatsPage() {
|
||||
const router = useRouter();
|
||||
@@ -59,8 +60,20 @@ export default function StatsPage() {
|
||||
|
||||
{/* Dashboard */}
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<div className="w-8 h-8 border-2 border-orange-500 border-t-transparent rounded-full animate-spin"></div>
|
||||
<div className="space-y-6">
|
||||
{/* 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>
|
||||
) : (
|
||||
<AnalyticsDashboard bottles={bottles} />
|
||||
|
||||
102
src/components/Skeletons.tsx
Normal file
102
src/components/Skeletons.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
70
src/hooks/useOptimistic.ts
Normal file
70
src/hooks/useOptimistic.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -18,19 +18,25 @@ const translations: Record<Locale, TranslationKeys> = { de, en };
|
||||
const I18nContext = createContext<I18nContextType | undefined>(undefined);
|
||||
|
||||
export const I18nProvider = ({ children }: { children: ReactNode }) => {
|
||||
const [locale, setLocaleState] = useState<Locale>('de');
|
||||
const [locale, setLocaleState] = useState<Locale>('en');
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Check for saved preference first
|
||||
const savedLocale = localStorage.getItem('locale') as Locale;
|
||||
if (savedLocale && (savedLocale === 'de' || savedLocale === 'en')) {
|
||||
setLocaleState(savedLocale);
|
||||
} else {
|
||||
// Try to detect browser language
|
||||
const browserLang = navigator.language.split('-')[0];
|
||||
if (browserLang === 'en') {
|
||||
setLocaleState('en');
|
||||
// Auto-detect from browser: default to English, switch to German if detected
|
||||
const browserLang = navigator.language.toLowerCase();
|
||||
if (browserLang.startsWith('de')) {
|
||||
setLocaleState('de');
|
||||
localStorage.setItem('locale', 'de');
|
||||
} else {
|
||||
localStorage.setItem('locale', 'en');
|
||||
}
|
||||
}
|
||||
setIsInitialized(true);
|
||||
}, []);
|
||||
|
||||
const setLocale = (newLocale: Locale) => {
|
||||
|
||||
Reference in New Issue
Block a user