Frontend: App shell, navigation, routing, and design tokens #32
25
frontend/src/app.d.ts
vendored
Normal file
25
frontend/src/app.d.ts
vendored
Normal 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 {};
|
||||||
146
frontend/src/hooks.server.test.ts
Normal file
146
frontend/src/hooks.server.test.ts
Normal 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');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
50
frontend/src/hooks.server.ts
Normal file
50
frontend/src/hooks.server.ts
Normal 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);
|
||||||
|
};
|
||||||
19
frontend/src/lib/nav/AppShell.svelte
Normal file
19
frontend/src/lib/nav/AppShell.svelte
Normal 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>
|
||||||
37
frontend/src/lib/nav/AppShell.test.ts
Normal file
37
frontend/src/lib/nav/AppShell.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
43
frontend/src/lib/nav/DesktopSidebar.svelte
Normal file
43
frontend/src/lib/nav/DesktopSidebar.svelte
Normal 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>
|
||||||
61
frontend/src/lib/nav/DesktopSidebar.test.ts
Normal file
61
frontend/src/lib/nav/DesktopSidebar.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
37
frontend/src/lib/nav/MobileTabBar.routes.test.ts
Normal file
37
frontend/src/lib/nav/MobileTabBar.routes.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
23
frontend/src/lib/nav/MobileTabBar.svelte
Normal file
23
frontend/src/lib/nav/MobileTabBar.svelte
Normal 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>
|
||||||
55
frontend/src/lib/nav/MobileTabBar.test.ts
Normal file
55
frontend/src/lib/nav/MobileTabBar.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
23
frontend/src/lib/nav/TabletNavBar.svelte
Normal file
23
frontend/src/lib/nav/TabletNavBar.svelte
Normal 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>
|
||||||
38
frontend/src/lib/nav/TabletNavBar.test.ts
Normal file
38
frontend/src/lib/nav/TabletNavBar.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
60
frontend/src/lib/nav/nav.test.ts
Normal file
60
frontend/src/lib/nav/nav.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
39
frontend/src/lib/nav/nav.ts
Normal file
39
frontend/src/lib/nav/nav.ts
Normal 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: '⚙️' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
8
frontend/src/routes/(app)/+layout.server.ts
Normal file
8
frontend/src/routes/(app)/+layout.server.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import type { LayoutServerLoad } from './$types';
|
||||||
|
|
||||||
|
export const load: LayoutServerLoad = async ({ locals }) => {
|
||||||
|
return {
|
||||||
|
benutzer: locals.benutzer!,
|
||||||
|
haushalt: locals.haushalt!
|
||||||
|
};
|
||||||
|
};
|
||||||
9
frontend/src/routes/(app)/+layout.svelte
Normal file
9
frontend/src/routes/(app)/+layout.svelte
Normal 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>
|
||||||
1
frontend/src/routes/(app)/members/+page.svelte
Normal file
1
frontend/src/routes/(app)/members/+page.svelte
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<h1 class="text-2xl font-medium p-6">Mitglieder</h1>
|
||||||
1
frontend/src/routes/(app)/planner/+page.svelte
Normal file
1
frontend/src/routes/(app)/planner/+page.svelte
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<h1 class="text-2xl font-medium p-6">Planer</h1>
|
||||||
1
frontend/src/routes/(app)/recipes/+page.svelte
Normal file
1
frontend/src/routes/(app)/recipes/+page.svelte
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<h1 class="text-2xl font-medium p-6">Rezepte</h1>
|
||||||
1
frontend/src/routes/(app)/settings/+page.svelte
Normal file
1
frontend/src/routes/(app)/settings/+page.svelte
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<h1 class="text-2xl font-medium p-6">Einstellungen</h1>
|
||||||
1
frontend/src/routes/(app)/shopping/+page.svelte
Normal file
1
frontend/src/routes/(app)/shopping/+page.svelte
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<h1 class="text-2xl font-medium p-6">Einkaufsliste</h1>
|
||||||
12
frontend/src/routes/(public)/+layout.svelte
Normal file
12
frontend/src/routes/(public)/+layout.svelte
Normal 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>
|
||||||
2
frontend/src/routes/(public)/login/+page.svelte
Normal file
2
frontend/src/routes/(public)/login/+page.svelte
Normal 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>
|
||||||
6
frontend/src/routes/+page.server.ts
Normal file
6
frontend/src/routes/+page.server.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { redirect } from '@sveltejs/kit';
|
||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async () => {
|
||||||
|
redirect(302, '/planner');
|
||||||
|
};
|
||||||
1
frontend/src/routes/+page.svelte
Normal file
1
frontend/src/routes/+page.svelte
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<p>Weiterleitung...</p>
|
||||||
18
frontend/vite.config.ts
Normal file
18
frontend/vite.config.ts
Normal 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']
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user