Adds front-end set-up

This commit is contained in:
2026-06-20 17:04:17 +02:00
parent efd1569c26
commit 7dfc3a9692
62 changed files with 6875 additions and 3 deletions
@@ -0,0 +1,37 @@
import { useEffect } from 'react';
import { Outlet, useNavigate } from '@tanstack/react-router';
import { useAuth } from '@/contexts/auth-context';
import { Sidebar } from '@/components/layout/Sidebar';
import { Topbar } from '@/components/layout/Topbar';
/**
* Shell for authenticated routes. Renders the persistent chrome and reacts to
* runtime auth loss (e.g. a failed refresh during an API call) by redirecting
* to /login (BR-U1-14).
*/
export function AppLayout() {
const { isAuthenticated, status } = useAuth();
const navigate = useNavigate();
useEffect(() => {
if (status === 'guest') {
void navigate({ to: '/login' });
}
}, [status, navigate]);
if (!isAuthenticated) {
return null;
}
return (
<div className="flex min-h-svh">
<Sidebar />
<div className="flex flex-1 flex-col">
<Topbar />
<main className="flex-1 p-6" data-testid="app-main">
<Outlet />
</main>
</div>
</div>
);
}
@@ -0,0 +1,53 @@
import { Link } from '@tanstack/react-router';
import { useTranslation } from 'react-i18next';
import { LayoutDashboard, Users, FileText } from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
interface NavItem {
to: string;
labelKey: string;
icon: LucideIcon;
testId: string;
}
const NAV_ITEMS: NavItem[] = [
{ to: '/dashboard', labelKey: 'nav.dashboard', icon: LayoutDashboard, testId: 'nav-dashboard' },
{ to: '/users', labelKey: 'nav.users', icon: Users, testId: 'nav-users' },
{ to: '/cms', labelKey: 'nav.cms', icon: FileText, testId: 'nav-cms' },
];
export function Sidebar() {
const { t } = useTranslation();
return (
<aside
className="hidden w-64 shrink-0 border-r border-border bg-card md:flex md:flex-col"
data-testid="app-sidebar"
>
<div className="flex h-16 items-center gap-2 border-b border-border px-6">
<span className="h-3 w-3 rounded-full bg-primary" aria-hidden="true" />
<span className="text-lg font-semibold">{t('common.appName')}</span>
</div>
<nav className="flex-1 space-y-1 p-3">
{NAV_ITEMS.map((item) => {
const Icon = item.icon;
return (
<Link
key={item.to}
to={item.to}
data-testid={item.testId}
className="flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
activeProps={{
className: cn('bg-accent text-accent-foreground'),
}}
>
<Icon className="size-4" />
{t(item.labelKey)}
</Link>
);
})}
</nav>
</aside>
);
}
+14
View File
@@ -0,0 +1,14 @@
import { LanguageSwitcher } from '@/i18n/LanguageSwitcher';
import { UserMenu } from '@/components/layout/UserMenu';
export function Topbar() {
return (
<header
className="flex h-16 items-center justify-end gap-1 border-b border-border bg-background px-6"
data-testid="app-topbar"
>
<LanguageSwitcher />
<UserMenu />
</header>
);
}
@@ -0,0 +1,61 @@
import { useNavigate } from '@tanstack/react-router';
import { useTranslation } from 'react-i18next';
import { LogOut, User as UserIcon } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { useAuth } from '@/contexts/auth-context';
export function UserMenu() {
const { t } = useTranslation();
const { user, logout } = useAuth();
const navigate = useNavigate();
const handleLogout = async () => {
await logout();
await navigate({ to: '/login' });
};
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
aria-label={user?.name ?? 'User menu'}
data-testid="user-menu-button"
>
<UserIcon />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>
<div className="flex flex-col">
<span className="font-medium" data-testid="user-menu-name">
{user?.name}
</span>
<span className="text-xs font-normal text-muted-foreground">
{user?.email}
</span>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => {
void handleLogout();
}}
data-testid="user-menu-logout"
>
<LogOut />
{t('userMenu.logout')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
+53
View File
@@ -0,0 +1,53 @@
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:size-4 [&_svg]:shrink-0',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
outline:
'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-10 px-4 py-2',
sm: 'h-9 rounded-md px-3',
lg: 'h-11 rounded-md px-8',
icon: 'h-10 w-10',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button';
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
},
);
Button.displayName = 'Button';
// eslint-disable-next-line react-refresh/only-export-components
export { buttonVariants };
+39
View File
@@ -0,0 +1,39 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
export function Card({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
className={cn(
'rounded-lg border border-border bg-card text-card-foreground shadow-sm',
className,
)}
{...props}
/>
);
}
export function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
return <div className={cn('flex flex-col space-y-1.5 p-6', className)} {...props} />;
}
export function CardTitle({ className, ...props }: React.ComponentProps<'h2'>) {
return (
<h2
className={cn('text-xl font-semibold leading-none tracking-tight', className)}
{...props}
/>
);
}
export function CardDescription({ className, ...props }: React.ComponentProps<'p'>) {
return <p className={cn('text-sm text-muted-foreground', className)} {...props} />;
}
export function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
return <div className={cn('p-6 pt-0', className)} {...props} />;
}
export function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
return <div className={cn('flex items-center p-6 pt-0', className)} {...props} />;
}
@@ -0,0 +1,70 @@
/* eslint-disable react-refresh/only-export-components */
import * as React from 'react';
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
import { Check } from 'lucide-react';
import { cn } from '@/lib/utils';
export const DropdownMenu = DropdownMenuPrimitive.Root;
export const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
export const DropdownMenuContent = React.forwardRef<
React.ComponentRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'z-50 min-w-[12rem] overflow-hidden rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-md',
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
export const DropdownMenuItem = React.forwardRef<
React.ComponentRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
'relative flex cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className,
)}
{...props}
/>
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
export const DropdownMenuLabel = React.forwardRef<
React.ComponentRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn('px-2 py-1.5 text-sm font-semibold', className)}
{...props}
/>
));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
export const DropdownMenuSeparator = React.forwardRef<
React.ComponentRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn('-mx-1 my-1 h-px bg-border', className)}
{...props}
/>
));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
/** A checkable indicator for selected items (e.g. the active language). */
export function DropdownMenuCheck({ checked }: { checked: boolean }) {
return <Check className={cn('ml-auto', checked ? 'opacity-100' : 'opacity-0')} />;
}
+19
View File
@@ -0,0 +1,19 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
export const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<'input'>>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
ref={ref}
{...props}
/>
);
},
);
Input.displayName = 'Input';
+18
View File
@@ -0,0 +1,18 @@
import * as React from 'react';
import * as LabelPrimitive from '@radix-ui/react-label';
import { cn } from '@/lib/utils';
export const Label = React.forwardRef<
React.ComponentRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
className,
)}
{...props}
/>
));
Label.displayName = LabelPrimitive.Root.displayName;
+6
View File
@@ -0,0 +1,6 @@
import { Toaster as SonnerToaster } from 'sonner';
/** App toast surface (NFR-U1-03 / Q5-B). */
export function Toaster() {
return <SonnerToaster position="top-right" richColors closeButton />;
}