tsc --noEmit and oxlint web/src are now both clean; vp check passes.
- htmx: one canonical Htmx interface in shims.d.ts (ajax/process/trigger
per the htmx 2.x API). alpine.ts had a second, conflicting Window.htmx
declaration (TS2717) — removed; bookPicker/collections lose their
(window as any) escapes now that Window.htmx is properly typed.
- tsconfig lib ES2020 -> ES2021 + DOM.Iterable: replaceAll exists
(escapeHtml) and for..of over querySelectorAll results is legal.
Type-checking only — vite emits at esnext regardless.
- custom-section-builder: selectedBooks holds identity fields only;
typed as Pick<BookInfo, ...> instead of forcing partial API shapes.
The API-critical BookInfo contract is untouched.
- toast: real bug found by the type system — setup.ts passes 'warning'
but getToastConfig had no entry, so the warning toast threw TypeError
at runtime instead of displaying. Added the type + config. Uses
bg-yellow-500 (verified present in scanned templates => generated by
Tailwind); the one intentional behavior change in this commit.
- book-detail: instanceof narrowing replaces an as-cast; unused
AnnotationSource import removed.
- password_validation: explicit HTMLElement | null for the checklist
list (querySelector('ul') infers HTMLUListElement, conflicting with
the container-as-list branch).
- stats: tooltip parsed value typed via annotation (bar=scalar,
line={x,y}); dead getActiveRange() removed (was already tree-shaken
out of every bundle).
- library-switcher: onSwitch honestly typed void | Promise<void>
(series.ts passes a sync navigator).
- 20 unused catch bindings converted to optional catch binding.
Verification: fresh build is byte-reproducible; every chunk is
identical to the pre-fix build except main.js (instanceof narrowing,
regex fix, stats refactor), reader.js (destructure), and the shared
storage chunk (renamed — carries toast.ts). reader/main diffs audited
hunk-by-hunk against the intended edits.
979 lines
34 KiB
TypeScript
979 lines
34 KiB
TypeScript
import { Alpine } from "./alpine";
|
|
import { showToast } from "./toast";
|
|
import { searchTagSuggestions, type TagSuggestion } from "./tag-dropdown";
|
|
import { renderNoteHtml } from "./markdown";
|
|
import { strToU8, zipSync } from "fflate";
|
|
import {
|
|
buildAnnotationRows,
|
|
annotationItemMarkdown,
|
|
triggerDownload,
|
|
bannerBookTitle,
|
|
type AnnotationRow,
|
|
} from "./annotations-banner";
|
|
|
|
function getMediaId(): string {
|
|
const parts = window.location.pathname.split("/");
|
|
return parts[parts.length - 1] || "";
|
|
}
|
|
|
|
function getAuthHeader(): string {
|
|
const token = localStorage.getItem("token");
|
|
return token ? `Bearer ${token}` : "";
|
|
}
|
|
|
|
function showProgressSyncModal(): void {
|
|
const modal = document.getElementById("progress-sync-modal");
|
|
if (modal) modal.classList.remove("hidden");
|
|
}
|
|
|
|
function showNotesModal(): void {
|
|
const modal = document.getElementById("notes-modal");
|
|
if (modal) modal.classList.remove("hidden");
|
|
}
|
|
|
|
function hideProgressSyncModal(): void {
|
|
const modal = document.getElementById("progress-sync-modal");
|
|
if (modal) modal.classList.add("hidden");
|
|
}
|
|
|
|
function hideNotesModal(): void {
|
|
const modal = document.getElementById("notes-modal");
|
|
if (modal) modal.classList.add("hidden");
|
|
}
|
|
|
|
function showReaderPlaceholder(): void {
|
|
showToast("Ebook reader coming soon!", "info");
|
|
}
|
|
|
|
function showMetadataEditor(): void {
|
|
const modal = document.getElementById("metadata-editor-modal");
|
|
if (modal) modal.classList.remove("hidden");
|
|
}
|
|
|
|
function hideMetadataEditor(): void {
|
|
const modal = document.getElementById("metadata-editor-modal");
|
|
if (modal) modal.classList.add("hidden");
|
|
}
|
|
|
|
function collectFormData(editorTags: string[]): Record<string, unknown> {
|
|
const modal = document.getElementById("metadata-editor-modal");
|
|
if (!modal) return {};
|
|
|
|
const data: Record<string, unknown> = {};
|
|
const inputs = modal.querySelectorAll<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>(
|
|
"input[name], textarea[name], select[name]",
|
|
);
|
|
for (const el of inputs) {
|
|
if (el.name === "tags") continue;
|
|
if (el.type === "number") {
|
|
const val = parseFloat(el.value);
|
|
data[el.name] = isNaN(val) ? 0 : val;
|
|
} else if (el instanceof HTMLInputElement && el.type === "checkbox") {
|
|
data[el.name] = el.checked;
|
|
} else {
|
|
data[el.name] = el.value;
|
|
}
|
|
}
|
|
|
|
data.tags = editorTags;
|
|
|
|
const contribStr = data.contributors as string;
|
|
if (typeof contribStr === "string") {
|
|
data.contributors = contribStr
|
|
.split(",")
|
|
.map((c: string) => c.trim())
|
|
.filter(Boolean);
|
|
}
|
|
|
|
// Narrators arrive as one comma-separated form field (mirrors
|
|
// contributors); empty → drop the key so non-audio saves don't ship [].
|
|
const narrStr = data.narrators as string;
|
|
if (typeof narrStr === "string") {
|
|
const narrators = narrStr
|
|
.split(",")
|
|
.map((n: string) => n.trim())
|
|
.filter(Boolean);
|
|
if (narrators.length) data.narrators = narrators;
|
|
else delete data.narrators;
|
|
}
|
|
|
|
// A2b: authors/publishers are arrays server-side (write truth); the form
|
|
// fields are comma-separated strings exactly like narrators.
|
|
for (const key of ["authors", "publishers"] as const) {
|
|
const raw = data[key] as string;
|
|
if (typeof raw === "string") {
|
|
const list = raw
|
|
.split(",")
|
|
.map((v: string) => v.trim())
|
|
.filter(Boolean);
|
|
if (list.length) data[key] = list;
|
|
else delete data[key];
|
|
}
|
|
}
|
|
|
|
return data;
|
|
}
|
|
|
|
export {
|
|
showReaderPlaceholder,
|
|
showMetadataEditor,
|
|
showProgressSyncModal,
|
|
showNotesModal,
|
|
hideProgressSyncModal,
|
|
hideNotesModal,
|
|
};
|
|
|
|
interface MetadataEditorState {
|
|
openSections: Record<string, boolean>;
|
|
hasExistingCover: boolean;
|
|
newCoverPreview: string;
|
|
coverFile: Blob | null;
|
|
coverAction: string;
|
|
saving: boolean;
|
|
rescanning: boolean;
|
|
resetting: boolean;
|
|
userRating: number;
|
|
ratingHover: number;
|
|
ratingSaving: boolean;
|
|
conflictId: string;
|
|
conflictWinner: string;
|
|
readSaving: boolean;
|
|
toggleSection(section: string): void;
|
|
showMetadataEditor(): void;
|
|
hideMetadataEditor(): void;
|
|
handleCoverUpload(event: Event): void;
|
|
removeCover(): void;
|
|
saveMetadata(): Promise<void>;
|
|
rescanBook(): Promise<void>;
|
|
resetMetadata(): Promise<void>;
|
|
starFill(i: number): string;
|
|
ratingText(): string;
|
|
setRating(value: number): Promise<void>;
|
|
clearRating(): Promise<void>;
|
|
hasReview: boolean;
|
|
reviewHtml: string;
|
|
reviewOpen: boolean;
|
|
reviewDraft: string;
|
|
reviewSaving: boolean;
|
|
openReviewEditor(): void;
|
|
saveReview(): Promise<void>;
|
|
deleteReview(): Promise<void>;
|
|
toggleRead(read: boolean): Promise<void>;
|
|
resolveConflict(conflictId: string, winner: string): Promise<void>;
|
|
restoreDeletedAnnotation(annotationType: string, annotationId: string): Promise<void>;
|
|
purgeDeletedAnnotation(annotationType: string, annotationId: string): Promise<void>;
|
|
}
|
|
|
|
Alpine.data("bookDetail", () => {
|
|
const coverImg = document.querySelector(
|
|
"#metadata-editor-modal img#metadata-cover-preview",
|
|
) as HTMLImageElement | null;
|
|
const hasCover = coverImg?.src && !coverImg.src.includes("placeholder-book.svg");
|
|
|
|
const initialTags: string[] = [];
|
|
const tagBadges = document.querySelectorAll("#metadata-editor-modal [data-editor-tag]");
|
|
tagBadges.forEach((el) => {
|
|
const tag = el.getAttribute("data-editor-tag");
|
|
if (tag) initialTags.push(tag);
|
|
});
|
|
|
|
return {
|
|
openSections: { basic: true } as Record<string, boolean>,
|
|
hasExistingCover: !!hasCover,
|
|
newCoverPreview: "",
|
|
coverFile: null as Blob | null,
|
|
coverAction: "keep",
|
|
saving: false,
|
|
rescanning: false,
|
|
resetting: false,
|
|
userRating: 0,
|
|
ratingHover: 0,
|
|
ratingSaving: false,
|
|
// Review state: hasReview/reviewHtml hydrate from the server-rendered
|
|
// raw markdown in #review-raw; reviewDraft is the editor's working copy.
|
|
hasReview: false,
|
|
reviewHtml: "",
|
|
reviewOpen: false,
|
|
reviewDraft: "",
|
|
reviewSaving: false,
|
|
conflictId: "",
|
|
conflictWinner: "",
|
|
readSaving: false,
|
|
editorTags: initialTags,
|
|
tagSearch: "",
|
|
tagSuggestions: [] as TagSuggestion[],
|
|
showTagDropdown: false,
|
|
highlightedTagIndex: -1,
|
|
|
|
// ----- annotations banner (objective 7) -----
|
|
annotationsBannerOpen: false,
|
|
annotationsBannerLoading: false,
|
|
annotationsExportOpen: false,
|
|
bannerMediaId: getMediaId(),
|
|
bannerRows: [] as AnnotationRow[],
|
|
bannerLoaded: false,
|
|
bannerError: "",
|
|
bannerEditOpen: false,
|
|
bannerEditRow: null as AnnotationRow | null,
|
|
bannerEditText: "",
|
|
// raw source kept for the export builder (not exposed to the template)
|
|
bannerSources: [] as { row: AnnotationRow; source: string; highlightText: string }[],
|
|
|
|
showReaderPlaceholder,
|
|
showMetadataEditor,
|
|
hideMetadataEditor,
|
|
showNotesModal,
|
|
showProgressSyncModal,
|
|
hideProgressSyncModal,
|
|
hideNotesModal,
|
|
|
|
async toggleAnnotationsBanner() {
|
|
this.annotationsBannerOpen = !this.annotationsBannerOpen;
|
|
if (this.annotationsBannerOpen && !this.bannerLoaded) {
|
|
await this.fetchBannerAnnotations();
|
|
}
|
|
},
|
|
hideAnnotationsBanner(): void {
|
|
this.annotationsBannerOpen = false;
|
|
this.hideExportDialog();
|
|
},
|
|
async fetchBannerAnnotations() {
|
|
const mediaId = getMediaId();
|
|
this.annotationsBannerLoading = true;
|
|
try {
|
|
const [noteResp, hlResp, bmResp] = await Promise.all([
|
|
fetch(`/api/media-items/${mediaId}/notes`, {
|
|
headers: { Authorization: getAuthHeader() },
|
|
}),
|
|
fetch(`/api/media-items/${mediaId}/highlights`, {
|
|
headers: { Authorization: getAuthHeader() },
|
|
}),
|
|
fetch(`/api/media-items/${mediaId}/bookmarks`, {
|
|
headers: { Authorization: getAuthHeader() },
|
|
}),
|
|
]);
|
|
// A partial failure must not read as "no annotations": a failed
|
|
// endpoint used to be published as an empty list, silently wiping
|
|
// that kind from the banner while the other kind stayed (found
|
|
// auditing the app's fetch-wipe fix, bookhoard-app 07bbb4c).
|
|
// Keep the previous rows; the next banner open retries.
|
|
if (!noteResp.ok || !hlResp.ok || !bmResp.ok) {
|
|
console.warn(
|
|
`banner annotation load failed ` +
|
|
`(notes ${noteResp.status}, highlights ${hlResp.status}, bookmarks ${bmResp.status}) — keeping previous`,
|
|
);
|
|
return;
|
|
}
|
|
const notes = await noteResp.json();
|
|
const highlights = await hlResp.json();
|
|
const bookmarks = await bmResp.json();
|
|
const built = buildAnnotationRows(
|
|
notes,
|
|
highlights,
|
|
Array.isArray(bookmarks) ? bookmarks : [],
|
|
);
|
|
this.bannerRows = built.rows;
|
|
this.bannerSources = built.sources;
|
|
this.bannerLoaded = true;
|
|
} catch (e) {
|
|
this.bannerError = String(e);
|
|
showToast("Could not load annotations", "error");
|
|
} finally {
|
|
this.annotationsBannerLoading = false;
|
|
}
|
|
},
|
|
// ----- note edit/delete at the banner (objective 8a) -----
|
|
startBannerEdit(row: AnnotationRow): void {
|
|
// Both kinds carry the raw note body on their source: highlights
|
|
// hold the ATTACHED note (never the highlight itself), location
|
|
// notes hold their content. The old code parsed row.rich (rendered
|
|
// HTML — no \n\n separators in it) and came up empty every time.
|
|
this.bannerEditRow = row;
|
|
this.bannerEditText = String(
|
|
this.bannerSources.find((s) => s.row.key === row.key)?.source ?? "",
|
|
);
|
|
this.bannerEditOpen = true;
|
|
},
|
|
async saveBannerNote() {
|
|
const row = this.bannerEditRow;
|
|
const content = String(this.bannerEditText || "").trim();
|
|
this.bannerEditOpen = false;
|
|
if (!row || (!content && row.kind === "note")) return;
|
|
const mediaId = getMediaId();
|
|
try {
|
|
if (row.kind === "bookmark") {
|
|
// Full-replace PUT: title + position ride along from the row's
|
|
// stored values; the note is the editable part.
|
|
const bmId = row.key.slice("bookmark-".length);
|
|
const src = this.bannerSources.find((s) => s.row.key === row.key);
|
|
const resp = await fetch(`/api/media-items/${mediaId}/bookmarks/${bmId}`, {
|
|
method: "PUT",
|
|
headers: { Authorization: getAuthHeader(), "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
title: String(src?.highlightText || "Bookmark"),
|
|
notes: content,
|
|
position: row.loc || "",
|
|
}),
|
|
});
|
|
if (!resp.ok) throw new Error(String(resp.status));
|
|
} else if (row.kind === "note") {
|
|
const noteId = row.key.slice("note-".length);
|
|
// The stored position rides along: the server's PUT hard-sets the
|
|
// position column.
|
|
const position =
|
|
(row.loc || "").startsWith("cfi:") || (row.loc || "").startsWith("page:")
|
|
? row.loc
|
|
: undefined;
|
|
const resp = await fetch(`/api/media-items/${mediaId}/notes/${noteId}`, {
|
|
method: "PUT",
|
|
headers: { Authorization: getAuthHeader(), "Content-Type": "application/json" },
|
|
body: JSON.stringify({ content, position }),
|
|
});
|
|
if (!resp.ok) throw new Error(String(resp.status));
|
|
} else {
|
|
// Highlight-attached note: the full-replace highlight PUT; empty
|
|
// content clears the note (allowed by the validator).
|
|
const hlId = row.key.slice("hl-".length);
|
|
const src = this.bannerSources.find((s) => s.row.key === row.key);
|
|
if (content === String(src?.source ?? "")) return;
|
|
// The banner only ever changes note_text: read the highlight list,
|
|
// then full-replace the row with the new note text.
|
|
const hlListResp = await fetch(`/api/media-items/${mediaId}/highlights`, {
|
|
headers: { Authorization: getAuthHeader() },
|
|
});
|
|
if (!hlListResp.ok) throw new Error(String(hlListResp.status));
|
|
const all = await hlListResp.json();
|
|
const hl = all.find((h: any) => h.id === hlId);
|
|
if (!hl) throw new Error("highlight gone");
|
|
hl.note_text = content;
|
|
const resp = await fetch(`/api/media-items/${mediaId}/highlights/${hlId}`, {
|
|
method: "PUT",
|
|
headers: { Authorization: getAuthHeader(), "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
selection_text: typeof hl.selection_text === "string" ? hl.selection_text : "",
|
|
start_position:
|
|
typeof (hl.start_position ?? "") === "string" ? hl.start_position : "",
|
|
end_position: typeof (hl.end_position ?? "") === "string" ? hl.end_position : "",
|
|
epubcfi_start: hl.epubcfi_start ?? "",
|
|
epubcfi_end: hl.epubcfi_end ?? "",
|
|
color: hl.color || undefined,
|
|
note_text: content,
|
|
note_id: hl.note_id ?? "",
|
|
percentage_start: hl.percentage_start ?? 0,
|
|
percentage_end: hl.percentage_end ?? 0,
|
|
chapter_reference: hl.chapter_reference ?? 0,
|
|
}),
|
|
});
|
|
if (!resp.ok) throw new Error(String(resp.status));
|
|
}
|
|
await this.fetchBannerAnnotations();
|
|
showToast("Note saved", "success");
|
|
} catch {
|
|
showToast("Could not save the note", "error");
|
|
}
|
|
},
|
|
async deleteBannerNote(row: AnnotationRow) {
|
|
if (row.kind !== "note") return;
|
|
const mediaId = getMediaId();
|
|
const noteId = row.key.slice("note-".length);
|
|
try {
|
|
const resp = await fetch(`/api/media-items/${mediaId}/notes/${noteId}`, {
|
|
method: "DELETE",
|
|
headers: { Authorization: getAuthHeader() },
|
|
});
|
|
if (!resp.ok) throw new Error(String(resp.status));
|
|
// Tombstoned server-side: restorable from the Deleted modal.
|
|
showToast("Note deleted — restorable from Deleted", "success");
|
|
await this.fetchBannerAnnotations();
|
|
} catch {
|
|
showToast("Could not delete the note", "error");
|
|
}
|
|
},
|
|
async deleteBannerBookmark(row: AnnotationRow) {
|
|
if (row.kind !== "bookmark") return;
|
|
const mediaId = getMediaId();
|
|
const bmId = row.key.slice("bookmark-".length);
|
|
try {
|
|
const resp = await fetch(`/api/media-items/${mediaId}/bookmarks/${bmId}`, {
|
|
method: "DELETE",
|
|
headers: { Authorization: getAuthHeader() },
|
|
});
|
|
if (!resp.ok) throw new Error(String(resp.status));
|
|
// Tombstoned server-side: restorable from the Deleted modal.
|
|
showToast("Bookmark deleted — restorable from Deleted", "success");
|
|
await this.fetchBannerAnnotations();
|
|
} catch {
|
|
showToast("Could not delete the bookmark", "error");
|
|
}
|
|
},
|
|
async deleteBannerHighlight(row: AnnotationRow) {
|
|
if (row.kind !== "highlight") return;
|
|
const mediaId = getMediaId();
|
|
const hlId = row.key.slice("hl-".length);
|
|
try {
|
|
const resp = await fetch(`/api/media-items/${mediaId}/highlights/${hlId}`, {
|
|
method: "DELETE",
|
|
headers: { Authorization: getAuthHeader() },
|
|
});
|
|
if (!resp.ok) throw new Error(String(resp.status));
|
|
// Tombstoned server-side: restorable from the Deleted modal.
|
|
showToast("Highlight deleted — restorable from Deleted", "success");
|
|
await this.fetchBannerAnnotations();
|
|
} catch {
|
|
showToast("Could not delete the highlight", "error");
|
|
}
|
|
},
|
|
showExportDialog(): void {
|
|
if (this.bannerRows.length === 0) return;
|
|
this.annotationsExportOpen = true;
|
|
},
|
|
hideExportDialog(): void {
|
|
this.annotationsExportOpen = false;
|
|
},
|
|
async exportAnnotations(mode: "single" | "multi") {
|
|
this.hideExportDialog();
|
|
if (this.bannerRows.length === 0) return;
|
|
const items = this.bannerSources.map(annotationItemMarkdown);
|
|
const title = bannerBookTitle();
|
|
try {
|
|
if (mode === "single") {
|
|
const blob = new Blob([items.join("\n")], {
|
|
type: "text/markdown",
|
|
});
|
|
triggerDownload(blob, `${title}-annotations.md`);
|
|
} else {
|
|
// One file per item: 1.md … N.md, zipped client-side.
|
|
const files: Record<string, Uint8Array> = {};
|
|
items.forEach((text, i) => {
|
|
files[`${i + 1}.md`] = strToU8(text);
|
|
});
|
|
const zipped = zipSync(files);
|
|
triggerDownload(
|
|
new Blob([zipped], { type: "application/zip" }),
|
|
`${title}-annotations.zip`,
|
|
);
|
|
}
|
|
showToast("Annotations exported", "success");
|
|
} catch {
|
|
showToast("Export failed", "error");
|
|
}
|
|
},
|
|
|
|
async resolveConflict(conflictId: string, winner: string) {
|
|
try {
|
|
const resp = await fetch(`/api/conflicts/${conflictId}/resolve`, {
|
|
method: "POST",
|
|
headers: {
|
|
Authorization: getAuthHeader(),
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({ winner }),
|
|
});
|
|
|
|
if (!resp.ok) {
|
|
const err = await resp.json().catch(() => ({}));
|
|
throw new Error(err.error || err.message || "Failed to resolve conflict");
|
|
}
|
|
|
|
hideProgressSyncModal();
|
|
showToast("Conflict resolved successfully", "success");
|
|
setTimeout(() => window.location.reload(), 500);
|
|
} catch (e) {
|
|
showToast(e instanceof Error ? e.message : "Failed to resolve conflict", "error");
|
|
}
|
|
},
|
|
|
|
async restoreDeletedAnnotation(annotationType: string, annotationId: string) {
|
|
const mediaId = getMediaId();
|
|
try {
|
|
const resp = await fetch(
|
|
`/api/media-items/${mediaId}/annotations/${annotationId}/restore`,
|
|
{
|
|
method: "POST",
|
|
headers: {
|
|
Authorization: getAuthHeader(),
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({ annotation_type: annotationType }),
|
|
},
|
|
);
|
|
if (!resp.ok) {
|
|
const err = await resp.json().catch(() => ({}));
|
|
throw new Error(err.error || "Failed to restore annotation");
|
|
}
|
|
showToast("Annotation restored", "success");
|
|
setTimeout(() => window.location.reload(), 500);
|
|
} catch (e) {
|
|
showToast(e instanceof Error ? e.message : "Failed to restore annotation", "error");
|
|
}
|
|
},
|
|
|
|
async purgeDeletedAnnotation(annotationType: string, annotationId: string) {
|
|
if (
|
|
!confirm(
|
|
"Permanently delete this annotation? This cannot be undone and it will not reappear on any device.",
|
|
)
|
|
) {
|
|
return;
|
|
}
|
|
const mediaId = getMediaId();
|
|
try {
|
|
const resp = await fetch(
|
|
`/api/media-items/${mediaId}/annotations/${annotationId}?annotation_type=${encodeURIComponent(annotationType)}`,
|
|
{
|
|
method: "DELETE",
|
|
headers: { Authorization: getAuthHeader() },
|
|
},
|
|
);
|
|
if (!resp.ok) {
|
|
const err = await resp.json().catch(() => ({}));
|
|
throw new Error(err.error || "Failed to delete annotation");
|
|
}
|
|
showToast("Annotation permanently deleted", "success");
|
|
setTimeout(() => window.location.reload(), 500);
|
|
} catch (e) {
|
|
showToast(e instanceof Error ? e.message : "Failed to delete annotation", "error");
|
|
}
|
|
},
|
|
|
|
init() {
|
|
const ratingAttr = document.body.getAttribute("data-rating");
|
|
this.userRating = ratingAttr ? parseInt(ratingAttr, 10) || 0 : 0;
|
|
|
|
// Hydrate the review card from the server-rendered raw markdown.
|
|
const reviewRaw = document.getElementById("review-raw");
|
|
if (reviewRaw) {
|
|
const source = reviewRaw.textContent || "";
|
|
this.hasReview = source.trim().length > 0;
|
|
this.reviewHtml = renderNoteHtml(source);
|
|
}
|
|
|
|
const conflictId = document.body.getAttribute("data-conflict-id");
|
|
const conflictWinner = document.body.getAttribute("data-conflict-winner");
|
|
this.conflictId = conflictId && conflictId !== "null" ? conflictId : "";
|
|
this.conflictWinner = conflictWinner && conflictWinner !== "null" ? conflictWinner : "";
|
|
|
|
const link = document.getElementById("back-link");
|
|
if (!link) return;
|
|
const storageKey = "book_detail_back";
|
|
if (document.referrer) {
|
|
try {
|
|
const ref = new URL(document.referrer);
|
|
if (
|
|
ref.origin === window.location.origin &&
|
|
!ref.pathname.startsWith("/readers/") &&
|
|
ref.pathname !== window.location.pathname
|
|
) {
|
|
sessionStorage.setItem(storageKey, ref.pathname + ref.search);
|
|
}
|
|
} catch {}
|
|
} else {
|
|
sessionStorage.removeItem(storageKey);
|
|
}
|
|
const backUrl = sessionStorage.getItem(storageKey) || "/dashboard";
|
|
link.addEventListener("click", (e: Event) => {
|
|
e.preventDefault();
|
|
sessionStorage.removeItem(storageKey);
|
|
window.location.href = backUrl;
|
|
});
|
|
},
|
|
|
|
toggleSection(section: string) {
|
|
this.openSections[section] = !this.openSections[section];
|
|
},
|
|
|
|
starFill(i: number): string {
|
|
const display = this.ratingHover || this.userRating;
|
|
if (i * 2 <= display) {
|
|
return "color: var(--accent);";
|
|
} else if (i * 2 - 1 === display) {
|
|
return "background: linear-gradient(90deg, var(--accent) 50%, var(--text-secondary) 50%); -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent;";
|
|
}
|
|
return "color: var(--text-secondary);";
|
|
},
|
|
|
|
ratingText(): string {
|
|
if (this.userRating === 0) return "(not rated)";
|
|
return `(${(this.userRating / 2).toFixed(1)} / 5)`;
|
|
},
|
|
|
|
async setRating(value: number) {
|
|
if (this.ratingSaving) return;
|
|
this.ratingSaving = true;
|
|
const mediaId = getMediaId();
|
|
try {
|
|
const resp = await fetch(`/api/media-items/${mediaId}/rating`, {
|
|
method: "POST",
|
|
headers: {
|
|
Authorization: getAuthHeader(),
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({ rating: value }),
|
|
});
|
|
if (!resp.ok) {
|
|
const err = await resp.json().catch(() => ({}));
|
|
throw new Error(err.error || "Failed to save rating");
|
|
}
|
|
this.userRating = value;
|
|
this.ratingHover = 0;
|
|
showToast("Rating saved", "success");
|
|
} catch (e) {
|
|
showToast(e instanceof Error ? e.message : "Failed to save rating", "error");
|
|
} finally {
|
|
this.ratingSaving = false;
|
|
}
|
|
},
|
|
|
|
async clearRating() {
|
|
if (this.ratingSaving) return;
|
|
this.ratingSaving = true;
|
|
const mediaId = getMediaId();
|
|
try {
|
|
const resp = await fetch(`/api/media-items/${mediaId}/rating`, {
|
|
method: "DELETE",
|
|
headers: { Authorization: getAuthHeader() },
|
|
});
|
|
if (!resp.ok) {
|
|
const err = await resp.json().catch(() => ({}));
|
|
throw new Error(err.error || "Failed to clear rating");
|
|
}
|
|
this.userRating = 0;
|
|
this.ratingHover = 0;
|
|
showToast("Rating cleared", "success");
|
|
} catch (e) {
|
|
showToast(e instanceof Error ? e.message : "Failed to clear rating", "error");
|
|
} finally {
|
|
this.ratingSaving = false;
|
|
}
|
|
},
|
|
|
|
openReviewEditor() {
|
|
// Seed the draft from the CURRENT rendered source: the raw markdown
|
|
// lives in #review-raw when a review exists, otherwise start empty.
|
|
const reviewRaw = document.getElementById("review-raw");
|
|
this.reviewDraft = reviewRaw ? reviewRaw.textContent || "" : "";
|
|
this.reviewOpen = true;
|
|
},
|
|
|
|
async saveReview() {
|
|
if (this.reviewSaving) return;
|
|
this.reviewSaving = true;
|
|
const mediaId = getMediaId();
|
|
try {
|
|
const resp = await fetch(`/api/media-items/${mediaId}/review`, {
|
|
method: "PUT",
|
|
headers: {
|
|
Authorization: getAuthHeader(),
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({ review: this.reviewDraft }),
|
|
});
|
|
if (!resp.ok) {
|
|
const err = await resp.json().catch(() => ({}));
|
|
throw new Error(err.error || "Failed to save review");
|
|
}
|
|
// Reload so the server-rendered card (or its absence) matches.
|
|
window.location.reload();
|
|
} catch (e) {
|
|
showToast(e instanceof Error ? e.message : "Failed to save review", "error");
|
|
this.reviewSaving = false;
|
|
}
|
|
},
|
|
|
|
async deleteReview() {
|
|
if (this.reviewSaving || !this.hasReview) return;
|
|
this.reviewSaving = true;
|
|
const mediaId = getMediaId();
|
|
try {
|
|
const resp = await fetch(`/api/media-items/${mediaId}/review`, {
|
|
method: "DELETE",
|
|
headers: { Authorization: getAuthHeader() },
|
|
});
|
|
if (!resp.ok) {
|
|
const err = await resp.json().catch(() => ({}));
|
|
throw new Error(err.error || "Failed to delete review");
|
|
}
|
|
window.location.reload();
|
|
} catch (e) {
|
|
showToast(e instanceof Error ? e.message : "Failed to delete review", "error");
|
|
this.reviewSaving = false;
|
|
}
|
|
},
|
|
|
|
async toggleRead(read: boolean) {
|
|
if (this.readSaving) return;
|
|
this.readSaving = true;
|
|
const mediaId = getMediaId();
|
|
try {
|
|
// Clear any active sync conflict first. Resolving arms a 10-minute
|
|
// suppression window so the progress write below does not spawn a new
|
|
// conflict. The winner only needs to be a valid source key; the final
|
|
// read/unread state is set by the progress write that follows.
|
|
if (this.conflictId && this.conflictWinner) {
|
|
const cr = await fetch(`/api/conflicts/${this.conflictId}/resolve`, {
|
|
method: "POST",
|
|
headers: {
|
|
Authorization: getAuthHeader(),
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({ winner: this.conflictWinner }),
|
|
});
|
|
// 400 means it was already resolved - treat as no conflict.
|
|
if (!cr.ok && cr.status !== 400) {
|
|
const err = await cr.json().catch(() => ({}));
|
|
throw new Error(err.error || err.message || "Failed to clear sync conflict");
|
|
}
|
|
}
|
|
|
|
if (read) {
|
|
// Mark as Read: PUT percentage 1.0. (Cannot PUT 0 to unread - the
|
|
// server silently ignores percentage < 0.005 when progress > 0.01.)
|
|
const resp = await fetch(`/api/media-items/${mediaId}/progress`, {
|
|
method: "PUT",
|
|
headers: {
|
|
Authorization: getAuthHeader(),
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({ percentage: 1.0 }),
|
|
});
|
|
if (!resp.ok) {
|
|
const err = await resp.json().catch(() => ({}));
|
|
throw new Error(err.error || "Failed to mark as read");
|
|
}
|
|
} else {
|
|
// Mark as Unread: DELETE the progress row. Notes, highlights and
|
|
// ratings are independent and are NOT affected.
|
|
const resp = await fetch(`/api/media-items/${mediaId}/progress`, {
|
|
method: "DELETE",
|
|
headers: { Authorization: getAuthHeader() },
|
|
});
|
|
if (!resp.ok) {
|
|
const err = await resp.json().catch(() => ({}));
|
|
throw new Error(err.error || "Failed to mark as unread");
|
|
}
|
|
}
|
|
|
|
showToast(read ? "Marked as read" : "Marked as unread", "success");
|
|
setTimeout(() => window.location.reload(), 500);
|
|
} catch (e) {
|
|
showToast(e instanceof Error ? e.message : "Failed to update read state", "error");
|
|
} finally {
|
|
this.readSaving = false;
|
|
}
|
|
},
|
|
|
|
handleCoverUpload(event: Event) {
|
|
const input = event.target as HTMLInputElement;
|
|
const file = input.files?.[0];
|
|
if (!file) return;
|
|
|
|
this.coverFile = file;
|
|
this.coverAction = "upload";
|
|
|
|
const reader = new FileReader();
|
|
reader.onload = (e) => {
|
|
const preview = document.getElementById("metadata-cover-preview") as HTMLImageElement;
|
|
if (preview && e.target?.result) {
|
|
preview.src = e.target.result as string;
|
|
}
|
|
this.newCoverPreview = e.target?.result as string;
|
|
};
|
|
reader.readAsDataURL(file);
|
|
},
|
|
|
|
removeCover() {
|
|
this.coverAction = "remove";
|
|
this.coverFile = null;
|
|
this.newCoverPreview = "";
|
|
const preview = document.getElementById("metadata-cover-preview") as HTMLImageElement;
|
|
if (preview) {
|
|
preview.src = "/static/placeholder-book.svg";
|
|
}
|
|
},
|
|
|
|
async saveMetadata() {
|
|
if (this.saving) return;
|
|
this.saving = true;
|
|
|
|
try {
|
|
const mediaId = getMediaId();
|
|
const formData = collectFormData(this.editorTags);
|
|
formData.cover_action = this.coverAction;
|
|
|
|
if (this.coverFile) {
|
|
const fd = new FormData();
|
|
for (const [key, value] of Object.entries(formData)) {
|
|
if (Array.isArray(value)) {
|
|
fd.append(key, JSON.stringify(value));
|
|
} else if (typeof value === "boolean") {
|
|
fd.append(key, value ? "true" : "false");
|
|
} else {
|
|
fd.append(key, String(value));
|
|
}
|
|
}
|
|
fd.append("cover_file", this.coverFile, "cover.jpg");
|
|
|
|
const resp = await fetch(`/api/media-items/${mediaId}`, {
|
|
method: "PUT",
|
|
headers: { Authorization: getAuthHeader() },
|
|
body: fd,
|
|
});
|
|
|
|
if (!resp.ok) {
|
|
const err = await resp.json().catch(() => ({}));
|
|
throw new Error(err.error || "Failed to save metadata");
|
|
}
|
|
} else {
|
|
const resp = await fetch(`/api/media-items/${mediaId}`, {
|
|
method: "PUT",
|
|
headers: {
|
|
Authorization: getAuthHeader(),
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify(formData),
|
|
});
|
|
|
|
if (!resp.ok) {
|
|
const err = await resp.json().catch(() => ({}));
|
|
throw new Error(err.error || "Failed to save metadata");
|
|
}
|
|
}
|
|
|
|
hideMetadataEditor();
|
|
showToast("Metadata saved successfully", "success");
|
|
setTimeout(() => window.location.reload(), 500);
|
|
} catch (e) {
|
|
showToast(e instanceof Error ? e.message : "Failed to save metadata", "error");
|
|
} finally {
|
|
this.saving = false;
|
|
}
|
|
},
|
|
|
|
async rescanBook() {
|
|
if (this.rescanning) return;
|
|
this.rescanning = true;
|
|
const mediaId = getMediaId();
|
|
try {
|
|
const resp = await fetch(`/api/media-items/${mediaId}/rescan`, {
|
|
method: "POST",
|
|
headers: { Authorization: getAuthHeader() },
|
|
});
|
|
if (!resp.ok) {
|
|
const err = await resp.json().catch(() => ({}));
|
|
throw new Error(err.error || "Failed to rescan book");
|
|
}
|
|
showToast("Book rescanned successfully", "success");
|
|
setTimeout(() => window.location.reload(), 500);
|
|
} catch (e) {
|
|
showToast(e instanceof Error ? e.message : "Failed to rescan book", "error");
|
|
} finally {
|
|
this.rescanning = false;
|
|
}
|
|
},
|
|
|
|
async resetMetadata() {
|
|
if (this.resetting) return;
|
|
if (
|
|
!window.confirm(
|
|
"Reset all metadata to scanned defaults? Manual edits and custom covers will be discarded.",
|
|
)
|
|
) {
|
|
return;
|
|
}
|
|
this.resetting = true;
|
|
const mediaId = getMediaId();
|
|
try {
|
|
const resp = await fetch(`/api/media-items/${mediaId}/rescan?reset_overrides=true`, {
|
|
method: "POST",
|
|
headers: { Authorization: getAuthHeader() },
|
|
});
|
|
if (!resp.ok) {
|
|
const err = await resp.json().catch(() => ({}));
|
|
throw new Error(err.error || "Failed to reset metadata");
|
|
}
|
|
showToast("Metadata reset to scanned defaults", "success");
|
|
setTimeout(() => window.location.reload(), 500);
|
|
} catch (e) {
|
|
showToast(e instanceof Error ? e.message : "Failed to reset metadata", "error");
|
|
} finally {
|
|
this.resetting = false;
|
|
}
|
|
},
|
|
|
|
async searchEditorTags() {
|
|
const libraryId = document.body.getAttribute("data-library-id") || "";
|
|
if (!this.tagSearch || this.tagSearch.length < 2 || !libraryId) {
|
|
this.tagSuggestions = [];
|
|
this.showTagDropdown = false;
|
|
return;
|
|
}
|
|
const results = await searchTagSuggestions(this.tagSearch, libraryId);
|
|
const current = this.editorTags.map((t: string) => t.toLowerCase());
|
|
this.tagSuggestions = results.filter(
|
|
(r: TagSuggestion) => !current.includes(r.value.toLowerCase()),
|
|
);
|
|
this.showTagDropdown = this.tagSuggestions.length > 0;
|
|
this.highlightedTagIndex = -1;
|
|
},
|
|
|
|
addEditorTag(tag: string) {
|
|
const trimmed = tag.trim();
|
|
if (!trimmed) return;
|
|
const lower = trimmed.toLowerCase();
|
|
if (this.editorTags.some((t: string) => t.toLowerCase() === lower)) return;
|
|
this.editorTags.push(trimmed);
|
|
this.tagSearch = "";
|
|
this.showTagDropdown = false;
|
|
this.tagSuggestions = [];
|
|
this.highlightedTagIndex = -1;
|
|
},
|
|
|
|
removeEditorTag(index: number) {
|
|
this.editorTags.splice(index, 1);
|
|
},
|
|
|
|
selectEditorTagSuggestion(tag: string) {
|
|
this.addEditorTag(tag);
|
|
},
|
|
|
|
hideEditorTagDropdown() {
|
|
setTimeout(() => {
|
|
this.showTagDropdown = false;
|
|
}, 200);
|
|
},
|
|
|
|
onTagKeydown(event: KeyboardEvent) {
|
|
if (event.key === "ArrowDown") {
|
|
event.preventDefault();
|
|
if (this.showTagDropdown && this.tagSuggestions.length > 0) {
|
|
this.highlightedTagIndex = (this.highlightedTagIndex + 1) % this.tagSuggestions.length;
|
|
}
|
|
} else if (event.key === "ArrowUp") {
|
|
event.preventDefault();
|
|
if (this.showTagDropdown && this.tagSuggestions.length > 0) {
|
|
this.highlightedTagIndex =
|
|
this.highlightedTagIndex <= 0
|
|
? this.tagSuggestions.length - 1
|
|
: this.highlightedTagIndex - 1;
|
|
}
|
|
} else if (event.key === "Enter") {
|
|
event.preventDefault();
|
|
if (this.highlightedTagIndex >= 0 && this.showTagDropdown) {
|
|
this.addEditorTag(this.tagSuggestions[this.highlightedTagIndex].value);
|
|
} else if (this.tagSearch.trim()) {
|
|
this.addEditorTag(this.tagSearch);
|
|
}
|
|
} else if (event.key === ",") {
|
|
event.preventDefault();
|
|
if (this.tagSearch.trim()) {
|
|
this.addEditorTag(this.tagSearch);
|
|
}
|
|
} else if (event.key === "Escape") {
|
|
this.showTagDropdown = false;
|
|
this.tagSuggestions = [];
|
|
this.highlightedTagIndex = -1;
|
|
}
|
|
},
|
|
} as MetadataEditorState;
|
|
});
|