diff --git a/apps/web/next-env.d.ts b/apps/web/next-env.d.ts
new file mode 100644
index 0000000..830fb59
--- /dev/null
+++ b/apps/web/next-env.d.ts
@@ -0,0 +1,6 @@
+///
+///
+///
+
+// NOTE: This file should not be edited
+// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css
index 3ac21be..6ffe7d4 100644
--- a/apps/web/src/app/globals.css
+++ b/apps/web/src/app/globals.css
@@ -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));
+ }
+}
diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx
index 897fec6..e5e9323 100644
--- a/apps/web/src/app/page.tsx
+++ b/apps/web/src/app/page.tsx
@@ -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 (
-
- Tessera
-
- Portal wird geladen...
-
-
+
+
+ {/* Grid icon */}
+
+
+
+
+ {/* Empty state text (D-16) */}
+
+ {t('empty')}
+
+
+ {/* Add widget button */}
+
+
+
);
}
diff --git a/apps/web/src/components/layout/app-shell.tsx b/apps/web/src/components/layout/app-shell.tsx
new file mode 100644
index 0000000..8d4009d
--- /dev/null
+++ b/apps/web/src/components/layout/app-shell.tsx
@@ -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 (
+
+
+
+
+ {children}
+
+
+ );
+}
diff --git a/apps/web/src/components/layout/header.tsx b/apps/web/src/components/layout/header.tsx
new file mode 100644
index 0000000..cc20100
--- /dev/null
+++ b/apps/web/src/components/layout/header.tsx
@@ -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 (
+
+ {/* Left: Logo + Mobile hamburger */}
+
+
+
+ {t('common.appName')}
+
+
+
+ {/* Center: Breadcrumb / page title */}
+
+
+ {t('header.breadcrumb.home')}
+
+
+
+ {/* Right: Actions */}
+
+
+
+ {/* User avatar placeholder - wired to auth in Phase 2 */}
+
+
+
+ );
+}
diff --git a/apps/web/src/components/layout/sidebar-footer.tsx b/apps/web/src/components/layout/sidebar-footer.tsx
new file mode 100644
index 0000000..7c14eee
--- /dev/null
+++ b/apps/web/src/components/layout/sidebar-footer.tsx
@@ -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 (
+
+ {/* Settings */}
+
+
+ {/* User info placeholder */}
+
+
+ {!isCollapsed && (
+
+ {t('userPlaceholder')}
+
+ )}
+
+
+ );
+}
diff --git a/apps/web/src/components/layout/sidebar.tsx b/apps/web/src/components/layout/sidebar.tsx
new file mode 100644
index 0000000..230cdf1
--- /dev/null
+++ b/apps/web/src/components/layout/sidebar.tsx
@@ -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 = (
+
+ {/* Navigation items */}
+
+
+ {/* Collapse/Expand toggle */}
+
+
+
+
+ {/* Sidebar footer (D-06) */}
+
+
+ );
+
+ return (
+ <>
+ {/* Desktop sidebar */}
+
+
+ {/* Mobile sidebar overlay (D-03) */}
+ {isMobileOpen && (
+
+ {/* Backdrop */}
+
setMobileOpen(false)}
+ aria-hidden="true"
+ />
+ {/* Sidebar panel */}
+
+
+ )}
+ >
+ );
+}
diff --git a/apps/web/src/components/locale-switcher.tsx b/apps/web/src/components/locale-switcher.tsx
new file mode 100644
index 0000000..be639cb
--- /dev/null
+++ b/apps/web/src/components/locale-switcher.tsx
@@ -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 (
+
+ );
+}
diff --git a/apps/web/src/components/theme-toggle.tsx b/apps/web/src/components/theme-toggle.tsx
new file mode 100644
index 0000000..f205959
--- /dev/null
+++ b/apps/web/src/components/theme-toggle.tsx
@@ -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 (
+
+ );
+}
diff --git a/apps/web/src/lib/stores/sidebar-store.ts b/apps/web/src/lib/stores/sidebar-store.ts
new file mode 100644
index 0000000..aa387af
--- /dev/null
+++ b/apps/web/src/lib/stores/sidebar-store.ts
@@ -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
()(
+ persist(
+ (set) => ({
+ isCollapsed: false,
+ isMobileOpen: false,
+ toggle: () => set((state) => ({ isCollapsed: !state.isCollapsed })),
+ setMobileOpen: (open) => set({ isMobileOpen: open }),
+ }),
+ { name: 'tessera-sidebar' }
+ )
+);
diff --git a/apps/web/src/messages/de.json b/apps/web/src/messages/de.json
index e26100c..27c70f2 100644
--- a/apps/web/src/messages/de.json
+++ b/apps/web/src/messages/de.json
@@ -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"
diff --git a/apps/web/src/messages/en.json b/apps/web/src/messages/en.json
index 2fe427a..1de1900 100644
--- a/apps/web/src/messages/en.json
+++ b/apps/web/src/messages/en.json
@@ -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"