feat: chapter chevrons, sticky app frame, and focus mode (0.21.0)

Reader gets chapter-aware chevrons + a persistent app frame +
distraction-free focus mode.

- Single-mode chevrons (and ArrowLeft/Right + j/k) advance pages
  within the chapter and fall through to the adjacent chapter at the
  boundaries. Last page of last chapter / first page of first
  chapter disables the chevron and silent-no-ops on the keypress.
- Continuous-mode gets a fixed bottom bar with prev/next chapter
  buttons; arrows + j/k jump chapters directly.
- `?page=N` and `?page=last` URL query lets the prev-chapter jump
  land on the previous chapter's last page.
- Layout header is fixed at the top; reader nav is sticky just
  below it; both stay visible while scrolling so reading settings
  are always reachable.
- New "Focus" toggle in the reader nav hides the layout header,
  reader nav, and bottom chapter bar with smooth 220ms slide
  animations. Exit via Esc or a small floating Minimize2 button at
  the top-right (low resting opacity, full on hover). Reset on
  reader unmount so it doesn't leak to other pages.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-05-17 20:39:03 +02:00
parent c95c1805df
commit 7aa6e7e6d9
8 changed files with 468 additions and 29 deletions

2
backend/Cargo.lock generated
View File

@@ -1033,7 +1033,7 @@ checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]] [[package]]
name = "mangalord" name = "mangalord"
version = "0.20.0" version = "0.21.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"argon2", "argon2",

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "mangalord" name = "mangalord"
version = "0.20.0" version = "0.21.0"
edition = "2021" edition = "2021"
[lib] [lib]

View File

@@ -1,6 +1,6 @@
{ {
"name": "mangalord-frontend", "name": "mangalord-frontend",
"version": "0.20.0", "version": "0.21.0",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {

View File

@@ -0,0 +1,33 @@
/**
* Cross-component flag for the reader's "hide all chrome" view.
*
* The reader page toggles this; the root layout reads it to hide the
* top app navbar; the reader itself reads it to hide its own nav bar
* and bottom chapter bar. CSS handles the slide animations via a
* `data-reader-fullscreen` attribute on `<html>` so the entire frame
* (layout chrome included) stays synchronised.
*
* Always reset on reader unmount — letting the flag leak across
* navigation would orphan a hidden app navbar on other pages.
*/
let active = $state(false);
export const readerFullscreen = {
get value() {
return active;
},
set value(v: boolean) {
active = v;
if (typeof document !== 'undefined') {
if (v) document.documentElement.dataset.readerFullscreen = 'true';
else delete document.documentElement.dataset.readerFullscreen;
}
},
toggle() {
this.value = !active;
},
/** Force off — call from the reader's onDestroy. */
reset() {
this.value = false;
}
};

View File

@@ -60,6 +60,13 @@
--icon-md: 18px; --icon-md: 18px;
--icon-lg: 22px; --icon-lg: 22px;
/* App-frame heights (fixed-position bars at the top and bottom of
the viewport). Used by the layout + reader to reserve content
space and animate fullscreen mode. Recomputed once if the
header padding/font-size ever changes — keep in sync. */
--app-header-h: 60px;
--reader-bar-h: 56px;
--z-dropdown: 10; --z-dropdown: 10;
--z-sticky: 50; --z-sticky: 50;
--z-modal: 100; --z-modal: 100;

View File

@@ -149,6 +149,24 @@
font-size: var(--font-sm); 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 { .session {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -186,7 +204,17 @@
main { main {
padding: var(--space-4); padding: var(--space-4);
/* Reserve room for the fixed header so its presence doesn't
overlap content. The min-height is a fallback that matches
the header at typical viewport sizes (6072px); resize
observers would be more accurate but the gap is forgiving. */
padding-top: calc(var(--app-header-h) + var(--space-4));
max-width: 64rem; max-width: 64rem;
margin: 0 auto; margin: 0 auto;
transition: padding-top 220ms ease-out;
}
:global(html[data-reader-fullscreen='true']) main {
padding-top: var(--space-4);
} }
</style> </style>

View File

@@ -1,9 +1,11 @@
<script lang="ts"> <script lang="ts">
import { onMount, onDestroy } from 'svelte'; import { onMount, onDestroy } from 'svelte';
import { goto } from '$app/navigation';
import { fileUrl } from '$lib/api/client'; import { fileUrl } from '$lib/api/client';
import { GAP_PX, type ReaderPageGap } from '$lib/api/preferences'; import { GAP_PX, type ReaderPageGap } from '$lib/api/preferences';
import { preferences } from '$lib/preferences.svelte'; import { preferences } from '$lib/preferences.svelte';
import { updateReadProgress } from '$lib/api/read_progress'; import { updateReadProgress } from '$lib/api/read_progress';
import { readerFullscreen } from '$lib/reader-fullscreen.svelte';
import { session } from '$lib/session.svelte'; import { session } from '$lib/session.svelte';
import ChevronLeft from '@lucide/svelte/icons/chevron-left'; import ChevronLeft from '@lucide/svelte/icons/chevron-left';
import ChevronRight from '@lucide/svelte/icons/chevron-right'; import ChevronRight from '@lucide/svelte/icons/chevron-right';
@@ -11,11 +13,14 @@
import BookImage from '@lucide/svelte/icons/book-image'; import BookImage from '@lucide/svelte/icons/book-image';
import FileText from '@lucide/svelte/icons/file-text'; import FileText from '@lucide/svelte/icons/file-text';
import ScrollText from '@lucide/svelte/icons/scroll-text'; import ScrollText from '@lucide/svelte/icons/scroll-text';
import Maximize2 from '@lucide/svelte/icons/maximize-2';
import Minimize2 from '@lucide/svelte/icons/minimize-2';
let { data } = $props(); let { data } = $props();
const manga = $derived(data.manga); const manga = $derived(data.manga);
const chapter = $derived(data.chapter); const chapter = $derived(data.chapter);
const pages = $derived(data.pages); const pages = $derived(data.pages);
const chapters = $derived(data.chapters);
const mode = $derived(preferences.readerMode); const mode = $derived(preferences.readerMode);
const gapPx = $derived(GAP_PX[preferences.readerPageGap]); const gapPx = $derived(GAP_PX[preferences.readerPageGap]);
@@ -26,14 +31,86 @@
: `${manga.title} — Ch. ${chapter.number}` : `${manga.title} — Ch. ${chapter.number}`
); );
let index = $state(0); // Prev/next chapter computed from the chapter list. listChapters
// returns chapters in number ASC order; we still resolve via find
// rather than index because the current chapter's position may
// not be `chapter.number - 1` (sparse numbering / chapter 0.5 /
// future skipped numbers).
const sortedChapters = $derived(
[...chapters].sort((a, b) => a.number - b.number)
);
const currentIdx = $derived(
sortedChapters.findIndex((c) => c.id === chapter.id)
);
const prevChapter = $derived(
currentIdx > 0 ? sortedChapters[currentIdx - 1] : null
);
const nextChapter = $derived(
currentIdx >= 0 && currentIdx < sortedChapters.length - 1
? sortedChapters[currentIdx + 1]
: null
);
// Seed the initial page index from `?page=`. Numeric values are
// 1-indexed and clamped to the chapter's page count; the sentinel
// `last` lands on the final page (used by the prev-chapter chevron
// when going backwards through the series). Component remounts on
// chapter navigation, so this only runs at the start of each
// chapter — referencing `data` here is intentional.
// svelte-ignore state_referenced_locally
const initialIndex = (() => {
const req = data.requestedPage;
if (req === 'last') return Math.max(0, data.pages.length - 1);
if (typeof req === 'number') {
return Math.min(Math.max(0, req - 1), Math.max(0, data.pages.length - 1));
}
return 0;
})();
let index = $state(initialIndex);
let continuousPageEls: HTMLImageElement[] = $state([]); let continuousPageEls: HTMLImageElement[] = $state([]);
// ---- Navigation ----
//
// In single mode: page-by-page within the chapter, falling through
// to the adjacent chapter at the boundaries.
// In continuous mode: chevrons / arrow keys jump straight to the
// adjacent chapter — there is no in-page concept when everything
// is stacked.
function jumpToPrevChapter() {
if (!prevChapter) return;
// Land on the LAST page of the previous chapter so the back-
// navigation feels continuous in single mode. Harmless in
// continuous mode (the reader just shows everything).
const target = mode === 'single' ? `?page=last` : '';
void goto(`/manga/${manga.id}/chapter/${prevChapter.number}${target}`);
}
function jumpToNextChapter() {
if (!nextChapter) return;
void goto(`/manga/${manga.id}/chapter/${nextChapter.number}`);
}
function next() { function next() {
if (index < pages.length - 1) index += 1; if (mode === 'single') {
if (index < pages.length - 1) {
index += 1;
return;
}
jumpToNextChapter();
} else {
jumpToNextChapter();
}
} }
function prev() { function prev() {
if (index > 0) index -= 1; if (mode === 'single') {
if (index > 0) {
index -= 1;
return;
}
jumpToPrevChapter();
} else {
jumpToPrevChapter();
}
} }
function first() { function first() {
index = 0; index = 0;
@@ -42,22 +119,56 @@
index = pages.length - 1; index = pages.length - 1;
} }
// Whether the prev/next chevron should be enabled. Always render
// the button — disabling rather than hiding gives the boundary a
// shape so users can tell they're at the edge.
const canPrev = $derived(
mode === 'single' ? index > 0 || prevChapter !== null : prevChapter !== null
);
const canNext = $derived(
mode === 'single'
? index < pages.length - 1 || nextChapter !== null
: nextChapter !== null
);
function onKeydown(e: KeyboardEvent) { function onKeydown(e: KeyboardEvent) {
// Don't hijack keys while the user is typing in an input. // Don't hijack keys while the user is typing in an input.
const target = e.target as HTMLElement | null; const target = e.target as HTMLElement | null;
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA')) return; if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA')) return;
// In continuous mode, native scrolling handles Space/PageDown/arrows; // Esc always exits fullscreen if active — applies in both
// we still wire Home/End to scrollIntoView so jumping to the chapter // modes, including when the bars are hidden. Handled before
// bounds stays a one-keypress action. // the per-mode switches so it doesn't get shadowed.
if (e.key === 'Escape' && readerFullscreen.value) {
e.preventDefault();
readerFullscreen.value = false;
return;
}
// In continuous mode, native scrolling handles Space / PageDown /
// Up / Down; Left + Right (and the vim j/k aliases) jump
// chapters because there's no in-page concept here. Home / End
// still scroll to chapter bounds via scrollIntoView.
if (mode === 'continuous') { if (mode === 'continuous') {
if (e.key === 'Home') { switch (e.key) {
case 'ArrowLeft':
case 'k':
e.preventDefault();
jumpToPrevChapter();
break;
case 'ArrowRight':
case 'j':
e.preventDefault();
jumpToNextChapter();
break;
case 'Home':
e.preventDefault(); e.preventDefault();
continuousPageEls[0]?.scrollIntoView({ block: 'start' }); continuousPageEls[0]?.scrollIntoView({ block: 'start' });
} else if (e.key === 'End') { break;
case 'End':
e.preventDefault(); e.preventDefault();
continuousPageEls[continuousPageEls.length - 1]?.scrollIntoView({ continuousPageEls[
block: 'end' continuousPageEls.length - 1
}); ]?.scrollIntoView({ block: 'end' });
break;
} }
return; return;
} }
@@ -236,6 +347,10 @@
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
window.removeEventListener('pagehide', beaconFinalProgress); window.removeEventListener('pagehide', beaconFinalProgress);
} }
// Don't let the fullscreen flag leak to non-reader pages —
// otherwise the layout header would stay slid-off on /upload
// or /profile after navigating away.
readerFullscreen.reset();
// For SPA navigation (e.g., clicking the back-link) the page // For SPA navigation (e.g., clicking the back-link) the page
// doesn't unload, so `pagehide` won't fire — flush via a // doesn't unload, so `pagehide` won't fire — flush via a
// normal fetch. Tab-close paths land on the beacon above. // normal fetch. Tab-close paths land on the beacon above.
@@ -308,6 +423,7 @@
<option value="large">Large gap</option> <option value="large">Large gap</option>
</select> </select>
</label> </label>
{/if} {/if}
</div> </div>
@@ -318,8 +434,39 @@
{pages.length} {pages.length === 1 ? 'page' : 'pages'} {pages.length} {pages.length === 1 ? 'page' : 'pages'}
{/if} {/if}
</span> </span>
<button
type="button"
class="fullscreen-toggle"
onclick={() => readerFullscreen.toggle()}
aria-label="Enter focus mode (hide top + bottom bars)"
title="Focus mode"
data-testid="reader-fullscreen-toggle"
>
<Maximize2 size={16} aria-hidden="true" />
<span>Focus</span>
</button>
</nav> </nav>
<!--
Floating exit affordance — only rendered while focus mode is on.
Lives in the top-right corner with a low resting opacity so it
doesn't distract from the page, but is reachable with a quick
glance + click. Esc also exits.
-->
{#if readerFullscreen.value}
<button
type="button"
class="fullscreen-exit"
onclick={() => (readerFullscreen.value = false)}
aria-label="Exit focus mode (or press Esc)"
title="Exit focus mode (Esc)"
data-testid="reader-fullscreen-exit"
>
<Minimize2 size={16} aria-hidden="true" />
</button>
{/if}
{#if pages.length === 0} {#if pages.length === 0}
<p class="empty" data-testid="reader-empty">This chapter has no pages yet.</p> <p class="empty" data-testid="reader-empty">This chapter has no pages yet.</p>
{:else if mode === 'single'} {:else if mode === 'single'}
@@ -328,8 +475,12 @@
type="button" type="button"
class="nav prev" class="nav prev"
onclick={prev} onclick={prev}
disabled={index === 0} disabled={!canPrev}
aria-label="Previous page" aria-label={index === 0
? prevChapter
? `Previous chapter (${prevChapter.number})`
: 'Previous chapter'
: 'Previous page'}
data-testid="reader-prev" data-testid="reader-prev"
> >
<ChevronLeft size={22} aria-hidden="true" /> <ChevronLeft size={22} aria-hidden="true" />
@@ -347,8 +498,12 @@
type="button" type="button"
class="nav next" class="nav next"
onclick={next} onclick={next}
disabled={index === pages.length - 1} disabled={!canNext}
aria-label="Next page" aria-label={index === pages.length - 1
? nextChapter
? `Next chapter (${nextChapter.number})`
: 'Next chapter'
: 'Next page'}
data-testid="reader-next" data-testid="reader-next"
> >
<ChevronRight size={22} aria-hidden="true" /> <ChevronRight size={22} aria-hidden="true" />
@@ -380,18 +535,85 @@
/> />
{/each} {/each}
</div> </div>
<!-- Sticky bottom bar — always visible at the foot of the viewport
in continuous mode. Slides off when full-screen is toggled. -->
<div class="chapter-bar" data-testid="chevrons-inline-bottom">
<button
type="button"
class="inline-btn"
onclick={jumpToPrevChapter}
disabled={!prevChapter}
data-testid="chapter-bar-prev"
>
<ChevronLeft size={16} aria-hidden="true" />
<span>
{prevChapter
? `Previous chapter (Ch. ${prevChapter.number})`
: 'No previous chapter'}
</span>
</button>
<span class="chapter-bar-current" aria-hidden="true">
Ch. {chapter.number}{#if chapter.title}{chapter.title}{/if}
</span>
<button
type="button"
class="inline-btn"
onclick={jumpToNextChapter}
disabled={!nextChapter}
data-testid="chapter-bar-next"
>
<span>
{nextChapter
? `Next chapter (Ch. ${nextChapter.number})`
: 'No next chapter'}
</span>
<ChevronRight size={16} aria-hidden="true" />
</button>
</div>
{/if} {/if}
<style> <style>
/* Sticky under the fixed layout header. The bar takes its natural
box at the top of `main`; once the reader scrolls past, it
sticks just below where the layout header sits in the viewport.
Focus mode slides it up off-screen via a transform AND clips its
height to 0 so the chapter pages get the full top of the viewport. */
.reader-nav { .reader-nav {
position: sticky;
top: calc(var(--app-header-h, 60px) - var(--space-4));
z-index: 10;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding-top: var(--space-2);
padding-bottom: var(--space-2); padding-bottom: var(--space-2);
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
margin-bottom: var(--space-3); margin: 0 calc(-1 * var(--space-4)) var(--space-3);
padding-left: var(--space-4);
padding-right: var(--space-4);
background: var(--bg);
gap: var(--space-3); gap: var(--space-3);
flex-wrap: wrap; flex-wrap: wrap;
transition:
transform 220ms ease-out,
max-height 220ms ease-out,
opacity 220ms ease-out,
padding 220ms ease-out,
margin 220ms ease-out;
max-height: 200px;
overflow: hidden;
}
:global(html[data-reader-fullscreen='true']) .reader-nav {
transform: translateY(-100%);
max-height: 0;
padding-top: 0;
padding-bottom: 0;
margin-bottom: 0;
opacity: 0;
border-bottom-color: transparent;
pointer-events: none;
} }
.back { .back {
@@ -555,6 +777,131 @@
border-color var(--transition); border-color var(--transition);
} }
/* ===== Continuous-mode chapter bar (sticky bottom) =====
Fixed at the viewport bottom so chapter-jump controls stay one
click away no matter how far the user has scrolled. Slides
offscreen in focus mode via the same translate trick as the
reader nav above. */
.chapter-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: var(--z-sticky);
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
padding: var(--space-2) var(--space-4);
background: var(--surface);
border-top: 1px solid var(--border);
min-height: var(--reader-bar-h);
transform: translateY(0);
transition:
transform 220ms ease-out,
opacity 220ms ease-out;
}
:global(html[data-reader-fullscreen='true']) .chapter-bar {
transform: translateY(100%);
opacity: 0;
pointer-events: none;
}
.chapter-bar-current {
color: var(--text-muted);
font-size: var(--font-sm);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
flex-shrink: 1;
}
.inline-btn {
display: inline-flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
background: transparent;
color: var(--text);
border: 1px solid var(--border-strong);
border-radius: var(--radius-md);
cursor: pointer;
font-size: var(--font-sm);
min-height: 36px;
white-space: nowrap;
}
.inline-btn:hover:not(:disabled) {
background: var(--surface-elevated);
border-color: var(--primary);
}
.inline-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* Leave room above the fixed bottom bar so the last image isn't
hidden when the user scrolls to the chapter end. */
.continuous {
padding-bottom: calc(var(--reader-bar-h) + var(--space-3));
}
/* ===== Focus-mode controls ===== */
.fullscreen-toggle {
display: inline-flex;
align-items: center;
gap: var(--space-1);
padding: 0 var(--space-2);
height: 32px;
background: var(--surface);
color: var(--text-muted);
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm);
cursor: pointer;
font-size: var(--font-xs);
}
.fullscreen-toggle:hover {
background: var(--surface-elevated);
color: var(--text);
border-color: var(--primary);
}
/* Small floating exit affordance — corner-pinned, low resting
opacity so it doesn't sit on the chapter image too aggressively
but is still findable without hover. */
.fullscreen-exit {
position: fixed;
top: var(--space-3);
right: var(--space-3);
z-index: var(--z-modal);
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
padding: 0;
background: var(--surface);
color: var(--text);
border: 1px solid var(--border-strong);
border-radius: 50%;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
opacity: 0.4;
cursor: pointer;
transition:
opacity var(--transition),
background var(--transition);
}
.fullscreen-exit:hover,
.fullscreen-exit:focus-visible {
opacity: 1;
background: var(--surface-elevated);
}
.nav:hover:not(:disabled) { .nav:hover:not(:disabled) {
background: var(--surface-elevated); background: var(--surface-elevated);
border-color: var(--primary); border-color: var(--primary);

View File

@@ -1,20 +1,44 @@
import { getManga } from '$lib/api/mangas'; import { getManga } from '$lib/api/mangas';
import { getChapter, getChapterPages } from '$lib/api/chapters'; import { getChapter, getChapterPages, listChapters } from '$lib/api/chapters';
import { getMyReadProgressForManga } from '$lib/api/read_progress'; import { getMyReadProgressForManga } from '$lib/api/read_progress';
import type { PageLoad } from './$types'; import type { PageLoad } from './$types';
export const ssr = false; export const ssr = false;
export const load: PageLoad = async ({ params }) => { export const load: PageLoad = async ({ params, url }) => {
const number = Number(params.n); const number = Number(params.n);
const [manga, chapter, pages, readProgress] = await Promise.all([ const [manga, chapter, pages, readProgress, chapterList] = await Promise.all([
getManga(params.id), getManga(params.id),
getChapter(params.id, number), getChapter(params.id, number),
getChapterPages(params.id, number), getChapterPages(params.id, number),
// `null` for guests or first-time openers — the reader uses // `null` for guests or first-time openers — the reader uses
// this to seed its session-local high-water mark so the // this to seed its session-local high-water mark.
// first debounced write doesn't regress page=1. getMyReadProgressForManga(params.id),
getMyReadProgressForManga(params.id) // Loaded so the reader can compute prev/next chapter for the
// chevron-driven chapter navigation. limit=200 covers every
// realistic series; mangas with more chapters will lose some
// chapter-jump precision at the tail edge but the in-page
// navigation still works fine.
listChapters(params.id, { limit: 200 })
]); ]);
return { manga, chapter, pages, readProgress }; return {
manga,
chapter,
pages,
readProgress,
chapters: chapterList.items,
// `?page=N` lets the prev-chapter chevron land directly on the
// last page of the chapter it just navigated to. `last` is a
// convenience sentinel for "however many pages this chapter
// has"; numeric values are clamped to the chapter's page
// count in the component.
requestedPage: parsePageQuery(url.searchParams.get('page'))
};
}; };
function parsePageQuery(raw: string | null): number | 'last' | null {
if (!raw) return null;
if (raw === 'last') return 'last';
const n = Number(raw);
return Number.isFinite(n) && n >= 1 ? Math.floor(n) : null;
}