From 5ae1e28ef25c3ebc83714394ba69379dfca470cc Mon Sep 17 00:00:00 2001 From: Leopoldadmin Date: Sun, 19 Jul 2026 17:25:35 +0200 Subject: [PATCH] feat: add navigation layout with sidebar, header, auth guard, mobile responsive --- frontend/app/[locale]/layout.tsx | 53 ++++++++++ frontend/components/Navigation.tsx | 162 +++++++++++++++++++++++++++++ frontend/components/UserMenu.tsx | 92 ++++++++++++++++ frontend/messages/de.json | 5 + frontend/messages/en.json | 5 + 5 files changed, 317 insertions(+) create mode 100644 frontend/app/[locale]/layout.tsx create mode 100644 frontend/components/Navigation.tsx create mode 100644 frontend/components/UserMenu.tsx diff --git a/frontend/app/[locale]/layout.tsx b/frontend/app/[locale]/layout.tsx new file mode 100644 index 0000000..11ec10b --- /dev/null +++ b/frontend/app/[locale]/layout.tsx @@ -0,0 +1,53 @@ +'use client'; + +import { useEffect, useState, type ReactNode } from 'react'; +import { useRouter } from 'next/navigation'; +import { I18nProvider } from '@/lib/i18n'; +import { ToastProvider } from '@/components/ui/Toast'; +import { Navigation } from '@/components/Navigation'; +import { isAuthenticated } from '@/lib/api'; + +export default function LocaleLayout({ + children, + params, +}: { + children: ReactNode; + params: { locale: string }; +}) { + const router = useRouter(); + const [checked, setChecked] = useState(false); + + // Auth guard: redirect to /login if no token is present + useEffect(() => { + if (!isAuthenticated()) { + router.replace('/login'); + return; + } + setChecked(true); + }, [router]); + + // Show nothing while redirecting (avoids flashing protected content) + if (!checked) { + return ( +
+
Loading…
+
+ ); + } + + const locale = params.locale === 'en' ? 'en' : 'de'; + + return ( + + +
+ + {/* Main content area — offset for sidebar on desktop */} +
+ {children} +
+
+
+
+ ); +} diff --git a/frontend/components/Navigation.tsx b/frontend/components/Navigation.tsx new file mode 100644 index 0000000..5ecd351 --- /dev/null +++ b/frontend/components/Navigation.tsx @@ -0,0 +1,162 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import Link from 'next/link'; +import { usePathname } from 'next/navigation'; +import { useI18n } from '@/lib/i18n'; +import { UserMenu } from '@/components/UserMenu'; + +interface NavItem { + href: string; + labelKey: string; + icon: React.ReactNode; +} + +const navItems: NavItem[] = [ + { + href: '/de/fahrzeuge', + labelKey: 'nav.vehicles', + icon: ( + + + + + ), + }, + { + href: '/de/kontakte', + labelKey: 'nav.contacts', + icon: ( + + + + ), + }, + { + href: '/de/verkauf', + labelKey: 'nav.sales', + icon: ( + + + + ), + }, + { + href: '/de/ki-copilot', + labelKey: 'nav.ki-copilot', + icon: ( + + + + ), + }, + { + href: '/de/ocr', + labelKey: 'nav.ocr', + icon: ( + + + + ), + }, + { + href: '/de/retouch', + labelKey: 'nav.retouch', + icon: ( + + + + ), + }, +]; + +export function Navigation() { + const pathname = usePathname(); + const { t } = useI18n(); + const [mobileOpen, setMobileOpen] = useState(false); + + // Close mobile sidebar on route change + useEffect(() => { + setMobileOpen(false); + }, [pathname]); + + function isActive(href: string): boolean { + // Extract the path segment after locale (e.g. /de/fahrzeuge -> fahrzeuge) + const segments = href.split('/'); // ['', 'de', 'fahrzeuge'] + const section = segments[2]; // 'fahrzeuge' + if (!section) return false; + return pathname.includes(`/${section}`); + } + + return ( + <> + {/* Mobile header bar with hamburger */} +
+ + {t('nav.appName')} + +
+ + {/* Mobile overlay */} + {mobileOpen && ( +
setMobileOpen(false)} + data-testid="mobile-overlay" + /> + )} + + {/* Sidebar */} + + + {/* Mobile spacer so content starts below header */} +
+ + ); +} diff --git a/frontend/components/UserMenu.tsx b/frontend/components/UserMenu.tsx new file mode 100644 index 0000000..d10621e --- /dev/null +++ b/frontend/components/UserMenu.tsx @@ -0,0 +1,92 @@ +'use client'; + +import { useState, useEffect, useRef } from 'react'; +import { useRouter } from 'next/navigation'; +import { clearTokens } from '@/lib/api'; +import { useI18n } from '@/lib/i18n'; + +/** + * Decodes the email from a JWT access token stored in localStorage. + * Returns null if no token is present or the payload cannot be parsed. + */ +function decodeEmailFromToken(token: string | null): string | null { + if (!token) return null; + const parts = token.split('.'); + if (parts.length !== 3) return null; + try { + const payload = JSON.parse(atob(parts[1])); + return payload.email || payload.sub || null; + } catch { + return null; + } +} + +export function UserMenu() { + const router = useRouter(); + const { t } = useI18n(); + const [email, setEmail] = useState(null); + const [open, setOpen] = useState(false); + const menuRef = useRef(null); + + useEffect(() => { + const token = localStorage.getItem('access_token'); + setEmail(decodeEmailFromToken(token)); + }, []); + + useEffect(() => { + function handleClickOutside(e: MouseEvent) { + if (menuRef.current && !menuRef.current.contains(e.target as Node)) { + setOpen(false); + } + } + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + function handleLogout() { + clearTokens(); + router.push('/login'); + } + + return ( +
+ + {open && ( +
+
+

{t('nav.settings')}

+

{email || '—'}

+
+ +
+ )} +
+ ); +} diff --git a/frontend/messages/de.json b/frontend/messages/de.json index 146148e..fea554e 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -14,6 +14,11 @@ "nav.sales": "Verkäufe", "nav.settings": "Einstellungen", "nav.logout": "Abmelden", + "nav.ki-copilot": "KI-Copilot", + "nav.ocr": "OCR", + "nav.retouch": "Bildretusche", + "nav.menu": "Menü", + "nav.appName": "ERP Nutzfahrzeuge", "common.save": "Speichern", "common.cancel": "Abbrechen", "common.delete": "Löschen", diff --git a/frontend/messages/en.json b/frontend/messages/en.json index b9484ae..c90343b 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -14,6 +14,11 @@ "nav.sales": "Sales", "nav.settings": "Settings", "nav.logout": "Logout", + "nav.ki-copilot": "AI Copilot", + "nav.ocr": "OCR", + "nav.retouch": "Image Retouch", + "nav.menu": "Menu", + "nav.appName": "ERP Commercial Vehicles", "common.save": "Save", "common.cancel": "Cancel", "common.delete": "Delete",