feat: add navigation layout with sidebar, header, auth guard, mobile responsive
This commit is contained in:
@@ -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 (
|
||||
<div className="flex items-center justify-center h-screen bg-background">
|
||||
<div className="text-text-muted text-sm">Loading…</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const locale = params.locale === 'en' ? 'en' : 'de';
|
||||
|
||||
return (
|
||||
<I18nProvider initialLocale={locale}>
|
||||
<ToastProvider>
|
||||
<div className="min-h-screen bg-background">
|
||||
<Navigation />
|
||||
{/* Main content area — offset for sidebar on desktop */}
|
||||
<main className="lg:ml-64 p-4 lg:p-8" data-testid="main-content">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</ToastProvider>
|
||||
</I18nProvider>
|
||||
);
|
||||
}
|
||||
@@ -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: (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 17a2 2 0 11-4 0 2 2 0 014 0zM19 17a2 2 0 11-4 0 2 2 0 014 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16V6a1 1 0 00-1-1H4a1 1 0 00-1 1v10a1 1 0 001 1h1m8-1a1 1 0 01-1 1H9m4-1V8a1 1 0 011-1h2.586a1 1 0 01.707.293l3.414 3.414a1 1 0 01.293.707V16a1 1 0 01-1 1h-1m-4-1a1 1 0 001 1h1M5 17a2 2 0 104 0m-4 0a2 2 0 114 0m6 0a2 2 0 104 0m-4 0a2 2 0 114 0" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
href: '/de/kontakte',
|
||||
labelKey: 'nav.contacts',
|
||||
icon: (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
href: '/de/verkauf',
|
||||
labelKey: 'nav.sales',
|
||||
icon: (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
href: '/de/ki-copilot',
|
||||
labelKey: 'nav.ki-copilot',
|
||||
icon: (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
href: '/de/ocr',
|
||||
labelKey: 'nav.ocr',
|
||||
icon: (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
href: '/de/retouch',
|
||||
labelKey: 'nav.retouch',
|
||||
icon: (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
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 */}
|
||||
<header
|
||||
className="lg:hidden fixed top-0 left-0 right-0 z-40 bg-surface border-b border-border flex items-center justify-between px-4 h-14"
|
||||
data-testid="mobile-header"
|
||||
>
|
||||
<button
|
||||
onClick={() => setMobileOpen((v) => !v)}
|
||||
className="p-2 rounded-lg hover:bg-primary/10 transition-colors"
|
||||
aria-label={t('nav.menu')}
|
||||
data-testid="hamburger-toggle"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6 text-text" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={mobileOpen ? 'M6 18L18 6M6 6l12 12' : 'M4 6h16M4 12h16M4 18h16'} />
|
||||
</svg>
|
||||
</button>
|
||||
<span className="text-sm font-semibold text-text" data-testid="mobile-app-name">{t('nav.appName')}</span>
|
||||
<UserMenu />
|
||||
</header>
|
||||
|
||||
{/* Mobile overlay */}
|
||||
{mobileOpen && (
|
||||
<div
|
||||
className="lg:hidden fixed inset-0 z-30 bg-black/40"
|
||||
onClick={() => setMobileOpen(false)}
|
||||
data-testid="mobile-overlay"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Sidebar */}
|
||||
<aside
|
||||
className={`fixed top-0 left-0 z-30 h-full w-64 bg-surface border-r border-border flex flex-col transition-transform duration-200 lg:translate-x-0 ${mobileOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'}`}
|
||||
data-testid="sidebar"
|
||||
>
|
||||
{/* Desktop logo / app name */}
|
||||
<div className="hidden lg:flex items-center h-14 px-6 border-b border-border">
|
||||
<span className="text-lg font-bold text-primary" data-testid="desktop-app-name">{t('nav.appName')}</span>
|
||||
</div>
|
||||
|
||||
{/* Mobile spacer for header height */}
|
||||
<div className="lg:hidden h-14" />
|
||||
|
||||
{/* Nav links */}
|
||||
<nav className="flex-1 overflow-y-auto py-4 px-3 space-y-1" data-testid="nav-links">
|
||||
{navItems.map((item) => {
|
||||
const active = isActive(item.href);
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors ${active ? 'bg-primary text-white' : 'text-text hover:bg-primary/10'}`}
|
||||
data-testid={`nav-link-${item.href.split('/')[2]}`}
|
||||
>
|
||||
{item.icon}
|
||||
<span>{t(item.labelKey)}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* Desktop user menu at bottom */}
|
||||
<div className="hidden lg:flex items-center justify-end px-4 py-3 border-t border-border">
|
||||
<UserMenu />
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Mobile spacer so content starts below header */}
|
||||
<div className="lg:hidden h-14" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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<string | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(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 (
|
||||
<div className="relative" ref={menuRef} data-testid="user-menu">
|
||||
<button
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-primary/10 transition-colors text-text"
|
||||
aria-label={t('nav.logout')}
|
||||
data-testid="user-menu-toggle"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 text-text-muted" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||
</svg>
|
||||
<span className="text-sm font-medium hidden sm:inline" data-testid="user-email">
|
||||
{email || '—'}
|
||||
</span>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 text-text-muted" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
{open && (
|
||||
<div
|
||||
className="absolute right-0 mt-2 w-56 bg-surface border border-border rounded-lg shadow-lg py-1 z-50"
|
||||
data-testid="user-menu-dropdown"
|
||||
>
|
||||
<div className="px-4 py-2 border-b border-border">
|
||||
<p className="text-xs text-text-muted">{t('nav.settings')}</p>
|
||||
<p className="text-sm font-medium text-text truncate">{email || '—'}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="w-full text-left px-4 py-2 text-sm text-error hover:bg-error/10 transition-colors flex items-center gap-2"
|
||||
data-testid="logout-button"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
|
||||
</svg>
|
||||
{t('nav.logout')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user