refactor(layout): extract AppNav and UserMenu sub-components
Split +layout.svelte (205 lines) into: - AppNav.svelte: logo + nav links with active-state styling - UserMenu.svelte: avatar button, dropdown, click-outside handler Layout drops from 205 → 80 lines. Part of #75 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,11 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import './layout.css';
|
import './layout.css';
|
||||||
import { enhance } from '$app/forms';
|
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
|
||||||
import { setLocale, getLocale } from '$lib/paraglide/runtime';
|
import { setLocale, getLocale } from '$lib/paraglide/runtime';
|
||||||
import ThemeToggle from '$lib/components/ThemeToggle.svelte';
|
import ThemeToggle from '$lib/components/ThemeToggle.svelte';
|
||||||
|
import AppNav from './AppNav.svelte';
|
||||||
|
import UserMenu from './UserMenu.svelte';
|
||||||
|
|
||||||
let { children, data } = $props();
|
let { children, data } = $props();
|
||||||
|
|
||||||
@@ -28,26 +28,12 @@ const isAuthPage = $derived(
|
|||||||
['/login', '/forgot-password', '/reset-password'].some((p) => page.url.pathname.startsWith(p))
|
['/login', '/forgot-password', '/reset-password'].some((p) => page.url.pathname.startsWith(p))
|
||||||
);
|
);
|
||||||
|
|
||||||
let userMenuOpen = $state(false);
|
|
||||||
|
|
||||||
const userInitials = $derived.by(() => {
|
const userInitials = $derived.by(() => {
|
||||||
const first = data?.user?.firstName?.[0];
|
const first = data?.user?.firstName?.[0];
|
||||||
const last = data?.user?.lastName?.[0];
|
const last = data?.user?.lastName?.[0];
|
||||||
if (first && last) return (first + last).toUpperCase();
|
if (first && last) return (first + last).toUpperCase();
|
||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
|
|
||||||
function clickOutside(node: HTMLElement) {
|
|
||||||
const handleClick = (event: MouseEvent) => {
|
|
||||||
if (node && !node.contains(event.target as Node) && !event.defaultPrevented) {
|
|
||||||
userMenuOpen = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
document.addEventListener('click', handleClick, true);
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener('click', handleClick, true);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="min-h-screen bg-canvas" data-hydrated={hydrated || undefined}>
|
<div class="min-h-screen bg-canvas" data-hydrated={hydrated || undefined}>
|
||||||
@@ -59,58 +45,7 @@ function clickOutside(node: HTMLElement) {
|
|||||||
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||||
<div class="flex h-16 justify-between">
|
<div class="flex h-16 justify-between">
|
||||||
<!-- Logo & Nav -->
|
<!-- Logo & Nav -->
|
||||||
<div class="flex">
|
<AppNav isAdmin={isAdmin} />
|
||||||
<div class="mr-10 flex flex-shrink-0 items-center">
|
|
||||||
<a href="/" class="flex items-center" aria-label="Familienarchiv">
|
|
||||||
<span class="font-sans text-xl font-bold tracking-widest text-ink uppercase"
|
|
||||||
>Familienarchiv</span
|
|
||||||
>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<nav class="hidden items-center sm:flex sm:space-x-1">
|
|
||||||
<a
|
|
||||||
href="/"
|
|
||||||
class="inline-flex items-center px-3 py-1.5 font-sans text-xs font-bold tracking-widest uppercase transition-colors
|
|
||||||
{page.url.pathname === '/' || page.url.pathname.startsWith('/documents')
|
|
||||||
? 'rounded bg-nav-active text-ink'
|
|
||||||
: 'rounded text-ink-2 hover:bg-muted hover:text-ink'}"
|
|
||||||
>
|
|
||||||
{m.nav_documents()}
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<a
|
|
||||||
href="/persons"
|
|
||||||
class="inline-flex items-center px-3 py-1.5 font-sans text-xs font-bold tracking-widest uppercase transition-colors
|
|
||||||
{page.url.pathname.startsWith('/persons')
|
|
||||||
? 'rounded bg-nav-active text-ink'
|
|
||||||
: 'rounded text-ink-2 hover:bg-muted hover:text-ink'}"
|
|
||||||
>
|
|
||||||
{m.nav_persons()}
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<a
|
|
||||||
href="/conversations"
|
|
||||||
class="inline-flex items-center px-3 py-1.5 font-sans text-xs font-bold tracking-widest uppercase transition-colors
|
|
||||||
{page.url.pathname.startsWith('/conversations')
|
|
||||||
? 'rounded bg-nav-active text-ink'
|
|
||||||
: 'rounded text-ink-2 hover:bg-muted hover:text-ink'}"
|
|
||||||
>
|
|
||||||
{m.nav_conversations()}
|
|
||||||
</a>
|
|
||||||
{#if isAdmin}
|
|
||||||
<a
|
|
||||||
href="/admin"
|
|
||||||
class="inline-flex items-center px-3 py-1.5 font-sans text-xs font-bold tracking-widest uppercase transition-colors
|
|
||||||
{page.url.pathname.startsWith('/admin')
|
|
||||||
? 'rounded bg-nav-active text-ink'
|
|
||||||
: 'rounded text-ink-2 hover:bg-muted hover:text-ink'}"
|
|
||||||
>
|
|
||||||
{m.nav_admin()}
|
|
||||||
</a>
|
|
||||||
{/if}
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Right Side -->
|
<!-- Right Side -->
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
@@ -134,64 +69,7 @@ function clickOutside(node: HTMLElement) {
|
|||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
|
|
||||||
<!-- User menu -->
|
<!-- User menu -->
|
||||||
<div
|
<UserMenu userInitials={userInitials} />
|
||||||
class="relative"
|
|
||||||
{@attach clickOutside}
|
|
||||||
onkeydown={(e) => { if (e.key === 'Escape') userMenuOpen = false; }}
|
|
||||||
role="none"
|
|
||||||
>
|
|
||||||
{#if userInitials}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-expanded={userMenuOpen}
|
|
||||||
aria-haspopup="true"
|
|
||||||
onclick={() => (userMenuOpen = !userMenuOpen)}
|
|
||||||
class="flex h-8 w-8 items-center justify-center rounded-full bg-primary font-sans text-xs font-bold text-white transition-opacity hover:opacity-80"
|
|
||||||
>
|
|
||||||
{userInitials}
|
|
||||||
</button>
|
|
||||||
{:else}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label={m.nav_profile()}
|
|
||||||
aria-expanded={userMenuOpen}
|
|
||||||
aria-haspopup="true"
|
|
||||||
onclick={() => (userMenuOpen = !userMenuOpen)}
|
|
||||||
class="inline-flex items-center gap-1.5 px-3 py-2 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase transition-colors hover:text-ink"
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
src="/degruyter-icons/Simple/Small-16px/SVG/Action/Account-SM.svg"
|
|
||||||
alt=""
|
|
||||||
aria-hidden="true"
|
|
||||||
class="h-4 w-4 opacity-50"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if userMenuOpen}
|
|
||||||
<div
|
|
||||||
class="absolute top-full right-0 z-50 mt-1 min-w-[10rem] rounded-sm border border-line bg-overlay shadow-md"
|
|
||||||
>
|
|
||||||
<a
|
|
||||||
href="/profile"
|
|
||||||
onclick={() => (userMenuOpen = false)}
|
|
||||||
class="block px-4 py-2.5 font-sans text-xs font-bold tracking-widest text-ink-2 uppercase transition-colors hover:bg-muted hover:text-ink"
|
|
||||||
>
|
|
||||||
{m.nav_profile()}
|
|
||||||
</a>
|
|
||||||
<div class="border-t border-line">
|
|
||||||
<form action="/logout" method="POST" use:enhance>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="w-full px-4 py-2.5 text-left font-sans text-xs font-bold tracking-widest text-ink-3 uppercase transition-colors hover:bg-muted hover:text-ink"
|
|
||||||
>
|
|
||||||
{m.nav_logout()}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
59
frontend/src/routes/AppNav.svelte
Normal file
59
frontend/src/routes/AppNav.svelte
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } from '$app/state';
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
|
||||||
|
let { isAdmin = false }: { isAdmin?: boolean } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex">
|
||||||
|
<div class="mr-10 flex flex-shrink-0 items-center">
|
||||||
|
<a href="/" class="flex items-center" aria-label="Familienarchiv">
|
||||||
|
<span class="font-sans text-xl font-bold tracking-widest text-ink uppercase"
|
||||||
|
>Familienarchiv</span
|
||||||
|
>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav class="hidden items-center sm:flex sm:space-x-1">
|
||||||
|
<a
|
||||||
|
href="/"
|
||||||
|
class="inline-flex items-center px-3 py-1.5 font-sans text-xs font-bold tracking-widest uppercase transition-colors
|
||||||
|
{page.url.pathname === '/' || page.url.pathname.startsWith('/documents')
|
||||||
|
? 'rounded bg-nav-active text-ink'
|
||||||
|
: 'rounded text-ink-2 hover:bg-muted hover:text-ink'}"
|
||||||
|
>
|
||||||
|
{m.nav_documents()}
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="/persons"
|
||||||
|
class="inline-flex items-center px-3 py-1.5 font-sans text-xs font-bold tracking-widest uppercase transition-colors
|
||||||
|
{page.url.pathname.startsWith('/persons')
|
||||||
|
? 'rounded bg-nav-active text-ink'
|
||||||
|
: 'rounded text-ink-2 hover:bg-muted hover:text-ink'}"
|
||||||
|
>
|
||||||
|
{m.nav_persons()}
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="/conversations"
|
||||||
|
class="inline-flex items-center px-3 py-1.5 font-sans text-xs font-bold tracking-widest uppercase transition-colors
|
||||||
|
{page.url.pathname.startsWith('/conversations')
|
||||||
|
? 'rounded bg-nav-active text-ink'
|
||||||
|
: 'rounded text-ink-2 hover:bg-muted hover:text-ink'}"
|
||||||
|
>
|
||||||
|
{m.nav_conversations()}
|
||||||
|
</a>
|
||||||
|
{#if isAdmin}
|
||||||
|
<a
|
||||||
|
href="/admin"
|
||||||
|
class="inline-flex items-center px-3 py-1.5 font-sans text-xs font-bold tracking-widest uppercase transition-colors
|
||||||
|
{page.url.pathname.startsWith('/admin')
|
||||||
|
? 'rounded bg-nav-active text-ink'
|
||||||
|
: 'rounded text-ink-2 hover:bg-muted hover:text-ink'}"
|
||||||
|
>
|
||||||
|
{m.nav_admin()}
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
81
frontend/src/routes/UserMenu.svelte
Normal file
81
frontend/src/routes/UserMenu.svelte
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
|
||||||
|
let { userInitials }: { userInitials: string | null } = $props();
|
||||||
|
|
||||||
|
let userMenuOpen = $state(false);
|
||||||
|
|
||||||
|
function clickOutside(node: HTMLElement) {
|
||||||
|
const handleClick = (event: MouseEvent) => {
|
||||||
|
if (node && !node.contains(event.target as Node) && !event.defaultPrevented) {
|
||||||
|
userMenuOpen = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('click', handleClick, true);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('click', handleClick, true);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="relative"
|
||||||
|
{@attach clickOutside}
|
||||||
|
onkeydown={(e) => {
|
||||||
|
if (e.key === 'Escape') userMenuOpen = false;
|
||||||
|
}}
|
||||||
|
role="none"
|
||||||
|
>
|
||||||
|
{#if userInitials}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-expanded={userMenuOpen}
|
||||||
|
aria-haspopup="true"
|
||||||
|
onclick={() => (userMenuOpen = !userMenuOpen)}
|
||||||
|
class="flex h-8 w-8 items-center justify-center rounded-full bg-primary font-sans text-xs font-bold text-white transition-opacity hover:opacity-80"
|
||||||
|
>
|
||||||
|
{userInitials}
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={m.nav_profile()}
|
||||||
|
aria-expanded={userMenuOpen}
|
||||||
|
aria-haspopup="true"
|
||||||
|
onclick={() => (userMenuOpen = !userMenuOpen)}
|
||||||
|
class="inline-flex items-center gap-1.5 px-3 py-2 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase transition-colors hover:text-ink"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src="/degruyter-icons/Simple/Small-16px/SVG/Action/Account-SM.svg"
|
||||||
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
|
class="h-4 w-4 opacity-50"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if userMenuOpen}
|
||||||
|
<div
|
||||||
|
class="absolute top-full right-0 z-50 mt-1 min-w-[10rem] rounded-sm border border-line bg-overlay shadow-md"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href="/profile"
|
||||||
|
onclick={() => (userMenuOpen = false)}
|
||||||
|
class="block px-4 py-2.5 font-sans text-xs font-bold tracking-widest text-ink-2 uppercase transition-colors hover:bg-muted hover:text-ink"
|
||||||
|
>
|
||||||
|
{m.nav_profile()}
|
||||||
|
</a>
|
||||||
|
<div class="border-t border-line">
|
||||||
|
<form action="/logout" method="POST" use:enhance>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="w-full px-4 py-2.5 text-left font-sans text-xs font-bold tracking-widest text-ink-3 uppercase transition-colors hover:bg-muted hover:text-ink"
|
||||||
|
>
|
||||||
|
{m.nav_logout()}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
Reference in New Issue
Block a user