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:
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
/// <reference path="./.next/types/routes.d.ts" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
@@ -96,3 +96,10 @@ body {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* App shell main content responsive margin */
|
||||
@media (min-width: 768px) {
|
||||
.app-shell-main {
|
||||
margin-left: var(--current-sidebar-width, var(--sidebar-width));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,45 @@
|
||||
export default function Home() {
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { AppShell } from '@/components/layout/app-shell';
|
||||
|
||||
export default function DashboardPage() {
|
||||
const t = useTranslations('dashboard');
|
||||
|
||||
return (
|
||||
<main className="flex min-h-screen flex-col items-center justify-center">
|
||||
<h1 className="text-4xl font-bold">Tessera</h1>
|
||||
<p className="mt-4 text-lg text-gray-600">
|
||||
Portal wird geladen...
|
||||
</p>
|
||||
</main>
|
||||
<AppShell>
|
||||
<div className="flex flex-col items-center justify-center min-h-[60vh] gap-4">
|
||||
{/* Grid icon */}
|
||||
<div className="rounded-lg bg-muted p-4">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="48"
|
||||
height="48"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
<rect x="3" y="3" width="7" height="7" rx="1" />
|
||||
<rect x="14" y="3" width="7" height="7" rx="1" />
|
||||
<rect x="14" y="14" width="7" height="7" rx="1" />
|
||||
<rect x="3" y="14" width="7" height="7" rx="1" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Empty state text (D-16) */}
|
||||
<p className="text-lg text-muted-foreground">
|
||||
{t('empty')}
|
||||
</p>
|
||||
|
||||
{/* Add widget button */}
|
||||
<button className="mt-2 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity">
|
||||
{t('addWidget')}
|
||||
</button>
|
||||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { useTransition } from 'react';
|
||||
|
||||
export function LocaleSwitcher() {
|
||||
const router = useRouter();
|
||||
const locale = useLocale();
|
||||
const t = useTranslations('locale');
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
function switchLocale() {
|
||||
const newLocale = locale === 'de' ? 'en' : 'de';
|
||||
document.cookie = `NEXT_LOCALE=${newLocale};path=/;max-age=31536000`;
|
||||
startTransition(() => {
|
||||
router.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
const currentLabel = locale === 'de' ? t('de') : t('en');
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={switchLocale}
|
||||
disabled={isPending}
|
||||
className="inline-flex items-center justify-center rounded-md px-2.5 py-1.5 text-sm font-medium text-muted-foreground hover:bg-muted hover:text-foreground transition-colors disabled:opacity-50"
|
||||
aria-label={currentLabel}
|
||||
>
|
||||
{locale === 'de' ? 'EN' : 'DE'}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
'use client';
|
||||
|
||||
import { useTheme } from 'next-themes';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export function ThemeToggle() {
|
||||
const { theme, setTheme } = useTheme();
|
||||
const t = useTranslations('theme');
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
function cycleTheme() {
|
||||
if (theme === 'light') {
|
||||
setTheme('dark');
|
||||
} else if (theme === 'dark') {
|
||||
setTheme('system');
|
||||
} else {
|
||||
setTheme('light');
|
||||
}
|
||||
}
|
||||
|
||||
const label = mounted
|
||||
? theme === 'light'
|
||||
? t('light')
|
||||
: theme === 'dark'
|
||||
? t('dark')
|
||||
: t('system')
|
||||
: '';
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={cycleTheme}
|
||||
className="inline-flex items-center justify-center rounded-md p-2 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
|
||||
aria-label={label}
|
||||
title={label}
|
||||
>
|
||||
{!mounted ? (
|
||||
<span className="h-5 w-5" />
|
||||
) : theme === 'light' ? (
|
||||
<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"
|
||||
>
|
||||
<circle cx="12" cy="12" r="5" />
|
||||
<line x1="12" y1="1" x2="12" y2="3" />
|
||||
<line x1="12" y1="21" x2="12" y2="23" />
|
||||
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64" />
|
||||
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78" />
|
||||
<line x1="1" y1="12" x2="3" y2="12" />
|
||||
<line x1="21" y1="12" x2="23" y2="12" />
|
||||
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36" />
|
||||
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22" />
|
||||
</svg>
|
||||
) : theme === 'dark' ? (
|
||||
<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"
|
||||
>
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
|
||||
</svg>
|
||||
) : (
|
||||
<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"
|
||||
>
|
||||
<rect x="2" y="3" width="20" height="14" rx="2" ry="2" />
|
||||
<line x1="8" y1="21" x2="16" y2="21" />
|
||||
<line x1="12" y1="17" x2="12" y2="21" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
interface SidebarState {
|
||||
isCollapsed: boolean;
|
||||
isMobileOpen: boolean;
|
||||
toggle: () => void;
|
||||
setMobileOpen: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export const useSidebarStore = create<SidebarState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
isCollapsed: false,
|
||||
isMobileOpen: false,
|
||||
toggle: () => set((state) => ({ isCollapsed: !state.isCollapsed })),
|
||||
setMobileOpen: (open) => set({ isMobileOpen: open }),
|
||||
}),
|
||||
{ name: 'tessera-sidebar' }
|
||||
)
|
||||
);
|
||||
@@ -3,7 +3,8 @@
|
||||
"appName": "Tessera",
|
||||
"loading": "Laden...",
|
||||
"save": "Speichern",
|
||||
"cancel": "Abbrechen"
|
||||
"cancel": "Abbrechen",
|
||||
"menu": "Menu"
|
||||
},
|
||||
"header": {
|
||||
"search": "Suchen...",
|
||||
@@ -18,6 +19,7 @@
|
||||
"settings": "Einstellungen",
|
||||
"collapse": "Einklappen",
|
||||
"expand": "Ausklappen",
|
||||
"userPlaceholder": "Benutzer",
|
||||
"categories": {
|
||||
"label": "Kategorien",
|
||||
"empty": "Keine Module"
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
"appName": "Tessera",
|
||||
"loading": "Loading...",
|
||||
"save": "Save",
|
||||
"cancel": "Cancel"
|
||||
"cancel": "Cancel",
|
||||
"menu": "Menu"
|
||||
},
|
||||
"header": {
|
||||
"search": "Search...",
|
||||
@@ -18,6 +19,7 @@
|
||||
"settings": "Settings",
|
||||
"collapse": "Collapse",
|
||||
"expand": "Expand",
|
||||
"userPlaceholder": "User",
|
||||
"categories": {
|
||||
"label": "Categories",
|
||||
"empty": "No modules"
|
||||
|
||||
Reference in New Issue
Block a user