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; userRating: number; ratingHover: number; ratingSaving: boolean; conflictId: string; conflictWinner: string; readSaving: boolean; toggleSection(section: string): void; showMetadataEditor(): void; hideMetadataEditor(): void; handleCoverUpload(event: Event): void; generateCover(): Promise; removeCover(): void; saveMetadata(): Promise; starFill(i: number): string; ratingText(): string; setRating(value: number): Promise; clearRating(): Promise; toggleRead(read: boolean): Promise; resolveConflict(conflictId: string, winner: string): Promise; restoreDeletedAnnotation(annotationType: string, annotationId: string): Promise; purgeDeletedAnnotation(annotationType: string, annotationId: 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, userRating: 0, ratingHover: 0, ratingSaving: false, conflictId: "", conflictWinner: "", readSaving: 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", ); } }, 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; 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; } }, 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); }, 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; });