Files
Mangalord/frontend/src/routes/+layout.svelte

244 lines
7.4 KiB
Svelte

<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { goto } from '$app/navigation';
import { logout } from '$lib/api/auth';
import { preferences } from '$lib/preferences.svelte';
import { session } from '$lib/session.svelte';
import { theme } from '$lib/theme.svelte';
import Upload from '@lucide/svelte/icons/upload';
import UserCircle from '@lucide/svelte/icons/user-circle';
import Bookmark from '@lucide/svelte/icons/bookmark';
import FolderOpen from '@lucide/svelte/icons/folder-open';
import LogOut from '@lucide/svelte/icons/log-out';
import '$lib/styles/tokens.css';
let { children } = $props();
let loggingOut = $state(false);
let headerEl: HTMLElement | undefined = $state();
onMount(() => {
theme.init();
preferences.init();
if (!session.loaded) session.refresh();
// Publish the header's measured height as a CSS custom
// property so sticky descendants (e.g. the reader nav) can
// pin themselves directly below it without guessing. A
// ResizeObserver keeps it in sync as the viewport reflows
// (the nav `flex-wrap: wrap`s on narrow widths), the user
// zooms, or fonts swap. Hard-coded pixel offsets in tokens
// are wrong in principle — actual height varies with all
// of the above.
if (!headerEl) return;
const publish = () => {
document.documentElement.style.setProperty(
'--app-header-h',
`${headerEl!.offsetHeight}px`
);
};
publish();
const ro = new ResizeObserver(publish);
ro.observe(headerEl);
return () => ro.disconnect();
});
// Pull fresh server preferences whenever the user changes (login,
// logout, account switch). The store's seq guard keeps the most recent
// response authoritative.
$effect(() => {
if (session.user) preferences.refresh();
});
onDestroy(() => theme.destroy());
async function handleLogout() {
loggingOut = true;
try {
await logout();
} finally {
session.setUser(null);
// Don't let user A's reader preferences linger for the next
// person who uses this browser (or as guest state for the
// same user). Resets state + localStorage.
preferences.clearForLogout();
loggingOut = false;
goto('/login');
}
}
</script>
<header bind:this={headerEl}>
<nav aria-label="primary">
<a class="brand" href="/">Mangalord</a>
<a class="nav-link" href="/upload">
<Upload size={18} aria-hidden="true" />
<span>Upload</span>
</a>
<a class="nav-link" href="/profile" data-testid="nav-profile">
<UserCircle size={18} aria-hidden="true" />
<span>Profile</span>
</a>
<a class="nav-link" href="/bookmarks">
<Bookmark size={18} aria-hidden="true" />
<span>Bookmarks</span>
</a>
<a class="nav-link" href="/collections">
<FolderOpen size={18} aria-hidden="true" />
<span>Collections</span>
</a>
</nav>
<div class="session" data-testid="session-area">
{#if !session.loaded}
<span data-testid="session-loading" aria-busy="true"></span>
{:else if session.user}
<span class="username" data-testid="session-user">{session.user.username}</span>
<button
class="icon-btn"
type="button"
onclick={handleLogout}
disabled={loggingOut}
aria-label="Logout"
title="Logout"
>
{#if loggingOut}
<span class="logging-out"></span>
{:else}
<LogOut size={18} aria-hidden="true" />
{/if}
</button>
{:else}
<a class="text-link" href="/login" data-testid="nav-login">Login</a>
<a class="text-link" href="/register" data-testid="nav-register">Register</a>
{/if}
</div>
</header>
<main>
{@render children()}
</main>
<style>
header {
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--border);
background: var(--surface);
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space-4);
flex-wrap: wrap;
}
nav {
display: flex;
align-items: center;
gap: var(--space-1);
}
.brand {
font-weight: var(--weight-semibold);
font-size: var(--font-lg);
color: var(--text);
padding: var(--space-2) var(--space-3);
margin-right: var(--space-2);
}
.brand:hover {
text-decoration: none;
color: var(--primary);
}
.nav-link {
display: inline-flex;
align-items: center;
gap: var(--space-2);
color: var(--text);
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-md);
font-size: var(--font-sm);
transition: background var(--transition);
}
.nav-link:hover {
background: var(--surface-elevated);
text-decoration: none;
}
.text-link {
color: var(--primary);
padding: var(--space-2) var(--space-3);
font-size: var(--font-sm);
}
/* App frame: header is fixed at the viewport top with a slide
transition so reader fullscreen (set via `data-reader-fullscreen`
on `<html>`) can hide it without jolting the layout. `main` pays
the gap with a matching padding-top that animates in lockstep. */
header {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: var(--z-sticky);
transform: translateY(0);
transition: transform 220ms ease-out;
}
:global(html[data-reader-fullscreen='true']) header {
transform: translateY(-100%);
}
.session {
display: flex;
align-items: center;
gap: var(--space-1);
}
.username {
color: var(--text-muted);
font-size: var(--font-sm);
padding: 0 var(--space-2);
}
.icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
padding: 0;
background: transparent;
color: var(--text-muted);
border: 1px solid transparent;
border-radius: var(--radius-md);
}
.icon-btn:hover:not(:disabled) {
background: var(--surface-elevated);
color: var(--text);
}
.logging-out {
font-size: var(--font-base);
line-height: 1;
}
main {
padding: var(--space-4);
/* Reserve room for the fixed header so its presence doesn't
overlap content. The header height comes from a runtime
ResizeObserver (see onMount above) so this always tracks
the rendered size. */
padding-top: calc(var(--app-header-h) + var(--space-4));
max-width: 64rem;
margin: 0 auto;
transition: padding-top 220ms ease-out;
}
:global(html[data-reader-fullscreen='true']) main {
/* No top reservation in focus mode — the chapter image runs
edge-to-edge once the header has slid off. */
padding-top: 0;
}
</style>