+
+
+ {#each chapter.subitems ?? [] as subchapter (subchapter.id)}
+
+ onnavigate(subchapter.href)}
+ >
+
+ {subchapter.label}
+
+
+
+ {/each}
+
+
+
+ {:else}
+
+ onnavigate(chapter.href)}
+ >
+ {chapter.label}
+
+
+ {/if}
+ {/each}
+
+
+
+
diff --git a/frontend/src/lib/reader/legacy-cache.ts b/frontend/src/lib/reader/legacy-cache.ts
new file mode 100644
index 0000000..d609a41
--- /dev/null
+++ b/frontend/src/lib/reader/legacy-cache.ts
@@ -0,0 +1,29 @@
+const PURGED_FLAG = 'chitai:locations-purged';
+
+/** Keys the epub.js reader wrote: `${bookId}-locations`. */
+const LEGACY_KEY = /^\d+-locations$/;
+
+/**
+ * Drops the epub.js locations cache, once per browser.
+ *
+ * foliate computes progress from section byte sizes at open time, so there is no
+ * locations pre-pass and nothing to cache. The old entries are not small — a
+ * few hundred KB of JSON per long book against a 5–10 MB origin quota — and a
+ * heavy reader sitting near the cap would make the new settings write throw
+ * QuotaExceededError.
+ *
+ * Removable once deployments have had a release to run it; see TODO.md.
+ */
+export function purgeLegacyLocationCache() {
+ try {
+ if (localStorage.getItem(PURGED_FLAG)) return;
+
+ for (const key of Object.keys(localStorage)) {
+ if (LEGACY_KEY.test(key)) localStorage.removeItem(key);
+ }
+
+ localStorage.setItem(PURGED_FLAG, '1');
+ } catch (error) {
+ console.warn('Could not purge the legacy locations cache', error);
+ }
+}
diff --git a/frontend/src/lib/reader/progress.ts b/frontend/src/lib/reader/progress.ts
new file mode 100644
index 0000000..db2d44d
--- /dev/null
+++ b/frontend/src/lib/reader/progress.ts
@@ -0,0 +1,114 @@
+interface PendingProgress {
+ percentage: number;
+ epub_cfi: string;
+ completed: boolean;
+}
+
+/** Treat the last stretch as finished: fraction is a float and never lands on 1. */
+const COMPLETE_AT = 0.99;
+
+const SAVE_DEBOUNCE_MS = 3000;
+
+/**
+ * Debounced reading-progress writer.
+ *
+ * Progress goes through the catch-all proxy rather than a remote function. That
+ * looks like a convention violation but is the documented exception: the browser
+ * itself must send this, and the closing write uses sendBeacon, which needs a
+ * plain URL and body rather than a remote command's envelope. The httpOnly
+ * authToken cookie rides along and the proxy attaches the bearer header.
+ */
+export class ProgressReporter {
+ #url: string;
+ #timer: ReturnType | undefined;
+ #pending: PendingProgress | null = null;
+ #listening = false;
+
+ constructor(bookId: string | number) {
+ this.#url = `/api/books/progress/${bookId}`;
+ }
+
+ /**
+ * Starts flushing on the way out.
+ *
+ * pagehide and a hidden visibilitychange, not beforeunload: mobile Safari
+ * fires beforeunload unreliably and it blocks the bfcache.
+ */
+ listen() {
+ if (this.#listening || typeof document === 'undefined') return;
+ document.addEventListener('visibilitychange', this.#onVisibilityChange);
+ window.addEventListener('pagehide', this.#onPageHide);
+ this.#listening = true;
+ }
+
+ record(percentage: number, epubCfi: string) {
+ this.#pending = {
+ percentage,
+ epub_cfi: epubCfi,
+ completed: percentage >= COMPLETE_AT
+ };
+
+ clearTimeout(this.#timer);
+ this.#timer = setTimeout(() => void this.flush(), SAVE_DEBOUNCE_MS);
+ }
+
+ /** Sends anything outstanding and waits for it. */
+ async flush() {
+ const body = this.#take();
+ if (!body) return;
+
+ try {
+ const response = await fetch(this.#url, {
+ method: 'POST',
+ // Without this the browser stamps text/plain and the proxy forwards it
+ // verbatim. Litestar decodes it anyway, which is why it went unnoticed.
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ keepalive: true
+ });
+
+ // A failed save must not interrupt reading, but should not be silent either.
+ if (!response.ok) console.error('Could not save reading progress', response.status);
+ } catch (error) {
+ console.error('Could not save reading progress', error);
+ }
+ }
+
+ /**
+ * Fire-and-forget flush for teardown.
+ *
+ * The old reader cleared its debounce timer on destroy without flushing, so a
+ * page turn within three seconds of leaving was dropped — and leaving is
+ * exactly when someone turns a last page.
+ */
+ flushSync() {
+ const body = this.#take();
+ if (!body) return;
+
+ const blob = new Blob([JSON.stringify(body)], { type: 'application/json' });
+ if (!navigator.sendBeacon?.(this.#url, blob)) void this.flush();
+ }
+
+ dispose() {
+ this.flushSync();
+ clearTimeout(this.#timer);
+ if (!this.#listening) return;
+ document.removeEventListener('visibilitychange', this.#onVisibilityChange);
+ window.removeEventListener('pagehide', this.#onPageHide);
+ this.#listening = false;
+ }
+
+ /** Claims the pending write so it cannot be sent twice. */
+ #take(): PendingProgress | null {
+ const body = this.#pending;
+ this.#pending = null;
+ clearTimeout(this.#timer);
+ return body;
+ }
+
+ #onVisibilityChange = () => {
+ if (document.visibilityState === 'hidden') this.flushSync();
+ };
+
+ #onPageHide = () => this.flushSync();
+}
diff --git a/frontend/src/lib/reader/stylesheet.ts b/frontend/src/lib/reader/stylesheet.ts
index 067360e..74d0dc9 100644
--- a/frontend/src/lib/reader/stylesheet.ts
+++ b/frontend/src/lib/reader/stylesheet.ts
@@ -16,9 +16,11 @@ export interface ReaderPalette {
*