diff --git a/frontend/src/lib/components/reader/epub-reader.svelte b/frontend/src/lib/components/reader/epub-reader.svelte index f197745..3cd4577 100644 --- a/frontend/src/lib/components/reader/epub-reader.svelte +++ b/frontend/src/lib/components/reader/epub-reader.svelte @@ -1,329 +1,111 @@ - + + {title ? `${title} — Chitai` : 'Reader — Chitai'} + - - - -

Chapters

-
- - - - {#each chapters as chapter (chapter.href)} - {#if chapter.subitems.length > 0} - -
- - navigateToChapter(chapter)}> - {chapter.label} - - - - - -
- - - {#each chapter.subitems as subchapter (subchapter.href)} - - navigateToChapter(subchapter)} - > - - {subchapter.label} - - - - {/each} - - -
- {:else} - - navigateToChapter(chapter)}> - {chapter.label} - - - {/if} - {/each} -
-
-
-
+ + {#if isReady} + + {/if}
@@ -333,7 +115,7 @@ (isSidebarOpen = !isSidebarOpen)} - disabled={!isReaderVisible} + disabled={!isReady || toc.length === 0} > @@ -344,17 +126,17 @@ {title || 'Reader'} - {#if isReaderVisible} + {#if isReady} {/if} @@ -362,14 +144,13 @@ {#if loadError} -

This book wouldn't open

{loadError}

- @@ -379,63 +160,53 @@
- {:else if !isReaderVisible} -
- - Opening… + {:else} + {#if !isReady} +
+ + Opening… +
+ {/if} + +
+ + +
+ {#if file} + (loadError = message)} + onready={(detail) => { + toc = detail.toc; + isReady = true; + }} + /> + {/if} +
+ +
{/if} - - -
- - -
- - -
- - diff --git a/frontend/src/lib/components/reader/foliate-view.svelte b/frontend/src/lib/components/reader/foliate-view.svelte new file mode 100644 index 0000000..55e82ff --- /dev/null +++ b/frontend/src/lib/components/reader/foliate-view.svelte @@ -0,0 +1,230 @@ + + + + +
+ + diff --git a/frontend/src/lib/components/reader/reader-toc.svelte b/frontend/src/lib/components/reader/reader-toc.svelte new file mode 100644 index 0000000..0029884 --- /dev/null +++ b/frontend/src/lib/components/reader/reader-toc.svelte @@ -0,0 +1,86 @@ + + + + +

Chapters

+
+ + + + {#each toc as chapter (chapter.id)} + {#if chapter.subitems?.length} + +
+ + onnavigate(chapter.href)} + > + {chapter.label} + + + + + +
+ + + {#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 { *