feat(01-02): portal layout with header, sidebar, theme toggle, locale switcher

- Zustand sidebar store with persist middleware (collapse/expand, mobile open)
- Sticky header with logo, breadcrumb, theme toggle, locale switcher, user avatar
- Collapsible sidebar with Dashboard/Marketplace nav, accordion categories, footer
- Mobile responsive: hamburger menu with overlay sidebar on small screens
- Theme toggle cycling light/dark/system with mounted guard
- Locale switcher setting NEXT_LOCALE cookie with router.refresh
- Empty dashboard state with grid icon, "Keine Widgets aktiv" text, add button
- AppShell composing header + sidebar + responsive main content area
- All user-visible strings via useTranslations (UI-03 compliance)
This commit is contained in:
2026-06-18 10:27:46 +02:00
parent f3791c6cdd
commit 6039387857
12 changed files with 577 additions and 9 deletions
@@ -0,0 +1,34 @@
'use client';
import { useEffect, useState } from 'react';
import { useSidebarStore } from '@/lib/stores/sidebar-store';
import { Header } from '@/components/layout/header';
import { Sidebar } from '@/components/layout/sidebar';
export function AppShell({ children }: { children: React.ReactNode }) {
const isCollapsed = useSidebarStore((s) => s.isCollapsed);
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
// Desktop: sidebar pushes content via margin-left
// Mobile: sidebar overlays, no margin needed (handled by app-shell-main class in globals.css)
const sidebarWidth = isCollapsed
? 'var(--sidebar-width-collapsed)'
: 'var(--sidebar-width)';
return (
<div className="min-h-screen bg-background">
<Header />
<Sidebar />
<main
className="app-shell-main min-h-[calc(100vh-var(--header-height))] transition-all duration-200 p-6"
style={mounted ? { '--current-sidebar-width': sidebarWidth } as React.CSSProperties : undefined}
>
{children}
</main>
</div>
);
}
+76
View File
@@ -0,0 +1,76 @@
'use client';
import { useTranslations } from 'next-intl';
import { useSidebarStore } from '@/lib/stores/sidebar-store';
import { ThemeToggle } from '@/components/theme-toggle';
import { LocaleSwitcher } from '@/components/locale-switcher';
export function Header() {
const t = useTranslations();
const setMobileOpen = useSidebarStore((s) => s.setMobileOpen);
return (
<header className="sticky top-0 z-50 flex h-[var(--header-height)] items-center border-b border-border bg-background px-4 shadow-sm">
{/* Left: Logo + Mobile hamburger */}
<div className="flex items-center gap-3">
<button
onClick={() => setMobileOpen(true)}
className="inline-flex items-center justify-center rounded-md p-2 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors md:hidden"
aria-label={t('common.menu')}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="3" y1="6" x2="21" y2="6" />
<line x1="3" y1="12" x2="21" y2="12" />
<line x1="3" y1="18" x2="21" y2="18" />
</svg>
</button>
<span className="text-lg font-bold text-primary">
{t('common.appName')}
</span>
</div>
{/* Center: Breadcrumb / page title */}
<div className="flex-1 px-4 text-center">
<span className="text-sm text-muted-foreground truncate">
{t('header.breadcrumb.home')}
</span>
</div>
{/* Right: Actions */}
<div className="flex items-center gap-2">
<ThemeToggle />
<LocaleSwitcher />
{/* User avatar placeholder - wired to auth in Phase 2 */}
<div
className="h-8 w-8 rounded-full bg-muted flex items-center justify-center text-xs text-muted-foreground"
aria-label={t('header.userMenu')}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
</div>
</div>
</header>
);
}
@@ -0,0 +1,61 @@
'use client';
import { useTranslations } from 'next-intl';
import { useSidebarStore } from '@/lib/stores/sidebar-store';
export function SidebarFooter() {
const t = useTranslations('sidebar');
const isCollapsed = useSidebarStore((s) => s.isCollapsed);
return (
<div className="border-t border-sidebar-border p-3 flex flex-col gap-2">
{/* Settings */}
<button className="flex items-center gap-3 rounded-md px-2 py-1.5 text-sm text-sidebar-foreground hover:bg-muted transition-colors">
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="shrink-0"
>
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg>
{!isCollapsed && (
<span className="truncate">{t('settings')}</span>
)}
</button>
{/* User info placeholder */}
<div className="flex items-center gap-3 px-2 py-1.5">
<div className="h-7 w-7 shrink-0 rounded-full bg-muted flex items-center justify-center">
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="text-muted-foreground"
>
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
</div>
{!isCollapsed && (
<span className="text-sm text-sidebar-foreground truncate">
{t('userPlaceholder')}
</span>
)}
</div>
</div>
);
}
+193
View File
@@ -0,0 +1,193 @@
'use client';
import { useState } from 'react';
import { useTranslations } from 'next-intl';
import { useSidebarStore } from '@/lib/stores/sidebar-store';
import { SidebarFooter } from '@/components/layout/sidebar-footer';
export function Sidebar() {
const t = useTranslations('sidebar');
const tCommon = useTranslations('common');
const { isCollapsed, isMobileOpen, toggle, setMobileOpen } = useSidebarStore();
const [categoriesOpen, setCategoriesOpen] = useState(false);
const sidebarContent = (
<div className="flex h-full flex-col bg-sidebar">
{/* Navigation items */}
<nav className="flex-1 overflow-y-auto p-3">
<ul className="flex flex-col gap-1">
{/* Dashboard - active by default (D-15) */}
<li>
<a
href="/"
className="flex items-center gap-3 rounded-md px-2 py-2 text-sm font-medium bg-sidebar-accent text-sidebar-accent-foreground transition-colors"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="shrink-0"
>
<rect x="3" y="3" width="7" height="7" />
<rect x="14" y="3" width="7" height="7" />
<rect x="14" y="14" width="7" height="7" />
<rect x="3" y="14" width="7" height="7" />
</svg>
{!isCollapsed && <span className="truncate">{t('dashboard')}</span>}
</a>
</li>
{/* Marketplace */}
<li>
<a
href="/marketplace"
className="flex items-center gap-3 rounded-md px-2 py-2 text-sm text-sidebar-foreground hover:bg-muted transition-colors"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="shrink-0"
>
<circle cx="9" cy="21" r="1" />
<circle cx="20" cy="21" r="1" />
<path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6" />
</svg>
{!isCollapsed && <span className="truncate">{t('marketplace')}</span>}
</a>
</li>
</ul>
{/* Categories accordion (D-05) */}
{!isCollapsed && (
<div className="mt-6">
<button
onClick={() => setCategoriesOpen(!categoriesOpen)}
className="flex w-full items-center justify-between rounded-md px-2 py-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground hover:text-foreground transition-colors"
>
<span>{t('categories.label')}</span>
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={`transition-transform duration-200 ${categoriesOpen ? 'rotate-180' : ''}`}
>
<polyline points="6 9 12 15 18 9" />
</svg>
</button>
{categoriesOpen && (
<div className="mt-1 px-2 py-2 text-sm text-muted-foreground">
{t('categories.empty')}
</div>
)}
</div>
)}
</nav>
{/* Collapse/Expand toggle */}
<div className="hidden md:block border-t border-sidebar-border p-2">
<button
onClick={toggle}
className="flex w-full items-center gap-3 rounded-md px-2 py-1.5 text-sm text-sidebar-foreground hover:bg-muted transition-colors"
aria-label={isCollapsed ? t('expand') : t('collapse')}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={`shrink-0 transition-transform duration-200 ${isCollapsed ? 'rotate-180' : ''}`}
>
<polyline points="15 18 9 12 15 6" />
</svg>
{!isCollapsed && (
<span className="truncate">{t('collapse')}</span>
)}
</button>
</div>
{/* Sidebar footer (D-06) */}
<SidebarFooter />
</div>
);
return (
<>
{/* Desktop sidebar */}
<aside
className="fixed left-0 top-[var(--header-height)] z-30 hidden h-[calc(100vh-var(--header-height))] border-r border-sidebar-border transition-all duration-200 md:block"
style={{
width: isCollapsed
? 'var(--sidebar-width-collapsed)'
: 'var(--sidebar-width)',
}}
>
{sidebarContent}
</aside>
{/* Mobile sidebar overlay (D-03) */}
{isMobileOpen && (
<div className="fixed inset-0 z-40 md:hidden">
{/* Backdrop */}
<div
className="fixed inset-0 bg-black/50"
onClick={() => setMobileOpen(false)}
aria-hidden="true"
/>
{/* Sidebar panel */}
<aside className="fixed left-0 top-0 z-50 h-full w-[var(--sidebar-width)] bg-sidebar shadow-lg">
{/* Mobile close button */}
<div className="flex h-[var(--header-height)] items-center justify-between border-b border-sidebar-border px-4">
<span className="text-lg font-bold text-primary">
{tCommon('appName')}
</span>
<button
onClick={() => setMobileOpen(false)}
className="rounded-md p-2 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
{sidebarContent}
</aside>
</div>
)}
</>
);
}