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
This commit is contained in:
2026-01-19 23:01:00 +01:00
parent 004698b604
commit 5c00be59f1
17 changed files with 191 additions and 16 deletions

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

@@ -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,
};
}