Files
Mangalord/frontend/src/routes/+layout.svelte
MechaCat02 7c3c9cf699 feat: app-wide toast notifications
Adds a client-side toast store (info/success/error, auto-dismiss with a
sticky option) and a Toaster component mounted in the root layout, wired to
the reserved --z-toast token. Gives mutations a consistent way to surface
success/failure instead of inline-only text or silent catches.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 21:36:34 +02:00

443 lines
15 KiB
Svelte

<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { logout } from '$lib/api/auth';
import { authConfig } from '$lib/auth-config.svelte';
import { preferences } from '$lib/preferences.svelte';
import { session } from '$lib/session.svelte';
import { theme } from '$lib/theme.svelte';
import AppBar from '$lib/components/AppBar.svelte';
import BottomNav, { type BottomNavTab } from '$lib/components/BottomNav.svelte';
import NavProgress from '$lib/components/NavProgress.svelte';
import Toaster from '$lib/components/Toaster.svelte';
import IconButton from '$lib/components/IconButton.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 Shield from '@lucide/svelte/icons/shield';
import House from '@lucide/svelte/icons/house';
import BookOpen from '@lucide/svelte/icons/book-open';
import User from '@lucide/svelte/icons/user';
import '$lib/styles/tokens.css';
let { children, data } = $props();
let loggingOut = $state(false);
let headerEl: HTMLElement | undefined = $state();
let appBarEl: HTMLElement | undefined = $state();
let bottomNavEl: HTMLElement | undefined = $state();
// Static-route title map. Dynamic pages (manga / author / collection /
// chapter) override this via their own <svelte:head><title>, since the
// title depends on data the layout doesn't have. Routes omitted here
// (notably the dynamic ones) fall through to the bare brand and rely
// on the page to set the descriptive form.
const STATIC_TITLES: Record<string, string> = {
'/': 'Mangalord',
'/login': 'Mangalord | Login',
'/register': 'Mangalord | Register',
'/upload': 'Mangalord | Upload',
'/bookmarks': 'Mangalord | Bookmarks',
'/collections': 'Mangalord | Collections',
'/library': 'Mangalord | Library',
'/profile': 'Mangalord | Profile',
'/profile/account': 'Mangalord | Account',
'/profile/bookmarks': 'Mangalord | Bookmarks',
'/profile/collections': 'Mangalord | Collections',
'/profile/history': 'Mangalord | Reading history',
'/profile/preferences': 'Mangalord | Preferences',
'/admin': 'Mangalord | Admin',
'/admin/mangas': 'Mangalord | Admin · Mangas',
'/admin/users': 'Mangalord | Admin · Users',
'/admin/system': 'Mangalord | Admin · System'
};
const layoutTitle = $derived(STATIC_TITLES[$page.route?.id ?? ''] ?? 'Mangalord');
// Mobile bottom-nav tabs. Library is its own wrapper at /library
// (Phase 5) hosting a SegmentedControl over Bookmarks / Collections /
// History; the older top-level /bookmarks + /collections routes stay
// active for desktop users and as deep-link targets, so they remain
// in the `match` set. Upload replaced the placeholder Browse tab
// (which used to share Home's destination) with a real action; the
// curated-feed split is deferred until there's curated content to
// show.
const MOBILE_TABS: BottomNavTab[] = [
{ label: 'Home', href: '/', icon: House, match: [] },
{ label: 'Upload', href: '/upload', icon: Upload, match: ['/upload'] },
{
label: 'Library',
href: '/library',
icon: BookOpen,
match: [
'/library',
'/bookmarks',
'/collections',
'/profile/bookmarks',
'/profile/collections',
'/profile/history'
]
},
{
label: 'Account',
href: '/profile/account',
icon: User,
// `/admin` keeps the Account tab highlighted while an admin is in
// the admin area — its only mobile entry point is the Account hub.
match: ['/profile', '/profile/preferences', '/admin']
}
];
// Auth-gate and reader routes hide the mobile chrome entirely — the
// bottom nav would be visual clutter on a login form and would fight
// the reader's own chrome for screen real-estate. The desktop header
// is unaffected; it continues to follow the reader fullscreen slide
// transform.
const HIDE_MOBILE_CHROME_PATHS = new Set(['/login', '/register']);
const HIDE_MOBILE_CHROME_ROUTES = new Set([
'/manga/[id]',
'/manga/[id]/chapter/[chapter_id]'
]);
const showMobileChrome = $derived.by(() => {
if (HIDE_MOBILE_CHROME_PATHS.has($page.url.pathname)) return false;
if (HIDE_MOBILE_CHROME_ROUTES.has($page.route?.id ?? '')) return false;
return true;
});
// Seed authConfig from the universal layout load. $effect keeps
// the store in sync if `data` is replaced by a subsequent layout
// load (client-side nav). The first run also covers initial
// hydration so the navbar's register link reflects the real
// server flag without a separate fetch.
$effect(() => {
authConfig.seed(data.authConfig);
});
onMount(() => {
theme.init();
preferences.init();
if (!session.loaded) session.refresh();
});
// Three runtime height publishers — each is an independent $effect
// keyed by its element ref, so they handle mount, unmount, and
// SPA navigation (e.g. entering / leaving the reader, which
// toggles `showMobileChrome`) without leaking observers.
//
// Skipping publish when offsetHeight === 0 is what lets the
// desktop header and the mobile AppBar coexist in the DOM: only
// the one visible at the current viewport publishes a non-zero
// height; the other (display: none) stays at its CSS fallback.
$effect(() => {
if (!headerEl) return;
const el = headerEl;
const publish = () => {
const h = el.offsetHeight;
if (h > 0) document.documentElement.style.setProperty('--app-header-h', `${h}px`);
};
publish();
const ro = new ResizeObserver(publish);
ro.observe(el);
return () => ro.disconnect();
});
$effect(() => {
if (!appBarEl) return;
const el = appBarEl;
const publish = () => {
const h = el.offsetHeight;
if (h > 0)
document.documentElement.style.setProperty('--mobile-app-bar-h', `${h}px`);
};
publish();
const ro = new ResizeObserver(publish);
ro.observe(el);
return () => ro.disconnect();
});
$effect(() => {
if (!bottomNavEl) return;
const el = bottomNavEl;
const publish = () => {
const h = el.offsetHeight;
if (h > 0)
document.documentElement.style.setProperty('--app-bottom-nav-h', `${h}px`);
};
publish();
const ro = new ResizeObserver(publish);
ro.observe(el);
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>
<svelte:head>
<title>{layoutTitle}</title>
</svelte:head>
<NavProgress />
{#if showMobileChrome}
<div class="mobile-app-bar-wrap" bind:this={appBarEl}>
<AppBar title="Mangalord" testid="mobile-app-bar" />
</div>
{/if}
<header bind:this={headerEl} class="desktop-header">
<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>
{#if session.user?.is_admin}
<a class="nav-link" href="/admin" data-testid="nav-admin">
<Shield size={18} aria-hidden="true" />
<span>Admin</span>
</a>
{/if}
</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>
<IconButton
size={36}
radius="md"
onclick={handleLogout}
disabled={loggingOut}
aria-label="Logout"
title="Logout"
>
{#if loggingOut}
<span class="logging-out"></span>
{:else}
<LogOut size={18} aria-hidden="true" />
{/if}
</IconButton>
{:else}
<a class="text-link" href="/login" data-testid="nav-login">Login</a>
{#if authConfig.self_register_enabled}
<a class="text-link" href="/register" data-testid="nav-register">Register</a>
{/if}
{/if}
</div>
</header>
<main>
{@render children()}
</main>
{#if showMobileChrome}
<div class="mobile-bottom-nav-wrap" bind:this={bottomNavEl}>
<BottomNav tabs={MOBILE_TABS} currentPath={$page.url.pathname} />
</div>
{/if}
<Toaster />
<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);
}
.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;
}
@media (max-width: 640px) {
:global(html[data-mobile-full-bleed='true']) main {
/* Mobile detail page surrenders the layout's top reservation
so its hero can sit at the viewport top under a transparent
page-specific app bar. Horizontal and bottom padding are
preserved so the body content (description, chapter list)
keeps its gutters; the hero negates the horizontal padding
itself via negative margins. */
padding-top: 0;
}
}
/* Mobile chrome (AppBar + BottomNav) is rendered always when the
route opts in, but CSS-hides above the breakpoint so the desktop
header is the only visible top-chrome. The ResizeObserver in the
script ignores zero offsetHeight so the hidden element doesn't
clobber the visible one's published var. */
.mobile-app-bar-wrap {
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']) .mobile-app-bar-wrap {
transform: translateY(-100%);
}
.mobile-app-bar-wrap,
.mobile-bottom-nav-wrap {
display: none;
}
@media (max-width: 640px) {
.desktop-header {
display: none;
}
.mobile-app-bar-wrap,
.mobile-bottom-nav-wrap {
display: block;
}
main {
/* On mobile, swap the desktop-header reservation for the
AppBar's. BottomNav reservation goes on the bottom so
the last bit of content isn't trapped under the bar.
16px horizontal gutter matches iOS/Material standard
and gives chips, description text, and chapter rows
enough breathing room from the screen edge. */
padding-top: calc(var(--mobile-app-bar-h) + var(--space-3));
padding-bottom: calc(var(--app-bottom-nav-h) + var(--space-4));
padding-left: var(--space-4);
padding-right: var(--space-4);
}
}
</style>