Files
bookhoard/web/src/reader/reader.ts
T
john-okeefe 163b3162b9 feat(reader): wire up reading progress save and restore
The web reader had all the infrastructure for progress persistence
(updateReadingProgress/getReadingProgress API functions, PUT/GET
endpoints, database queries) but the reader.ts never called them.

Changes:
- Add debounced (2s) saveProgress call on every relocate event that
  PUTs percentage, current_page, total_pages, and epubcfi to the
  existing /api/media-items/:id/progress endpoint
- Replace renderer.next() with view.init({ lastLocation }) to restore
  the reader to the last saved position on load (CFI first, then
  fraction fallback, then default first page)
- Pass savedPercentage and savedCfi from server-side progress data
  through readerInitExpr config to the JS initReader function
- Add mediaItemId and saveTimeout to the Alpine data object

This fixes both the blank /progress page and the missing progress
section on book detail pages — both were empty because the
reading_progress table never received any data from the web reader.
2026-04-23 21:08:30 -04:00

704 lines
21 KiB
TypeScript

import "foliate-js/view.js";
import { Alpine } from "../alpine";
import { loadSettings, saveSettings } from "./settings-manager";
import { getToken } from "../storage";
const FONT_MAP: Record<string, string> = {
literata: '"Literata"',
crimson: '"Crimson Pro"',
"source-serif": '"Source Serif 4"',
"eb-garamond": '"EB Garamond"',
libertinus: '"Libertinus Serif"',
"noto-serif": '"Noto Serif"',
"charis-sil": '"Charis SIL"',
"ibm-plex": '"IBM Plex Serif"',
};
interface FontFile {
family: string;
url: string;
weight: string;
style: string;
}
const FONT_FILES: FontFile[] = [
{
family: '"Literata"',
url: "/static/fonts/literata/Literata-Variable.woff2",
weight: "100 900",
style: "normal",
},
{
family: '"Literata"',
url: "/static/fonts/literata/Literata-Italic-Variable.woff2",
weight: "100 900",
style: "italic",
},
{
family: '"Crimson Pro"',
url: "/static/fonts/crimson/CrimsonPro-Variable.woff2",
weight: "100 900",
style: "normal",
},
{
family: '"Crimson Pro"',
url: "/static/fonts/crimson/CrimsonPro-Italic-Variable.woff2",
weight: "100 900",
style: "italic",
},
{
family: '"Source Serif 4"',
url: "/static/fonts/source-serif/SourceSerif4-Variable.woff2",
weight: "100 900",
style: "normal",
},
{
family: '"Source Serif 4"',
url: "/static/fonts/source-serif/SourceSerif4-Italic-Variable.woff2",
weight: "100 900",
style: "italic",
},
{
family: '"EB Garamond"',
url: "/static/fonts/eb-garamond/EBGaramond-Variable.woff2",
weight: "100 900",
style: "normal",
},
{
family: '"EB Garamond"',
url: "/static/fonts/eb-garamond/EBGaramond-Italic-Variable.woff2",
weight: "100 900",
style: "italic",
},
{
family: '"Libertinus Serif"',
url: "/static/fonts/libertinus/LibertinusSerif-Regular.woff2",
weight: "400",
style: "normal",
},
{
family: '"Libertinus Serif"',
url: "/static/fonts/libertinus/LibertinusSerif-Bold.woff2",
weight: "700",
style: "normal",
},
{
family: '"Libertinus Serif"',
url: "/static/fonts/libertinus/LibertinusSerif-Italic.woff2",
weight: "400",
style: "italic",
},
{
family: '"Libertinus Serif"',
url: "/static/fonts/libertinus/LibertinusSerif-BoldItalic.woff2",
weight: "700",
style: "italic",
},
{
family: '"Noto Serif"',
url: "/static/fonts/noto-serif/NotoSerif-Variable.woff2",
weight: "100 900",
style: "normal",
},
{
family: '"Noto Serif"',
url: "/static/fonts/noto-serif/NotoSerif-Italic-Variable.woff2",
weight: "100 900",
style: "italic",
},
{
family: '"Charis SIL"',
url: "/static/fonts/charis-sil/CharisSIL-Regular.woff2",
weight: "400",
style: "normal",
},
{
family: '"Charis SIL"',
url: "/static/fonts/charis-sil/CharisSIL-Bold.woff2",
weight: "700",
style: "normal",
},
{
family: '"Charis SIL"',
url: "/static/fonts/charis-sil/CharisSIL-Italic.woff2",
weight: "400",
style: "italic",
},
{
family: '"Charis SIL"',
url: "/static/fonts/charis-sil/CharisSIL-BoldItalic.woff2",
weight: "700",
style: "italic",
},
{
family: '"IBM Plex Serif"',
url: "/static/fonts/ibm-plex/IBMPlexSerif-Regular.woff2",
weight: "400",
style: "normal",
},
{
family: '"IBM Plex Serif"',
url: "/static/fonts/ibm-plex/IBMPlexSerif-Bold.woff2",
weight: "700",
style: "normal",
},
{
family: '"IBM Plex Serif"',
url: "/static/fonts/ibm-plex/IBMPlexSerif-Italic.woff2",
weight: "400",
style: "italic",
},
{
family: '"IBM Plex Serif"',
url: "/static/fonts/ibm-plex/IBMPlexSerif-BoldItalic.woff2",
weight: "700",
style: "italic",
},
];
let fontBlobUrls: Record<string, string> = {};
let fontsLoaded = false;
async function loadFontBlobUrls(): Promise<void> {
if (fontsLoaded) return;
const entries = await Promise.all(
FONT_FILES.map(async (f) => {
try {
const resp = await fetch(f.url);
const blob = await resp.blob();
const blobUrl = URL.createObjectURL(blob);
return { key: f.url, blobUrl };
} catch (_e) {
return { key: f.url, blobUrl: f.url };
}
}),
);
for (const { key, blobUrl } of entries) {
fontBlobUrls[key] = blobUrl;
}
fontsLoaded = true;
}
function buildFontFaceCSS(): string {
return FONT_FILES.map((f) => {
const src = fontBlobUrls[f.url] ?? f.url;
return `@font-face {
font-family: ${f.family};
src: url("${src}") format("woff2");
font-weight: ${f.weight};
font-style: ${f.style};
}`;
}).join("\n");
}
interface ThemeColors {
fg: string;
bg: string;
link: string;
}
const THEME_COLORS: Record<string, { light: ThemeColors; dark: ThemeColors }> =
{
light: {
light: { fg: "#1a1a1a", bg: "#fafafa", link: "#0066cc" },
dark: { fg: "#e8e8e8", bg: "#1a1a1a", link: "#60a5fa" },
},
paper: {
light: { fg: "#2d2d2d", bg: "#fdfbf7", link: "#0284c7" },
dark: { fg: "#ebe5dd", bg: "#1c1917", link: "#38bdf8" },
},
sepia: {
light: { fg: "#4a3728", bg: "#f4ecd8", link: "#8b4513" },
dark: { fg: "#d4c4a8", bg: "#2b2420", link: "#d97706" },
},
parchment: {
light: { fg: "#5c4033", bg: "#f0e6d3", link: "#92400e" },
dark: { fg: "#e8dcc8", bg: "#3d2b1f", link: "#b45309" },
},
warm: {
light: { fg: "#2b2b2b", bg: "#faf8f0", link: "#d97706" },
dark: { fg: "#f5e6d3", bg: "#2d2416", link: "#fbbf24" },
},
candlelight: {
light: { fg: "#3d2b1f", bg: "#fef3c7", link: "#b45309" },
dark: { fg: "#fde68a", bg: "#451a03", link: "#fb923c" },
},
azure: {
light: { fg: "#262d48", bg: "#cedef5", link: "#2d53e5" },
dark: { fg: "#babee1", bg: "#282e47", link: "#0ea5e9" },
},
sky: {
light: { fg: "#1e3a5f", bg: "#e0f2fe", link: "#0284c7" },
dark: { fg: "#bae6fd", bg: "#0c4a6e", link: "#0ea5e9" },
},
arctic: {
light: { fg: "#1e3a5f", bg: "#f0f9ff", link: "#0284c7" },
dark: { fg: "#bfdbfe", bg: "#1e293b", link: "#38bdf8" },
},
frost: {
light: { fg: "#334155", bg: "#f8fafc", link: "#38bdf8" },
dark: { fg: "#e0f2fe", bg: "#0f172a", link: "#7dd3fc" },
},
dusk: {
light: { fg: "#3d2914", bg: "#fef3e2", link: "#ea580c" },
dark: { fg: "#f5d5b8", bg: "#2d1f14", link: "#fb923c" },
},
sunset: {
light: { fg: "#4a1d1d", bg: "#fff7ed", link: "#f97316" },
dark: { fg: "#fed7aa", bg: "#431407", link: "#fb923c" },
},
twilight: {
light: { fg: "#4c1d95", bg: "#f5f3ff", link: "#8b5cf6" },
dark: { fg: "#ddd6fe", bg: "#2e1065", link: "#a78bfa" },
},
forest: {
light: { fg: "#1a2e1a", bg: "#e8efe8", link: "#15803d" },
dark: { fg: "#c8dcc8", bg: "#1a2e1a", link: "#22c55e" },
},
moss: {
light: { fg: "#14532d", bg: "#dcfce7", link: "#22c55e" },
dark: { fg: "#86efac", bg: "#052e16", link: "#4ade80" },
},
slate: {
light: { fg: "#334155", bg: "#f8fafc", link: "#475569" },
dark: { fg: "#e2e8f0", bg: "#1e293b", link: "#94a3b8" },
},
oled: {
light: { fg: "#000000", bg: "#ffffff", link: "#0066cc" },
dark: { fg: "#ffffff", bg: "#000000", link: "#3b82f6" },
},
solarized: {
light: { fg: "#657b83", bg: "#fdf6e3", link: "#268bd2" },
dark: { fg: "#839496", bg: "#002b36", link: "#268bd2" },
},
};
const getCSS = ({
fontFamily,
fontSize,
lineHeight,
justify,
hyphenate,
themeName,
themeMode,
}: {
fontFamily: string;
fontSize: number;
lineHeight: number;
justify: boolean;
hyphenate: boolean;
themeName: string;
themeMode: string;
}) => {
const themeSet = THEME_COLORS[themeName] ?? THEME_COLORS.light;
const theme = themeMode === "dark" ? themeSet.dark : themeSet.light;
const font = FONT_MAP[fontFamily] ?? '"Literata"';
const fontFamilyRule = fontFamily
? `
body {
font-family: ${font}, serif !important;
}
body * {
font-family: inherit !important;
}`
: "";
return `
${buildFontFaceCSS()}
@namespace epub "http://www.idpf.org/2007/ops";
html {
color-scheme: light dark;
font-size: ${fontSize}px;
line-height: ${lineHeight};
}
${fontFamilyRule}
html, body {
color: ${theme.fg} !important;
background-color: ${theme.bg} !important;
}
body * {
color: inherit !important;
border-color: currentColor !important;
}
a:any-link {
color: ${theme.link} !important;
text-decoration-color: color-mix(in srgb, currentColor 20%, transparent);
text-underline-offset: .1em;
}
svg, img {
background-color: transparent !important;
}
aside[epub|type~="footnote"] {
display: none;
}
p, li, blockquote, dd {
line-height: ${lineHeight} !important;
text-align: ${justify ? "justify" : "start"} !important;
hyphens: ${hyphenate ? "auto" : "none"};
}
[align="left"] { text-align: left; }
[align="right"] { text-align: right; }
[align="center"] { text-align: center; }
[align="justify"] { text-align: justify; }
pre {
white-space: pre-wrap !important;
tab-size: 2;
}
::selection {
background-color: rgba(128, 128, 128, 0.3);
}
img, svg, video {
max-width: 100%;
height: auto;
}
html {
hanging-punctuation: allow-end last;
orphans: 2;
widows: 2;
}
`;
};
document.addEventListener("alpine:init", () => {
Alpine.data("readerShell", () => ({
view: null as any,
renderer: null as any,
book: null as any,
zoomPercent: 100,
isFixedLayout: false,
isPDF: false,
interactionMode: "select" as string,
magnifierEnabled: false,
progressText: "",
sliderValue: 0,
settings: null as ReaderSettings | null,
justify: true,
hyphenate: true,
tocOpen: false,
settingsOpen: false,
bookmarksOpen: false,
navigatorOpen: false,
tocItems: [] as any[],
mediaItemId: "" as string,
saveTimeout: null as ReturnType<typeof setTimeout> | null,
readingTheme: "light" as string,
readingMode: "light" as string,
readingFont: "literata" as string,
fontSize: 16 as number,
lineHeight: 1.6 as number,
async initReader(config: {
mediaItemId: string;
fileUrl: string;
formatGroup: string;
readingDirection: string;
mangaType: string;
savedPercentage?: number;
savedCfi?: string;
}) {
this.mediaItemId = config.mediaItemId;
this.settings = await loadSettings();
if (this.settings) {
this.readingTheme = this.settings.reading_theme || "light";
this.readingFont = this.settings.reading_font || "literata";
this.fontSize = this.settings.font_size || 18;
this.lineHeight = this.settings.line_height || 1.6;
if (this.settings.reading_mode) {
this.readingMode = this.settings.reading_mode;
} else {
this.readingMode = this.detectChromeDarkMode() ? "dark" : "light";
}
}
const viewport = document.getElementById("reader-viewport")!;
const themeSet = THEME_COLORS[this.readingTheme] ?? THEME_COLORS.light;
const themeColors =
this.readingMode === "dark" ? themeSet.dark : themeSet.light;
viewport.style.backgroundColor = themeColors.bg;
this.view = document.getElementById("reader-view") as any;
await loadFontBlobUrls();
const resp = await fetch(config.fileUrl, {
headers: { Authorization: `Bearer ${getToken()}` },
});
const blob = await resp.blob();
const fileName = new URL(config.fileUrl, window.location.origin).pathname;
const file = new File([blob], fileName, { type: blob.type });
await this.view.open(file);
this.renderer = this.view.renderer;
this.book = this.view.book;
this.isFixedLayout = this.view.isFixedLayout;
if (this.isFixedLayout) {
this.isPDF = (this.renderer as any).isPDF;
this.renderer.addEventListener("zoom", () => {
this.zoomPercent = this.renderer.zoomPercent;
});
} else {
this.renderer.setStyles?.(this.buildCSS());
}
this.view.addEventListener("load", (e: any) => {
const { doc } = e.detail;
const link = doc.createElement("link");
link.rel = "stylesheet";
link.href = "/static/reader-fonts.css";
doc.head.append(link);
doc.addEventListener("keydown", (ev: KeyboardEvent) =>
this.handleKeydown(ev),
);
});
this.view.addEventListener("relocate", (e: any) => {
const { fraction, location, pageItem, cfi } = e.detail;
const percent = new Intl.NumberFormat("en", {
style: "percent",
}).format(fraction);
const loc = pageItem
? `Page ${pageItem.label}`
: `Loc ${location.current}`;
this.progressText = `${percent} · ${loc}`;
this.sliderValue = fraction;
const slider = document.getElementById(
"progress-slider",
) as HTMLInputElement;
if (slider) {
slider.value = fraction;
slider.title = `${percent} · ${loc}`;
}
this.debouncedSaveProgress(fraction, location, cfi);
});
const slider = document.getElementById(
"progress-slider",
) as HTMLInputElement;
if (slider && this.book.dir) {
slider.dir = this.book.dir;
}
if (this.view.getSectionFractions) {
const tickMarks = document.getElementById("tick-marks");
if (tickMarks) {
for (const fraction of this.view.getSectionFractions()) {
const option = document.createElement("option");
option.value = fraction;
tickMarks.append(option);
}
}
}
document.addEventListener("keydown", (ev: KeyboardEvent) =>
this.handleKeydown(ev),
);
if (config.savedCfi) {
await this.view.init({ lastLocation: config.savedCfi });
} else if (config.savedPercentage && config.savedPercentage > 0) {
await this.view.init({ lastLocation: { fraction: config.savedPercentage } });
} else {
await this.view.init({});
}
},
debouncedSaveProgress(fraction: number, location: any, cfi: string) {
if (this.saveTimeout) clearTimeout(this.saveTimeout);
this.saveTimeout = setTimeout(() => {
this.saveProgress(fraction, location, cfi);
}, 2000);
},
async saveProgress(fraction: number, location: any, cfi: string) {
const token = getToken();
if (!token || !this.mediaItemId) return;
try {
await fetch(`/api/media-items/${this.mediaItemId}/progress`, {
method: "PUT",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
percentage: fraction,
current_page: location?.current ?? 0,
total_pages: location?.total ?? 0,
epubcfi: cfi || "",
}),
});
} catch (_e) {
// silent fail — progress save is non-critical
}
},
zoomIn() {
if (!this.isFixedLayout) return;
const newScale = Math.min(10, this.renderer.currentScale * 1.2);
this.renderer.setAttribute("zoom", newScale);
this.zoomPercent = this.renderer.zoomPercent;
},
zoomOut() {
if (!this.isFixedLayout) return;
const newScale = Math.max(0.1, this.renderer.currentScale / 1.2);
this.renderer.setAttribute("zoom", newScale);
this.zoomPercent = this.renderer.zoomPercent;
},
resetZoom() {
if (!this.isFixedLayout) return;
this.renderer.resetZoom();
this.renderer.dragOffset = { x: 0, y: 0 };
this.zoomPercent = 100;
},
toggleMagnifier() {
if (!this.isFixedLayout) return;
this.renderer.toggleMagnifier();
this.magnifierEnabled = this.renderer.zoomMagnifierEnabled;
},
toggleInteractionMode() {
if (!this.isFixedLayout) return;
const current =
this.renderer.getAttribute("interaction-mode") || "select";
const next = current === "select" ? "pan" : "select";
this.renderer.setAttribute("interaction-mode", next);
this.interactionMode = next;
},
goLeft() {
this.view?.goLeft?.();
},
goRight() {
this.view?.goRight?.();
},
nextPage() {
this.view?.next?.();
},
previousPage() {
this.view?.prev?.();
},
goToFraction(value: string) {
this.view?.goToFraction?.(parseFloat(value));
},
toggleTOC() {
this.tocOpen = !this.tocOpen;
if (this.tocOpen && this.tocItems.length === 0 && this.book?.toc) {
this.tocItems = this.flattenTOC(this.book.toc);
}
},
toggleSettings() {
this.settingsOpen = !this.settingsOpen;
},
toggleBookmarks() {
this.bookmarksOpen = !this.bookmarksOpen;
},
toggleNavigator() {
this.navigatorOpen = !this.navigatorOpen;
},
toggleWindowShade(panelEl: HTMLElement) {
panelEl.classList.toggle("panel-collapsed");
},
flattenTOC(items: any[], depth = 0): any[] {
const result: any[] = [];
for (const item of items) {
result.push({ ...item, depth });
if (item.subitems?.length) {
result.push(...this.flattenTOC(item.subitems, depth + 1));
}
}
return result;
},
goToTOCItem(item: any) {
if (this.view && item.href) {
this.view.goTo(item.href);
this.tocOpen = false;
}
},
goToBookmarkTarget(cfi: string) {
if (this.view && cfi) {
this.view.goTo(cfi);
this.bookmarksOpen = false;
}
},
applyTheme() {
const viewport = document.getElementById("reader-viewport")!;
const themeSet = THEME_COLORS[this.readingTheme] ?? THEME_COLORS.light;
const themeColors =
this.readingMode === "dark" ? themeSet.dark : themeSet.light;
viewport.style.backgroundColor = themeColors.bg;
this.applyStyles();
saveSettings({
reading_theme: this.readingTheme,
reading_mode: this.readingMode,
});
},
toggleReadingMode() {
this.readingMode = this.readingMode === "dark" ? "light" : "dark";
this.applyTheme();
},
detectChromeDarkMode(): boolean {
const darkChromeThemes = [
"theme-tokyo-night",
"theme-dracula",
"theme-nord",
"theme-monokai",
"theme-one-dark",
"theme-material",
"theme-catppuccin",
];
return darkChromeThemes.some((t) => document.body.classList.contains(t));
},
applyFont() {
this.applyStyles();
saveSettings({
reading_font: this.readingFont,
font_size: this.fontSize,
line_height: this.lineHeight,
});
},
applyStyles() {
if (!this.renderer?.setStyles) return;
this.renderer.setStyles(this.buildCSS());
},
buildCSS() {
return getCSS({
fontFamily: this.readingFont,
fontSize: this.fontSize,
lineHeight: this.lineHeight,
justify: this.justify,
hyphenate: this.hyphenate,
themeName: this.readingTheme,
themeMode: this.readingMode,
});
},
restoreDefaults() {
this.readingTheme = "light";
this.readingMode = this.detectChromeDarkMode() ? "dark" : "light";
this.readingFont = "literata";
this.fontSize = 18;
this.lineHeight = 1.6;
this.justify = true;
this.hyphenate = true;
this.applyTheme();
this.applyFont();
},
async addBookmark() {
const token = getToken();
if (!token || !this.view) return;
const location = this.view.lastLocation;
if (!location) return;
try {
await fetch("/readers/bookmarks", {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
title: `Bookmark at ${this.progressText}`,
position: JSON.stringify(location),
}),
});
} catch (_e) {
/* ignore bookmark errors for now */
}
},
handleKeydown(event: KeyboardEvent) {
const k = event.key;
if (k === "ArrowLeft" || k === "h") this.goLeft();
else if (k === "ArrowRight" || k === "l") this.goRight();
else if (k === "+" || k === "=") this.zoomIn();
else if (k === "-" || k === "_") this.zoomOut();
else if (k === "0") this.resetZoom();
else if (k === "Escape") {
if (this.isFixedLayout && this.renderer?.zoomMagnifierEnabled) {
this.toggleMagnifier();
}
}
},
}));
});
Alpine.start();