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:
2
backend/Cargo.lock
generated
2
backend/Cargo.lock
generated
@@ -1033,7 +1033,7 @@ checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||
|
||||
[[package]]
|
||||
name = "mangalord"
|
||||
version = "0.20.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"argon2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "mangalord"
|
||||
version = "0.20.0"
|
||||
version = "0.21.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mangalord-frontend",
|
||||
"version": "0.20.0",
|
||||
"version": "0.21.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
33
frontend/src/lib/reader-fullscreen.svelte.ts
Normal file
33
frontend/src/lib/reader-fullscreen.svelte.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
@@ -60,6 +60,13 @@
|
||||
--icon-md: 18px;
|
||||
--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-sticky: 50;
|
||||
--z-modal: 100;
|
||||
|
||||
@@ -149,6 +149,24 @@
|
||||
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;
|
||||
@@ -186,7 +204,17 @@
|
||||
|
||||
main {
|
||||
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 (60–72px); resize
|
||||
observers would be more accurate but the gap is forgiving. */
|
||||
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 {
|
||||
padding-top: var(--space-4);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { fileUrl } from '$lib/api/client';
|
||||
import { GAP_PX, type ReaderPageGap } from '$lib/api/preferences';
|
||||
import { preferences } from '$lib/preferences.svelte';
|
||||
import { updateReadProgress } from '$lib/api/read_progress';
|
||||
import { readerFullscreen } from '$lib/reader-fullscreen.svelte';
|
||||
import { session } from '$lib/session.svelte';
|
||||
import ChevronLeft from '@lucide/svelte/icons/chevron-left';
|
||||
import ChevronRight from '@lucide/svelte/icons/chevron-right';
|
||||
@@ -11,11 +13,14 @@
|
||||
import BookImage from '@lucide/svelte/icons/book-image';
|
||||
import FileText from '@lucide/svelte/icons/file-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();
|
||||
const manga = $derived(data.manga);
|
||||
const chapter = $derived(data.chapter);
|
||||
const pages = $derived(data.pages);
|
||||
const chapters = $derived(data.chapters);
|
||||
|
||||
const mode = $derived(preferences.readerMode);
|
||||
const gapPx = $derived(GAP_PX[preferences.readerPageGap]);
|
||||
@@ -26,14 +31,86 @@
|
||||
: `${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([]);
|
||||
|
||||
// ---- 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() {
|
||||
if (index < pages.length - 1) index += 1;
|
||||
if (mode === 'single') {
|
||||
if (index < pages.length - 1) {
|
||||
index += 1;
|
||||
return;
|
||||
}
|
||||
jumpToNextChapter();
|
||||
} else {
|
||||
jumpToNextChapter();
|
||||
}
|
||||
}
|
||||
function prev() {
|
||||
if (index > 0) index -= 1;
|
||||
if (mode === 'single') {
|
||||
if (index > 0) {
|
||||
index -= 1;
|
||||
return;
|
||||
}
|
||||
jumpToPrevChapter();
|
||||
} else {
|
||||
jumpToPrevChapter();
|
||||
}
|
||||
}
|
||||
function first() {
|
||||
index = 0;
|
||||
@@ -42,22 +119,56 @@
|
||||
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) {
|
||||
// Don't hijack keys while the user is typing in an input.
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA')) return;
|
||||
// In continuous mode, native scrolling handles Space/PageDown/arrows;
|
||||
// we still wire Home/End to scrollIntoView so jumping to the chapter
|
||||
// bounds stays a one-keypress action.
|
||||
// Esc always exits fullscreen if active — applies in both
|
||||
// modes, including when the bars are hidden. Handled before
|
||||
// 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 (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();
|
||||
continuousPageEls[0]?.scrollIntoView({ block: 'start' });
|
||||
} else if (e.key === 'End') {
|
||||
break;
|
||||
case 'End':
|
||||
e.preventDefault();
|
||||
continuousPageEls[continuousPageEls.length - 1]?.scrollIntoView({
|
||||
block: 'end'
|
||||
});
|
||||
continuousPageEls[
|
||||
continuousPageEls.length - 1
|
||||
]?.scrollIntoView({ block: 'end' });
|
||||
break;
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -236,6 +347,10 @@
|
||||
if (typeof window !== 'undefined') {
|
||||
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
|
||||
// doesn't unload, so `pagehide` won't fire — flush via a
|
||||
// normal fetch. Tab-close paths land on the beacon above.
|
||||
@@ -308,6 +423,7 @@
|
||||
<option value="large">Large gap</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -318,8 +434,39 @@
|
||||
{pages.length} {pages.length === 1 ? 'page' : 'pages'}
|
||||
{/if}
|
||||
</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>
|
||||
|
||||
<!--
|
||||
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}
|
||||
<p class="empty" data-testid="reader-empty">This chapter has no pages yet.</p>
|
||||
{:else if mode === 'single'}
|
||||
@@ -328,8 +475,12 @@
|
||||
type="button"
|
||||
class="nav prev"
|
||||
onclick={prev}
|
||||
disabled={index === 0}
|
||||
aria-label="Previous page"
|
||||
disabled={!canPrev}
|
||||
aria-label={index === 0
|
||||
? prevChapter
|
||||
? `Previous chapter (${prevChapter.number})`
|
||||
: 'Previous chapter'
|
||||
: 'Previous page'}
|
||||
data-testid="reader-prev"
|
||||
>
|
||||
<ChevronLeft size={22} aria-hidden="true" />
|
||||
@@ -347,8 +498,12 @@
|
||||
type="button"
|
||||
class="nav next"
|
||||
onclick={next}
|
||||
disabled={index === pages.length - 1}
|
||||
aria-label="Next page"
|
||||
disabled={!canNext}
|
||||
aria-label={index === pages.length - 1
|
||||
? nextChapter
|
||||
? `Next chapter (${nextChapter.number})`
|
||||
: 'Next chapter'
|
||||
: 'Next page'}
|
||||
data-testid="reader-next"
|
||||
>
|
||||
<ChevronRight size={22} aria-hidden="true" />
|
||||
@@ -380,18 +535,85 @@
|
||||
/>
|
||||
{/each}
|
||||
</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}
|
||||
|
||||
<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 {
|
||||
position: sticky;
|
||||
top: calc(var(--app-header-h, 60px) - var(--space-4));
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-top: var(--space-2);
|
||||
padding-bottom: var(--space-2);
|
||||
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);
|
||||
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 {
|
||||
@@ -555,6 +777,131 @@
|
||||
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) {
|
||||
background: var(--surface-elevated);
|
||||
border-color: var(--primary);
|
||||
|
||||
@@ -1,20 +1,44 @@
|
||||
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 type { PageLoad } from './$types';
|
||||
|
||||
export const ssr = false;
|
||||
|
||||
export const load: PageLoad = async ({ params }) => {
|
||||
export const load: PageLoad = async ({ params, url }) => {
|
||||
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),
|
||||
getChapter(params.id, number),
|
||||
getChapterPages(params.id, number),
|
||||
// `null` for guests or first-time openers — the reader uses
|
||||
// this to seed its session-local high-water mark so the
|
||||
// first debounced write doesn't regress page=1.
|
||||
getMyReadProgressForManga(params.id)
|
||||
// this to seed its session-local high-water mark.
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user