Phase 1 of the mobile redesign: add the reusable primitives every later phase plugs into, and switch the layout to a 4-tab bottom nav + compact AppBar on phone viewports while leaving the desktop header untouched above 640px. - New primitives: BottomNav, Sheet, AppBar, SegmentedControl, all with vitest unit tests. - Layout swaps the desktop header for AppBar + BottomNav under the existing 640px breakpoint. Login / register / reader routes opt out of the mobile chrome. - Library tab currently points at /bookmarks; the curated /library wrapper lands in Phase 5. - Independent ResizeObservers publish --app-header-h, --mobile-app-bar-h, --app-bottom-nav-h, with a zero-height skip so hidden bars don't clobber the visible one's value. - viewport-fit=cover + env(safe-area-inset-*) tokens for notched devices and the iOS home indicator. - Playwright spec covers visibility at 390px / 1280px viewports and tab navigation. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
429 lines
14 KiB
Svelte
429 lines
14 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 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 Search from '@lucide/svelte/icons/search';
|
|
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',
|
|
'/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 consolidates Bookmarks / Collections /
|
|
// History — it currently links to /bookmarks as the closest existing
|
|
// route; Phase 5 introduces /library with internal segmented sub-tabs
|
|
// and this href flips at that point. Browse temporarily shares Home's
|
|
// destination until the curated-feed split lands.
|
|
const MOBILE_TABS: BottomNavTab[] = [
|
|
{ label: 'Home', href: '/', icon: House, match: [] },
|
|
{ label: 'Browse', href: '/', icon: Search, match: [] },
|
|
{
|
|
label: 'Library',
|
|
href: '/bookmarks',
|
|
icon: BookOpen,
|
|
match: [
|
|
'/bookmarks',
|
|
'/collections',
|
|
'/profile/bookmarks',
|
|
'/profile/collections',
|
|
'/profile/history'
|
|
]
|
|
},
|
|
{
|
|
label: 'Account',
|
|
href: '/profile/account',
|
|
icon: User,
|
|
match: ['/profile', '/profile/preferences']
|
|
}
|
|
];
|
|
|
|
// 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 showMobileChrome = $derived.by(() => {
|
|
if (HIDE_MOBILE_CHROME_PATHS.has($page.url.pathname)) return false;
|
|
if ($page.route?.id === '/manga/[id]/chapter/[chapter_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>
|
|
|
|
{#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>
|
|
<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>
|
|
{#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}
|
|
|
|
<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;
|
|
}
|
|
|
|
/* 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. */
|
|
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-3);
|
|
padding-right: var(--space-3);
|
|
}
|
|
}
|
|
</style>
|