import { Alpine } from "./alpine"; import { showToast } from "./toast"; import { generateCoverBlob } from "./cover-generator"; import { searchTagSuggestions, type TagSuggestion } from "./tag-dropdown"; 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 { const modal = document.getElementById("metadata-editor-modal"); if (!modal) return {}; const data: Record = {}; const inputs = modal.querySelectorAll( "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.type === "checkbox") { data[el.name] = (el as HTMLInputElement).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); } return data; } export { showReaderPlaceholder, showMetadataEditor, 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; resolveConflict(conflictId: string, winner: string): 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 : ""; 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, coverGenerating: false, hasExistingCover: !!hasCover, newCoverPreview: "", coverFile: null as Blob | null, coverAction: "keep", saving: false, editorTags: initialTags, tagSearch: "", tagSuggestions: [] as TagSuggestion[], showTagDropdown: false, highlightedTagIndex: -1, showReaderPlaceholder, showMetadataEditor, hideMetadataEditor, showNotesModal, showProgressSyncModal, hideProgressSyncModal, hideNotesModal, 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", ); } }, 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(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 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; });