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"