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:
@@ -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',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user