fix(reader): stabilize chrome panels, bookmarks end-to-end, dead UI removal

Phase 0 of the reader redesign:

- Panels no longer render under the top/bottom bars: sidebars get
  measured insets (same resize/safe-area mechanism as the viewport);
  panel max-height now derives from the bounded sidebar instead of a
  100vh guess; right-side border targets the actual sidebar.
- Bookmarks work end-to-end for the first time: REST CRUD under
  /api/media-items/:id/bookmarks (create/delete route through
  AnnotationService for dedup/LWW/tombstones), fix UpdateMediaBookmark
  referencing nonexistent updated_at column, frontend posts to the
  real API with per-format position (CFI vs page), live list with
  jump + delete instead of SSR-only snapshot.
- Fix chapter matching in progress saves: boundaries were compared by
  a nonexistent tocItem property, so chapter was never persisted.
- Remove dead UI: Navigator panel stub, empty dictionary popup shell,
  unwired Chrome Behavior select; purge 160 stale build artifacts.
- Reader chrome now follows the user's app theme instead of hardcoded
  theme-tokyo-night.
This commit is contained in:
2026-08-14 09:05:33 -04:00
parent 03cb4c7869
commit ba95cc3e8b
11 changed files with 449 additions and 295 deletions
+116 -23
View File
@@ -382,7 +382,13 @@ document.addEventListener("alpine:init", () => {
tocOpen: false,
settingsOpen: false,
bookmarksOpen: false,
navigatorOpen: false,
bookmarkItems: [] as {
id: string;
title: string;
positionLabel: string;
cfi: string;
page: number | null;
}[],
tocItems: [] as any[],
mediaItemId: "" as string,
saveTimeout: null as ReturnType<typeof setTimeout> | null,
@@ -446,8 +452,16 @@ document.addEventListener("alpine:init", () => {
savedCfi?: string;
savedPage?: number;
savedTotalPages?: number;
bookmarks?: {
id: string;
title: string;
positionLabel: string;
cfi: string;
page: number | null;
}[];
}) {
this.mediaItemId = config.mediaItemId;
this.bookmarkItems = config.bookmarks ?? [];
this.settings = await loadSettings();
if (this.settings) {
this.progressMode = this.settings.progress_mode || "pages";
@@ -499,7 +513,7 @@ document.addEventListener("alpine:init", () => {
this.renderer.setStyles?.(this.buildCSS());
}
this.view.addEventListener("load", (e: any) => {
const { doc, index } = e.detail;
const { doc } = e.detail;
const link = doc.createElement("link");
link.rel = "stylesheet";
link.href = "/static/reader-fonts.css";
@@ -587,8 +601,19 @@ document.addEventListener("alpine:init", () => {
const vp = document.getElementById("reader-viewport");
if (!top || !bot || !vp) return;
const margin = 6;
vp.style.setProperty("top", `${top.offsetHeight + margin}px`);
vp.style.setProperty("bottom", `${bot.offsetHeight + margin}px`);
const topInset = `${top.offsetHeight + margin}px`;
const bottomInset = `${bot.offsetHeight + margin}px`;
vp.style.setProperty("top", topInset);
vp.style.setProperty("bottom", bottomInset);
// Sidebars (TOC/settings/bookmarks panels) must sit below/above the
// chrome bars, tracking their measured heights (safe-area insets, wrap).
for (const id of ["left-sidebar", "right-sidebar"]) {
const sidebar = document.getElementById(id);
if (sidebar) {
sidebar.style.setProperty("top", topInset);
sidebar.style.setProperty("bottom", bottomInset);
}
}
},
setupViewportInsets() {
const top = document.getElementById("reader-topbar");
@@ -639,7 +664,7 @@ document.addEventListener("alpine:init", () => {
if (tocItem) {
if (tocItem.label) {
const boundaryIdx = this.chapterBoundaries.findIndex(
(b: any) => b.tocItem === tocItem,
(b: any) => b.label === tocItem.label,
);
if (boundaryIdx !== -1) {
body.chapter = boundaryIdx;
@@ -730,9 +755,6 @@ document.addEventListener("alpine:init", () => {
toggleBookmarks() {
this.bookmarksOpen = !this.bookmarksOpen;
},
toggleNavigator() {
this.navigatorOpen = !this.navigatorOpen;
},
toggleWindowShade(panelEl: HTMLElement) {
panelEl.classList.toggle("panel-collapsed");
},
@@ -752,11 +774,17 @@ document.addEventListener("alpine:init", () => {
this.tocOpen = false;
}
},
goToBookmarkTarget(cfi: string) {
if (this.view && cfi) {
this.view.goTo(cfi);
this.bookmarksOpen = false;
goToBookmark(item: { cfi: string; page: number | null }) {
if (!this.view) return;
if (item.cfi) {
this.view.goTo(item.cfi);
} else if (item.page != null && item.page > 0) {
// Fixed-layout/comic: sections are pages; foliate takes an index.
this.view.goTo(item.page - 1);
} else {
return;
}
this.bookmarksOpen = false;
},
applyTheme() {
const viewport = document.getElementById("reader-viewport")!;
@@ -821,27 +849,92 @@ document.addEventListener("alpine:init", () => {
this.applyTheme();
this.applyFont();
},
async refreshBookmarks() {
const token = getToken();
if (!token || !this.mediaItemId) return;
try {
const resp = await fetch(
`/api/media-items/${this.mediaItemId}/bookmarks`,
{ headers: { Authorization: `Bearer ${token}` } },
);
if (!resp.ok) return;
const rows = await resp.json();
this.bookmarkItems = (rows as any[]).map((r) => ({
id: r.id,
title: r.title ?? "",
positionLabel: r.position?.String ?? r.position ?? "",
cfi: r.cfi_position?.String ?? r.cfi_position ?? "",
page:
r.page_number != null
? (r.page_number?.Int32 ?? r.page_number)
: null,
}));
} catch (_e) {
/* leave the existing list on fetch failure */
}
},
async addBookmark() {
const token = getToken();
if (!token || !this.view) return;
if (!token || !this.view || !this.mediaItemId) return;
const location = this.view.lastLocation;
if (!location) return;
const cfi = (!this.isFixedLayout && location.cfi) || "";
const page = this.isFixedLayout
? (this.renderer?.index ?? 0) + 1
: 0;
try {
await fetch("/readers/bookmarks", {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
const resp = await fetch(
`/api/media-items/${this.mediaItemId}/bookmarks`,
{
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
title: `Bookmark at ${this.progressText || "current position"}`,
position: this.isFixedLayout
? `page:${page}`
: cfi
? `cfi:${cfi}`
: "",
cfi_position: cfi,
page_number: page,
chapter_number: this.chapterNumberForProgress() || 0,
percentage: location.fraction ?? 0,
}),
},
body: JSON.stringify({
title: `Bookmark at ${this.progressText}`,
position: JSON.stringify(location),
}),
});
);
if (resp.ok) await this.refreshBookmarks();
} catch (_e) {
/* ignore bookmark errors for now */
}
},
async deleteBookmark(id: string) {
const token = getToken();
if (!token || !this.mediaItemId) return;
try {
const resp = await fetch(
`/api/media-items/${this.mediaItemId}/bookmarks/${id}`,
{ method: "DELETE", headers: { Authorization: `Bearer ${token}` } },
);
if (resp.ok || resp.status === 204) {
this.bookmarkItems = this.bookmarkItems.filter((b) => b.id !== id);
}
} catch (_e) {
/* ignore bookmark errors for now */
}
},
chapterNumberForProgress(): number {
const tocItem = this.lastRelocateDetail?.tocItem;
if (!tocItem?.label) return 0;
const idx = this.chapterBoundaries.findIndex(
(b: any) => b.label === tocItem.label,
);
return idx === -1 ? 0 : idx;
},
formatProgressParts(
fraction: number,
location: { current: number; next: number; total: number },