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:
+116
-23
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user