Frontend: App shell, navigation, routing, and design tokens #32

Merged
marcel merged 19 commits from feat/issue-16-design-system into master 2026-04-02 14:14:17 +02:00
26 changed files with 717 additions and 0 deletions

25
frontend/src/app.d.ts vendored Normal file
View File

@@ -0,0 +1,25 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
namespace App {
interface Error {
meldung?: string;
}
interface Locals {
benutzer?: {
id: string;
name: string;
rolle: 'planer' | 'mitglied';
};
haushalt?: {
id: string | undefined;
name: string;
};
}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};

View File

@@ -0,0 +1,146 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock $env/dynamic/private before importing anything
vi.mock('$env/dynamic/private', () => ({
env: { BACKEND_URL: 'http://localhost:8080' }
}));
// Mock the apiClient
const mockGet = vi.fn();
vi.mock('$lib/server/api', () => ({
apiClient: () => ({ GET: mockGet })
}));
describe('auth guard (hooks.server.ts handle)', () => {
let handle: any;
beforeEach(async () => {
mockGet.mockReset();
const mod = await import('./hooks.server');
handle = mod.handle;
});
function createEvent(pathname: string, cookie?: string) {
const resolve = vi.fn().mockResolvedValue(new Response('ok'));
const event = {
url: new URL(`http://localhost${pathname}`),
cookies: {
get: vi.fn().mockImplementation((name: string) => {
if (name === 'JSESSIONID') return cookie;
return undefined;
})
},
locals: {} as any,
fetch: vi.fn()
};
return { event, resolve };
}
it('allows public routes without auth', async () => {
const { event, resolve } = createEvent('/login');
await handle({ event, resolve });
expect(resolve).toHaveBeenCalledWith(event);
});
it.each(['/login', '/login/', '/register', '/invite/abc123'])(
'allows public route %s without auth',
async (path) => {
const { event, resolve } = createEvent(path);
await handle({ event, resolve });
expect(resolve).toHaveBeenCalledWith(event);
}
);
it.each(['/_app/immutable/chunks/app.js', '/favicon.ico'])(
'allows static asset %s without auth',
async (path) => {
const { event, resolve } = createEvent(path);
await handle({ event, resolve });
expect(resolve).toHaveBeenCalledWith(event);
}
);
it('redirects unauthenticated requests to /login with redirect param', async () => {
const { event, resolve } = createEvent('/recipes/abc');
try {
await handle({ event, resolve });
expect.unreachable();
} catch (e: any) {
expect(e.status).toBe(302);
expect(e.location).toBe('/login?redirect=%2Frecipes%2Fabc');
}
});
it('populates event.locals.benutzer on valid session', async () => {
mockGet.mockResolvedValue({
data: {
data: {
id: '123',
displayName: 'Max',
householdId: 'h1',
householdName: 'Familie Müller',
householdRole: 'planer',
email: 'max@example.com',
systemRole: 'user'
}
},
error: undefined
});
const { event, resolve } = createEvent('/planner', 'valid-session');
await handle({ event, resolve });
expect(event.locals.benutzer).toEqual({
id: '123',
name: 'Max',
rolle: 'planer'
});
expect(event.locals.haushalt).toEqual({
id: 'h1',
name: 'Familie Müller'
});
expect(resolve).toHaveBeenCalledWith(event);
});
it('handles user without household gracefully', async () => {
mockGet.mockResolvedValue({
data: {
data: {
id: '456',
displayName: 'Neu',
householdId: null,
householdName: null,
householdRole: null,
email: 'neu@example.com',
systemRole: 'user'
}
},
error: undefined
});
const { event, resolve } = createEvent('/planner', 'valid-session');
await handle({ event, resolve });
expect(event.locals.benutzer).toEqual({
id: '456',
name: 'Neu',
rolle: 'mitglied'
});
expect(event.locals.haushalt).toEqual({
id: undefined,
name: 'Kein Haushalt'
});
expect(resolve).toHaveBeenCalledWith(event);
});
it('redirects to /login with redirect param when session validation fails', async () => {
mockGet.mockResolvedValue({ data: undefined, error: { status: 401 } });
const { event, resolve } = createEvent('/planner', 'bad-session');
try {
await handle({ event, resolve });
expect.unreachable();
} catch (e: any) {
expect(e.status).toBe(302);
expect(e.location).toBe('/login?redirect=%2Fplanner');
}
});
});

View File

@@ -0,0 +1,50 @@
import type { Handle } from '@sveltejs/kit';
import { redirect } from '@sveltejs/kit';
import { apiClient } from '$lib/server/api';
const PUBLIC_ROUTES = ['/login', '/register', '/invite'];
const STATIC_PREFIXES = ['/_app/', '/favicon'];
function isPublicRoute(pathname: string): boolean {
if (STATIC_PREFIXES.some((prefix) => pathname.startsWith(prefix))) {
return true;
}
return PUBLIC_ROUTES.some((route) => pathname === route || pathname.startsWith(route + '/'));
}
function loginRedirect(pathname: string): never {
const target = '/login?redirect=' + encodeURIComponent(pathname);
redirect(302, target);
}
export const handle: Handle = async ({ event, resolve }) => {
if (isPublicRoute(event.url.pathname)) {
return resolve(event);
}
const sessionCookie = event.cookies.get('JSESSIONID');
if (!sessionCookie) {
loginRedirect(event.url.pathname);
}
const api = apiClient(event.fetch);
const { data, error } = await api.GET('/v1/auth/me');
if (error || !data?.data) {
loginRedirect(event.url.pathname);
}
const user = data.data;
event.locals.benutzer = {
id: user.id!,
name: user.displayName!,
rolle: (user.householdRole as 'planer' | 'mitglied') ?? 'mitglied'
};
event.locals.haushalt = {
id: user.householdId ?? undefined,
name: user.householdName ?? 'Kein Haushalt'
};
return resolve(event);
};

View File

@@ -0,0 +1,19 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import MobileTabBar from './MobileTabBar.svelte';
import TabletNavBar from './TabletNavBar.svelte';
import DesktopSidebar from './DesktopSidebar.svelte';
let { appName, householdName, children }: { appName: string; householdName: string; children?: Snippet } = $props();
</script>
<div class="flex min-h-screen bg-[var(--color-page)]">
<DesktopSidebar {appName} {householdName} />
<div class="flex flex-1 flex-col">
<TabletNavBar />
<main class="flex-1">
{@render children?.()}
</main>
<MobileTabBar />
</div>
</div>

View File

@@ -0,0 +1,37 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import AppShell from './AppShell.svelte';
vi.mock('$app/stores', async () => {
const { readable } = await import('svelte/store');
return {
page: readable({ url: new URL('http://localhost/planner') })
};
});
describe('AppShell', () => {
const defaultProps = { appName: 'Mealprep', householdName: 'Familie Müller' };
it('renders the DesktopSidebar', () => {
render(AppShell, { props: defaultProps });
expect(screen.getByTestId('variety-widget-slot')).toBeInTheDocument();
});
it('renders the MobileTabBar nav', () => {
render(AppShell, { props: defaultProps });
const navs = screen.getAllByLabelText('Hauptnavigation');
expect(navs.length).toBeGreaterThanOrEqual(2);
});
it('renders a main content area', () => {
render(AppShell, { props: defaultProps });
expect(screen.getByRole('main')).toBeInTheDocument();
});
it('renders all navigation links from all nav variants', () => {
render(AppShell, { props: defaultProps });
const links = screen.getAllByRole('link');
// Mobile: 4, Tablet: 4, Desktop: 5 = 13 total
expect(links).toHaveLength(13);
});
});

View File

@@ -0,0 +1,43 @@
<script lang="ts">
import { page } from '$app/stores';
import { desktopNavSections, isActiveRoute } from './nav';
let { appName, householdName }: { appName: string; householdName: string } = $props();
</script>
<aside
class="hidden lg:flex flex-col sticky top-0 h-screen w-[224px] min-w-[224px] border-r border-[var(--color-border)] bg-white"
>
<div class="px-[18px] pt-[14px] pb-[14px] border-b border-[var(--color-border)]">
<div class="flex items-center gap-2">
<div class="w-[22px] h-[22px] bg-[var(--green)] rounded-[var(--radius-sm)]"></div>
<span class="font-[var(--font-display)] text-[15px] font-medium">{appName}</span>
</div>
<p class="text-[10px] text-[var(--color-text-muted)]">{householdName}</p>
</div>
<nav aria-label="Hauptnavigation" class="flex-1 overflow-y-auto px-2 py-1">
{#each desktopNavSections as section (section.title)}
<p
class="text-[8px] font-medium uppercase tracking-[0.1em] text-[var(--color-text-muted)] font-[var(--font-sans)] px-3 pt-4 pb-1"
>
{section.title}
</p>
{#each section.items as item (item.href)}
{@const active = isActiveRoute(item.href, $page.url.pathname)}
<a
href={item.href}
aria-current={active ? 'page' : undefined}
class="px-3 py-[7px] text-[13px] font-[var(--font-sans)] rounded-[var(--radius-md)] flex items-center gap-2 {active
? 'bg-[var(--green-tint)] text-[var(--green-dark)] font-medium'
: 'hover:bg-[var(--color-subtle)]'}"
>
<span class="w-[20px] text-[16px] text-center">{item.icon}</span>
{item.label}
</a>
{/each}
{/each}
</nav>
<div data-testid="variety-widget-slot" class="mt-auto p-3"></div>
</aside>

View File

@@ -0,0 +1,61 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import DesktopSidebar from './DesktopSidebar.svelte';
vi.mock('$app/stores', async () => {
const { readable } = await import('svelte/store');
return {
page: readable({ url: new URL('http://localhost/planner') })
};
});
describe('DesktopSidebar', () => {
it('renders the app name', () => {
render(DesktopSidebar, { props: { appName: 'Mealprep', householdName: 'Familie Müller' } });
expect(screen.getByText('Mealprep')).toBeInTheDocument();
});
it('renders the household name', () => {
render(DesktopSidebar, { props: { appName: 'Mealprep', householdName: 'Familie Müller' } });
expect(screen.getByText('Familie Müller')).toBeInTheDocument();
});
it('renders Plan section with 3 items', () => {
render(DesktopSidebar, { props: { appName: 'Mealprep', householdName: 'Familie Müller' } });
expect(screen.getByText('Plan')).toBeInTheDocument();
expect(screen.getByText('Planer')).toBeInTheDocument();
expect(screen.getByText('Rezepte')).toBeInTheDocument();
expect(screen.getByText('Einkauf')).toBeInTheDocument();
});
it('renders Household section with 2 items', () => {
render(DesktopSidebar, { props: { appName: 'Mealprep', householdName: 'Familie Müller' } });
expect(screen.getByText('Haushalt')).toBeInTheDocument();
expect(screen.getByText('Mitglieder')).toBeInTheDocument();
expect(screen.getByText('Einstellungen')).toBeInTheDocument();
});
it('has 5 navigation links total', () => {
render(DesktopSidebar, { props: { appName: 'Mealprep', householdName: 'Familie Müller' } });
const links = screen.getAllByRole('link');
expect(links).toHaveLength(5);
});
it('marks active item with aria-current="page"', () => {
render(DesktopSidebar, { props: { appName: 'Mealprep', householdName: 'Familie Müller' } });
const plannerLink = screen.getByRole('link', { name: /planer/i });
expect(plannerLink).toHaveAttribute('aria-current', 'page');
});
it('non-active items do not have aria-current', () => {
render(DesktopSidebar, { props: { appName: 'Mealprep', householdName: 'Familie Müller' } });
const recipesLink = screen.getByRole('link', { name: /rezepte/i });
expect(recipesLink).not.toHaveAttribute('aria-current');
});
it('renders a variety widget slot area', () => {
render(DesktopSidebar, { props: { appName: 'Mealprep', householdName: 'Familie Müller' } });
const widget = screen.getByTestId('variety-widget-slot');
expect(widget).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,37 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/svelte';
const { pageStore } = vi.hoisted(() => {
const { writable } = require('svelte/store');
const pageStore = writable({ url: new URL('http://localhost/planner') });
return { pageStore };
});
vi.mock('$app/stores', () => ({
page: pageStore
}));
import MobileTabBar from './MobileTabBar.svelte';
describe('MobileTabBar active state per route', () => {
it.each([
['/planner', 'Planer'],
['/recipes', 'Rezepte'],
['/shopping', 'Einkauf'],
['/settings', 'Einstellungen']
])('on %s, %s is active and others are not', (route, expectedActiveLabel) => {
pageStore.set({ url: new URL(`http://localhost${route}`) });
const { unmount } = render(MobileTabBar);
const activeLink = screen.getByRole('link', { name: new RegExp(expectedActiveLabel) });
expect(activeLink).toHaveAttribute('aria-current', 'page');
const allLinks = screen.getAllByRole('link');
const inactiveLinks = allLinks.filter((link) => !link.textContent?.includes(expectedActiveLabel));
for (const link of inactiveLinks) {
expect(link).not.toHaveAttribute('aria-current');
}
unmount();
});
});

View File

@@ -0,0 +1,23 @@
<script lang="ts">
import { page } from '$app/stores';
import { mobileNavItems, isActiveRoute } from './nav';
</script>
<nav
aria-label="Hauptnavigation"
class="fixed bottom-0 w-full flex justify-around bg-white border-t pb-[env(safe-area-inset-bottom,20px)] md:hidden"
>
{#each mobileNavItems as item (item.href)}
{@const active = isActiveRoute(item.href, $page.url.pathname)}
<a
href={item.href}
aria-current={active ? 'page' : undefined}
class="flex flex-col items-center gap-1 py-2 px-3 rounded-[var(--radius-md)] text-[10px] font-[var(--font-sans)] min-h-[44px] min-w-[44px] {active
? 'bg-[var(--green-tint)] text-[var(--green-dark)] font-medium'
: ''}"
>
<span class="text-[16px]">{item.icon}</span>
{item.label}
</a>
{/each}
</nav>

View File

@@ -0,0 +1,55 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import MobileTabBar from './MobileTabBar.svelte';
vi.mock('$app/stores', async () => {
const { readable } = await import('svelte/store');
return {
page: readable({ url: new URL('http://localhost/planner') })
};
});
describe('MobileTabBar', () => {
it('renders 4 nav items', () => {
render(MobileTabBar);
const links = screen.getAllByRole('link');
expect(links).toHaveLength(4);
});
it('renders correct labels', () => {
render(MobileTabBar);
expect(screen.getByText('Planer')).toBeInTheDocument();
expect(screen.getByText('Rezepte')).toBeInTheDocument();
expect(screen.getByText('Einkauf')).toBeInTheDocument();
expect(screen.getByText('Einstellungen')).toBeInTheDocument();
});
it('links have correct hrefs', () => {
render(MobileTabBar);
const links = screen.getAllByRole('link');
expect(links[0]).toHaveAttribute('href', '/planner');
expect(links[1]).toHaveAttribute('href', '/recipes');
expect(links[2]).toHaveAttribute('href', '/shopping');
expect(links[3]).toHaveAttribute('href', '/settings');
});
it('marks active item with aria-current="page"', () => {
render(MobileTabBar);
const plannerLink = screen.getByRole('link', { name: /planer/i });
expect(plannerLink).toHaveAttribute('aria-current', 'page');
});
it('renders icons for each nav item', () => {
render(MobileTabBar);
expect(screen.getByText('📅')).toBeInTheDocument();
expect(screen.getByText('📖')).toBeInTheDocument();
expect(screen.getByText('🛒')).toBeInTheDocument();
expect(screen.getByText('⚙️')).toBeInTheDocument();
});
it('non-active items do not have aria-current', () => {
render(MobileTabBar);
const recipesLink = screen.getByRole('link', { name: /rezepte/i });
expect(recipesLink).not.toHaveAttribute('aria-current');
});
});

View File

@@ -0,0 +1,23 @@
<script lang="ts">
import { page } from '$app/stores';
import { mobileNavItems, isActiveRoute } from './nav';
</script>
<nav
aria-label="Hauptnavigation"
class="hidden md:flex lg:hidden gap-2 items-center p-2"
>
{#each mobileNavItems as item (item.href)}
{@const active = isActiveRoute(item.href, $page.url.pathname)}
<a
href={item.href}
aria-current={active ? 'page' : undefined}
class="px-4 py-2 rounded-[var(--radius-md)] text-[13px] font-[var(--font-sans)] {active
? 'bg-[var(--green-tint)] text-[var(--green-dark)] font-medium'
: 'hover:bg-[var(--color-subtle)]'}"
>
<span>{item.icon}</span>
{item.label}
</a>
{/each}
</nav>

View File

@@ -0,0 +1,38 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import TabletNavBar from './TabletNavBar.svelte';
vi.mock('$app/stores', async () => {
const { readable } = await import('svelte/store');
return {
page: readable({ url: new URL('http://localhost/planner') })
};
});
describe('TabletNavBar', () => {
it('renders 4 nav items', () => {
render(TabletNavBar);
const links = screen.getAllByRole('link');
expect(links).toHaveLength(4);
});
it('renders correct labels', () => {
render(TabletNavBar);
expect(screen.getByText('Planer')).toBeInTheDocument();
expect(screen.getByText('Rezepte')).toBeInTheDocument();
expect(screen.getByText('Einkauf')).toBeInTheDocument();
expect(screen.getByText('Einstellungen')).toBeInTheDocument();
});
it('marks active item with aria-current="page"', () => {
render(TabletNavBar);
const plannerLink = screen.getByRole('link', { name: /planer/i });
expect(plannerLink).toHaveAttribute('aria-current', 'page');
});
it('renders as horizontal pill navigation', () => {
render(TabletNavBar);
const nav = screen.getByLabelText('Hauptnavigation');
expect(nav).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,60 @@
import { describe, it, expect } from 'vitest';
import { mobileNavItems, desktopNavSections, isActiveRoute } from './nav';
describe('nav config', () => {
describe('mobileNavItems', () => {
it('has exactly 4 items', () => {
expect(mobileNavItems).toHaveLength(4);
});
it('contains Planner, Recipes, Shopping, Settings in order', () => {
const labels = mobileNavItems.map((item) => item.label);
expect(labels).toEqual(['Planer', 'Rezepte', 'Einkauf', 'Einstellungen']);
});
it('each item has href, label, and icon', () => {
for (const item of mobileNavItems) {
expect(item).toHaveProperty('href');
expect(item).toHaveProperty('label');
expect(item).toHaveProperty('icon');
expect(item.href).toMatch(/^\//);
}
});
});
describe('desktopNavSections', () => {
it('has 2 sections: Plan and Household', () => {
expect(desktopNavSections).toHaveLength(2);
expect(desktopNavSections[0].title).toBe('Plan');
expect(desktopNavSections[1].title).toBe('Haushalt');
});
it('Plan section has Planner, Recipes, Shopping', () => {
const labels = desktopNavSections[0].items.map((item) => item.label);
expect(labels).toEqual(['Planer', 'Rezepte', 'Einkauf']);
});
it('Household section has Members, Settings', () => {
const labels = desktopNavSections[1].items.map((item) => item.label);
expect(labels).toEqual(['Mitglieder', 'Einstellungen']);
});
});
describe('isActiveRoute', () => {
it('matches exact route', () => {
expect(isActiveRoute('/planner', '/planner')).toBe(true);
});
it('matches sub-route', () => {
expect(isActiveRoute('/planner', '/planner/week')).toBe(true);
});
it('does not match route with similar prefix', () => {
expect(isActiveRoute('/settings', '/settings-advanced')).toBe(false);
});
it('does not match unrelated route', () => {
expect(isActiveRoute('/planner', '/recipes')).toBe(false);
});
});
});

View File

@@ -0,0 +1,39 @@
export interface NavItem {
href: string;
label: string;
icon: string;
}
export interface NavSection {
title: string;
items: NavItem[];
}
export const mobileNavItems: NavItem[] = [
{ href: '/planner', label: 'Planer', icon: '📅' },
{ href: '/recipes', label: 'Rezepte', icon: '📖' },
{ href: '/shopping', label: 'Einkauf', icon: '🛒' },
{ href: '/settings', label: 'Einstellungen', icon: '⚙️' }
];
export function isActiveRoute(href: string, pathname: string): boolean {
return pathname === href || pathname.startsWith(href + '/');
}
export const desktopNavSections: NavSection[] = [
{
title: 'Plan',
items: [
{ href: '/planner', label: 'Planer', icon: '📅' },
{ href: '/recipes', label: 'Rezepte', icon: '📖' },
{ href: '/shopping', label: 'Einkauf', icon: '🛒' }
]
},
{
title: 'Haushalt',
items: [
{ href: '/members', label: 'Mitglieder', icon: '👥' },
{ href: '/settings', label: 'Einstellungen', icon: '⚙️' }
]
}
];

View File

@@ -0,0 +1,8 @@
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = async ({ locals }) => {
return {
benutzer: locals.benutzer!,
haushalt: locals.haushalt!
};
};

View File

@@ -0,0 +1,9 @@
<script lang="ts">
import AppShell from '$lib/nav/AppShell.svelte';
let { data, children } = $props();
</script>
<AppShell appName="Mealprep" householdName={data.haushalt.name}>
{@render children()}
</AppShell>

View File

@@ -0,0 +1 @@
<h1 class="text-2xl font-medium p-6">Mitglieder</h1>

View File

@@ -0,0 +1 @@
<h1 class="text-2xl font-medium p-6">Planer</h1>

View File

@@ -0,0 +1 @@
<h1 class="text-2xl font-medium p-6">Rezepte</h1>

View File

@@ -0,0 +1 @@
<h1 class="text-2xl font-medium p-6">Einstellungen</h1>

View File

@@ -0,0 +1 @@
<h1 class="text-2xl font-medium p-6">Einkaufsliste</h1>

View File

@@ -0,0 +1,12 @@
<script lang="ts">
let { children } = $props();
</script>
<div class="flex min-h-screen">
<div class="hidden md:flex md:w-1/2 bg-[var(--green)] items-center justify-center">
<span class="font-[var(--font-display)] text-4xl text-white font-medium">Mealprep</span>
</div>
<div class="flex-1 flex items-center justify-center p-6">
{@render children()}
</div>
</div>

View File

@@ -0,0 +1,2 @@
<h1 class="text-2xl font-medium">Anmelden</h1>
<p class="text-[var(--color-text-muted)] mt-2">Login-Formular folgt.</p>

View File

@@ -0,0 +1,6 @@
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async () => {
redirect(302, '/planner');
};

View File

@@ -0,0 +1 @@
<p>Weiterleitung...</p>

18
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,18 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vitest/config';
import tailwindcss from '@tailwindcss/vite';
export default defineConfig({
plugins: [tailwindcss(), sveltekit()],
test: {
include: ['src/**/*.{test,spec}.{js,ts}'],
globals: true,
environment: 'jsdom',
setupFiles: ['src/test-setup.ts']
},
// Required for vitest: resolves Svelte to client entry (not server).
// SvelteKit's plugin overrides this for SSR builds — verified safe.
resolve: {
conditions: ['browser']
}
});