forked from cardosofelipe/fast-next-template
Revert Zustand persist middleware approach and restore AuthInitializer
- Remove persist middleware from authStore (causing hooks timing issues) - Restore original AuthInitializer component pattern - Keep good Phase 3 optimizations: - Theme FOUC fix (inline script) - React Query refetchOnWindowFocus disabled - Code splitting for dev/auth components - Shared form components (FormField, useFormError) - Store location in lib/stores
This commit is contained in:
@@ -5,7 +5,15 @@
|
||||
*/
|
||||
|
||||
import type { Metadata } from 'next';
|
||||
import { ComponentShowcase } from '@/components/dev/ComponentShowcase';
|
||||
import dynamic from 'next/dynamic';
|
||||
|
||||
// Code-split heavy dev component (787 lines)
|
||||
const ComponentShowcase = dynamic(
|
||||
() => import('@/components/dev/ComponentShowcase').then((mod) => mod.ComponentShowcase),
|
||||
{
|
||||
loading: () => <div className="p-8 text-center text-muted-foreground">Loading components...</div>,
|
||||
}
|
||||
);
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Component Showcase | Dev',
|
||||
|
||||
66
frontend/src/app/dev/docs/design-system/[...slug]/page.tsx
Normal file
66
frontend/src/app/dev/docs/design-system/[...slug]/page.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Dynamic Documentation Route
|
||||
* Renders markdown files from docs/ directory
|
||||
* Access: /dev/docs/design-system/01-foundations, etc.
|
||||
*/
|
||||
|
||||
import { notFound } from 'next/navigation';
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import matter from 'gray-matter';
|
||||
import { MarkdownContent } from '@/components/docs/MarkdownContent';
|
||||
|
||||
interface DocPageProps {
|
||||
params: Promise<{ slug: string[] }>;
|
||||
}
|
||||
|
||||
// Generate static params for all documentation files
|
||||
export async function generateStaticParams() {
|
||||
const docsDir = path.join(process.cwd(), 'docs', 'design-system');
|
||||
|
||||
try {
|
||||
const files = await fs.readdir(docsDir);
|
||||
const mdFiles = files.filter(file => file.endsWith('.md'));
|
||||
|
||||
return mdFiles.map(file => ({
|
||||
slug: [file.replace(/\.md$/, '')],
|
||||
}));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Get markdown file content
|
||||
async function getDocContent(slug: string[]) {
|
||||
const filePath = path.join(process.cwd(), 'docs', 'design-system', ...slug) + '.md';
|
||||
|
||||
try {
|
||||
const fileContent = await fs.readFile(filePath, 'utf-8');
|
||||
const { data, content } = matter(fileContent);
|
||||
|
||||
return {
|
||||
frontmatter: data,
|
||||
content,
|
||||
filePath: slug.join('/'),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export default async function DocPage({ params }: DocPageProps) {
|
||||
const { slug } = await params;
|
||||
const doc = await getDocContent(slug);
|
||||
|
||||
if (!doc) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="mx-auto max-w-4xl">
|
||||
<MarkdownContent content={doc.content} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Documentation Hub
|
||||
* Central hub for all design system documentation
|
||||
* Access: /docs
|
||||
* Access: /dev/docs
|
||||
*/
|
||||
|
||||
import Link from 'next/link';
|
||||
@@ -22,14 +22,14 @@ const gettingStartedDocs: DocItem[] = [
|
||||
{
|
||||
title: 'Quick Start',
|
||||
description: '5-minute crash course to get up and running with the design system',
|
||||
href: '/docs/design-system/00-quick-start',
|
||||
href: '/dev/docs/design-system/00-quick-start',
|
||||
icon: <Sparkles className="h-5 w-5" />,
|
||||
badge: 'Start Here',
|
||||
},
|
||||
{
|
||||
title: 'README',
|
||||
description: 'Complete overview and learning paths for the design system',
|
||||
href: '/docs/design-system/README',
|
||||
href: '/dev/docs/design-system/README',
|
||||
icon: <BookOpen className="h-5 w-5" />,
|
||||
},
|
||||
];
|
||||
@@ -38,43 +38,43 @@ const coreConceptsDocs: DocItem[] = [
|
||||
{
|
||||
title: 'Foundations',
|
||||
description: 'Colors (OKLCH), typography, spacing, and shadows',
|
||||
href: '/docs/design-system/01-foundations',
|
||||
href: '/dev/docs/design-system/01-foundations',
|
||||
icon: <Palette className="h-5 w-5" />,
|
||||
},
|
||||
{
|
||||
title: 'Components',
|
||||
description: 'shadcn/ui component library guide and usage patterns',
|
||||
href: '/docs/design-system/02-components',
|
||||
href: '/dev/docs/design-system/02-components',
|
||||
icon: <Code2 className="h-5 w-5" />,
|
||||
},
|
||||
{
|
||||
title: 'Layouts',
|
||||
description: 'Layout patterns with Grid vs Flex decision trees',
|
||||
href: '/docs/design-system/03-layouts',
|
||||
href: '/dev/docs/design-system/03-layouts',
|
||||
icon: <Layout className="h-5 w-5" />,
|
||||
},
|
||||
{
|
||||
title: 'Spacing Philosophy',
|
||||
description: 'Parent-controlled spacing strategy and best practices',
|
||||
href: '/docs/design-system/04-spacing-philosophy',
|
||||
href: '/dev/docs/design-system/04-spacing-philosophy',
|
||||
icon: <FileCode className="h-5 w-5" />,
|
||||
},
|
||||
{
|
||||
title: 'Component Creation',
|
||||
description: 'When to create vs compose components',
|
||||
href: '/docs/design-system/05-component-creation',
|
||||
href: '/dev/docs/design-system/05-component-creation',
|
||||
icon: <Code2 className="h-5 w-5" />,
|
||||
},
|
||||
{
|
||||
title: 'Forms',
|
||||
description: 'Form patterns with react-hook-form and Zod validation',
|
||||
href: '/docs/design-system/06-forms',
|
||||
href: '/dev/docs/design-system/06-forms',
|
||||
icon: <FileCode className="h-5 w-5" />,
|
||||
},
|
||||
{
|
||||
title: 'Accessibility',
|
||||
description: 'WCAG AA compliance, keyboard navigation, and screen readers',
|
||||
href: '/docs/design-system/07-accessibility',
|
||||
href: '/dev/docs/design-system/07-accessibility',
|
||||
icon: <Accessibility className="h-5 w-5" />,
|
||||
},
|
||||
];
|
||||
@@ -83,39 +83,21 @@ const referencesDocs: DocItem[] = [
|
||||
{
|
||||
title: 'AI Guidelines',
|
||||
description: 'Rules and best practices for AI code generation',
|
||||
href: '/docs/design-system/08-ai-guidelines',
|
||||
href: '/dev/docs/design-system/08-ai-guidelines',
|
||||
icon: <Lightbulb className="h-5 w-5" />,
|
||||
badge: 'AI',
|
||||
},
|
||||
{
|
||||
title: 'Quick Reference',
|
||||
description: 'Cheat sheet for quick lookups and common patterns',
|
||||
href: '/docs/design-system/99-reference',
|
||||
href: '/dev/docs/design-system/99-reference',
|
||||
icon: <Search className="h-5 w-5" />,
|
||||
},
|
||||
];
|
||||
|
||||
export default function DocsHub() {
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
{/* Header */}
|
||||
<header className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||
<div className="container mx-auto flex h-16 items-center justify-between px-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<BookOpen className="h-6 w-6 text-primary" />
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold">FastNext Documentation</h1>
|
||||
<p className="text-xs text-muted-foreground">Design System & Component Library</p>
|
||||
</div>
|
||||
</div>
|
||||
<Link href="/dev">
|
||||
<Button variant="outline" size="sm">
|
||||
Interactive Demos
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="bg-background">
|
||||
{/* Hero Section */}
|
||||
<section className="border-b bg-gradient-to-b from-background to-muted/20 py-16">
|
||||
<div className="container mx-auto px-4">
|
||||
@@ -128,19 +110,19 @@ export default function DocsHub() {
|
||||
accessible, and maintainable user interfaces with the FastNext design system.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-3 justify-center">
|
||||
<Link href="/docs/design-system/00-quick-start">
|
||||
<Link href="/dev/docs/design-system/00-quick-start">
|
||||
<Button size="lg" className="gap-2">
|
||||
<Sparkles className="h-4 w-4" />
|
||||
Get Started
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/docs/design-system/README">
|
||||
<Link href="/dev/docs/design-system/README">
|
||||
<Button variant="outline" size="lg" className="gap-2">
|
||||
<BookOpen className="h-4 w-4" />
|
||||
Full Documentation
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/dev">
|
||||
<Link href="/dev/components">
|
||||
<Button variant="outline" size="lg" className="gap-2">
|
||||
<Code2 className="h-4 w-4" />
|
||||
View Examples
|
||||
@@ -269,35 +251,6 @@ export default function DocsHub() {
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="mt-16 border-t py-8">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<div className="flex flex-col gap-6 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground mb-1">
|
||||
FastNext Design System
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Built with shadcn/ui, Tailwind CSS, and OKLCH colors
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<Link href="/dev">
|
||||
<Button variant="ghost" size="sm">
|
||||
Interactive Demos
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/docs/design-system/00-quick-start">
|
||||
<Button variant="ghost" size="sm">
|
||||
Quick Start
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -574,7 +574,7 @@ const { register, handleSubmit, formState: { errors } } = useForm({
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Learn more:{' '}
|
||||
<Link
|
||||
href="/docs/design-system/06-forms"
|
||||
href="/dev/docs/design-system/06-forms"
|
||||
className="font-medium hover:text-foreground"
|
||||
>
|
||||
Forms Documentation
|
||||
|
||||
10
frontend/src/app/dev/layout.tsx
Normal file
10
frontend/src/app/dev/layout.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Dev Layout
|
||||
* Shared layout for all development routes
|
||||
*/
|
||||
|
||||
import { DevLayout } from '@/components/dev/DevLayout';
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
return <DevLayout>{children}</DevLayout>;
|
||||
}
|
||||
@@ -509,7 +509,7 @@ export default function LayoutsPage() {
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Learn more:{' '}
|
||||
<Link
|
||||
href="/docs/design-system/03-layouts"
|
||||
href="/dev/docs/design-system/03-layouts"
|
||||
className="font-medium hover:text-foreground"
|
||||
>
|
||||
Layout Documentation
|
||||
|
||||
@@ -70,45 +70,45 @@ const documentationLinks = [
|
||||
{
|
||||
title: 'Quick Start',
|
||||
description: '5-minute crash course',
|
||||
href: '/docs/design-system/00-quick-start',
|
||||
href: '/dev/docs/design-system/00-quick-start',
|
||||
},
|
||||
{
|
||||
title: 'Complete Documentation',
|
||||
description: 'Full design system guide',
|
||||
href: '/docs/design-system/README',
|
||||
href: '/dev/docs',
|
||||
},
|
||||
{
|
||||
title: 'AI Guidelines',
|
||||
description: 'Rules for AI code generation',
|
||||
href: '/docs/design-system/08-ai-guidelines',
|
||||
href: '/dev/docs/design-system/08-ai-guidelines',
|
||||
},
|
||||
{
|
||||
title: 'Quick Reference',
|
||||
description: 'Cheat sheet for lookups',
|
||||
href: '/docs/design-system/99-reference',
|
||||
href: '/dev/docs/design-system/99-reference',
|
||||
},
|
||||
];
|
||||
|
||||
export default function DesignSystemHub() {
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
{/* Header */}
|
||||
<header className="border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="space-y-4">
|
||||
<div className="bg-background">
|
||||
{/* Hero Section */}
|
||||
<section className="border-b bg-gradient-to-b from-background to-muted/20 py-12">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="space-y-4 max-w-3xl">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="h-8 w-8 text-primary" />
|
||||
<h1 className="text-4xl font-bold tracking-tight">
|
||||
Design System Hub
|
||||
</h1>
|
||||
</div>
|
||||
<p className="text-lg text-muted-foreground max-w-2xl">
|
||||
Interactive demonstrations, live examples, and copy-paste code for
|
||||
<p className="text-lg text-muted-foreground">
|
||||
Interactive demonstrations, live examples, and comprehensive documentation for
|
||||
the FastNext design system. Built with shadcn/ui + Tailwind CSS 4.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</section>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="container mx-auto px-4 py-12">
|
||||
@@ -276,32 +276,6 @@ export default function DesignSystemHub() {
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="mt-16 border-t py-8">
|
||||
<div className="container mx-auto px-4 text-center text-sm text-muted-foreground">
|
||||
<p>
|
||||
FastNext Design System • Built with{' '}
|
||||
<a
|
||||
href="https://ui.shadcn.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium hover:text-foreground"
|
||||
>
|
||||
shadcn/ui
|
||||
</a>
|
||||
{' + '}
|
||||
<a
|
||||
href="https://tailwindcss.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium hover:text-foreground"
|
||||
>
|
||||
Tailwind CSS 4
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
|
||||
import type { Metadata } from 'next';
|
||||
import dynamic from 'next/dynamic';
|
||||
import Link from 'next/link';
|
||||
import { ArrowLeft, Ruler } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -16,8 +17,22 @@ import {
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Example, ExampleSection } from '@/components/dev/Example';
|
||||
import { BeforeAfter } from '@/components/dev/BeforeAfter';
|
||||
|
||||
// Code-split heavy dev components
|
||||
const Example = dynamic(
|
||||
() => import('@/components/dev/Example').then((mod) => ({ default: mod.Example })),
|
||||
{ loading: () => <div className="animate-pulse h-32 bg-muted rounded" /> }
|
||||
);
|
||||
|
||||
const ExampleSection = dynamic(
|
||||
() => import('@/components/dev/Example').then((mod) => ({ default: mod.ExampleSection })),
|
||||
{ loading: () => <div className="animate-pulse h-24 bg-muted rounded" /> }
|
||||
);
|
||||
|
||||
const BeforeAfter = dynamic(
|
||||
() => import('@/components/dev/BeforeAfter').then((mod) => ({ default: mod.BeforeAfter })),
|
||||
{ loading: () => <div className="animate-pulse h-48 bg-muted rounded" /> }
|
||||
);
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Spacing Patterns | Dev',
|
||||
@@ -507,7 +522,7 @@ export default function SpacingPage() {
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Learn more:{' '}
|
||||
<Link
|
||||
href="/docs/design-system/04-spacing-philosophy"
|
||||
href="/dev/docs/design-system/04-spacing-philosophy"
|
||||
className="font-medium hover:text-foreground"
|
||||
>
|
||||
Spacing Philosophy Documentation
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
/**
|
||||
* Dynamic Documentation Route
|
||||
* Renders markdown files from docs/ directory
|
||||
* Access: /docs/design-system/01-foundations, etc.
|
||||
*/
|
||||
|
||||
import { notFound } from 'next/navigation';
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import matter from 'gray-matter';
|
||||
import Link from 'next/link';
|
||||
import { ArrowLeft, FileText } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { MarkdownContent } from '@/components/docs/MarkdownContent';
|
||||
|
||||
interface DocPageProps {
|
||||
params: Promise<{ slug: string[] }>;
|
||||
}
|
||||
|
||||
// Generate static params for all documentation files
|
||||
export async function generateStaticParams() {
|
||||
const docsDir = path.join(process.cwd(), 'docs', 'design-system');
|
||||
|
||||
try {
|
||||
const files = await fs.readdir(docsDir);
|
||||
const mdFiles = files.filter(file => file.endsWith('.md'));
|
||||
|
||||
return mdFiles.map(file => ({
|
||||
slug: ['design-system', file.replace(/\.md$/, '')],
|
||||
}));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Get markdown file content
|
||||
async function getDocContent(slug: string[]) {
|
||||
const filePath = path.join(process.cwd(), 'docs', ...slug) + '.md';
|
||||
|
||||
try {
|
||||
const fileContent = await fs.readFile(filePath, 'utf-8');
|
||||
const { data, content } = matter(fileContent);
|
||||
|
||||
return {
|
||||
frontmatter: data,
|
||||
content,
|
||||
filePath: slug.join('/'),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export default async function DocPage({ params }: DocPageProps) {
|
||||
const { slug } = await params;
|
||||
const doc = await getDocContent(slug);
|
||||
|
||||
if (!doc) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
// Extract title from first heading or use filename
|
||||
const title = doc.content.match(/^#\s+(.+)$/m)?.[1] || slug[slug.length - 1];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
{/* Header */}
|
||||
<header className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||
<div className="container mx-auto flex h-16 items-center gap-4 px-4">
|
||||
<Link href="/dev">
|
||||
<Button variant="ghost" size="icon" aria-label="Back to design system">
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
</Link>
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="h-5 w-5 text-muted-foreground" />
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold">{title}</h1>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{doc.filePath}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Content */}
|
||||
<main className="container mx-auto px-4 py-8">
|
||||
<div className="mx-auto max-w-4xl">
|
||||
<MarkdownContent content={doc.content} />
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="mt-16 border-t py-6">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="mx-auto max-w-4xl">
|
||||
<Separator className="mb-6" />
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
FastNext Design System Documentation
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Link href="/dev">
|
||||
<Button variant="outline" size="sm">
|
||||
View Interactive Demos
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/docs/design-system/README">
|
||||
<Button variant="outline" size="sm">
|
||||
Documentation Home
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
||||
import { useState } from 'react';
|
||||
import { ThemeProvider } from '@/components/theme';
|
||||
import { AuthInitializer } from '@/components/auth';
|
||||
|
||||
export function Providers({ children }: { children: React.ReactNode }) {
|
||||
const [queryClient] = useState(
|
||||
@@ -26,7 +27,7 @@ export function Providers({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{/* AuthInitializer removed - Zustand persist middleware handles auto-hydration */}
|
||||
<AuthInitializer />
|
||||
{children}
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
</QueryClientProvider>
|
||||
|
||||
@@ -63,16 +63,11 @@ function LoadingSpinner() {
|
||||
export function AuthGuard({ children, requireAdmin = false, fallback }: AuthGuardProps) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const { isAuthenticated, isLoading: authLoading, user, _hasHydrated } = useAuthStore();
|
||||
const { isAuthenticated, isLoading: authLoading, user } = useAuthStore();
|
||||
|
||||
// Fetch user data if authenticated but user not loaded
|
||||
const { isLoading: userLoading } = useMe();
|
||||
|
||||
// Wait for store to hydrate from localStorage to prevent hook order issues
|
||||
if (!_hasHydrated) {
|
||||
return fallback ? <>{fallback}</> : <LoadingSpinner />;
|
||||
}
|
||||
|
||||
// Determine overall loading state
|
||||
const isLoading = authLoading || (isAuthenticated && !user && userLoading);
|
||||
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
// Authentication components
|
||||
|
||||
// Auth initialization
|
||||
export { AuthInitializer } from './AuthInitializer';
|
||||
|
||||
// Route protection
|
||||
export { AuthGuard } from './AuthGuard';
|
||||
|
||||
|
||||
@@ -9,11 +9,10 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import {
|
||||
Moon, Sun, Mail, User,
|
||||
Mail, User,
|
||||
Settings, LogOut, Shield, AlertCircle, Info,
|
||||
Trash2, ArrowLeft
|
||||
Trash2
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
@@ -73,41 +72,10 @@ import { Example, ExampleGrid, ExampleSection } from './Example';
|
||||
* Component showcase
|
||||
*/
|
||||
export function ComponentShowcase() {
|
||||
const [isDark, setIsDark] = useState(false);
|
||||
const [checked, setChecked] = useState(false);
|
||||
|
||||
const toggleTheme = () => {
|
||||
setIsDark(!isDark);
|
||||
document.documentElement.classList.toggle('dark');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
{/* Header */}
|
||||
<header className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||
<div className="container mx-auto flex h-16 items-center justify-between px-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href="/dev">
|
||||
<Button variant="ghost" size="icon" aria-label="Back to hub">
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">Component Showcase</h1>
|
||||
<p className="text-sm text-muted-foreground">All shadcn/ui components with code</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={toggleTheme}
|
||||
aria-label="Toggle theme"
|
||||
>
|
||||
{isDark ? <Sun className="h-5 w-5" /> : <Moon className="h-5 w-5" />}
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="bg-background">
|
||||
{/* Content */}
|
||||
<main className="container mx-auto px-4 py-8">
|
||||
<div className="space-y-12">
|
||||
|
||||
119
frontend/src/components/dev/DevLayout.tsx
Normal file
119
frontend/src/components/dev/DevLayout.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
/* istanbul ignore file */
|
||||
|
||||
/**
|
||||
* DevLayout Component
|
||||
* Shared layout for all /dev routes with navigation and theme toggle
|
||||
* This file is excluded from coverage as it's a development tool
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { Code2, Palette, LayoutDashboard, Box, FileText, BookOpen, Home } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ThemeToggle } from '@/components/theme';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface DevLayoutProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const navItems = [
|
||||
{
|
||||
title: 'Hub',
|
||||
href: '/dev',
|
||||
icon: Home,
|
||||
exact: true,
|
||||
},
|
||||
{
|
||||
title: 'Components',
|
||||
href: '/dev/components',
|
||||
icon: Box,
|
||||
},
|
||||
{
|
||||
title: 'Forms',
|
||||
href: '/dev/forms',
|
||||
icon: FileText,
|
||||
},
|
||||
{
|
||||
title: 'Layouts',
|
||||
href: '/dev/layouts',
|
||||
icon: LayoutDashboard,
|
||||
},
|
||||
{
|
||||
title: 'Spacing',
|
||||
href: '/dev/spacing',
|
||||
icon: Palette,
|
||||
},
|
||||
{
|
||||
title: 'Docs',
|
||||
href: '/dev/docs',
|
||||
icon: BookOpen,
|
||||
},
|
||||
];
|
||||
|
||||
export function DevLayout({ children }: DevLayoutProps) {
|
||||
const pathname = usePathname();
|
||||
|
||||
const isActive = (href: string, exact?: boolean) => {
|
||||
if (exact) {
|
||||
return pathname === href;
|
||||
}
|
||||
return pathname.startsWith(href);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
{/* Header */}
|
||||
<header className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||
<div className="container mx-auto px-4">
|
||||
{/* Single Row: Logo + Badge + Navigation + Theme Toggle */}
|
||||
<div className="flex h-14 items-center justify-between gap-6">
|
||||
{/* Left: Logo + Badge */}
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<Code2 className="h-5 w-5 text-primary" />
|
||||
<h1 className="text-base font-semibold">FastNext</h1>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
Dev
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Center: Navigation */}
|
||||
<nav className="flex gap-1 overflow-x-auto flex-1">
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const active = isActive(item.href, item.exact);
|
||||
|
||||
return (
|
||||
<Link key={item.href} href={item.href}>
|
||||
<Button
|
||||
variant={active ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
className={cn(
|
||||
'gap-2 whitespace-nowrap',
|
||||
!active && 'text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
{item.title}
|
||||
</Button>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* Right: Theme Toggle */}
|
||||
<div className="shrink-0">
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main Content */}
|
||||
<main>{children}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,9 @@
|
||||
/**
|
||||
* Authentication Store - Zustand with secure token storage
|
||||
* Implements proper state management with validation and automatic persistence
|
||||
* Implements proper state management with validation
|
||||
*/
|
||||
|
||||
import { create } from 'zustand';
|
||||
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||
import { saveTokens, getTokens, clearTokens } from '@/lib/auth/storage';
|
||||
|
||||
/**
|
||||
@@ -31,7 +30,6 @@ interface AuthState {
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
tokenExpiresAt: number | null; // Unix timestamp
|
||||
_hasHydrated: boolean; // Internal flag for persist middleware
|
||||
|
||||
// Actions
|
||||
setAuth: (user: User, accessToken: string, refreshToken: string, expiresIn?: number) => Promise<void>;
|
||||
@@ -40,7 +38,6 @@ interface AuthState {
|
||||
clearAuth: () => Promise<void>;
|
||||
loadAuthFromStorage: () => Promise<void>;
|
||||
isTokenExpired: () => boolean;
|
||||
setHasHydrated: (hasHydrated: boolean) => void; // Internal method for persist
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -71,63 +68,14 @@ function calculateExpiry(expiresIn?: number): number {
|
||||
return Date.now() + seconds * 1000;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom storage adapter for Zustand persist
|
||||
* Uses our encrypted token storage functions
|
||||
*/
|
||||
const authStorage = {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
getItem: async (_name: string): Promise<string | null> => {
|
||||
try {
|
||||
const tokens = await getTokens();
|
||||
if (!tokens) return null;
|
||||
|
||||
// Return the tokens as a JSON string that persist middleware expects
|
||||
return JSON.stringify({
|
||||
state: {
|
||||
accessToken: tokens.accessToken,
|
||||
refreshToken: tokens.refreshToken,
|
||||
isAuthenticated: !!(tokens.accessToken && tokens.refreshToken),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to load auth from storage:', error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
setItem: async (_name: string, value: string): Promise<void> => {
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
const { accessToken, refreshToken } = parsed.state;
|
||||
|
||||
if (accessToken && refreshToken) {
|
||||
await saveTokens({ accessToken, refreshToken });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to save auth to storage:', error);
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
removeItem: async (_name: string): Promise<void> => {
|
||||
try {
|
||||
await clearTokens();
|
||||
} catch (error) {
|
||||
console.error('Failed to clear auth from storage:', error);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export const useAuthStore = create<AuthState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
// Initial state
|
||||
user: null,
|
||||
accessToken: null,
|
||||
refreshToken: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: false, // No longer needed - persist handles hydration
|
||||
tokenExpiresAt: null,
|
||||
_hasHydrated: false,
|
||||
export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
// Initial state
|
||||
user: null,
|
||||
accessToken: null,
|
||||
refreshToken: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: true, // Start as loading to check stored tokens
|
||||
tokenExpiresAt: null,
|
||||
|
||||
// Set complete auth state (user + tokens)
|
||||
setAuth: async (user, accessToken, refreshToken, expiresIn) => {
|
||||
@@ -210,58 +158,50 @@ export const useAuthStore = create<AuthState>()(
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* @deprecated No longer needed with persist middleware
|
||||
* The persist middleware automatically hydrates tokens on store initialization
|
||||
* Kept for backward compatibility but does nothing
|
||||
*/
|
||||
loadAuthFromStorage: async () => {
|
||||
// No-op: persist middleware handles this automatically
|
||||
console.warn('loadAuthFromStorage() is deprecated and no longer necessary');
|
||||
},
|
||||
// Load auth from storage on app start
|
||||
loadAuthFromStorage: async () => {
|
||||
try {
|
||||
const tokens = await getTokens();
|
||||
|
||||
// Check if current token is expired
|
||||
isTokenExpired: () => {
|
||||
const { tokenExpiresAt } = get();
|
||||
if (!tokenExpiresAt) return true;
|
||||
return Date.now() >= tokenExpiresAt;
|
||||
},
|
||||
|
||||
// Internal method for persist middleware
|
||||
setHasHydrated: (hasHydrated) => {
|
||||
set({ _hasHydrated: hasHydrated });
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'auth_store', // Storage key
|
||||
storage: createJSONStorage(() => authStorage),
|
||||
partialize: (state) => ({
|
||||
// Only persist tokens and auth status, not user or computed values
|
||||
accessToken: state.accessToken,
|
||||
refreshToken: state.refreshToken,
|
||||
isAuthenticated: state.isAuthenticated,
|
||||
}),
|
||||
onRehydrateStorage: () => {
|
||||
return (state, error) => {
|
||||
if (error) {
|
||||
console.error('Failed to rehydrate auth store:', error);
|
||||
}
|
||||
// Mark store as hydrated to prevent rendering issues
|
||||
if (state) {
|
||||
state.setHasHydrated(true);
|
||||
}
|
||||
};
|
||||
},
|
||||
if (tokens?.accessToken && tokens?.refreshToken) {
|
||||
// Validate token format
|
||||
if (isValidToken(tokens.accessToken) && isValidToken(tokens.refreshToken)) {
|
||||
set({
|
||||
accessToken: tokens.accessToken,
|
||||
refreshToken: tokens.refreshToken,
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
// User will be loaded separately via API call
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load auth from storage:', error);
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// No valid tokens found
|
||||
set({ isLoading: false });
|
||||
},
|
||||
|
||||
// Check if current token is expired
|
||||
isTokenExpired: () => {
|
||||
const { tokenExpiresAt } = get();
|
||||
if (!tokenExpiresAt) return true;
|
||||
return Date.now() >= tokenExpiresAt;
|
||||
},
|
||||
}));
|
||||
|
||||
/**
|
||||
* @deprecated No longer needed with persist middleware
|
||||
* The persist middleware automatically hydrates the store on initialization
|
||||
* Kept for backward compatibility but does nothing
|
||||
* Initialize auth store from storage
|
||||
* Call this on app startup
|
||||
* Errors are logged but don't throw to prevent app crashes
|
||||
*/
|
||||
export async function initializeAuth(): Promise<void> {
|
||||
// No-op: persist middleware handles initialization automatically
|
||||
console.warn('initializeAuth() is deprecated and no longer necessary');
|
||||
try {
|
||||
await useAuthStore.getState().loadAuthFromStorage();
|
||||
} catch (error) {
|
||||
// Log error but don't throw - app should continue even if auth init fails
|
||||
console.error('Failed to initialize auth:', error);
|
||||
}
|
||||
}
|
||||
|
||||
22
frontend/src/middleware.ts
Normal file
22
frontend/src/middleware.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import type { NextRequest } from 'next/server';
|
||||
|
||||
export function middleware(request: NextRequest) {
|
||||
const { pathname } = request.nextUrl;
|
||||
|
||||
// Block access to /dev routes in production
|
||||
if (pathname.startsWith('/dev')) {
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
|
||||
if (isProduction) {
|
||||
// Return 404 in production
|
||||
return new NextResponse(null, { status: 404 });
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: '/dev/:path*',
|
||||
};
|
||||
@@ -23,12 +23,10 @@ let mockAuthState: {
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
user: any;
|
||||
_hasHydrated: boolean;
|
||||
} = {
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
user: null,
|
||||
_hasHydrated: true, // In tests, assume store is always hydrated
|
||||
};
|
||||
|
||||
jest.mock('@/lib/stores/authStore', () => ({
|
||||
@@ -71,7 +69,6 @@ describe('AuthGuard', () => {
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
user: null,
|
||||
_hasHydrated: true, // In tests, assume store is always hydrated
|
||||
};
|
||||
mockMeState = {
|
||||
isLoading: false,
|
||||
@@ -85,7 +82,6 @@ describe('AuthGuard', () => {
|
||||
isAuthenticated: false,
|
||||
isLoading: true,
|
||||
user: null,
|
||||
_hasHydrated: true,
|
||||
};
|
||||
|
||||
render(
|
||||
@@ -104,7 +100,6 @@ describe('AuthGuard', () => {
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
user: null,
|
||||
_hasHydrated: true,
|
||||
};
|
||||
mockMeState = {
|
||||
isLoading: true,
|
||||
@@ -126,7 +121,6 @@ describe('AuthGuard', () => {
|
||||
isAuthenticated: false,
|
||||
isLoading: true,
|
||||
user: null,
|
||||
_hasHydrated: true,
|
||||
};
|
||||
|
||||
render(
|
||||
@@ -148,7 +142,6 @@ describe('AuthGuard', () => {
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
user: null,
|
||||
_hasHydrated: true,
|
||||
};
|
||||
|
||||
render(
|
||||
@@ -179,7 +172,6 @@ describe('AuthGuard', () => {
|
||||
created_at: '2024-01-01',
|
||||
updated_at: '2024-01-01',
|
||||
},
|
||||
_hasHydrated: true,
|
||||
};
|
||||
|
||||
render(
|
||||
@@ -205,11 +197,10 @@ describe('AuthGuard', () => {
|
||||
first_name: 'Admin',
|
||||
last_name: 'User',
|
||||
is_active: true,
|
||||
is_superuser: true, // Admin user must have is_superuser: true
|
||||
is_superuser: true,
|
||||
created_at: '2024-01-01',
|
||||
updated_at: '2024-01-01',
|
||||
},
|
||||
_hasHydrated: true,
|
||||
};
|
||||
|
||||
render(
|
||||
@@ -228,7 +219,7 @@ describe('AuthGuard', () => {
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
user: {
|
||||
id: '1',
|
||||
id: '1',
|
||||
email: 'user@example.com',
|
||||
first_name: 'Regular',
|
||||
last_name: 'User',
|
||||
@@ -237,7 +228,6 @@ describe('AuthGuard', () => {
|
||||
created_at: '2024-01-01',
|
||||
updated_at: '2024-01-01',
|
||||
},
|
||||
_hasHydrated: true,
|
||||
};
|
||||
|
||||
render(
|
||||
@@ -259,7 +249,7 @@ describe('AuthGuard', () => {
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
user: {
|
||||
id: '1',
|
||||
id: '1',
|
||||
email: 'user@example.com',
|
||||
first_name: 'Regular',
|
||||
last_name: 'User',
|
||||
@@ -268,7 +258,6 @@ describe('AuthGuard', () => {
|
||||
created_at: '2024-01-01',
|
||||
updated_at: '2024-01-01',
|
||||
},
|
||||
_hasHydrated: true,
|
||||
};
|
||||
|
||||
render(
|
||||
@@ -289,7 +278,6 @@ describe('AuthGuard', () => {
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
user: null,
|
||||
_hasHydrated: true,
|
||||
};
|
||||
|
||||
render(
|
||||
@@ -313,7 +301,6 @@ describe('AuthGuard', () => {
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
user: null,
|
||||
_hasHydrated: true,
|
||||
};
|
||||
mockMeState = {
|
||||
isLoading: true,
|
||||
@@ -335,7 +322,7 @@ describe('AuthGuard', () => {
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
user: {
|
||||
id: '1',
|
||||
id: '1',
|
||||
email: 'user@example.com',
|
||||
first_name: 'Test',
|
||||
last_name: 'User',
|
||||
@@ -344,7 +331,6 @@ describe('AuthGuard', () => {
|
||||
created_at: '2024-01-01',
|
||||
updated_at: '2024-01-01',
|
||||
},
|
||||
_hasHydrated: true,
|
||||
};
|
||||
mockMeState = {
|
||||
isLoading: false,
|
||||
|
||||
Reference in New Issue
Block a user