From 65c08fb52319dcd9574d22a5e25fd699a6e47e26 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Sun, 10 May 2026 11:53:50 -0400 Subject: [PATCH] feat(frontend): wire metadata editor Alpine component in book-detail.ts Replace placeholder toast with full metadata editor Alpine data component: - Modal show/hide (showMetadataEditor, hideMetadataEditor) - Accordion section toggle - Cover upload via FileReader preview - Cover generation via dynamic cover-generator import - Cover removal with placeholder fallback - saveMetadata(): collects form data, sends PUT as JSON or multipart depending on whether a cover file is present - Back button fix: skip overwriting sessionStorage back URL when referrer is the current page (preserves navigation after page reload) --- web/src/book-detail.ts | 301 ++++++++++++++++++++++++++++++++++++----- 1 file changed, 270 insertions(+), 31 deletions(-) diff --git a/web/src/book-detail.ts b/web/src/book-detail.ts index 1f39908..b1d72e8 100644 --- a/web/src/book-detail.ts +++ b/web/src/book-detail.ts @@ -1,56 +1,295 @@ import { Alpine } from "./alpine"; import { showToast } from "./toast"; +import { generateCoverBlob } from "./cover-generator"; + +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 showMetadataEditorPlaceholder(): void { - showToast("Metadata editor coming soon!", "info"); +function showMetadataEditor(): void { + const modal = document.getElementById("metadata-editor-modal"); + if (modal) modal.classList.remove("hidden"); } -function showProgressSyncModal(): void { - const modal = document.getElementById("progress-sync-modal"); - if (modal) { - modal.classList.remove("hidden"); - } +function hideMetadataEditor(): void { + const modal = document.getElementById("metadata-editor-modal"); + if (modal) modal.classList.add("hidden"); } -function showNotesModal(): void { - const modal = document.getElementById("notes-modal"); - if (modal) { - modal.classList.remove("hidden"); - } -} +function collectFormData(): Record { + const modal = document.getElementById("metadata-editor-modal"); + if (!modal) return {}; -function hideProgressSyncModal(): void { - const modal = document.getElementById("progress-sync-modal"); - if (modal) { - modal.classList.add("hidden"); + const data: Record = {}; + const inputs = modal.querySelectorAll( + "input[name], textarea[name], select[name]", + ); + for (const el of inputs) { + if (el.type === "number") { + const val = parseFloat(el.value); + data[el.name] = isNaN(val) ? 0 : val; + } else if (el.type === "checkbox") { + data[el.name] = (el as HTMLInputElement).checked; + } else { + data[el.name] = el.value; + } } -} -function hideNotesModal(): void { - const modal = document.getElementById("notes-modal"); - if (modal) { - modal.classList.add("hidden"); + const tagsStr = data.tags as string; + if (typeof tagsStr === "string") { + data.tags = tagsStr + .split(",") + .map((t: string) => t.trim()) + .filter(Boolean); } + + const contribStr = data.contributors as string; + if (typeof contribStr === "string") { + data.contributors = contribStr + .split(",") + .map((c: string) => c.trim()) + .filter(Boolean); + } + + return data; } export { showReaderPlaceholder, - showMetadataEditorPlaceholder, + showMetadataEditor, showProgressSyncModal, showNotesModal, hideProgressSyncModal, hideNotesModal, }; -Alpine.data("bookDetail", () => ({ - showReaderPlaceholder, - showMetadataEditorPlaceholder, - showProgressSyncModal, - showNotesModal, - hideProgressSyncModal, - hideNotesModal, -})); +interface MetadataEditorState { + openSections: Record; + coverGenerating: boolean; + hasExistingCover: boolean; + newCoverPreview: string; + coverFile: Blob | null; + coverAction: string; + saving: boolean; + toggleSection(section: string): void; + showMetadataEditor(): void; + hideMetadataEditor(): void; + handleCoverUpload(event: Event): void; + generateCover(): Promise; + removeCover(): void; + saveMetadata(): Promise; +} + +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 coverPreviewEl = document.querySelector( + ".aspect-\\[2\\/3\\] img", + ) as HTMLImageElement | null; + const coverSrc = coverPreviewEl?.src || ""; + const fileUrl = coverSrc && !coverSrc.includes("placeholder") ? coverSrc : ""; + + return { + openSections: { basic: true } as Record, + coverGenerating: false, + hasExistingCover: !!hasCover, + newCoverPreview: "", + coverFile: null as Blob | null, + coverAction: "keep", + saving: false, + + showReaderPlaceholder, + showMetadataEditor, + hideMetadataEditor, + showNotesModal, + showProgressSyncModal, + hideProgressSyncModal, + hideNotesModal, + + init() { + 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]; + }, + + 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); + }, + + async generateCover() { + this.coverGenerating = true; + try { + const formatGroup = + document + .querySelector('[data-format-group]') + ?.getAttribute("data-format-group") || "reflowable"; + + const blob = await generateCoverBlob(fileUrl, formatGroup); + if (!blob) return; + + this.coverFile = blob; + this.coverAction = "upload"; + + const preview = document.getElementById( + "metadata-cover-preview", + ) as HTMLImageElement; + if (preview) { + preview.src = URL.createObjectURL(blob); + } + this.newCoverPreview = URL.createObjectURL(blob); + showToast("Cover generated successfully", "success"); + } finally { + this.coverGenerating = false; + } + }, + + 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(); + 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; + } + }, + } as MetadataEditorState; +});