diff --git a/vite.config.ts b/vite.config.ts index 7e036618..fff6b5b6 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -39,6 +39,11 @@ const cleanStaleChunks = () => { }; export default defineConfig({ + // web/static holds vendored and build-output assets (htmx.min.js, + // compiled css) that must never be reformatted; only web/src is fmt scope. + fmt: { + ignorePatterns: ["web/static/**"], + }, resolve: { alias: { "@": "/web/src", diff --git a/web/src/admin.ts b/web/src/admin.ts index c1800153..085046c8 100644 --- a/web/src/admin.ts +++ b/web/src/admin.ts @@ -57,13 +57,8 @@ async function scanAllLibraries(): Promise { } } -function showScanProgress( - jobIds: string[], - libraryNames: Record, -): void { - const container = document.getElementById( - "scan-progress-container", - ) as HTMLElement; +function showScanProgress(jobIds: string[], libraryNames: Record): void { + const container = document.getElementById("scan-progress-container") as HTMLElement; const list = document.getElementById("library-progress-list") as HTMLElement; if (!container || !list) return; @@ -113,10 +108,7 @@ function showScanProgress( let scanPollInterval: ReturnType | undefined = undefined; -function pollScanProgress( - jobIds: string[], - _libraryNames: Record, -): void { +function pollScanProgress(jobIds: string[], _libraryNames: Record): void { const token = localStorage.getItem("token"); const startTime = Date.now(); const cancelledJobs = new Set(); @@ -168,14 +160,9 @@ function pollScanProgress( } // status.progress arrives 0-1 from the server; render as percent. - const overallProgress = - progressJobs > 0 ? Math.round((totalProgress / progressJobs) * 100) : 0; - const progressBar = document.getElementById( - "scan-progress-bar", - ) as HTMLElement; - const progressText = document.getElementById( - "scan-progress-text", - ) as HTMLElement; + const overallProgress = progressJobs > 0 ? Math.round((totalProgress / progressJobs) * 100) : 0; + const progressBar = document.getElementById("scan-progress-bar") as HTMLElement; + const progressText = document.getElementById("scan-progress-text") as HTMLElement; const statusText = document.getElementById("scan-status") as HTMLElement; if (progressBar) progressBar.style.width = overallProgress + "%"; @@ -241,9 +228,7 @@ async function cancelScan(jobId: string): Promise { const token = localStorage.getItem("token"); if (!token) return; - const btn = document.querySelector( - `[data-cancel-job="${jobId}"]`, - ); + const btn = document.querySelector(`[data-cancel-job="${jobId}"]`); if (btn) { btn.disabled = true; btn.textContent = "Cancelling..."; @@ -260,10 +245,7 @@ async function cancelScan(jobId: string): Promise { } showToast("Scan cancellation requested", "success"); } catch (e) { - showToast( - e instanceof Error ? e.message : "Failed to cancel scan", - "error", - ); + showToast(e instanceof Error ? e.message : "Failed to cancel scan", "error"); if (btn) { btn.disabled = false; btn.textContent = "Cancel"; @@ -291,10 +273,7 @@ async function cancelAllScans(): Promise { await Promise.allSettled(jobs.map((job) => cancelScan(job.job_id))); } catch (e) { - showToast( - e instanceof Error ? e.message : "Failed to cancel scans", - "error", - ); + showToast(e instanceof Error ? e.message : "Failed to cancel scans", "error"); } } @@ -361,9 +340,7 @@ function showScanResults( cancelled: number, ): void { const resultsDiv = document.getElementById("scan-results") as HTMLElement; - const contentDiv = document.getElementById( - "scan-results-content", - ) as HTMLElement; + const contentDiv = document.getElementById("scan-results-content") as HTMLElement; if (!resultsDiv || !contentDiv) return; @@ -381,16 +358,12 @@ function showScanResults( const statusText = document.getElementById("scan-status") as HTMLElement; if (statusText) { statusText.textContent = - cancelled > 0 - ? "Scan finished (some scans were cancelled)" - : "Scan complete!"; + cancelled > 0 ? "Scan finished (some scans were cancelled)" : "Scan complete!"; } } function hideScanProgress(): void { - const container = document.getElementById( - "scan-progress-container", - ) as HTMLElement; + const container = document.getElementById("scan-progress-container") as HTMLElement; if (container) container.classList.add("hidden"); setCancelAllVisible(false); } @@ -447,16 +420,10 @@ async function purgeArchivedItems(): Promise { throw new Error(err.error || "Failed to purge archived items"); } const data = await resp.json(); - showToast( - `Purged ${data.purged} archived item${data.purged === 1 ? "" : "s"}`, - "success", - ); + showToast(`Purged ${data.purged} archived item${data.purged === 1 ? "" : "s"}`, "success"); setTimeout(() => window.location.reload(), 700); } catch (e) { - showToast( - e instanceof Error ? e.message : "Failed to purge archived items", - "error", - ); + showToast(e instanceof Error ? e.message : "Failed to purge archived items", "error"); } } @@ -551,9 +518,7 @@ document.querySelectorAll("[data-unarchive]").forEach((el) => { el.addEventListener("click", () => unarchiveItem(el.dataset.unarchive || "")); }); document.querySelectorAll("[data-delete-archived]").forEach((el) => { - el.addEventListener("click", () => - deleteArchivedItem(el.dataset.deleteArchived || ""), - ); + el.addEventListener("click", () => deleteArchivedItem(el.dataset.deleteArchived || "")); }); document.querySelectorAll("[data-merge-source]").forEach((el) => { el.addEventListener("click", () => @@ -631,9 +596,7 @@ function bindDeleteLibraryModalClose(): void { ?.addEventListener("htmx:afterRequest", function (evt) { const detail = (evt as CustomEvent).detail as { successful?: boolean }; if (detail.successful) { - document - .getElementById("delete-library-modal") - ?.classList.add("hidden"); + document.getElementById("delete-library-modal")?.classList.add("hidden"); } }); } diff --git a/web/src/annotations-banner.ts b/web/src/annotations-banner.ts index 60c26b82..a6253b80 100644 --- a/web/src/annotations-banner.ts +++ b/web/src/annotations-banner.ts @@ -43,7 +43,10 @@ interface AnnotationSource { } /** Friendly human location label; mirrors the app's positionHint(). */ -function friendlyAnnotationLabel(position: string | null | undefined, fallbackText: string): string { +function friendlyAnnotationLabel( + position: string | null | undefined, + fallbackText: string, +): string { const raw = (position ?? "").trim(); if (raw) { // PDF/comic anchors: "page:N" @@ -61,9 +64,12 @@ function friendlyAnnotationLabel(position: string | null | undefined, fallbackTe } } // Spec fallback: the text's first few words (never a raw locator). - const words = stripNote(fallbackText || "").replace(/\s+/g, " ").trim(); + const words = stripNote(fallbackText || "") + .replace(/\s+/g, " ") + .trim(); if (words) { - const cut = words.length > 42 ? words.slice(0, words.lastIndexOf(" ", 42)) || words.slice(0, 42) : words; + const cut = + words.length > 42 ? words.slice(0, words.lastIndexOf(" ", 42)) || words.slice(0, 42) : words; return cut; } return "Location"; @@ -214,10 +220,7 @@ function buildAnnotationRows( } function escapeHtmlForRich(text: string): string { - return text - .replace(/&/g, "&") - .replace(//g, ">"); + return text.replace(/&/g, "&").replace(//g, ">"); } /** The export item text — identical in single and multi modes. */ diff --git a/web/src/api-explorer-docs.ts b/web/src/api-explorer-docs.ts index adab7eab..0e0b1fd2 100644 --- a/web/src/api-explorer-docs.ts +++ b/web/src/api-explorer-docs.ts @@ -16,9 +16,7 @@ function showDocMode(mode: "mock" | "real"): void { const responseStatus = document.getElementById("response-status"); const responseTime = document.getElementById("response-time"); const apiResponse = document.querySelector(".api-response"); - const requestBody = document.getElementById( - "request-body", - ) as HTMLTextAreaElement; + const requestBody = document.getElementById("request-body") as HTMLTextAreaElement; const tryItOut = document.getElementById("try-it-out"); if ( @@ -58,12 +56,8 @@ function showDocMode(mode: "mock" | "real"): void { } async function tryDocEndpoint(): Promise { - const methodSelect = document.getElementById( - "http-method", - ) as HTMLSelectElement; - const requestBody = document.getElementById( - "request-body", - ) as HTMLTextAreaElement; + const methodSelect = document.getElementById("http-method") as HTMLSelectElement; + const requestBody = document.getElementById("request-body") as HTMLTextAreaElement; const responseBody = document.getElementById("response-body"); const responseStatus = document.getElementById("response-status"); const responseTime = document.getElementById("response-time"); @@ -113,9 +107,7 @@ async function tryDocEndpoint(): Promise { } function copyDocRequest(): void { - const requestBody = document.getElementById( - "request-body", - ) as HTMLTextAreaElement; + const requestBody = document.getElementById("request-body") as HTMLTextAreaElement; if (requestBody) { navigator.clipboard.writeText(requestBody.value); } @@ -129,12 +121,8 @@ function copyDocResponse(): void { } function generateDocCURL(): void { - const methodSelect = document.getElementById( - "http-method", - ) as HTMLSelectElement; - const requestBody = document.getElementById( - "request-body", - ) as HTMLTextAreaElement; + const methodSelect = document.getElementById("http-method") as HTMLSelectElement; + const requestBody = document.getElementById("request-body") as HTMLTextAreaElement; if (!methodSelect || !requestBody) return; diff --git a/web/src/api-explorer.ts b/web/src/api-explorer.ts index 3d2c5b27..8c3a459c 100644 --- a/web/src/api-explorer.ts +++ b/web/src/api-explorer.ts @@ -10,13 +10,9 @@ interface ApiExplorerRequest { const requestHistory: ApiExplorerRequest[] = []; function sendApiRequest(): void { - const method = - (document.getElementById("api-method") as HTMLSelectElement)?.value || - "GET"; - const endpoint = - (document.getElementById("api-endpoint") as HTMLInputElement)?.value || ""; - const bodyText = - (document.getElementById("api-body") as HTMLTextAreaElement)?.value || ""; + const method = (document.getElementById("api-method") as HTMLSelectElement)?.value || "GET"; + const endpoint = (document.getElementById("api-endpoint") as HTMLInputElement)?.value || ""; + const bodyText = (document.getElementById("api-body") as HTMLTextAreaElement)?.value || ""; const token = localStorage.getItem("token"); @@ -64,11 +60,7 @@ function sendApiRequest(): void { }); } -function displayResponse( - response: Response, - data: unknown, - duration: number, -): void { +function displayResponse(response: Response, data: unknown, duration: number): void { const container = document.getElementById("api-response"); if (!container) return; @@ -150,12 +142,8 @@ function loadFromHistory(index: number): void { const request = requestHistory[index]; if (!request) return; - const methodSelect = document.getElementById( - "api-method", - ) as HTMLSelectElement; - const endpointInput = document.getElementById( - "api-endpoint", - ) as HTMLInputElement; + const methodSelect = document.getElementById("api-method") as HTMLSelectElement; + const endpointInput = document.getElementById("api-endpoint") as HTMLInputElement; const bodyInput = document.getElementById("api-body") as HTMLTextAreaElement; if (methodSelect) methodSelect.value = request.method; diff --git a/web/src/api.ts b/web/src/api.ts index cb8249a6..d6509049 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -37,10 +37,7 @@ async function apiPut(url: string, data?: unknown): Promise { }); } -async function apiDelete( - url: string, - data?: T, -): Promise { +async function apiDelete(url: string, data?: T): Promise { return fetch(`/api${url}`, { method: "DELETE", headers: { @@ -64,9 +61,7 @@ async function apiPatch(url: string, data?: unknown): Promise { async function handleResponse(response: Response): Promise { if (!response.ok) { - const errorData = await response - .json() - .catch(() => ({ error: "Unknown error" })); + const errorData = await response.json().catch(() => ({ error: "Unknown error" })); throw new Error(errorData.error || `HTTP ${response.status}`); } return response.json(); @@ -74,17 +69,14 @@ async function handleResponse(response: Response): Promise { async function handleVoidResponse(response: Response): Promise { if (!response.ok) { - const errorData = await response - .json() - .catch(() => ({ error: "Unknown error" })); + const errorData = await response.json().catch(() => ({ error: "Unknown error" })); throw new Error(errorData.error || `HTTP ${response.status}`); } } function handleError(error: unknown, context: string): void { console.error(`${context}:`, error); - const message = - error instanceof Error ? error.message : "An unexpected error occurred"; + const message = error instanceof Error ? error.message : "An unexpected error occurred"; showToast(message, "error"); } @@ -106,16 +98,11 @@ async function updateReadingProgress( mediaItemId: string, progress: ReadingProgress, ): Promise { - const response = await apiPut( - `/media-items/${mediaItemId}/progress`, - progress, - ); + const response = await apiPut(`/media-items/${mediaItemId}/progress`, progress); await handleVoidResponse(response); } -async function getReadingProgress( - mediaItemId: string, -): Promise { +async function getReadingProgress(mediaItemId: string): Promise { const response = await apiGet(`/media-items/${mediaItemId}/progress`); if (response.status === 404) { diff --git a/web/src/audio.ts b/web/src/audio.ts index 8fcf7775..24ad83ec 100644 --- a/web/src/audio.ts +++ b/web/src/audio.ts @@ -43,7 +43,11 @@ export function localOffsetFor(tracks: AudioTrack[], index: number, globalSecond // globalSecondsFor converts (track i, local seconds) back to the global // position — the inverse of localOffsetFor. -export function globalSecondsFor(tracks: AudioTrack[], index: number, localSeconds: number): number { +export function globalSecondsFor( + tracks: AudioTrack[], + index: number, + localSeconds: number, +): number { const track = tracks[index]; return (track ? track.start : 0) + Math.max(0, localSeconds); } @@ -309,10 +313,7 @@ Alpine.data("audioReader", () => ({ try { const [chaptersResp, progress] = await Promise.all([ fetch(readConfig().chapterEndpoint, { headers: getAuthHeaders() }), - fetch( - `/api/media-items/${this.mediaItemId}/progress`, - { headers: getAuthHeaders() }, - ), + fetch(`/api/media-items/${this.mediaItemId}/progress`, { headers: getAuthHeaders() }), ]); if (chaptersResp.ok) { const data = await chaptersResp.json(); @@ -375,9 +376,7 @@ Alpine.data("audioReader", () => ({ chapterTitle(): string { const ch = this.chapters[this.currentChapterIdx]; - return ch - ? ch.title || `Chapter ${ch.index + 1}` - : this.title; + return ch ? ch.title || `Chapter ${ch.index + 1}` : this.title; }, currentChapter(): number { @@ -407,7 +406,10 @@ Alpine.data("audioReader", () => ({ }, skip(direction: 1 | -1) { - const target = Math.max(0, this.globalPosition() + direction * (Number(this.skipSeconds) || 10)); + const target = Math.max( + 0, + this.globalPosition() + direction * (Number(this.skipSeconds) || 10), + ); this.seekTo(target); }, @@ -494,8 +496,7 @@ Alpine.data("audioReader", () => ({ .filter((b) => String(b.position || "").startsWith("audio:")) .sort( (a, b) => - this.bookmarkSeconds(String(a.position)) - - this.bookmarkSeconds(String(b.position)), + this.bookmarkSeconds(String(a.position)) - this.bookmarkSeconds(String(b.position)), ); } catch { // bookmarks are non-critical UI @@ -558,9 +559,7 @@ Alpine.data("audioReader", () => ({ }, async saveNote() { - const b = this.bookmarks.find( - (x) => String(x.id) === this.editingBookmarkId, - ); + const b = this.bookmarks.find((x) => String(x.id) === this.editingBookmarkId); if (!b) return; try { const resp = await fetch( @@ -585,10 +584,10 @@ Alpine.data("audioReader", () => ({ async deleteBookmark(b: Record) { try { - const resp = await fetch( - `/api/media-items/${this.mediaItemId}/bookmarks/${b.id}`, - { method: "DELETE", headers: getAuthHeaders() }, - ); + const resp = await fetch(`/api/media-items/${this.mediaItemId}/bookmarks/${b.id}`, { + method: "DELETE", + headers: getAuthHeaders(), + }); if (!resp.ok) throw new Error("delete failed"); await this.loadBookmarks(); } catch { diff --git a/web/src/book-detail.ts b/web/src/book-detail.ts index 755e1363..e7f0e9d8 100644 --- a/web/src/book-detail.ts +++ b/web/src/book-detail.ts @@ -169,9 +169,7 @@ 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 hasCover = coverImg?.src && !coverImg.src.includes("placeholder-book.svg"); const initialTags: string[] = []; const tagBadges = document.querySelectorAll("#metadata-editor-modal [data-editor-tag]"); @@ -293,7 +291,7 @@ Alpine.data("bookDetail", () => { // 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.bannerSources.find((s) => s.row.key === row.key)?.source ?? "", ); this.bannerEditOpen = true; }, @@ -308,7 +306,7 @@ Alpine.data("bookDetail", () => { // 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 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" }, @@ -323,8 +321,10 @@ Alpine.data("bookDetail", () => { 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 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" }, @@ -335,7 +335,7 @@ Alpine.data("bookDetail", () => { // 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); + 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. @@ -352,7 +352,8 @@ Alpine.data("bookDetail", () => { 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 : "", + 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 ?? "", @@ -479,10 +480,7 @@ Alpine.data("bookDetail", () => { showToast("Conflict resolved successfully", "success"); setTimeout(() => window.location.reload(), 500); } catch (e) { - showToast( - e instanceof Error ? e.message : "Failed to resolve conflict", - "error", - ); + showToast(e instanceof Error ? e.message : "Failed to resolve conflict", "error"); } }, @@ -507,10 +505,7 @@ Alpine.data("bookDetail", () => { showToast("Annotation restored", "success"); setTimeout(() => window.location.reload(), 500); } catch (e) { - showToast( - e instanceof Error ? e.message : "Failed to restore annotation", - "error", - ); + showToast(e instanceof Error ? e.message : "Failed to restore annotation", "error"); } }, @@ -538,10 +533,7 @@ Alpine.data("bookDetail", () => { showToast("Annotation permanently deleted", "success"); setTimeout(() => window.location.reload(), 500); } catch (e) { - showToast( - e instanceof Error ? e.message : "Failed to delete annotation", - "error", - ); + showToast(e instanceof Error ? e.message : "Failed to delete annotation", "error"); } }, @@ -560,8 +552,7 @@ Alpine.data("bookDetail", () => { 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 : ""; + this.conflictWinner = conflictWinner && conflictWinner !== "null" ? conflictWinner : ""; const link = document.getElementById("back-link"); if (!link) return; @@ -628,10 +619,7 @@ Alpine.data("bookDetail", () => { this.ratingHover = 0; showToast("Rating saved", "success"); } catch (e) { - showToast( - e instanceof Error ? e.message : "Failed to save rating", - "error", - ); + showToast(e instanceof Error ? e.message : "Failed to save rating", "error"); } finally { this.ratingSaving = false; } @@ -654,10 +642,7 @@ Alpine.data("bookDetail", () => { this.ratingHover = 0; showToast("Rating cleared", "success"); } catch (e) { - showToast( - e instanceof Error ? e.message : "Failed to clear rating", - "error", - ); + showToast(e instanceof Error ? e.message : "Failed to clear rating", "error"); } finally { this.ratingSaving = false; } @@ -691,10 +676,7 @@ Alpine.data("bookDetail", () => { // 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", - ); + showToast(e instanceof Error ? e.message : "Failed to save review", "error"); this.reviewSaving = false; } }, @@ -714,10 +696,7 @@ Alpine.data("bookDetail", () => { } window.location.reload(); } catch (e) { - showToast( - e instanceof Error ? e.message : "Failed to delete review", - "error", - ); + showToast(e instanceof Error ? e.message : "Failed to delete review", "error"); this.reviewSaving = false; } }, @@ -732,23 +711,18 @@ Alpine.data("bookDetail", () => { // 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 }), + 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", - ); + throw new Error(err.error || err.message || "Failed to clear sync conflict"); } } @@ -780,16 +754,10 @@ Alpine.data("bookDetail", () => { } } - showToast( - read ? "Marked as read" : "Marked as unread", - "success", - ); + 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", - ); + showToast(e instanceof Error ? e.message : "Failed to update read state", "error"); } finally { this.readSaving = false; } @@ -805,9 +773,7 @@ Alpine.data("bookDetail", () => { const reader = new FileReader(); reader.onload = (e) => { - const preview = document.getElementById( - "metadata-cover-preview", - ) as HTMLImageElement; + const preview = document.getElementById("metadata-cover-preview") as HTMLImageElement; if (preview && e.target?.result) { preview.src = e.target.result as string; } @@ -820,9 +786,7 @@ Alpine.data("bookDetail", () => { this.coverAction = "remove"; this.coverFile = null; this.newCoverPreview = ""; - const preview = document.getElementById( - "metadata-cover-preview", - ) as HTMLImageElement; + const preview = document.getElementById("metadata-cover-preview") as HTMLImageElement; if (preview) { preview.src = "/static/placeholder-book.svg"; } @@ -880,10 +844,7 @@ Alpine.data("bookDetail", () => { showToast("Metadata saved successfully", "success"); setTimeout(() => window.location.reload(), 500); } catch (e) { - showToast( - e instanceof Error ? e.message : "Failed to save metadata", - "error", - ); + showToast(e instanceof Error ? e.message : "Failed to save metadata", "error"); } finally { this.saving = false; } @@ -905,10 +866,7 @@ Alpine.data("bookDetail", () => { showToast("Book rescanned successfully", "success"); setTimeout(() => window.location.reload(), 500); } catch (e) { - showToast( - e instanceof Error ? e.message : "Failed to rescan book", - "error", - ); + showToast(e instanceof Error ? e.message : "Failed to rescan book", "error"); } finally { this.rescanning = false; } @@ -926,13 +884,10 @@ Alpine.data("bookDetail", () => { this.resetting = true; const mediaId = getMediaId(); try { - const resp = await fetch( - `/api/media-items/${mediaId}/rescan?reset_overrides=true`, - { - method: "POST", - headers: { Authorization: getAuthHeader() }, - }, - ); + 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"); @@ -940,10 +895,7 @@ Alpine.data("bookDetail", () => { 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", - ); + showToast(e instanceof Error ? e.message : "Failed to reset metadata", "error"); } finally { this.resetting = false; } @@ -958,7 +910,9 @@ Alpine.data("bookDetail", () => { } 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.tagSuggestions = results.filter( + (r: TagSuggestion) => !current.includes(r.value.toLowerCase()), + ); this.showTagDropdown = this.tagSuggestions.length > 0; this.highlightedTagIndex = -1; }, @@ -984,7 +938,9 @@ Alpine.data("bookDetail", () => { }, hideEditorTagDropdown() { - setTimeout(() => { this.showTagDropdown = false; }, 200); + setTimeout(() => { + this.showTagDropdown = false; + }, 200); }, onTagKeydown(event: KeyboardEvent) { @@ -996,9 +952,10 @@ Alpine.data("bookDetail", () => { } 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; + this.highlightedTagIndex = + this.highlightedTagIndex <= 0 + ? this.tagSuggestions.length - 1 + : this.highlightedTagIndex - 1; } } else if (event.key === "Enter") { event.preventDefault(); diff --git a/web/src/bookPicker.ts b/web/src/bookPicker.ts index 8fcd19a2..455c200a 100644 --- a/web/src/bookPicker.ts +++ b/web/src/bookPicker.ts @@ -51,9 +51,7 @@ Alpine.store("bookPicker", { const filterDiv = document.getElementById("book-picker-filters"); if (!filterDiv) return; - const inputs = filterDiv.querySelectorAll( - 'input[type="text"]', - ) as NodeListOf; + const inputs = filterDiv.querySelectorAll('input[type="text"]') as NodeListOf; inputs.forEach((input) => { input.value = ""; }); @@ -90,10 +88,7 @@ Alpine.store("bookPicker", { }); if (response.ok) { - showToast( - `Added ${this.selectedBooks.size} books to collection`, - "success", - ); + showToast(`Added ${this.selectedBooks.size} books to collection`, "success"); this.close(); location.reload(); } else { diff --git a/web/src/bookshelf.ts b/web/src/bookshelf.ts index 0684247b..c10248dd 100644 --- a/web/src/bookshelf.ts +++ b/web/src/bookshelf.ts @@ -2,10 +2,7 @@ import { Alpine } from "./alpine"; import { showToast } from "./toast"; -import type { - FilterGroupState, - FilterRuleState, -} from "./filter_builder"; +import type { FilterGroupState, FilterRuleState } from "./filter_builder"; function clearFilters(): void { // Clear all form state (reuses helper) @@ -65,12 +62,8 @@ function clearFormWithoutSubmit(): void { cleanupDynamicHiddenInputs(); // Reset pagination to defaults - const limitInput = filterForm.querySelector( - 'input[name="limit"]', - ) as HTMLInputElement; - const offsetInput = filterForm.querySelector( - 'input[name="offset"]', - ) as HTMLInputElement; + const limitInput = filterForm.querySelector('input[name="limit"]') as HTMLInputElement; + const offsetInput = filterForm.querySelector('input[name="offset"]') as HTMLInputElement; if (limitInput) limitInput.value = "50"; if (offsetInput) offsetInput.value = "0"; @@ -100,22 +93,21 @@ Alpine.data("bookshelf", () => ({ // inputs are dropped outright) and re-apply the form. Chips refresh // via the shared htmx:afterRequest hook. removeFilterChip(param: string): void { - const filterForm = document.getElementById( - "filter-form", - ) as HTMLFormElement; + const filterForm = document.getElementById("filter-form") as HTMLFormElement; if (!filterForm) return; // Tree chips (qf mode): "rule:" — remove that rule from the // builder tree, resync the wire inputs, re-apply. if (param.startsWith("rule:")) { const builder = builderData(); - const path = param.slice(5).split("-").map((n) => parseInt(n, 10)); + const path = param + .slice(5) + .split("-") + .map((n) => parseInt(n, 10)); builder?.removeNode(path); window.htmx.trigger(filterForm, "submit"); return; } - const input = filterForm.querySelector( - `[name="${param}"]`, - ) as HTMLInputElement | null; + const input = filterForm.querySelector(`[name="${param}"]`) as HTMLInputElement | null; if (input) { if (input.type === "hidden") { input.remove(); @@ -225,14 +217,10 @@ Alpine.data("bookshelf", () => ({ // Parse filters JSON (string → object) const filterData: Record = - typeof filter.filters === "string" - ? JSON.parse(filter.filters) - : filter.filters; + typeof filter.filters === "string" ? JSON.parse(filter.filters) : filter.filters; // Get the hidden filter form - const filterForm = document.getElementById( - "filter-form", - ) as HTMLFormElement; + const filterForm = document.getElementById("filter-form") as HTMLFormElement; if (!filterForm) { showToast("Filter form not found", "error"); return; @@ -250,9 +238,7 @@ Alpine.data("bookshelf", () => ({ // (qf wins over flat, the pinned precedence). Object.entries(filterData).forEach(([key, value]) => { if (!value) return; // Skip empty values - const visibleField = filterForm.querySelector( - `[name="${key}"]`, - ) as HTMLInputElement; + const visibleField = filterForm.querySelector(`[name="${key}"]`) as HTMLInputElement; if (visibleField) { if (visibleField.type === "checkbox") { visibleField.checked = value === "true"; @@ -336,9 +322,7 @@ function applyURLFilters(filterForm: HTMLFormElement | null): boolean { // ignored — the tree IS the query. const qf = params.get("qf"); if (qf) { - const input = filterForm.querySelector( - '[name="qf"]', - ) as HTMLInputElement | null; + const input = filterForm.querySelector('[name="qf"]') as HTMLInputElement | null; if (input) { input.value = qf; } else { @@ -376,9 +360,7 @@ function applyURLFilters(filterForm: HTMLFormElement | null): boolean { for (const name of filterNames) { const value = params.get(name); if (!value) continue; - const input = filterForm.querySelector( - `[name="${name}"]`, - ) as HTMLInputElement | null; + const input = filterForm.querySelector(`[name="${name}"]`) as HTMLInputElement | null; if (input) { input.value = value; applied = true; @@ -413,14 +395,10 @@ interface FilterChip { function prettifyGenreLabel(value: string): string { const trimmed = value.trim(); if (!trimmed || trimmed !== trimmed.toLowerCase()) return trimmed; - const small = new Set([ - "a", "an", "and", "of", "the", "in", "on", "for", "to", "or", - ]); + const small = new Set(["a", "an", "and", "of", "the", "in", "on", "for", "to", "or"]); return trimmed .split(/\s+/) - .map((w, i) => - i > 0 && small.has(w) ? w : w.charAt(0).toUpperCase() + w.slice(1), - ) + .map((w, i) => (i > 0 && small.has(w) ? w : w.charAt(0).toUpperCase() + w.slice(1))) .join(" "); } @@ -450,8 +428,7 @@ function facetChipLabel(name: string, value: string): string { abridged_filter: "Abridged", }; const label = names[name] ?? name; - const shown = - name === "genre_filter" ? prettifyGenreLabel(value) : value; + const shown = name === "genre_filter" ? prettifyGenreLabel(value) : value; return `${label}: ${shown}`; } @@ -459,9 +436,7 @@ function facetChipLabel(name: string, value: string): string { // the shareable URL (history.replaceState) — the single sync point for // form applies, URL deep-links, sort changes, and clears. function refreshFilterChips(): void { - const filterForm = document.getElementById( - "filter-form", - ) as HTMLFormElement; + const filterForm = document.getElementById("filter-form") as HTMLFormElement; const bookshelfElem = document.querySelector('[x-data*="bookshelf"]'); if (!filterForm || !bookshelfElem || !(bookshelfElem as any)._x_dataStack) { return; @@ -470,9 +445,7 @@ function refreshFilterChips(): void { if (typeof data.activeFilters === "undefined") return; const chips: FilterChip[] = []; - const qfInput = filterForm.querySelector( - '[name="qf"]', - ) as HTMLInputElement | null; + const qfInput = filterForm.querySelector('[name="qf"]') as HTMLInputElement | null; if (qfInput && qfInput.value) { // Tree mode: one chip per rule (red for negations — the chips @@ -482,24 +455,20 @@ function refreshFilterChips(): void { try { const tree = JSON.parse(qfInput.value) as FilterGroupState; const walk = (group: FilterGroupState, path: number[]) => { - (group.children as (FilterRuleState | FilterGroupState)[]).forEach( - (child, idx) => { - const childPath = [...path, idx]; - if ("children" in (child as FilterGroupState)) { - walk(child as FilterGroupState, childPath); - return; - } - const rule = child as FilterRuleState; - const label = builder - ? builder.chipLabelFor(rule) - : `${rule.field} ${rule.op}`; - chips.push({ - param: "rule:" + childPath.join("-"), - label, - negated: builder?.isNegated(rule) ?? false, - }); - }, - ); + (group.children as (FilterRuleState | FilterGroupState)[]).forEach((child, idx) => { + const childPath = [...path, idx]; + if ("children" in (child as FilterGroupState)) { + walk(child as FilterGroupState, childPath); + return; + } + const rule = child as FilterRuleState; + const label = builder ? builder.chipLabelFor(rule) : `${rule.field} ${rule.op}`; + chips.push({ + param: "rule:" + childPath.join("-"), + label, + negated: builder?.isNegated(rule) ?? false, + }); + }); }; walk(tree, []); } catch { @@ -527,12 +496,27 @@ function refreshFilterChips(): void { // shareable (plan: chips bar is URL-synced). const url = new URL(window.location.href); const keep = new Set([ - "author_filter", "series_filter", "genre_filter", "publisher_filter", - "tags_filter", "narrator_filter", "language_filter", "story_arc_filter", - "imprint_filter", "edition_filter", "age_rating_filter", - "format_group_filter", "manga_type_filter", "reading_direction_filter", - "explicit_filter", "abridged_filter", "year_min", "year_max", - "has_cover", "library_id", "qf", + "author_filter", + "series_filter", + "genre_filter", + "publisher_filter", + "tags_filter", + "narrator_filter", + "language_filter", + "story_arc_filter", + "imprint_filter", + "edition_filter", + "age_rating_filter", + "format_group_filter", + "manga_type_filter", + "reading_direction_filter", + "explicit_filter", + "abridged_filter", + "year_min", + "year_max", + "has_cover", + "library_id", + "qf", ]); const next = new URLSearchParams(); url.searchParams.forEach((value, key) => { @@ -551,9 +535,7 @@ function refreshFilterChips(): void { } function initBookshelf(): void { - const filterForm = document.getElementById( - "filter-form", - ) as HTMLFormElement; + const filterForm = document.getElementById("filter-form") as HTMLFormElement; // Active-filter chips refresh after every grid refetch — drawer // applies, sort changes, and pagination all include the form's @@ -565,9 +547,7 @@ function initBookshelf(): void { // The live match count is scope-aware: switching the top library // picker re-runs it (the count fetch reads the select at fetch time — // this is what makes it fire). - const librarySelect = document.getElementById( - "library-select", - ) as HTMLSelectElement | null; + const librarySelect = document.getElementById("library-select") as HTMLSelectElement | null; librarySelect?.addEventListener("change", () => { builderData()?.scheduleMatchCount(); }); @@ -587,13 +567,10 @@ function initBookshelf(): void { // Check if books were already rendered server-side const booksGrid = document.getElementById("books-grid"); - const hasServerBooks = - booksGrid && booksGrid.querySelector("[data-book-id]") !== null; + const hasServerBooks = booksGrid && booksGrid.querySelector("[data-book-id]") !== null; if (!hasServerBooks || urlApplied) { - const librarySelect = document.getElementById( - "library-select", - ) as HTMLSelectElement; + const librarySelect = document.getElementById("library-select") as HTMLSelectElement; librarySelect?.addEventListener("change", () => { localStorage.setItem("selectedLibrary", librarySelect.value); }); diff --git a/web/src/collection-rules.ts b/web/src/collection-rules.ts index d25291e3..3fca32bd 100644 --- a/web/src/collection-rules.ts +++ b/web/src/collection-rules.ts @@ -146,24 +146,14 @@ async function handleCreateRule(event: Event): Promise { if (!token || !collectionId) return; const fieldInput = document.getElementById("rule-field") as HTMLSelectElement; - const operatorInput = document.getElementById( - "rule-operator", - ) as HTMLSelectElement; + const operatorInput = document.getElementById("rule-operator") as HTMLSelectElement; const valueInput = document.getElementById("rule-value") as HTMLInputElement; - const enabledInput = document.getElementById( - "rule-enabled", - ) as HTMLInputElement; + const enabledInput = document.getElementById("rule-enabled") as HTMLInputElement; const priorityInput = document.querySelector( 'input[name="priority"]:checked', ) as HTMLInputElement; - if ( - !fieldInput || - !operatorInput || - !valueInput || - !enabledInput || - !priorityInput - ) { + if (!fieldInput || !operatorInput || !valueInput || !enabledInput || !priorityInput) { showToast("Missing form fields", "error"); return; } @@ -204,9 +194,7 @@ async function testRule(): Promise { if (!token) return; const fieldInput = document.getElementById("rule-field") as HTMLSelectElement; - const operatorInput = document.getElementById( - "rule-operator", - ) as HTMLSelectElement; + const operatorInput = document.getElementById("rule-operator") as HTMLSelectElement; const valueInput = document.getElementById("rule-value") as HTMLInputElement; if (!fieldInput || !operatorInput || !valueInput) { @@ -256,8 +244,7 @@ async function testRule(): Promise { '

Found ' + result.matches.length + " matching books:

"; - html += - '
'; + html += '
'; result.matches.slice(0, 20).forEach((book: TestRuleMatch) => { html += @@ -316,17 +303,14 @@ async function toggleRule(ruleId: string, enabled: boolean): Promise { if (!token || !collectionId) return; try { - const response = await fetch( - `/api/collections/${collectionId}/rules/${ruleId}`, - { - method: "PUT", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify({ enabled }), + const response = await fetch(`/api/collections/${collectionId}/rules/${ruleId}`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, }, - ); + body: JSON.stringify({ enabled }), + }); if (response.ok) { showToast("Rule updated", "success"); @@ -347,13 +331,10 @@ async function deleteRule(ruleId: string): Promise { if (!token || !collectionId) return; try { - const response = await fetch( - `/api/collections/${collectionId}/rules/${ruleId}`, - { - method: "DELETE", - headers: { Authorization: `Bearer ${token}` }, - }, - ); + const response = await fetch(`/api/collections/${collectionId}/rules/${ruleId}`, { + method: "DELETE", + headers: { Authorization: `Bearer ${token}` }, + }); if (response.ok) { showToast("Rule deleted", "success"); @@ -369,13 +350,9 @@ async function deleteRule(ruleId: string): Promise { function clearForm(): void { const fieldInput = document.getElementById("rule-field") as HTMLSelectElement; - const operatorInput = document.getElementById( - "rule-operator", - ) as HTMLSelectElement; + const operatorInput = document.getElementById("rule-operator") as HTMLSelectElement; const valueInput = document.getElementById("rule-value") as HTMLInputElement; - const enabledInput = document.getElementById( - "rule-enabled", - ) as HTMLInputElement; + const enabledInput = document.getElementById("rule-enabled") as HTMLInputElement; const priorityInput = document.querySelector( 'input[name="priority"][value="2"]', ) as HTMLInputElement; diff --git a/web/src/collections.ts b/web/src/collections.ts index d649ded4..c617cb21 100644 --- a/web/src/collections.ts +++ b/web/src/collections.ts @@ -1,10 +1,7 @@ import { Alpine } from "./alpine"; import { showToast } from "./toast"; import { createWebSocket } from "./websocket"; -import { - initLibrarySwitcher, - switchWithTransition, -} from "./library-switcher"; +import { initLibrarySwitcher, switchWithTransition } from "./library-switcher"; let collectionId: string | null = null; @@ -18,10 +15,7 @@ function initializeCollectionWebSocket(): void { createWebSocket({ onMessage: (message) => { - if ( - message.type === "collection_updated" && - message.data.collection_id === collectionId - ) { + if (message.type === "collection_updated" && message.data.collection_id === collectionId) { const actionText = message.data.action === "books_added" ? `Added ${message.data.count || 0} book(s)` @@ -100,10 +94,7 @@ function renderRules(rules: CollectionRule[]): void { .join(""); } -async function createRule( - collectionId: string, - rule: Partial, -): Promise { +async function createRule(collectionId: string, rule: Partial): Promise { const token = localStorage.getItem("token"); if (!token) return; @@ -137,13 +128,10 @@ async function deleteRule(collectionId: string, ruleId: string): Promise { if (!confirm("Are you sure you want to delete this rule?")) return; try { - const response = await fetch( - `/api/collections/${collectionId}/rules/${ruleId}`, - { - method: "DELETE", - headers: { Authorization: `Bearer ${token}` }, - }, - ); + const response = await fetch(`/api/collections/${collectionId}/rules/${ruleId}`, { + method: "DELETE", + headers: { Authorization: `Bearer ${token}` }, + }); if (response.ok) { showToast("Rule deleted", "success"); @@ -155,25 +143,19 @@ async function deleteRule(collectionId: string, ruleId: string): Promise { } } -async function testRule( - collectionId: string, - rule: Partial, -): Promise { +async function testRule(collectionId: string, rule: Partial): Promise { const token = localStorage.getItem("token"); if (!token) return; try { - const response = await fetch( - `/api/collections/${collectionId}/rules/test`, - { - method: "POST", - headers: { - Authorization: `Bearer ${token}`, - "Content-Type": "application/json", - }, - body: JSON.stringify(rule), + const response = await fetch(`/api/collections/${collectionId}/rules/test`, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", }, - ); + body: JSON.stringify(rule), + }); if (response.ok) { const results = await response.json(); @@ -229,9 +211,7 @@ const borderClasses: Record = { }; function selectColor(color: string): void { - const colorInput = document.getElementById( - "collection-color", - ) as HTMLInputElement; + const colorInput = document.getElementById("collection-color") as HTMLInputElement; if (colorInput) { colorInput.value = color; } @@ -239,9 +219,7 @@ function selectColor(color: string): void { (btn as HTMLElement).style.outline = "none"; (btn as HTMLElement).style.outlineOffset = "0"; }); - const selectedBtn = document.querySelector( - `.color-option[data-color="${color}"]`, - ) as HTMLElement; + const selectedBtn = document.querySelector(`.color-option[data-color="${color}"]`) as HTMLElement; if (selectedBtn) { selectedBtn.style.outline = "3px solid var(--text-primary)"; selectedBtn.style.outlineOffset = "3px"; @@ -256,9 +234,7 @@ function closeCollectionModal(): void { } function initColorSelection(): void { - const colorInput = document.getElementById( - "collection-color", - ) as HTMLInputElement; + const colorInput = document.getElementById("collection-color") as HTMLInputElement; if (colorInput && colorInput.value) { selectColor(colorInput.value); } @@ -291,9 +267,7 @@ function setupHTMXModalInit(): void { function showAllIcons(): void { populateIconGrid(); - const searchInput = document.getElementById( - "icon-search", - ) as HTMLInputElement; + const searchInput = document.getElementById("icon-search") as HTMLInputElement; if (searchInput) { searchInput.value = ""; } @@ -359,12 +333,8 @@ function populateIconGrid(): void { } function selectIcon(icon: string): void { - const iconInput = document.getElementById( - "collection-icon", - ) as HTMLInputElement; - const searchInput = document.getElementById( - "icon-search", - ) as HTMLInputElement; + const iconInput = document.getElementById("collection-icon") as HTMLInputElement; + const searchInput = document.getElementById("icon-search") as HTMLInputElement; if (iconInput) iconInput.value = icon; if (searchInput) searchInput.value = icon; @@ -374,9 +344,7 @@ function selectIcon(icon: string): void { (btn as HTMLElement).style.backgroundColor = ""; }); - const selectedBtn = document.querySelector( - `.icon-btn[data-emoji="${icon}"]`, - ) as HTMLElement; + const selectedBtn = document.querySelector(`.icon-btn[data-emoji="${icon}"]`) as HTMLElement; if (selectedBtn) { selectedBtn.style.outline = "2px solid var(--text-primary)"; selectedBtn.style.backgroundColor = "var(--bg-secondary)"; @@ -385,9 +353,7 @@ function selectIcon(icon: string): void { function filterIcons(searchTerm: string): void { const iconGrid = document.getElementById("icon-grid"); - const iconInput = document.getElementById( - "collection-icon", - ) as HTMLInputElement; + const iconInput = document.getElementById("collection-icon") as HTMLInputElement; if (iconInput) iconInput.value = searchTerm; if (!iconGrid) return; const buttons = iconGrid.querySelectorAll(".icon-btn"); @@ -400,9 +366,7 @@ function filterIcons(searchTerm: string): void { const matches = searchTerm === "" || emoji.includes(searchTerm) || - keywords.some((keyword) => - keyword.toLowerCase().includes(lowerSearchTerm), - ); + keywords.some((keyword) => keyword.toLowerCase().includes(lowerSearchTerm)); (btn as HTMLElement).style.display = matches ? "" : "none"; }); @@ -427,11 +391,10 @@ function initCollectionsPage(): void { // Rendering ownership: the grid is server-rendered markup. Swap // in the templ partial instead of re-rendering from JSON. const param = libraryId ? `?library_id=${encodeURIComponent(libraryId)}` : ""; - await window.htmx.ajax( - "GET", - `/collections/${collId}/books-grid${param}`, - { target: "#books-container", swap: "innerHTML" }, - ); + await window.htmx.ajax("GET", `/collections/${collId}/books-grid${param}`, { + target: "#books-container", + swap: "innerHTML", + }); const container = document.getElementById("books-container"); if (container) Alpine.initTree(container); }); @@ -461,13 +424,10 @@ async function doRemoveBook(id: string): Promise { const token = localStorage.getItem("token"); if (!token) return; try { - const response = await fetch( - `/api/collections/${getCollectionId()}/books/${id}`, - { - method: "DELETE", - headers: { Authorization: `Bearer ${token}` }, - }, - ); + const response = await fetch(`/api/collections/${getCollectionId()}/books/${id}`, { + method: "DELETE", + headers: { Authorization: `Bearer ${token}` }, + }); if (response.ok) { showToast("Removed from collection", "success"); setTimeout(() => location.reload(), 500); @@ -483,17 +443,14 @@ async function doBulkRemove(ids: string[]): Promise { const token = localStorage.getItem("token"); if (!token) return; try { - const response = await fetch( - `/api/collections/${getCollectionId()}/books/bulk-remove`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify({ book_ids: ids }), + const response = await fetch(`/api/collections/${getCollectionId()}/books/bulk-remove`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, }, - ); + body: JSON.stringify({ book_ids: ids }), + }); if (response.ok) { showToast(`Removed ${ids.length} book(s)`, "success"); setTimeout(() => location.reload(), 500); @@ -506,19 +463,14 @@ async function doBulkRemove(ids: string[]): Promise { } function filterCollectionBooks(): void { - const input = document.getElementById( - "collection-search", - ) as HTMLInputElement; + const input = document.getElementById("collection-search") as HTMLInputElement; if (!input) return; const query = input.value.toLowerCase(); - const cards = document.querySelectorAll( - ".collection-book-card", - ); + const cards = document.querySelectorAll(".collection-book-card"); cards.forEach((card) => { const title = (card.dataset.title || "").toLowerCase(); const author = (card.dataset.author || "").toLowerCase(); - card.style.display = - title.includes(query) || author.includes(query) ? "" : "none"; + card.style.display = title.includes(query) || author.includes(query) ? "" : "none"; }); } @@ -601,19 +553,11 @@ Alpine.data("collections", () => ({ // resulting order. The DOM cards are the server-rendered truth for CURRENT // order; the API is the truth for SAVED order. On failure the grid refresh // from the templ partial so the UI never lies. -async function moveCollectionBook( - this: any, - mediaId: string, - direction: number, -): Promise { +async function moveCollectionBook(this: any, mediaId: string, direction: number): Promise { const container = document.getElementById("books-container"); if (!container) return; - const cards = Array.from( - container.querySelectorAll(".collection-book-card"), - ); - const index = cards.findIndex( - (c) => c.dataset.mediaId === mediaId, - ); + const cards = Array.from(container.querySelectorAll(".collection-book-card")); + const index = cards.findIndex((c) => c.dataset.mediaId === mediaId); const target = index + direction; if (index < 0 || target < 0 || target >= cards.length) return; @@ -624,26 +568,21 @@ async function moveCollectionBook( container.insertBefore(cards[target], cards[index]); } - const orderedIds = Array.from( - container.querySelectorAll(".collection-book-card"), - ) + const orderedIds = Array.from(container.querySelectorAll(".collection-book-card")) .map((c) => c.dataset.mediaId) .filter((id): id is string => Boolean(id)); const token = localStorage.getItem("token"); if (!token) return; try { - const response = await fetch( - `/api/collections/${getCollectionId()}/items/order`, - { - method: "PUT", - headers: { - Authorization: `Bearer ${token}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ item_ids: orderedIds }), + const response = await fetch(`/api/collections/${getCollectionId()}/items/order`, { + method: "PUT", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", }, - ); + body: JSON.stringify({ item_ids: orderedIds }), + }); if (!response.ok) { showToast("Failed to save the new order", "error"); // Rendering ownership: the templ partial re-renders the true order. diff --git a/web/src/conflicts.ts b/web/src/conflicts.ts index d14453ff..a2e7784e 100644 --- a/web/src/conflicts.ts +++ b/web/src/conflicts.ts @@ -24,9 +24,7 @@ async function refreshConflicts(): Promise { // ownership: JS decides what shows; templ rendered what they look like). function filterConflicts(): void { - const select = document.getElementById( - "status-filter", - ) as HTMLSelectElement | null; + const select = document.getElementById("status-filter") as HTMLSelectElement | null; const filter = select?.value ?? "unresolved"; let total = 0; let visible = 0; @@ -44,9 +42,7 @@ function filterConflicts(): void { } function toggleAllConflicts(): void { - const selectAll = document.getElementById( - "select-all-conflicts", - ) as HTMLInputElement | null; + const selectAll = document.getElementById("select-all-conflicts") as HTMLInputElement | null; if (!selectAll) return; document.querySelectorAll(".conflict-item").forEach((item) => { if (item.style.display === "none") return; @@ -57,9 +53,7 @@ function toggleAllConflicts(): void { } function getSelectedConflictIds(): string[] { - return Array.from( - document.querySelectorAll(".conflict-checkbox:checked"), - ) + return Array.from(document.querySelectorAll(".conflict-checkbox:checked")) .map((box) => box.dataset.conflictId || "") .filter((id) => id !== ""); } @@ -75,12 +69,8 @@ function updateBulkActions(): void { function resolveSelected(): void { const ids = getSelectedConflictIds(); if (ids.length === 0) return; - const strategyEl = document.getElementById( - "bulk-strategy", - ) as HTMLSelectElement | null; - const strategy = (strategyEl?.value ?? "most_recent") as - | "most_recent" - | "highest_progress"; + const strategyEl = document.getElementById("bulk-strategy") as HTMLSelectElement | null; + const strategy = (strategyEl?.value ?? "most_recent") as "most_recent" | "highest_progress"; void bulkResolve(strategy, ids); } @@ -195,9 +185,7 @@ async function dismissAllResolved(): Promise { function showResolveModal(conflictId: string): void { const modal = document.getElementById("resolve-modal"); - const conflictIdInput = document.getElementById( - "resolve-conflict-id", - ) as HTMLInputElement; + const conflictIdInput = document.getElementById("resolve-conflict-id") as HTMLInputElement; if (modal && conflictIdInput) { conflictIdInput.value = conflictId; @@ -216,12 +204,8 @@ function handleResolveSubmit(event: Event): void { event.preventDefault(); const form = event.target as HTMLFormElement; - const conflictId = ( - form.querySelector("#resolve-conflict-id") as HTMLInputElement - )?.value; - const winner = ( - form.querySelector('input[name="winner"]:checked') as HTMLInputElement - )?.value; + const conflictId = (form.querySelector("#resolve-conflict-id") as HTMLInputElement)?.value; + const winner = (form.querySelector('input[name="winner"]:checked') as HTMLInputElement)?.value; if (!conflictId || !winner) { showToast("Please select a winner", "error"); diff --git a/web/src/custom-section-builder.ts b/web/src/custom-section-builder.ts index 27a28ade..e95208b2 100644 --- a/web/src/custom-section-builder.ts +++ b/web/src/custom-section-builder.ts @@ -174,16 +174,7 @@ const FILTER_FIELDS: FilterField[] = [ { id: "in", label: "In", requiresValue: true }, ], valueType: "select", - options: [ - "English", - "Spanish", - "French", - "German", - "Japanese", - "Chinese", - "Russian", - "Other", - ], + options: ["English", "Spanish", "French", "German", "Japanese", "Chinese", "Russian", "Other"], }, { id: "format", @@ -300,45 +291,28 @@ function addFilterRule(): void { container.appendChild(ruleElement); - const fieldSelect = ruleElement.querySelector( - ".field-select", - ) as HTMLSelectElement; - const removeBtn = ruleElement.querySelector( - ".remove-rule-btn", - ) as HTMLButtonElement; + const fieldSelect = ruleElement.querySelector(".field-select") as HTMLSelectElement; + const removeBtn = ruleElement.querySelector(".remove-rule-btn") as HTMLButtonElement; fieldSelect.addEventListener("change", () => onFieldChange(ruleElement)); removeBtn.addEventListener("click", () => removeFilterRule(ruleId)); } function onFieldChange(ruleElement: HTMLElement): void { - const fieldSelect = ruleElement.querySelector( - ".field-select", - ) as HTMLSelectElement; - const operatorSelect = ruleElement.querySelector( - ".operator-select", - ) as HTMLSelectElement; - const valueInput = ruleElement.querySelector( - ".value-input", - ) as HTMLInputElement; + const fieldSelect = ruleElement.querySelector(".field-select") as HTMLSelectElement; + const operatorSelect = ruleElement.querySelector(".operator-select") as HTMLSelectElement; + const valueInput = ruleElement.querySelector(".value-input") as HTMLInputElement; const fieldId = fieldSelect.value; const field = FILTER_FIELDS.find((f) => f.id === fieldId); operatorSelect.innerHTML = field - ? field.operators - .map((op) => ``) - .join("") + ? field.operators.map((op) => ``).join("") : ''; operatorSelect.disabled = !field; - if ( - field && - field.operators.some( - (op) => op.id === operatorSelect.value && op.requiresValue, - ) - ) { + if (field && field.operators.some((op) => op.id === operatorSelect.value && op.requiresValue)) { valueInput.classList.remove("hidden"); if (field.valueType === "select" && field.options) { @@ -356,9 +330,7 @@ function onFieldChange(ruleElement: HTMLElement): void { } operatorSelect.addEventListener("change", () => { - const selectedOp = field?.operators.find( - (op) => op.id === operatorSelect.value, - ); + const selectedOp = field?.operators.find((op) => op.id === operatorSelect.value); if (selectedOp?.requiresValue) { valueInput.classList.remove("hidden"); } else { @@ -384,15 +356,9 @@ function onBookSearchInput(): void { } async function searchBooks(): Promise { - const searchInput = document.getElementById( - "book-search", - ) as HTMLInputElement; - const librarySelect = document.getElementById( - "section-library", - ) as HTMLSelectElement; - const resultsContainer = document.getElementById( - "search-results", - ) as HTMLElement; + const searchInput = document.getElementById("book-search") as HTMLInputElement; + const librarySelect = document.getElementById("section-library") as HTMLSelectElement; + const resultsContainer = document.getElementById("search-results") as HTMLElement; const query = searchInput?.value.trim(); const libraryId = librarySelect?.value; @@ -426,9 +392,7 @@ async function searchBooks(): Promise { } function displaySearchResults(books: BookInfo[]): void { - const resultsContainer = document.getElementById( - "search-results", - ) as HTMLElement; + const resultsContainer = document.getElementById("search-results") as HTMLElement; if (!resultsContainer) return; if (books.length === 0) { @@ -458,11 +422,7 @@ function displaySearchResults(books: BookInfo[]): void { resultsContainer.classList.remove("hidden"); } -const addBookToSelection = ( - bookId: string, - title: string, - author: string, -): void => { +const addBookToSelection = (bookId: string, title: string, author: string): void => { if (selectedBooks.has(bookId)) { showToast("Book already selected", "error"); return; @@ -508,12 +468,8 @@ function updateSelectedBooksDisplay(): void { } async function loadPreview(): Promise { - const previewContainer = document.getElementById( - "preview-container", - ) as HTMLElement; - const librarySelect = document.getElementById( - "section-library", - ) as HTMLSelectElement; + const previewContainer = document.getElementById("preview-container") as HTMLElement; + const librarySelect = document.getElementById("section-library") as HTMLSelectElement; const libraryId = librarySelect?.value; if (!libraryId) { @@ -543,8 +499,7 @@ async function loadPreview(): Promise { } } catch (error) { console.error("Preview error:", error); - previewContainer.innerHTML = - '

Failed to load preview

'; + previewContainer.innerHTML = '

Failed to load preview

'; } } @@ -556,15 +511,9 @@ function gatherFilterRules(): FilterRule[] { const rules: FilterRule[] = []; ruleElements.forEach((element, index) => { - const fieldSelect = element.querySelector( - ".field-select", - ) as HTMLSelectElement; - const operatorSelect = element.querySelector( - ".operator-select", - ) as HTMLSelectElement; - const valueInput = element.querySelector( - ".value-input", - ) as HTMLInputElement; + const fieldSelect = element.querySelector(".field-select") as HTMLSelectElement; + const operatorSelect = element.querySelector(".operator-select") as HTMLSelectElement; + const valueInput = element.querySelector(".value-input") as HTMLInputElement; if (fieldSelect.value && operatorSelect.value) { rules.push({ @@ -581,9 +530,7 @@ function gatherFilterRules(): FilterRule[] { } function displayPreview(items: BookInfo[]): void { - const previewContainer = document.getElementById( - "preview-container", - ) as HTMLElement; + const previewContainer = document.getElementById("preview-container") as HTMLElement; if (!previewContainer) return; if (items.length === 0) { @@ -626,8 +573,7 @@ async function saveCustomSection(event: Event): Promise { const name = formData.get("name") as string; const icon = formData.get("icon") as string; const description = formData.get("description") as string; - const matchType = (document.getElementById("match-type") as HTMLSelectElement) - .value; + const matchType = (document.getElementById("match-type") as HTMLSelectElement).value; if (!libraryId || !name) { showToast("Please fill in required fields", "error"); diff --git a/web/src/dashboard.ts b/web/src/dashboard.ts index 0c673384..0b4f6f9d 100644 --- a/web/src/dashboard.ts +++ b/web/src/dashboard.ts @@ -7,9 +7,7 @@ const SCROLL_AMOUNT = 300; let scanListenerRegistered = false; function scrollCarousel(collectionId: string, direction: number): void { - const track = document.getElementById( - `carousel-track-${collectionId}`, - ) as HTMLElement; + const track = document.getElementById(`carousel-track-${collectionId}`) as HTMLElement; if (!track) return; const scrollAmount = direction * SCROLL_AMOUNT; @@ -17,20 +15,15 @@ function scrollCarousel(collectionId: string, direction: number): void { } async function openDashboardSettings(): Promise { - const librarySelect = document.getElementById( - "library-select", - ) as HTMLSelectElement; + const librarySelect = document.getElementById("library-select") as HTMLSelectElement; const libraryId = librarySelect?.value || ""; // Empty select = the All Libraries view: its preferences are the GLOBAL // scope — the server returns that row when library_id is absent. const query = libraryId ? `?library_id=${encodeURIComponent(libraryId)}` : ""; - const response = await fetch( - `/api/dashboard/preferences${query}`, - { - headers: { Authorization: `Bearer ${localStorage.getItem("token")}` }, - }, - ); + const response = await fetch(`/api/dashboard/preferences${query}`, { + headers: { Authorization: `Bearer ${localStorage.getItem("token")}` }, + }); if (response.ok) { const prefs = await response.json(); @@ -38,11 +31,8 @@ async function openDashboardSettings(): Promise { // Scope indicator: make it unmistakable what a save will customize. const scopeEl = document.getElementById("dashboard-scope-label"); if (scopeEl) { - const optionText = - librarySelect?.selectedOptions[0]?.textContent || libraryId; - scopeEl.textContent = ( - libraryId ? optionText : optionText || "All Libraries" - ) + const optionText = librarySelect?.selectedOptions[0]?.textContent || libraryId; + scopeEl.textContent = (libraryId ? optionText : optionText || "All Libraries") .replace(/\s*\(\d+\)$/, "") .trim(); } @@ -52,16 +42,12 @@ async function openDashboardSettings(): Promise { return; } - const modal = document.getElementById( - "dashboard-settings-modal", - ) as HTMLElement; + const modal = document.getElementById("dashboard-settings-modal") as HTMLElement; modal?.classList.remove("hidden"); } function applyPreferencesToModal(prefs: any): void { - const collectionList = document.getElementById( - "collection-list", - ) as HTMLElement; + const collectionList = document.getElementById("collection-list") as HTMLElement; if (!collectionList) return; const slider = document.querySelector( @@ -73,16 +59,12 @@ function applyPreferencesToModal(prefs: any): void { display.textContent = String(prefs.items_per_section); } - const items = collectionList.querySelectorAll( - "[data-collection-id]", - ) as NodeListOf; + const items = collectionList.querySelectorAll("[data-collection-id]") as NodeListOf; const orderedItems: HTMLElement[] = []; items.forEach((item) => { const collectionId = item.dataset.collectionId; - const checkbox = item.querySelector( - 'input[type="checkbox"]', - ) as HTMLInputElement; + const checkbox = item.querySelector('input[type="checkbox"]') as HTMLInputElement; const isHidden = prefs.hidden_collections.includes(collectionId); if (checkbox) checkbox.checked = !isHidden; @@ -99,32 +81,25 @@ function applyPreferencesToModal(prefs: any): void { } function closeDashboardSettings(): void { - const modal = document.getElementById( - "dashboard-settings-modal", - ) as HTMLElement; + const modal = document.getElementById("dashboard-settings-modal") as HTMLElement; modal?.classList.add("hidden"); } async function fetchAndRenderSections(libraryId: string): Promise { const param = libraryId ? `library_id=${libraryId}` : ""; - const response = await fetch( - `/api/dashboard/sections?${param}`, - { - headers: { - Authorization: `Bearer ${localStorage.getItem("token")}`, - "Content-Type": "application/json", - }, + const response = await fetch(`/api/dashboard/sections?${param}`, { + headers: { + Authorization: `Bearer ${localStorage.getItem("token")}`, + "Content-Type": "application/json", }, - ); + }); if (!response.ok) throw new Error("Failed to load sections"); const data = await response.json(); renderDashboardCollections(data.sections); } async function saveDashboardSettings(): Promise { - const collectionList = document.getElementById( - "collection-list", - ) as HTMLElement; + const collectionList = document.getElementById("collection-list") as HTMLElement; if (!collectionList) return; const collectionItems = collectionList.querySelectorAll( "[data-collection-id]", @@ -133,9 +108,7 @@ async function saveDashboardSettings(): Promise { const collectionOrder: string[] = []; collectionItems.forEach((item) => { const collectionId = item.dataset.collectionId; - const checkbox = item.querySelector( - 'input[type="checkbox"]', - ) as HTMLInputElement; + const checkbox = item.querySelector('input[type="checkbox"]') as HTMLInputElement; if (collectionId) { collectionOrder.push(collectionId); if (checkbox && !checkbox.checked) { @@ -144,12 +117,9 @@ async function saveDashboardSettings(): Promise { } }); const itemsPerCollection = - (document.querySelector("#items-count-display") as HTMLElement) - ?.textContent || "20"; + (document.querySelector("#items-count-display") as HTMLElement)?.textContent || "20"; try { - const librarySelect = document.getElementById( - "library-select", - ) as HTMLSelectElement; + const librarySelect = document.getElementById("library-select") as HTMLSelectElement; const libraryId = librarySelect?.value || ""; const response = await apiPut("/dashboard/preferences", { // Empty select = the All Libraries view: an explicit JSON null is @@ -255,9 +225,7 @@ function renderSectionHTML(section: SectionData): string { style="scrollbar-width: none; -ms-overflow-style: none;"> ${ section.items.length > 0 - ? section.items - .map((item) => renderBookCard(item)) - .join("") + ? section.items.map((item) => renderBookCard(item)).join("") : '

No items in this collection

' }
@@ -279,9 +247,7 @@ function renderSectionHTML(section: SectionData): string { } function renderDashboardCollections(sections: SectionData[]): void { - const container = document.getElementById( - "collections-container", - ) as HTMLElement; + const container = document.getElementById("collections-container") as HTMLElement; if (!container) return; const currentWood = container.getAttribute("data-wood"); @@ -342,7 +308,6 @@ function badgeTitleFor(badge: string): string { return ""; } - function renderBookCard(book: BookInfo): string { const coverUrl = book.cover_image_path || "/static/placeholder-book.svg"; const actionHref = book.has_conflict @@ -354,11 +319,7 @@ function renderBookCard(book: BookInfo): string { : isAudio ? `Listen to ${book.title}` : `Read ${book.title}`; - const actionTitle = book.has_conflict - ? "Resolve progress conflict" - : isAudio - ? "Listen" - : "Read"; + const actionTitle = book.has_conflict ? "Resolve progress conflict" : isAudio ? "Listen" : "Read"; const actionIcon = isAudio ? HEADPHONES_ICON : BOOK_OPEN_ICON; const badgeIcon = badgeIconFor(book.badge || ""); @@ -393,20 +354,13 @@ function renderBookCard(book: BookInfo): string { } async function reloadPage(): Promise { - const librarySelect = document.getElementById( - "library-select", - ) as HTMLSelectElement; + const librarySelect = document.getElementById("library-select") as HTMLSelectElement; const libraryId = librarySelect?.value || ""; - await switchWithTransition("collections-container", () => - fetchAndRenderSections(libraryId), - ); + await switchWithTransition("collections-container", () => fetchAndRenderSections(libraryId)); } -function updateItemsCount( - input: HTMLInputElement, - displayTarget: string, -): void { +function updateItemsCount(input: HTMLInputElement, displayTarget: string): void { const displayElement = document.getElementById(displayTarget) as HTMLElement; if (displayElement) { displayElement.textContent = input.value; @@ -414,9 +368,7 @@ function updateItemsCount( } function initDragAndDrop(): void { - const collectionList = document.getElementById( - "collection-list", - ) as HTMLElement; + const collectionList = document.getElementById("collection-list") as HTMLElement; if (!collectionList) return; let draggedItem: HTMLElement | null = null; @@ -440,11 +392,7 @@ function initDragAndDrop(): void { collectionList.addEventListener("dragover", (e: Event) => { e.preventDefault(); const target = e.target as HTMLElement; - if ( - target.classList.contains("collection-item") && - target !== draggedItem && - draggedItem - ) { + if (target.classList.contains("collection-item") && target !== draggedItem && draggedItem) { const rect = target.getBoundingClientRect(); const midY = rect.top + rect.height / 2; if ((e as DragEvent).clientY < midY) { @@ -509,8 +457,7 @@ function initDashboard() { switch (action) { case "scroll-carousel": { - const collectionId = - target.dataset.collectionId || actionElem?.dataset.collectionId; + const collectionId = target.dataset.collectionId || actionElem?.dataset.collectionId; const direction = parseInt( target.dataset.direction || actionElem?.dataset.direction || "0", ); @@ -531,8 +478,7 @@ function initDashboard() { break; case "restore-system-collection": { - const colName = - actionElem?.dataset.collectionName || target.dataset.collectionName; + const colName = actionElem?.dataset.collectionName || target.dataset.collectionName; const colTitle = actionElem?.dataset.collectionTitle || target.dataset.collectionTitle || @@ -565,55 +511,40 @@ function initDashboard() { if (!scanListenerRegistered) { scanListenerRegistered = true; window.addEventListener("bookhoard:scan-complete", async () => { - const librarySelect = document.getElementById( - "library-select", - ) as HTMLSelectElement; + const librarySelect = document.getElementById("library-select") as HTMLSelectElement; const libraryId = librarySelect?.value || ""; try { const param = libraryId ? `library_id=${libraryId}` : ""; - const response = await fetch( - `/api/dashboard/sections?${param}`, - { - headers: { - Authorization: `Bearer ${localStorage.getItem("token")}`, - "Content-Type": "application/json", - }, + const response = await fetch(`/api/dashboard/sections?${param}`, { + headers: { + Authorization: `Bearer ${localStorage.getItem("token")}`, + "Content-Type": "application/json", }, - ); + }); if (!response.ok) return; const data = await response.json(); - const container = document.getElementById( - "collections-container", - ) as HTMLElement; + const container = document.getElementById("collections-container") as HTMLElement; if (!container) return; - const freshSectionIds = new Set( - (data.sections as SectionData[]).map((s) => s.id), - ); + const freshSectionIds = new Set((data.sections as SectionData[]).map((s) => s.id)); for (const section of data.sections as SectionData[]) { - const track = document.getElementById( - `carousel-track-${section.id}`, - ); + const track = document.getElementById(`carousel-track-${section.id}`); if (!track) { - container.insertAdjacentHTML( - "beforeend", - renderSectionHTML(section), - ); + container.insertAdjacentHTML("beforeend", renderSectionHTML(section)); continue; } - track.innerHTML = section.items.length > 0 - ? section.items.map((item) => renderBookCard(item)).join("") - : '

No items in this collection

'; + track.innerHTML = + section.items.length > 0 + ? section.items.map((item) => renderBookCard(item)).join("") + : '

No items in this collection

'; } - const existingSections = container.querySelectorAll( - ".dashboard-collection", - ); + const existingSections = container.querySelectorAll(".dashboard-collection"); existingSections.forEach((sec) => { const secId = sec.getAttribute("data-collection-id"); if (secId && !freshSectionIds.has(secId)) { diff --git a/web/src/device-management.ts b/web/src/device-management.ts index cd62f8ec..a81879ff 100644 --- a/web/src/device-management.ts +++ b/web/src/device-management.ts @@ -30,15 +30,9 @@ async function handleAddDevice(event: Event): Promise { const token = getToken(); if (!token) return; - const deviceNameInput = document.getElementById( - "device-name", - ) as HTMLInputElement; - const deviceTypeInput = document.getElementById( - "device-type", - ) as HTMLSelectElement; - const deviceIdentifierInput = document.getElementById( - "device-identifier", - ) as HTMLInputElement; + const deviceNameInput = document.getElementById("device-name") as HTMLInputElement; + const deviceTypeInput = document.getElementById("device-type") as HTMLSelectElement; + const deviceIdentifierInput = document.getElementById("device-identifier") as HTMLInputElement; if (!deviceNameInput || !deviceTypeInput || !deviceIdentifierInput) return; @@ -59,10 +53,7 @@ async function handleAddDevice(event: Event): Promise { }); if (response.ok) { - showToast( - "Device registered! Check your device for sync instructions.", - "success", - ); + showToast("Device registered! Check your device for sync instructions.", "success"); hideAddDeviceModal(); window.location.reload(); } else { @@ -76,24 +67,12 @@ async function handleAddDevice(event: Event): Promise { function showDeviceSettings(deviceId: string): void { const modal = document.getElementById("device-settings-modal"); - const deviceIdInput = document.getElementById( - "settings-device-id", - ) as HTMLInputElement; - const deviceTypeInput = document.getElementById( - "settings-device-type", - ) as HTMLInputElement; - const deviceNameInput = document.getElementById( - "settings-device-name", - ) as HTMLInputElement; - const syncEnabledInput = document.getElementById( - "settings-sync-enabled", - ) as HTMLInputElement; - const autoSyncInput = document.getElementById( - "settings-auto-sync", - ) as HTMLInputElement; - const syncFrequencyInput = document.getElementById( - "settings-sync-frequency", - ) as HTMLInputElement; + const deviceIdInput = document.getElementById("settings-device-id") as HTMLInputElement; + const deviceTypeInput = document.getElementById("settings-device-type") as HTMLInputElement; + const deviceNameInput = document.getElementById("settings-device-name") as HTMLInputElement; + const syncEnabledInput = document.getElementById("settings-sync-enabled") as HTMLInputElement; + const autoSyncInput = document.getElementById("settings-auto-sync") as HTMLInputElement; + const syncFrequencyInput = document.getElementById("settings-sync-frequency") as HTMLInputElement; if (!modal || !deviceIdInput) return; @@ -111,8 +90,7 @@ function showDeviceSettings(deviceId: string): void { .then((device) => { if (deviceTypeInput) deviceTypeInput.value = device.device_type || ""; if (deviceNameInput) deviceNameInput.value = device.device_name || ""; - if (syncEnabledInput) - syncEnabledInput.checked = device.sync_enabled || false; + if (syncEnabledInput) syncEnabledInput.checked = device.sync_enabled || false; if (autoSyncInput) autoSyncInput.checked = device.auto_sync || false; if (syncFrequencyInput) syncFrequencyInput.value = String(device.sync_frequency_minutes || 60); @@ -129,32 +107,22 @@ function showDeviceSettings(deviceId: string): void { const viewSettings = firstCollection.view_settings || {}; const deviceSettings = viewSettings[deviceType] || {}; - const viewModeInput = document.getElementById( - "settings-view-mode", - ) as HTMLInputElement; - const sortOrderInput = document.getElementById( - "settings-sort-order", - ) as HTMLInputElement; + const viewModeInput = document.getElementById("settings-view-mode") as HTMLInputElement; + const sortOrderInput = document.getElementById("settings-sort-order") as HTMLInputElement; const itemsPerPageInput = document.getElementById( "settings-items-per-page", ) as HTMLInputElement; - const showCoversInput = document.getElementById( - "settings-show-covers", - ) as HTMLInputElement; + const showCoversInput = document.getElementById("settings-show-covers") as HTMLInputElement; const showProgressInput = document.getElementById( "settings-show-progress", ) as HTMLInputElement; - if (viewModeInput) - viewModeInput.value = deviceSettings.view_mode || "grid"; - if (sortOrderInput) - sortOrderInput.value = deviceSettings.sort_order || "name"; + if (viewModeInput) viewModeInput.value = deviceSettings.view_mode || "grid"; + if (sortOrderInput) sortOrderInput.value = deviceSettings.sort_order || "name"; if (itemsPerPageInput) itemsPerPageInput.value = String(deviceSettings.items_per_page || 24); - if (showCoversInput) - showCoversInput.checked = deviceSettings.show_covers !== false; - if (showProgressInput) - showProgressInput.checked = deviceSettings.show_progress || false; + if (showCoversInput) showCoversInput.checked = deviceSettings.show_covers !== false; + if (showProgressInput) showProgressInput.checked = deviceSettings.show_progress || false; } }) .catch((error) => { @@ -165,9 +133,7 @@ function showDeviceSettings(deviceId: string): void { } function showShelfMappings(deviceId: string): void { - const mappingsDeviceIdInput = document.getElementById( - "mappings-device-id", - ) as HTMLInputElement; + const mappingsDeviceIdInput = document.getElementById("mappings-device-id") as HTMLInputElement; const modal = document.getElementById("shelf-mappings-modal"); if (mappingsDeviceIdInput) mappingsDeviceIdInput.value = deviceId; @@ -226,41 +192,28 @@ async function loadShelfMappings(deviceId: string): Promise { '

No shelf mappings configured. Click "Add Mapping" to create one.

'; } } catch (error) { - container.innerHTML = - '

Failed to load mappings

'; + container.innerHTML = '

Failed to load mappings

'; } } function showAddMappingModal(): void { - const mappingsDeviceIdInput = document.getElementById( - "mappings-device-id", - ) as HTMLInputElement; - const mappingIdInput = document.getElementById( - "mapping-id", - ) as HTMLInputElement; - const mappingCollectionInput = document.getElementById( - "mapping-collection", - ) as HTMLSelectElement; - const mappingShelfNameInput = document.getElementById( - "mapping-shelf-name", - ) as HTMLInputElement; + const mappingsDeviceIdInput = document.getElementById("mappings-device-id") as HTMLInputElement; + const mappingIdInput = document.getElementById("mapping-id") as HTMLInputElement; + const mappingCollectionInput = document.getElementById("mapping-collection") as HTMLSelectElement; + const mappingShelfNameInput = document.getElementById("mapping-shelf-name") as HTMLInputElement; const mappingSyncDirectionInput = document.getElementById( "mapping-sync-direction", ) as HTMLSelectElement; const modal = document.getElementById("add-mapping-modal"); if (mappingsDeviceIdInput) { - const mappingDeviceIdInput = document.getElementById( - "mapping-device-id", - ) as HTMLInputElement; - if (mappingDeviceIdInput) - mappingDeviceIdInput.value = mappingsDeviceIdInput.value; + const mappingDeviceIdInput = document.getElementById("mapping-device-id") as HTMLInputElement; + if (mappingDeviceIdInput) mappingDeviceIdInput.value = mappingsDeviceIdInput.value; } if (mappingIdInput) mappingIdInput.value = ""; if (mappingCollectionInput) mappingCollectionInput.value = ""; if (mappingShelfNameInput) mappingShelfNameInput.value = ""; - if (mappingSyncDirectionInput) - mappingSyncDirectionInput.value = "bidirectional"; + if (mappingSyncDirectionInput) mappingSyncDirectionInput.value = "bidirectional"; loadCollections(); if (modal) modal.classList.remove("hidden"); @@ -274,9 +227,7 @@ function hideAddMappingModal(): void { } async function loadCollections(): Promise { - const select = document.getElementById( - "mapping-collection", - ) as HTMLSelectElement; + const select = document.getElementById("mapping-collection") as HTMLSelectElement; const token = getToken(); if (!select || !token) return; @@ -304,15 +255,9 @@ function editMapping( shelfName: string, syncDirection: string, ): void { - const mappingIdInput = document.getElementById( - "mapping-id", - ) as HTMLInputElement; - const mappingCollectionInput = document.getElementById( - "mapping-collection", - ) as HTMLSelectElement; - const mappingShelfNameInput = document.getElementById( - "mapping-shelf-name", - ) as HTMLInputElement; + const mappingIdInput = document.getElementById("mapping-id") as HTMLInputElement; + const mappingCollectionInput = document.getElementById("mapping-collection") as HTMLSelectElement; + const mappingShelfNameInput = document.getElementById("mapping-shelf-name") as HTMLInputElement; const mappingSyncDirectionInput = document.getElementById( "mapping-sync-direction", ) as HTMLSelectElement; @@ -321,8 +266,7 @@ function editMapping( if (mappingIdInput) mappingIdInput.value = mappingId; if (mappingCollectionInput) mappingCollectionInput.value = collectionId; if (mappingShelfNameInput) mappingShelfNameInput.value = shelfName; - if (mappingSyncDirectionInput) - mappingSyncDirectionInput.value = syncDirection; + if (mappingSyncDirectionInput) mappingSyncDirectionInput.value = syncDirection; if (modal) modal.classList.remove("hidden"); } @@ -331,18 +275,10 @@ async function handleSaveMapping(event: Event): Promise { event.preventDefault(); const token = getToken(); - const mappingsDeviceIdInput = document.getElementById( - "mapping-device-id", - ) as HTMLInputElement; - const mappingIdInput = document.getElementById( - "mapping-id", - ) as HTMLInputElement; - const mappingCollectionInput = document.getElementById( - "mapping-collection", - ) as HTMLSelectElement; - const mappingShelfNameInput = document.getElementById( - "mapping-shelf-name", - ) as HTMLInputElement; + const mappingsDeviceIdInput = document.getElementById("mapping-device-id") as HTMLInputElement; + const mappingIdInput = document.getElementById("mapping-id") as HTMLInputElement; + const mappingCollectionInput = document.getElementById("mapping-collection") as HTMLSelectElement; + const mappingShelfNameInput = document.getElementById("mapping-shelf-name") as HTMLInputElement; const mappingSyncDirectionInput = document.getElementById( "mapping-sync-direction", ) as HTMLSelectElement; @@ -398,21 +334,16 @@ async function deleteMapping(mappingId: string): Promise { if (!confirm("Are you sure you want to delete this mapping?")) return; const token = getToken(); - const mappingsDeviceIdInput = document.getElementById( - "mappings-device-id", - ) as HTMLInputElement; + const mappingsDeviceIdInput = document.getElementById("mappings-device-id") as HTMLInputElement; if (!token || !mappingsDeviceIdInput) return; const deviceId = mappingsDeviceIdInput.value; try { - const response = await fetch( - `/api/devices/${deviceId}/collections/${mappingId}`, - { - method: "DELETE", - headers: { Authorization: `Bearer ${token}` }, - }, - ); + const response = await fetch(`/api/devices/${deviceId}/collections/${mappingId}`, { + method: "DELETE", + headers: { Authorization: `Bearer ${token}` }, + }); if (response.ok) { showToast("Mapping deleted", "success"); @@ -435,9 +366,7 @@ async function handleSaveDeviceSettings(event: Event): Promise { event.preventDefault(); const token = getToken(); - const settingsDeviceIdInput = document.getElementById( - "settings-device-id", - ) as HTMLInputElement; + const settingsDeviceIdInput = document.getElementById("settings-device-id") as HTMLInputElement; const settingsDeviceTypeInput = document.getElementById( "settings-device-type", ) as HTMLInputElement; @@ -447,9 +376,7 @@ async function handleSaveDeviceSettings(event: Event): Promise { const settingsSyncEnabledInput = document.getElementById( "settings-sync-enabled", ) as HTMLInputElement; - const settingsAutoSyncInput = document.getElementById( - "settings-auto-sync", - ) as HTMLInputElement; + const settingsAutoSyncInput = document.getElementById("settings-auto-sync") as HTMLInputElement; const settingsSyncFrequencyInput = document.getElementById( "settings-sync-frequency", ) as HTMLInputElement; @@ -463,27 +390,14 @@ async function handleSaveDeviceSettings(event: Event): Promise { device_name: settingsDeviceNameInput.value, sync_enabled: settingsSyncEnabledInput?.checked || false, auto_sync: settingsAutoSyncInput?.checked || false, - sync_frequency_minutes: parseInt( - settingsSyncFrequencyInput?.value || "60", - 10, - ), + sync_frequency_minutes: parseInt(settingsSyncFrequencyInput?.value || "60", 10), }; - const viewModeInput = document.getElementById( - "settings-view-mode", - ) as HTMLSelectElement; - const sortOrderInput = document.getElementById( - "settings-sort-order", - ) as HTMLSelectElement; - const itemsPerPageInput = document.getElementById( - "settings-items-per-page", - ) as HTMLInputElement; - const showCoversInput = document.getElementById( - "settings-show-covers", - ) as HTMLInputElement; - const showProgressInput = document.getElementById( - "settings-show-progress", - ) as HTMLInputElement; + const viewModeInput = document.getElementById("settings-view-mode") as HTMLSelectElement; + const sortOrderInput = document.getElementById("settings-sort-order") as HTMLSelectElement; + const itemsPerPageInput = document.getElementById("settings-items-per-page") as HTMLInputElement; + const showCoversInput = document.getElementById("settings-show-covers") as HTMLInputElement; + const showProgressInput = document.getElementById("settings-show-progress") as HTMLInputElement; const viewSettings = { view_mode: viewModeInput?.value || "grid", @@ -536,10 +450,7 @@ async function handleSaveDeviceSettings(event: Event): Promise { } } -async function copyToClipboard( - text: string, - description: string, -): Promise { +async function copyToClipboard(text: string, description: string): Promise { try { await navigator.clipboard.writeText(text); showToast(`${description} copied!`, "success"); @@ -551,16 +462,10 @@ async function copyToClipboard( async function handleRevokeDevice(): Promise { const token = getToken(); - const settingsDeviceIdInput = document.getElementById( - "settings-device-id", - ) as HTMLInputElement; + const settingsDeviceIdInput = document.getElementById("settings-device-id") as HTMLInputElement; if (!token || !settingsDeviceIdInput) return; - if ( - !confirm( - "Are you sure you want to revoke this device? It will no longer be able to sync.", - ) - ) + if (!confirm("Are you sure you want to revoke this device? It will no longer be able to sync.")) return; const deviceId = settingsDeviceIdInput.value; diff --git a/web/src/docs.ts b/web/src/docs.ts index 2b4b12b7..844aa9a2 100644 --- a/web/src/docs.ts +++ b/web/src/docs.ts @@ -15,7 +15,7 @@ function toggleSection(el: HTMLElement): void { const items = el.nextElementSibling as HTMLElement; const arrow = el.querySelector(".section-arrow") as HTMLElement; if (!items || !arrow) return; - + const isCollapsed = items.getAttribute("data-collapsed") === "true"; if (isCollapsed) { @@ -30,9 +30,7 @@ function toggleSection(el: HTMLElement): void { } function initializeDocsSearch(): void { - const searchInput = document.getElementById( - "docs-search", - ) as HTMLInputElement; + const searchInput = document.getElementById("docs-search") as HTMLInputElement; const searchResults = document.getElementById("docs-search-results"); if (!searchInput || !searchResults) return; @@ -125,14 +123,15 @@ function initializeCodeCopyButtons(): void { document.querySelectorAll("pre code").forEach((block) => { const pre = block.parentElement; if (!pre) return; - + const wrapper = document.createElement("div"); wrapper.className = "relative group"; pre.parentNode.insertBefore(wrapper, pre); wrapper.appendChild(pre); const button = document.createElement("button"); - button.className = "absolute top-2 right-2 bg-background-secondary text-text-secondary px-2 py-1 rounded text-xs opacity-0 group-hover:opacity-100 transition-opacity border border-border hover:text-accent"; + button.className = + "absolute top-2 right-2 bg-background-secondary text-text-secondary px-2 py-1 rounded text-xs opacity-0 group-hover:opacity-100 transition-opacity border border-border hover:text-accent"; button.textContent = "Copy"; button.onclick = async () => { await navigator.clipboard.writeText(block.textContent || ""); @@ -154,4 +153,10 @@ Alpine.data("docs", () => ({ initializeCodeCopyButtons, })); -export { toggleSidebar, toggleSection, initializeDocsSearch, highlightCurrentPage, initializeCodeCopyButtons }; +export { + toggleSidebar, + toggleSection, + initializeDocsSearch, + highlightCurrentPage, + initializeCodeCopyButtons, +}; diff --git a/web/src/events.ts b/web/src/events.ts index 7691c98f..1cc732f7 100644 --- a/web/src/events.ts +++ b/web/src/events.ts @@ -50,61 +50,39 @@ function onDelegatedKeydown( }); } -function getDataAttribute( - element: HTMLElement, - name: string, -): string | undefined { +function getDataAttribute(element: HTMLElement, name: string): string | undefined { return element.dataset[name]; } -function setDataAttribute( - element: HTMLElement, - name: string, - value: string, -): void { +function setDataAttribute(element: HTMLElement, name: string, value: string): void { element.dataset[name] = value; } -function onClick( - element: HTMLElement | null, - handler: (event: MouseEvent) => void, -): void { +function onClick(element: HTMLElement | null, handler: (event: MouseEvent) => void): void { if (element) { element.addEventListener("click", handler); } } -function onSubmit( - element: HTMLFormElement | null, - handler: (event: Event) => void, -): void { +function onSubmit(element: HTMLFormElement | null, handler: (event: Event) => void): void { if (element) { element.addEventListener("submit", handler); } } -function onChange( - element: HTMLElement | null, - handler: (event: Event) => void, -): void { +function onChange(element: HTMLElement | null, handler: (event: Event) => void): void { if (element) { element.addEventListener("change", handler); } } -function onKeydown( - element: HTMLElement | null, - handler: (event: KeyboardEvent) => void, -): void { +function onKeydown(element: HTMLElement | null, handler: (event: KeyboardEvent) => void): void { if (element) { element.addEventListener("keydown", handler); } } -function onInput( - element: HTMLElement | null, - handler: (event: Event) => void, -): void { +function onInput(element: HTMLElement | null, handler: (event: Event) => void): void { if (element) { element.addEventListener("input", handler); } diff --git a/web/src/filter_builder.ts b/web/src/filter_builder.ts index 5593c4d0..d9c3c822 100644 --- a/web/src/filter_builder.ts +++ b/web/src/filter_builder.ts @@ -23,12 +23,12 @@ export interface OpMeta { arity: "none" | "one" | "two" | "many"; } -const op = ( - key: string, - label: string, - negated: boolean, - arity: OpMeta["arity"], -): OpMeta => ({ key, label, negated, arity }); +const op = (key: string, label: string, negated: boolean, arity: OpMeta["arity"]): OpMeta => ({ + key, + label, + negated, + arity, +}); // The map key DERIVES from meta.key — the wire value and the display // label cannot drift apart again (a mis-keyed entry threw every @@ -132,10 +132,25 @@ export const FIELD_GROUPS: { group: string; fields: FieldMeta[] }[] = [ F("isbn", "ISBN", "Book facts", "text", ["is", "is_not", "has", "missing"], "has"), F("asin", "ASIN", "Book facts", "text", ["is", "is_not", "has", "missing"], "has"), F("file_size", "File size", "Book facts", "number", NUM_OPS, "at_least"), - F("file_availability", "File availability", "Book facts", "fixed", - ["is", "is_not"], "is", false, ["available", "missing"]), + F( + "file_availability", + "File availability", + "Book facts", + "fixed", + ["is", "is_not"], + "is", + false, + ["available", "missing"], + ), F("cover", "Cover", "Book facts", "none", ["has", "missing"], "has"), - F("manually_edited", "Manually edited", "Book facts", "none", ["is_true", "is_false"], "is_true"), + F( + "manually_edited", + "Manually edited", + "Book facts", + "none", + ["is_true", "is_false"], + "is_true", + ), F("added_by", "Added by", "Book facts", "id", ["is", "has", "missing"], "has", true), ], }, @@ -148,39 +163,121 @@ export const FIELD_GROUPS: { group: string; fields: FieldMeta[] }[] = [ F("volume", "Volume", "Comics & manga", "number", NUM_OPS, "at_least"), F("format_group", "Format group", "Comics & manga", "text", ["is", "is_not"], "is", true), F("mime_type", "File type (mime)", "Comics & manga", "text", ["is", "is_not"], "is", true), - F("is_reflowable", "Reflowable", "Comics & manga", "none", ["is_true", "is_false"], "is_true"), - F("has_fixed_layout", "Fixed layout", "Comics & manga", "none", ["is_true", "is_false"], "is_true"), - F("is_black_and_white", "Black & white", "Comics & manga", "none", ["is_true", "is_false"], "is_true"), - F("manga_type", "Manga type", "Comics & manga", "text", ["is", "is_not", "empty"], "is", true), - F("reading_direction", "Reading direction", "Comics & manga", "text", ["is", "is_not", "empty"], "is", true), + F( + "is_reflowable", + "Reflowable", + "Comics & manga", + "none", + ["is_true", "is_false"], + "is_true", + ), + F( + "has_fixed_layout", + "Fixed layout", + "Comics & manga", + "none", + ["is_true", "is_false"], + "is_true", + ), + F( + "is_black_and_white", + "Black & white", + "Comics & manga", + "none", + ["is_true", "is_false"], + "is_true", + ), + F( + "manga_type", + "Manga type", + "Comics & manga", + "text", + ["is", "is_not", "empty"], + "is", + true, + ), + F( + "reading_direction", + "Reading direction", + "Comics & manga", + "text", + ["is", "is_not", "empty"], + "is", + true, + ), ], }, { group: "My reading", fields: [ - F("status", "Reading status", "My reading", "fixed", ["is"], "is", false, - ["unread", "reading", "finished"]), + F("status", "Reading status", "My reading", "fixed", ["is"], "is", false, [ + "unread", + "reading", + "finished", + ]), F("progress_percent", "Progress percent", "My reading", "number", NUM_OPS, "at_least"), F("last_read", "Last read", "My reading", "days", DATE_OPS, "within_days"), - F("my_rating", "My rating", "My reading", "fixed", - ["is", "at_least", "at_most", "unrated"], "at_least", false, - ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"]), + F( + "my_rating", + "My rating", + "My reading", + "fixed", + ["is", "at_least", "at_most", "unrated"], + "at_least", + false, + ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"], + ), F("review", "Has review", "My reading", "none", ["has", "missing"], "has"), F("notes", "Notes", "My reading", "none", ["has", "missing", "within_days"], "has"), F("highlights", "Highlights", "My reading", "none", ["has", "missing", "within_days"], "has"), - F("note_text", "Note text", "My reading", "text", ["contains", "not_contains", "empty"], "contains", true), - F("highlight_text", "Highlight text", "My reading", "text", ["contains", "not_contains", "empty"], "contains", true), - F("time_spent", "Time spent (minutes)", "My reading", "number", ["between", "at_least", "at_most"], "at_least"), - F("pages_read", "Pages read", "My reading", "number", ["between", "at_least", "at_most"], "at_least"), - F("collection", "In collection", "My reading", "id", - ["in", "not_in", "in_any", "in_none"], "in", true), + F( + "note_text", + "Note text", + "My reading", + "text", + ["contains", "not_contains", "empty"], + "contains", + true, + ), + F( + "highlight_text", + "Highlight text", + "My reading", + "text", + ["contains", "not_contains", "empty"], + "contains", + true, + ), + F( + "time_spent", + "Time spent (minutes)", + "My reading", + "number", + ["between", "at_least", "at_most"], + "at_least", + ), + F( + "pages_read", + "Pages read", + "My reading", + "number", + ["between", "at_least", "at_most"], + "at_least", + ), + F( + "collection", + "In collection", + "My reading", + "id", + ["in", "not_in", "in_any", "in_none"], + "in", + true, + ), ], }, { group: "Library", - fields: [ - F("library", "Library", "Library", "id", ["is", "is_not"], "is", true), - ], + fields: [F("library", "Library", "Library", "id", ["is", "is_not"], "is", true)], }, ]; @@ -194,10 +291,7 @@ for (const g of FIELD_GROUPS) { // Grouped ops per field, precomputed in the field's op order. const OPS_BY_FIELD = new Map(); for (const [, f] of FIELD_BY_KEY) { - OPS_BY_FIELD.set( - f.key, - f.ops.map((k) => OPS[k]).filter(Boolean), - ); + OPS_BY_FIELD.set(f.key, f.ops.map((k) => OPS[k]).filter(Boolean)); } // Flat-expressible fields: key → the flat wire param a `contains`/`is` @@ -237,8 +331,7 @@ export interface FilterGroupState { implicit?: boolean; } -export const isRule = (n: FilterRuleState | FilterGroupState): n is FilterRuleState => - "field" in n; +export const isRule = (n: FilterRuleState | FilterGroupState): n is FilterRuleState => "field" in n; // flatParamNames are the wire params the flat deep-link contract pins // (plus the two flat extras the builder owns). @@ -257,12 +350,14 @@ function ruleToFlatParam(rule: FilterRuleState): Record | null { if (!joined) return null; const flat = FLAT_PARAMS[rule.field]; if (flat && rule.op === "contains") return { [flat]: joined }; - if (flat && rule.op === "is" && - ["age_rating", "format_group", "manga_type", "reading_direction"].includes(rule.field)) { + if ( + flat && + rule.op === "is" && + ["age_rating", "format_group", "manga_type", "reading_direction"].includes(rule.field) + ) { return { [flat]: joined }; } - if (flat && rule.op === "is" && - ["story_arc", "imprint", "edition"].includes(rule.field)) { + if (flat && rule.op === "is" && ["story_arc", "imprint", "edition"].includes(rule.field)) { return { [flat]: joined }; } if (rule.field === "cover") { @@ -362,8 +457,7 @@ function projectRows( const childPath = [...path, idx]; const key = childPath.join("-"); // The slot chip: how this child joins the row above it. - const slotChip: "AND" | "OR" = - idx === 0 || group.mode === "all" ? "AND" : "OR"; + const slotChip: "AND" | "OR" = idx === 0 || group.mode === "all" ? "AND" : "OR"; if (!isRule(child)) { const g = child as FilterGroupState; if (g.implicit) { @@ -412,10 +506,7 @@ function projectRows( return; } const prev = group.children[idx - 1]; - const clickable = - idx > 0 && - !!prev && - (isRule(prev) || !!(prev as FilterGroupState).implicit); + const clickable = idx > 0 && !!prev && (isRule(prev) || !!(prev as FilterGroupState).implicit); out.push({ key, path: childPath, @@ -682,8 +773,7 @@ export function filterBuilderData() { return FIELD_GROUPS.map((g) => ({ group: g.group, fields: g.fields.filter( - (f) => - f.label.toLowerCase().includes(q) || f.key.toLowerCase().includes(q), + (f) => f.label.toLowerCase().includes(q) || f.key.toLowerCase().includes(q), ), })).filter((g) => g.fields.length > 0); }, @@ -719,9 +809,8 @@ export function filterBuilderData() { const params = new URLSearchParams(); params.set(rule.field, typed || " "); if (others) params.set("qf", JSON.stringify(others)); - const currentLibraryId = ( - document.getElementById("library-select") as HTMLSelectElement - )?.value; + const currentLibraryId = (document.getElementById("library-select") as HTMLSelectElement) + ?.value; if (currentLibraryId) params.set("library_id", currentLibraryId); // Browse (no typing) shows a slightly wider window than filter- // as-you-type. @@ -790,9 +879,8 @@ export function filterBuilderData() { } const params = new URLSearchParams(); params.set("qf", JSON.stringify(completed)); - const currentLibraryId = ( - document.getElementById("library-select") as HTMLSelectElement - )?.value; + const currentLibraryId = (document.getElementById("library-select") as HTMLSelectElement) + ?.value; if (currentLibraryId) params.set("library_id", currentLibraryId); this.matchCountLoading = true; try { @@ -911,13 +999,25 @@ export function filterBuilderData() { if (cover && cover.value) { rules.push({ field: "cover", op: cover.value === "true" ? "has" : "missing", values: [] }); } - const explicitIn = form.querySelector('input[name="explicit_filter"]') as HTMLInputElement | null; + const explicitIn = form.querySelector( + 'input[name="explicit_filter"]', + ) as HTMLInputElement | null; if (explicitIn && explicitIn.value) { - rules.push({ field: "explicit", op: explicitIn.value === "true" ? "is_true" : "is_false", values: [] }); + rules.push({ + field: "explicit", + op: explicitIn.value === "true" ? "is_true" : "is_false", + values: [], + }); } - const abridgedIn = form.querySelector('input[name="abridged_filter"]') as HTMLInputElement | null; + const abridgedIn = form.querySelector( + 'input[name="abridged_filter"]', + ) as HTMLInputElement | null; if (abridgedIn && abridgedIn.value) { - rules.push({ field: "abridged", op: abridgedIn.value === "true" ? "is_true" : "is_false", values: [] }); + rules.push({ + field: "abridged", + op: abridgedIn.value === "true" ? "is_true" : "is_false", + values: [], + }); } const yearMin = form.querySelector('input[name="year_min"]') as HTMLInputElement | null; if (yearMin && yearMin.value) { diff --git a/web/src/header.ts b/web/src/header.ts index 44042141..a9545b89 100644 --- a/web/src/header.ts +++ b/web/src/header.ts @@ -21,11 +21,7 @@ const restoreLibrarySelection = (): void => { const savedLibrary = getSelectedLibrary(); if (!savedLibrary) return; - const dropdownSelectors = [ - "#library-select", - "#selected-library", - "#library_id", - ]; + const dropdownSelectors = ["#library-select", "#selected-library", "#library_id"]; for (const selector of dropdownSelectors) { const dropdown = document.querySelector(selector) as HTMLSelectElement; diff --git a/web/src/library-switcher.ts b/web/src/library-switcher.ts index 5eaadcc2..9f4d20a6 100644 --- a/web/src/library-switcher.ts +++ b/web/src/library-switcher.ts @@ -43,11 +43,7 @@ export async function switchWithTransition( } try { - container.classList.add( - "opacity-0", - "transition-opacity", - "duration-150", - ); + container.classList.add("opacity-0", "transition-opacity", "duration-150"); await new Promise((resolve) => setTimeout(resolve, 150)); loading.classList.remove("hidden"); @@ -68,12 +64,7 @@ export async function switchWithTransition( container.classList.remove("transition-opacity", "duration-300"); }, 300); } catch (error) { - container.classList.remove( - "opacity-0", - "transition-opacity", - "duration-150", - "duration-300", - ); + container.classList.remove("opacity-0", "transition-opacity", "duration-150", "duration-300"); showToast("Failed to load library", "error"); console.error("Switch library error:", error); } finally { @@ -86,18 +77,14 @@ export interface LibrarySwitcherOptions { } export function initLibrarySwitcher(options: LibrarySwitcherOptions): void { - const librarySelect = document.getElementById( - "library-select", - ) as HTMLSelectElement; + const librarySelect = document.getElementById("library-select") as HTMLSelectElement; if (!librarySelect) return; const stored = localStorage.getItem("selectedLibrary"); if (stored === ALL_LIBRARIES) { librarySelect.value = ""; } else if (stored) { - const option = librarySelect.querySelector( - `option[value="${stored}"]`, - ); + const option = librarySelect.querySelector(`option[value="${stored}"]`); if (option) { librarySelect.value = stored; } diff --git a/web/src/linking.ts b/web/src/linking.ts index 20a503af..18489e6d 100644 --- a/web/src/linking.ts +++ b/web/src/linking.ts @@ -73,10 +73,7 @@ function renderUnlinkedBooks(books: UnlinkedBookData[]): void { .join(""); } -async function linkBook( - progressId: string, - mediaItemId: string, -): Promise { +async function linkBook(progressId: string, mediaItemId: string): Promise { const token = localStorage.getItem("token"); if (!token) return; @@ -151,10 +148,7 @@ async function getSuggestions(progressId: string): Promise { } } -function showSuggestionsModal( - progressId: string, - suggestions: PotentialMatchData[], -): void { +function showSuggestionsModal(progressId: string, suggestions: PotentialMatchData[]): void { const modal = document.getElementById("match-modal"); const content = document.getElementById("match-modal-content"); diff --git a/web/src/markdown.ts b/web/src/markdown.ts index c98b2cc4..6ca2e9df 100644 --- a/web/src/markdown.ts +++ b/web/src/markdown.ts @@ -14,35 +14,35 @@ const md = new MarkdownIt({ html: false, linkify: false }); md.use(footnote); export function renderNoteHtml(source: string): string { - if (!source) return ""; - const raw = md.render(source); - // Disallowing img/input/style/form by tag and keeping DOMPurify's default - // safe-URI scheme check (http/https/mailto + same-page anchors only) - // closes the link-injection hole, which is the one markdown pivot into - // script execution. - const clean = DOMPurify.sanitize(raw, { - ALLOWED_ATTR: ["href", "class", "id", "role"], - FORBID_TAGS: ["img", "input", "style", "form"], - }); - return clean; + if (!source) return ""; + const raw = md.render(source); + // Disallowing img/input/style/form by tag and keeping DOMPurify's default + // safe-URI scheme check (http/https/mailto + same-page anchors only) + // closes the link-injection hole, which is the one markdown pivot into + // script execution. + const clean = DOMPurify.sanitize(raw, { + ALLOWED_ATTR: ["href", "class", "id", "role"], + FORBID_TAGS: ["img", "input", "style", "form"], + }); + return clean; } // Compact rows may show a plain line instead of rich markup. export function stripNote(source: string): string { - if (!source) return ""; - const holder = document.createElement("div"); - holder.innerHTML = renderNoteHtml(source); - return (holder.textContent || "").replace(/\s+/g, " ").trim(); + if (!source) return ""; + const holder = document.createElement("div"); + holder.innerHTML = renderNoteHtml(source); + return (holder.textContent || "").replace(/\s+/g, " ").trim(); } // ----- rich paste: HTML flavor -> markdown source ----- const turndown = new TurndownService({ - // Keep the converted output inside our supported syntax: headings, - // emphasis, lists, code, quotes, links, tables. Everything else degrades. - headingStyle: "atx", - codeBlockStyle: "fenced", - bulletListMarker: "-", - hr: "---", + // Keep the converted output inside our supported syntax: headings, + // emphasis, lists, code, quotes, links, tables. Everything else degrades. + headingStyle: "atx", + codeBlockStyle: "fenced", + bulletListMarker: "-", + hr: "---", }); turndown.use(gfm); turndown.use(tables); @@ -53,50 +53,50 @@ turndown.addRule("noImages", { filter: "img", replacement: () => "" }); // headingless ones as raw HTML — which our no-raw-HTML parser would then // show as literal tags. Convert those to pipe syntax with a blank header. turndown.addRule("tablesNoHeading", { - filter: (node: Node) => { - const el = node as HTMLElement; - if (el.nodeName !== "TABLE" || !el.querySelector) return false; - return !el.querySelector("th") && el.querySelectorAll("tr").length > 0; - }, - replacement: (_content: string, node: Node) => { - const table = node as HTMLTableElement; - const rows = Array.from(table.querySelectorAll("tr")).map((tr) => - Array.from(tr.querySelectorAll("td, th")).map((cell) => - (cell.textContent ?? "").replace(/\s+/g, " ").trim().replace(/\|/g, "\\|"), - ), - ); - const cols = Math.max(1, ...rows.map((r) => r.length)); - const pad = (r: string[]) => { - const copy = [...r]; - while (copy.length < cols) copy.push(""); - return copy.map((c) => c || " "); - }; - const head = pad(rows[0] ?? []); - const lines = [ - "| " + head.join(" | ") + " |", - "|" + Array.from({ length: cols }, () => " --- ").join("|") + "|", - ...rows.slice(1).map((r) => "| " + pad(r).join(" | ") + " |"), - ]; - return "\n\n" + lines.join("\n") + "\n\n"; - }, + filter: (node: Node) => { + const el = node as HTMLElement; + if (el.nodeName !== "TABLE" || !el.querySelector) return false; + return !el.querySelector("th") && el.querySelectorAll("tr").length > 0; + }, + replacement: (_content: string, node: Node) => { + const table = node as HTMLTableElement; + const rows = Array.from(table.querySelectorAll("tr")).map((tr) => + Array.from(tr.querySelectorAll("td, th")).map((cell) => + (cell.textContent ?? "").replace(/\s+/g, " ").trim().replace(/\|/g, "\\|"), + ), + ); + const cols = Math.max(1, ...rows.map((r) => r.length)); + const pad = (r: string[]) => { + const copy = [...r]; + while (copy.length < cols) copy.push(""); + return copy.map((c) => c || " "); + }; + const head = pad(rows[0] ?? []); + const lines = [ + "| " + head.join(" | ") + " |", + "|" + Array.from({ length: cols }, () => " --- ").join("|") + "|", + ...rows.slice(1).map((r) => "| " + pad(r).join(" | ") + " |"), + ]; + return "\n\n" + lines.join("\n") + "\n\n"; + }, }); export function htmlToMarkdown(html: string): string { - return turndown.turndown(html); + return turndown.turndown(html); } /** Reads the paste event's clipboard and returns markdown converted from the * rich (text/html) flavor, or null when the clipboard holds no usable rich * content (plain pastes must fall through to the browser default). */ export function richPasteToMarkdown(event: ClipboardEvent): string | null { - const data = event.clipboardData; - if (!data) return null; - const html = data.getData("text/html"); - if (!html || !html.trim()) return null; - // A rich flavor that is just an HTML wrapper of plain text (some apps - // emit

...

wrappers for everything Chrome copies) still converts - // harmlessly to the same text, so no content sniffing is needed here. - const markdown = htmlToMarkdown(html); - if (!markdown || !markdown.trim()) return null; - return markdown; + const data = event.clipboardData; + if (!data) return null; + const html = data.getData("text/html"); + if (!html || !html.trim()) return null; + // A rich flavor that is just an HTML wrapper of plain text (some apps + // emit

...

wrappers for everything Chrome copies) still converts + // harmlessly to the same text, so no content sniffing is needed here. + const markdown = htmlToMarkdown(html); + if (!markdown || !markdown.trim()) return null; + return markdown; } diff --git a/web/src/reader/pdf-search.ts b/web/src/reader/pdf-search.ts index e26483f4..4ec54f61 100644 --- a/web/src/reader/pdf-search.ts +++ b/web/src/reader/pdf-search.ts @@ -60,17 +60,14 @@ export function buildPageText( const t = item.transform ?? [1, 0, 0, 1, 0, 0]; const baseline = t[5] ?? 0; const x = t[4] ?? 0; - const size = - Math.abs(item.height) || Math.abs(t[3]) || Math.abs(t[0]) || 10; + const size = Math.abs(item.height) || Math.abs(t[3]) || Math.abs(t[0]) || 10; const w = Math.abs(item.width) || 0; const h = size; let sep = ""; if (text && !text.endsWith(" ") && prevRight != null) { const newLine = - item.hasEOL || - prevBaseline == null || - Math.abs(baseline - prevBaseline) > size * 0.5; + item.hasEOL || prevBaseline == null || Math.abs(baseline - prevBaseline) > size * 0.5; const gap = x - prevRight; if (newLine || gap > size * 0.2) sep = " "; } @@ -106,9 +103,7 @@ export async function extractPdfPages( const page = await pdf.getPage(i + 1); const viewport = page.getViewport({ scale: 1 }); const tc = await page.getTextContent(); - pages.push( - buildPageText(tc.items, viewport.width, viewport.height, i), - ); + pages.push(buildPageText(tc.items, viewport.width, viewport.height, i)); onProgress?.((i + 1) / num); } return pages; diff --git a/web/src/reader/reader.ts b/web/src/reader/reader.ts index 88b1c050..08a6a804 100644 --- a/web/src/reader/reader.ts +++ b/web/src/reader/reader.ts @@ -6,19 +6,9 @@ import { loadSettings, saveSettings } from "./settings-manager"; import { getToken } from "../storage"; import { showToast } from "../toast"; import { renderNoteHtml, richPasteToMarkdown } from "../markdown"; -import { - extractPdfPages, - searchPdfPages, - type PdfPageText, -} from "./pdf-search"; +import { extractPdfPages, searchPdfPages, type PdfPageText } from "./pdf-search"; -const HIGHLIGHT_COLORS = [ - "#ffd54f", - "#a5d6a7", - "#90caf9", - "#f48fb1", - "#ce93d8", -]; +const HIGHLIGHT_COLORS = ["#ffd54f", "#a5d6a7", "#90caf9", "#f48fb1", "#ce93d8"]; // Rendered page-thumbnail canvases (page index → canvas). Module-level so // Alpine reactivity never wraps it and revisiting pages is instant. @@ -226,81 +216,80 @@ interface ThemeColors { link: string; } -const THEME_COLORS: Record = - { - light: { - light: { fg: "#1a1a1a", bg: "#fafafa", link: "#0066cc" }, - dark: { fg: "#e8e8e8", bg: "#1a1a1a", link: "#60a5fa" }, - }, - paper: { - light: { fg: "#2d2d2d", bg: "#fdfbf7", link: "#0284c7" }, - dark: { fg: "#ebe5dd", bg: "#1c1917", link: "#38bdf8" }, - }, - sepia: { - light: { fg: "#4a3728", bg: "#f4ecd8", link: "#8b4513" }, - dark: { fg: "#d4c4a8", bg: "#2b2420", link: "#d97706" }, - }, - parchment: { - light: { fg: "#5c4033", bg: "#f0e6d3", link: "#92400e" }, - dark: { fg: "#e8dcc8", bg: "#3d2b1f", link: "#b45309" }, - }, - warm: { - light: { fg: "#2b2b2b", bg: "#faf8f0", link: "#d97706" }, - dark: { fg: "#f5e6d3", bg: "#2d2416", link: "#fbbf24" }, - }, - candlelight: { - light: { fg: "#3d2b1f", bg: "#fef3c7", link: "#b45309" }, - dark: { fg: "#fde68a", bg: "#451a03", link: "#fb923c" }, - }, - azure: { - light: { fg: "#262d48", bg: "#cedef5", link: "#2d53e5" }, - dark: { fg: "#babee1", bg: "#282e47", link: "#0ea5e9" }, - }, - sky: { - light: { fg: "#1e3a5f", bg: "#e0f2fe", link: "#0284c7" }, - dark: { fg: "#bae6fd", bg: "#0c4a6e", link: "#0ea5e9" }, - }, - arctic: { - light: { fg: "#1e3a5f", bg: "#f0f9ff", link: "#0284c7" }, - dark: { fg: "#bfdbfe", bg: "#1e293b", link: "#38bdf8" }, - }, - frost: { - light: { fg: "#334155", bg: "#f8fafc", link: "#38bdf8" }, - dark: { fg: "#e0f2fe", bg: "#0f172a", link: "#7dd3fc" }, - }, - dusk: { - light: { fg: "#3d2914", bg: "#fef3e2", link: "#ea580c" }, - dark: { fg: "#f5d5b8", bg: "#2d1f14", link: "#fb923c" }, - }, - sunset: { - light: { fg: "#4a1d1d", bg: "#fff7ed", link: "#f97316" }, - dark: { fg: "#fed7aa", bg: "#431407", link: "#fb923c" }, - }, - twilight: { - light: { fg: "#4c1d95", bg: "#f5f3ff", link: "#8b5cf6" }, - dark: { fg: "#ddd6fe", bg: "#2e1065", link: "#a78bfa" }, - }, - forest: { - light: { fg: "#1a2e1a", bg: "#e8efe8", link: "#15803d" }, - dark: { fg: "#c8dcc8", bg: "#1a2e1a", link: "#22c55e" }, - }, - moss: { - light: { fg: "#14532d", bg: "#dcfce7", link: "#22c55e" }, - dark: { fg: "#86efac", bg: "#052e16", link: "#4ade80" }, - }, - slate: { - light: { fg: "#334155", bg: "#f8fafc", link: "#475569" }, - dark: { fg: "#e2e8f0", bg: "#1e293b", link: "#94a3b8" }, - }, - oled: { - light: { fg: "#000000", bg: "#ffffff", link: "#0066cc" }, - dark: { fg: "#ffffff", bg: "#000000", link: "#3b82f6" }, - }, - solarized: { - light: { fg: "#657b83", bg: "#fdf6e3", link: "#268bd2" }, - dark: { fg: "#839496", bg: "#002b36", link: "#268bd2" }, - }, - }; +const THEME_COLORS: Record = { + light: { + light: { fg: "#1a1a1a", bg: "#fafafa", link: "#0066cc" }, + dark: { fg: "#e8e8e8", bg: "#1a1a1a", link: "#60a5fa" }, + }, + paper: { + light: { fg: "#2d2d2d", bg: "#fdfbf7", link: "#0284c7" }, + dark: { fg: "#ebe5dd", bg: "#1c1917", link: "#38bdf8" }, + }, + sepia: { + light: { fg: "#4a3728", bg: "#f4ecd8", link: "#8b4513" }, + dark: { fg: "#d4c4a8", bg: "#2b2420", link: "#d97706" }, + }, + parchment: { + light: { fg: "#5c4033", bg: "#f0e6d3", link: "#92400e" }, + dark: { fg: "#e8dcc8", bg: "#3d2b1f", link: "#b45309" }, + }, + warm: { + light: { fg: "#2b2b2b", bg: "#faf8f0", link: "#d97706" }, + dark: { fg: "#f5e6d3", bg: "#2d2416", link: "#fbbf24" }, + }, + candlelight: { + light: { fg: "#3d2b1f", bg: "#fef3c7", link: "#b45309" }, + dark: { fg: "#fde68a", bg: "#451a03", link: "#fb923c" }, + }, + azure: { + light: { fg: "#262d48", bg: "#cedef5", link: "#2d53e5" }, + dark: { fg: "#babee1", bg: "#282e47", link: "#0ea5e9" }, + }, + sky: { + light: { fg: "#1e3a5f", bg: "#e0f2fe", link: "#0284c7" }, + dark: { fg: "#bae6fd", bg: "#0c4a6e", link: "#0ea5e9" }, + }, + arctic: { + light: { fg: "#1e3a5f", bg: "#f0f9ff", link: "#0284c7" }, + dark: { fg: "#bfdbfe", bg: "#1e293b", link: "#38bdf8" }, + }, + frost: { + light: { fg: "#334155", bg: "#f8fafc", link: "#38bdf8" }, + dark: { fg: "#e0f2fe", bg: "#0f172a", link: "#7dd3fc" }, + }, + dusk: { + light: { fg: "#3d2914", bg: "#fef3e2", link: "#ea580c" }, + dark: { fg: "#f5d5b8", bg: "#2d1f14", link: "#fb923c" }, + }, + sunset: { + light: { fg: "#4a1d1d", bg: "#fff7ed", link: "#f97316" }, + dark: { fg: "#fed7aa", bg: "#431407", link: "#fb923c" }, + }, + twilight: { + light: { fg: "#4c1d95", bg: "#f5f3ff", link: "#8b5cf6" }, + dark: { fg: "#ddd6fe", bg: "#2e1065", link: "#a78bfa" }, + }, + forest: { + light: { fg: "#1a2e1a", bg: "#e8efe8", link: "#15803d" }, + dark: { fg: "#c8dcc8", bg: "#1a2e1a", link: "#22c55e" }, + }, + moss: { + light: { fg: "#14532d", bg: "#dcfce7", link: "#22c55e" }, + dark: { fg: "#86efac", bg: "#052e16", link: "#4ade80" }, + }, + slate: { + light: { fg: "#334155", bg: "#f8fafc", link: "#475569" }, + dark: { fg: "#e2e8f0", bg: "#1e293b", link: "#94a3b8" }, + }, + oled: { + light: { fg: "#000000", bg: "#ffffff", link: "#0066cc" }, + dark: { fg: "#ffffff", bg: "#000000", link: "#3b82f6" }, + }, + solarized: { + light: { fg: "#657b83", bg: "#fdf6e3", link: "#268bd2" }, + dark: { fg: "#839496", bg: "#002b36", link: "#268bd2" }, + }, +}; const getCSS = ({ fontFamily, @@ -605,9 +594,7 @@ document.addEventListener("alpine:init", () => { ); }, init() { - this.pointerFine = window - .matchMedia("(hover: hover) and (pointer: fine)") - .matches; + this.pointerFine = window.matchMedia("(hover: hover) and (pointer: fine)").matches; const link = document.getElementById("reader-back"); if (!link) return; const storageKey = "reader_back"; @@ -626,9 +613,7 @@ document.addEventListener("alpine:init", () => { sessionStorage.removeItem(storageKey); } const backUrl = - sessionStorage.getItem(storageKey) || - link.getAttribute("href") || - "/dashboard"; + sessionStorage.getItem(storageKey) || link.getAttribute("href") || "/dashboard"; link.addEventListener("click", (e: Event) => { e.preventDefault(); sessionStorage.removeItem(storageKey); @@ -654,8 +639,7 @@ document.addEventListener("alpine:init", () => { // Reading flow for comics is a per-book preference (a webtoon title // vs. a paged manga volume); read before the renderer is chosen. this.comicFlow = - localStorage.getItem(`bookhoard:comic-flow:${config.mediaItemId}`) === - "webtoon" + localStorage.getItem(`bookhoard:comic-flow:${config.mediaItemId}`) === "webtoon" ? "webtoon" : "paged"; this.settings = await loadSettings(); @@ -667,8 +651,7 @@ document.addEventListener("alpine:init", () => { this.lineHeight = this.settings.line_height || 1.6; this.doublePageSpread = this.settings.double_page_spread ?? true; const behavior = this.settings.chrome_behavior || "auto-hide"; - this.chromeBehavior = - behavior === "always-visible" ? "always-visible" : "auto-hide"; + this.chromeBehavior = behavior === "always-visible" ? "always-visible" : "auto-hide"; this.tapZonesEnabled = this.settings.tap_zones_enabled ?? true; this.tapZoneSize = this.settings.tap_zone_size || 12; const mode = this.settings.pdf_interaction_mode; @@ -686,8 +669,7 @@ document.addEventListener("alpine:init", () => { } const viewport = document.getElementById("reader-viewport")!; const themeSet = THEME_COLORS[this.readingTheme] ?? THEME_COLORS.light; - const themeColors = - this.readingMode === "dark" ? themeSet.dark : themeSet.light; + const themeColors = this.readingMode === "dark" ? themeSet.dark : themeSet.light; viewport.style.backgroundColor = themeColors.bg; this.applyFxFilter(); this.view = document.getElementById("reader-view") as any; @@ -720,29 +702,26 @@ document.addEventListener("alpine:init", () => { } this.isPDF = (this.renderer as any).isPDF; // Click on a PDF/fixed-layout highlight rect → edit popover. - this.renderer.addEventListener( - "show-rect-annotation", - (e: any) => { - const { key, clientX, clientY } = e.detail; - // A tap anywhere on the page during rect adjust must not pop - // the editor mid-session (page turn is the cancel gesture). - if (this.pdfAdjust) return; - const h = this.highlightItems.find((x: any) => x.id === key); - if (!h) return; - this.openSelectionPopover({ - mode: "edit", - x: clientX, - y: clientY, - text: h.text, - cfi: "", - id: h.id, - color: h.color, - note: h.note, - pdfPage: h.pdfPage, - pdfRects: h.pdfRects, - }); - }, - ); + this.renderer.addEventListener("show-rect-annotation", (e: any) => { + const { key, clientX, clientY } = e.detail; + // A tap anywhere on the page during rect adjust must not pop + // the editor mid-session (page turn is the cancel gesture). + if (this.pdfAdjust) return; + const h = this.highlightItems.find((x: any) => x.id === key); + if (!h) return; + this.openSelectionPopover({ + mode: "edit", + x: clientX, + y: clientY, + text: h.text, + cfi: "", + id: h.id, + color: h.color, + note: h.note, + pdfPage: h.pdfPage, + pdfRects: h.pdfRects, + }); + }); this.renderer.addEventListener("zoom", () => { this.zoomPercent = this.renderer.zoomPercent; this.fxZoomed = this.renderer.zoom != null; @@ -754,27 +733,28 @@ document.addEventListener("alpine:init", () => { } this.view.addEventListener("load", (e: any) => { const { doc, index } = e.detail; - if (doc && typeof index === "number") - pdfFrameDocs.set(doc, index); + if (doc && typeof index === "number") pdfFrameDocs.set(doc, index); this.currentDoc = doc; const link = doc.createElement("link"); link.rel = "stylesheet"; link.href = "/static/reader-fonts.css"; doc.head.append(link); - doc.addEventListener("keydown", (ev: KeyboardEvent) => - this.handleKeydown(ev), - ); + doc.addEventListener("keydown", (ev: KeyboardEvent) => this.handleKeydown(ev)); // Edge bands work inside page documents too — screenY keeps the // check viewport-relative even when the iframe is content-sized // (fixed-layout pages and EPUB sections are separate documents). - doc.addEventListener("pointermove", (e: PointerEvent) => { - if (e.pointerType !== "mouse") return; - const vy = e.screenY - window.screenY; - if (vy <= window.innerHeight * 0.1 || vy >= window.innerHeight * 0.92) - this.pokeChrome(1200); - }, { - passive: true, - }); + doc.addEventListener( + "pointermove", + (e: PointerEvent) => { + if (e.pointerType !== "mouse") return; + const vy = e.screenY - window.screenY; + if (vy <= window.innerHeight * 0.1 || vy >= window.innerHeight * 0.92) + this.pokeChrome(1200); + }, + { + passive: true, + }, + ); // Tap zones inside the page document (iframe events never bubble // out to the host document, so the viewport listeners miss them). if (window.matchMedia("(pointer: coarse)").matches) { @@ -783,21 +763,14 @@ document.addEventListener("alpine:init", () => { // cancel armed tap actions and feed the host-surface guard. doc.addEventListener("selectionchange", () => { const sel = doc.getSelection(); - this.noteSelectionActivity( - !!sel && !sel.isCollapsed && !!sel.toString(), - ); + this.noteSelectionActivity(!!sel && !sel.isCollapsed && !!sel.toString()); // The gesture takeover swallows pointerup for touch selection // drags, so the popover's pointerup trigger never fires for // them; a selection that has been stable for a moment is the // settled gesture — open the popover for it. - if ( - this.isFixedLayout && - !this.treatAsComic && - doc.querySelector(".textLayer") - ) + if (this.isFixedLayout && !this.treatAsComic && doc.querySelector(".textLayer")) this.schedulePDFSelectionPopover(doc, index); - else if (!this.isFixedLayout) - this.scheduleSelectionPopover(doc, index); + else if (!this.isFixedLayout) this.scheduleSelectionPopover(doc, index); }); } // Clicks in the book dismiss the popover in ANY mode and for ANY @@ -809,8 +782,7 @@ document.addEventListener("alpine:init", () => { doc.addEventListener( "pointerdown", () => { - if (this.selectionPopover.open) - this.hideSelectionPopover(); + if (this.selectionPopover.open) this.hideSelectionPopover(); }, { passive: true }, ); @@ -825,15 +797,16 @@ document.addEventListener("alpine:init", () => { // (the gesture takeover swallows it) — they open the popover // through the selectionchange debounce below. if ( - e && e.pointerType === "touch" && this.lastTouchDownT && + e && + e.pointerType === "touch" && + this.lastTouchDownT && Date.now() - this.lastTouchDownT < 300 ) { sel?.removeAllRanges(); return; } if (!sel || sel.isCollapsed || !sel.rangeCount) { - if (this.selectionPopover.mode === "create") - this.hideSelectionPopover(); + if (this.selectionPopover.mode === "create") this.hideSelectionPopover(); return; } this.openPopoverForSelection(sel, doc, index); @@ -856,11 +829,7 @@ document.addEventListener("alpine:init", () => { // spread renders (during view.init), which is after this listener // attaches — the stale copy here would always be falsy. // Comic-treated PDFs never attach it: no highlights, like comics. - if ( - this.isFixedLayout && - !this.treatAsComic && - doc.querySelector(".textLayer") - ) { + if (this.isFixedLayout && !this.treatAsComic && doc.querySelector(".textLayer")) { doc.addEventListener( "pointerup", (e: PointerEvent) => @@ -901,13 +870,9 @@ document.addEventListener("alpine:init", () => { // CFI (renderCfi) while the stored locator stays a point CFI, so a // click reports the render value — match either or editing // device highlights is impossible. - const h = this.highlightItems.find( - (x) => x.cfi === value || x.renderCfi === value, - ); + const h = this.highlightItems.find((x) => x.cfi === value || x.renderCfi === value); if (!h) return; - const doc = this.renderer - ?.getContents?.() - ?.find((c: any) => c.index === index)?.doc; + const doc = this.renderer?.getContents?.()?.find((c: any) => c.index === index)?.doc; const frame = doc?.defaultView?.frameElement as HTMLElement | null; const iframeRect = frame?.getBoundingClientRect(); const rect = range.getBoundingClientRect(); @@ -934,8 +899,7 @@ document.addEventListener("alpine:init", () => { // intent — dismiss the chrome (guards keep drawers, selections, // and pinned mode up). if (e.detail?.reason === "snap") this.hideChromeNow(); - const { fraction, location, pageItem, cfi, tocItem, section } = - e.detail; + const { fraction, location, pageItem, cfi, tocItem, section } = e.detail; // Only dismiss the popover on an actual position change — the // paginator also emits no-op relocates (e.g. a tap's page-aligned // anchor), which would otherwise close a just-opened highlight @@ -944,9 +908,7 @@ document.addEventListener("alpine:init", () => { // fraction threshold alone covers section changes (any section // boundary moves the whole-book fraction far more than 0.1%). const prev = this.lastRelocateDetail; - const moved = - !prev || - Math.abs((prev.fraction ?? 0) - (fraction ?? 0)) > 0.001; + const moved = !prev || Math.abs((prev.fraction ?? 0) - (fraction ?? 0)) > 0.001; if (moved) this.hideSelectionPopover(); this.lastCfi = cfi || ""; this.lastRelocateDetail = { @@ -991,9 +953,7 @@ document.addEventListener("alpine:init", () => { } } } - document.addEventListener("keydown", (ev: KeyboardEvent) => - this.handleKeydown(ev), - ); + document.addEventListener("keydown", (ev: KeyboardEvent) => this.handleKeydown(ev)); // Reading position comes from the database, fetched fresh at open // (the rendered page carries no snapshot of it) — UNLESS an // annotations-banner jump handed one in via ?loc= (objective 7: @@ -1006,17 +966,17 @@ document.addEventListener("alpine:init", () => { } else if (this.isFixedLayout && saved.page != null && saved.page > 0) { // Fixed-layout & comics: a page index is the exact, universal locator. // A bare number navigates directly to the section index in foliate. - await this.view.init({ lastLocation: saved.page - 1 }) + await this.view.init({ lastLocation: saved.page - 1 }); } else if (jump?.cfi) { - await this.view.init({ lastLocation: jump.cfi }) + await this.view.init({ lastLocation: jump.cfi }); } else if (saved.cfi) { - await this.view.init({ lastLocation: saved.cfi }) + await this.view.init({ lastLocation: saved.cfi }); } else if (saved.percentage != null && saved.percentage > 0) { await this.view.init({ lastLocation: { fraction: saved.percentage }, - }) + }); } else { - await this.view.init({}) + await this.view.init({}); } // The position restored above (or the start of the book on a fresh // open) is the baseline: only relocations that actually move from @@ -1041,8 +1001,7 @@ document.addEventListener("alpine:init", () => { window.addEventListener("pageshow", (e: PageTransitionEvent) => { if (e.persisted) { this.lastSyncedCfi = this.lastCfi; - this.lastSyncedFraction = - this.lastRelocateDetail?.fraction ?? -1; + this.lastSyncedFraction = this.lastRelocateDetail?.fraction ?? -1; } }); this.fetchReadingSpeed(); @@ -1062,26 +1021,32 @@ document.addEventListener("alpine:init", () => { // Band check via screenY so it works identically for the host // document and content iframes (iframe clientY is content-space, // which is unrelated to the viewport in scrolled layouts). - document.addEventListener("pointermove", (e: PointerEvent) => { - if (e.pointerType !== "mouse") return; - const vy = e.screenY - window.screenY; - if (vy <= window.innerHeight * 0.1 || vy >= window.innerHeight * 0.92) - this.pokeChrome(1200); - }, { passive: true }); + document.addEventListener( + "pointermove", + (e: PointerEvent) => { + if (e.pointerType !== "mouse") return; + const vy = e.screenY - window.screenY; + if (vy <= window.innerHeight * 0.1 || vy >= window.innerHeight * 0.92) + this.pokeChrome(1200); + }, + { passive: true }, + ); // Dismiss the selection popover and tools menu on clicks outside them // (iframe clicks are covered by the selection tracker's collapsed // check; the ⋯ toggle is exempt so its own click re-toggles cleanly). - document.addEventListener("pointerdown", (e: PointerEvent) => { - const target = e.target as HTMLElement; - if (this.selectionPopover.open && - !target?.closest?.("#selection-popover")) { - this.hideSelectionPopover(); - } - if (this.toolsOpen && - !target?.closest?.(".reader-tools-popover, [data-tools-toggle]")) { - this.toolsOpen = false; - } - }, { passive: true }); + document.addEventListener( + "pointerdown", + (e: PointerEvent) => { + const target = e.target as HTMLElement; + if (this.selectionPopover.open && !target?.closest?.("#selection-popover")) { + this.hideSelectionPopover(); + } + if (this.toolsOpen && !target?.closest?.(".reader-tools-popover, [data-tools-toggle]")) { + this.toolsOpen = false; + } + }, + { passive: true }, + ); if (this.chromeBehavior === "always-visible") { this.chromeVisible = true; return; @@ -1141,9 +1106,7 @@ document.addEventListener("alpine:init", () => { // surface. document.addEventListener("selectionchange", () => { const sel = document.getSelection(); - this.noteSelectionActivity( - !!sel && !sel.isCollapsed && !!sel.toString(), - ); + this.noteSelectionActivity(!!sel && !sel.isCollapsed && !!sel.toString()); }); }, // Record selection state and abort any armed tap action: a selection @@ -1192,9 +1155,13 @@ document.addEventListener("alpine:init", () => { }, { passive: true }, ); - surface.addEventListener("contextmenu", () => { - longPressed = true; - }, { passive: true }); + surface.addEventListener( + "contextmenu", + () => { + longPressed = true; + }, + { passive: true }, + ); // The browser takes over the gesture (text selection, scroll) with // pointercancel — no pointerup will follow. Drop the tracked // pointer so stale state can never match a later touch. @@ -1210,8 +1177,7 @@ document.addEventListener("alpine:init", () => { "pointermove", (e: PointerEvent) => { if (e.pointerId !== downId) return; - if (Math.hypot(e.clientX - downX, e.clientY - downY) > 12) - moved = true; + if (Math.hypot(e.clientX - downX, e.clientY - downY) > 12) moved = true; }, { passive: true }, ); @@ -1247,12 +1213,7 @@ document.addEventListener("alpine:init", () => { if (moved || longPressed || dt > 500) return; if (!this.tapZonesEnabled) return; const target = e.target as HTMLElement | null; - if ( - target?.closest?.( - "a, button, input, textarea, select, [contenteditable]", - ) - ) - return; + if (target?.closest?.("a, button, input, textarea, select, [contenteditable]")) return; const sel = isDoc ? (surface as any).getSelection?.() : null; if (sel?.toString?.()) return; // Host-surface blind spot: selections living in content iframes @@ -1417,7 +1378,11 @@ document.addEventListener("alpine:init", () => { return layer; }, /** Host-document coordinates of the working range's two ends. */ - adjustHandlePoints(): { start: DOMRect | null; end: DOMRect | null; frame: HTMLElement | null } { + adjustHandlePoints(): { + start: DOMRect | null; + end: DOMRect | null; + frame: HTMLElement | null; + } { const ctx = this.adjustContext; if (!ctx) return { start: null, end: null, frame: null }; const frame = ctx.doc?.defaultView?.frameElement as HTMLElement | null; @@ -1506,7 +1471,11 @@ document.addEventListener("alpine:init", () => { * land next to punctuation → the boundary sits exactly there; * land inside a word → snap to that word's edge in the drag * direction; land on whitespace → trim toward the word. */ - snapBoundary(node: Node, offset: number, isStart: boolean): { node: Node; offset: number } | null { + snapBoundary( + node: Node, + offset: number, + isStart: boolean, + ): { node: Node; offset: number } | null { if (node.nodeType !== Node.TEXT_NODE) { // Element landing: descend to the nearest text position. const doc = node.ownerDocument; @@ -1630,10 +1599,7 @@ document.addEventListener("alpine:init", () => { const contents = this.renderer?.getContents?.() ?? []; const doc = contents .map((c: any) => c.doc) - .find( - (d: any) => - d && pdfFrameDocs.get(d) === index && d.defaultView?.frameElement, - ); + .find((d: any) => d && pdfFrameDocs.get(d) === index && d.defaultView?.frameElement); if (!doc) return null; const frame = doc.defaultView?.frameElement as HTMLElement | null; const wrapper = frame?.parentElement; @@ -1676,8 +1642,7 @@ document.addEventListener("alpine:init", () => { (doc.querySelector("img") as HTMLElement); const dr = canvas?.getBoundingClientRect(); if (!dr || !dr.width || !dr.height) return []; - const out: { x0: number; x1: number; y0: number; y1: number; ch: string }[] = - []; + const out: { x0: number; x1: number; y0: number; y1: number; ch: string }[] = []; const range = doc.createRange(); const spans = doc.querySelectorAll(".textLayer span"); spans.forEach((span) => { @@ -1745,13 +1710,7 @@ document.addEventListener("alpine:init", () => { const cx = (c.x0 + c.x1) / 2; const cy = (c.y0 + c.y1) / 2; if ( - p.pdfRects.some( - (r) => - cx >= r[0] && - cx <= r[0] + r[2] && - cy >= r[1] && - cy <= r[1] + r[3], - ) + p.pdfRects.some((r) => cx >= r[0] && cx <= r[0] + r[2] && cy >= r[1] && cy <= r[1] + r[3]) ) { if (startI < 0) startI = i; endI = i; @@ -1808,9 +1767,7 @@ document.addEventListener("alpine:init", () => { { which: "end", fx: last[0] + last[2], fy: last[1] }, ]; for (const pt of pts) { - let dot = layer.querySelector( - `.adjust-handle[data-which="${pt.which}"]`, - ); + let dot = layer.querySelector(`.adjust-handle[data-which="${pt.which}"]`); if (!dot) { dot = document.createElement("div"); dot.className = "adjust-handle"; @@ -1861,9 +1818,7 @@ document.addEventListener("alpine:init", () => { * clearly below the inter-line gap yet above intra-line baseline * variance — 0.6 of the char's own height does that (gap ≈ 1.0 × * height breaks; variance ≈ 0.1 × height stays). */ - charSpanRects( - chars: { x0: number; x1: number; y0: number; y1: number }[], - ): number[][] { + charSpanRects(chars: { x0: number; x1: number; y0: number; y1: number }[]): number[][] { const rects: number[][] = []; let line: typeof chars = []; let lastCy = -9; @@ -1898,11 +1853,7 @@ document.addEventListener("alpine:init", () => { * per-line unions. Pure fraction space after the mapping: no * caretRangeFromPoint, no coordinate-space conversion, works on * any browser engine (Firefox included). */ - pdfAdjustHandleMove( - which: "start" | "end", - clientX: number, - clientY: number, - ) { + pdfAdjustHandleMove(which: "start" | "end", clientX: number, clientY: number) { const ctx = this.pdfAdjust; if (!ctx) return; const map = this.pdfPageMapping(ctx.index); @@ -2012,10 +1963,7 @@ document.addEventListener("alpine:init", () => { this.pdfAdjustRaf = 0; } if (this.pdfAdjustOnRelocate) { - this.renderer?.removeEventListener?.( - "relocate", - this.pdfAdjustOnRelocate, - ); + this.renderer?.removeEventListener?.("relocate", this.pdfAdjustOnRelocate); this.pdfAdjustOnRelocate = null; } if (this.pdfAdjustOnZoom) { @@ -2031,14 +1979,10 @@ document.addEventListener("alpine:init", () => { this.selPopTimer = null; // A tap on a painted highlight re-opens the popover in edit mode // via its own path — never clobber it. - if (this.selectionPopover.open && this.selectionPopover.mode === "edit") - return; + if (this.selectionPopover.open && this.selectionPopover.mode === "edit") return; const sel = doc.getSelection(); if (!sel || sel.isCollapsed || !sel.rangeCount) { - if ( - this.selectionPopover.open && - this.selectionPopover.mode === "create" - ) + if (this.selectionPopover.open && this.selectionPopover.mode === "create") this.hideSelectionPopover(); this.hideSelectionHandles(); return; @@ -2057,14 +2001,10 @@ document.addEventListener("alpine:init", () => { anchorProbe.setStart(sel.anchorNode, sel.anchorOffset); // collapsed ranges report degenerate (0,0) rects — extend by // one character to get a measurable one - if ( - sel.anchorNode?.nodeType === 3 && - sel.anchorOffset < sel.anchorNode.length - ) + if (sel.anchorNode?.nodeType === 3 && sel.anchorOffset < sel.anchorNode.length) anchorProbe.setEnd(sel.anchorNode, sel.anchorOffset + 1); const rects = anchorProbe.getClientRects(); - const ar = - rects[rects.length - 1] || anchorProbe.getBoundingClientRect(); + const ar = rects[rects.length - 1] || anchorProbe.getBoundingClientRect(); // rect x is already in the iframe's own (content) space, // i.e. scroll-invariant: page = floor(x / column stride) const desired = (Math.floor(ar.x / r.size) + 1) * r.size; @@ -2101,14 +2041,10 @@ document.addEventListener("alpine:init", () => { this.selPopTimer = null; // A tap on a painted highlight re-opens the popover in edit mode // via its own path — never clobber it. - if (this.selectionPopover.open && this.selectionPopover.mode === "edit") - return; + if (this.selectionPopover.open && this.selectionPopover.mode === "edit") return; const sel = doc.getSelection(); if (!sel || sel.isCollapsed || !sel.rangeCount) { - if ( - this.selectionPopover.open && - this.selectionPopover.mode === "create" - ) + if (this.selectionPopover.open && this.selectionPopover.mode === "create") this.hideSelectionPopover(); return; } @@ -2148,13 +2084,7 @@ document.addEventListener("alpine:init", () => { const cx = (c.x0 + c.x1) / 2; const cy = (c.y0 + c.y1) / 2; if ( - selRects.some( - (r) => - cx >= r[0] && - cx <= r[0] + r[2] && - cy >= r[1] && - cy <= r[1] + r[3], - ) + selRects.some((r) => cx >= r[0] && cx <= r[0] + r[2] && cy >= r[1] && cy <= r[1] + r[3]) ) picked.push(i); }); @@ -2243,9 +2173,7 @@ document.addEventListener("alpine:init", () => { // below both (same +90 clearance the reflowable path uses). Fine // pointers (desktop) keep the above-the-selection placement. const coarse = window.matchMedia("(pointer: coarse)").matches; - const y = coarse - ? fr.top + first.bottom * sy + 90 - : fr.top + first.top * sy; + const y = coarse ? fr.top + first.bottom * sy + 90 : fr.top + first.top * sy; this.pdfSelDoc = doc; this.openSelectionPopover({ mode: "create", @@ -2273,18 +2201,15 @@ document.addEventListener("alpine:init", () => { const p = doc.createRange(); p.setStart(node, offset); if (node?.nodeType === 3) { - if (forward && offset < node.length) - p.setEnd(node, offset + 1); - else if (!forward && offset > 0) - p.setStart(node, offset - 1); + if (forward && offset < node.length) p.setEnd(node, offset + 1); + else if (!forward && offset > 0) p.setStart(node, offset - 1); } if (p.collapsed) return null; const rects = p.getClientRects(); return rects.length ? rects[forward ? 0 : rects.length - 1] : null; }; const caretAt = (x: number, y: number) => { - const caret = doc.caretRangeFromPoint?.(x, y) - ?? doc.caretPositionFromPoint?.(x, y); + const caret = doc.caretRangeFromPoint?.(x, y) ?? doc.caretPositionFromPoint?.(x, y); if (!caret) return null; return { node: caret.startContainer ?? caret.offsetNode, @@ -2297,12 +2222,10 @@ document.addEventListener("alpine:init", () => { // from the margin) const endRect = probeRect(range.endContainer, range.endOffset, false); if (endRect && endRect.x + endRect.width > right + TOL) { - const caret = caretAt( - right - 15, doc.documentElement.clientHeight - 40); + const caret = caretAt(right - 15, doc.documentElement.clientHeight - 40); if (caret && range.isPointInRange(caret.node, caret.offset)) { if (backward) - sel.setBaseAndExtent(caret.node, caret.offset, - sel.focusNode, sel.focusOffset); + sel.setBaseAndExtent(caret.node, caret.offset, sel.focusNode, sel.focusOffset); else sel.extend(caret.node, caret.offset); return true; } @@ -2313,11 +2236,8 @@ document.addEventListener("alpine:init", () => { if (startRect && startRect.x < left - TOL) { const caret = caretAt(left + 15, 40); if (caret && range.isPointInRange(caret.node, caret.offset)) { - if (backward) - sel.extend(caret.node, caret.offset); - else - sel.setBaseAndExtent(caret.node, caret.offset, - sel.focusNode, sel.focusOffset); + if (backward) sel.extend(caret.node, caret.offset); + else sel.setBaseAndExtent(caret.node, caret.offset, sel.focusNode, sel.focusOffset); return true; } } @@ -2360,8 +2280,7 @@ document.addEventListener("alpine:init", () => { cfi, cfiEnd, }); - if (coarse && this.view?.book?.dir !== "rtl") - this.showSelectionHandles(doc); + if (coarse && this.view?.book?.dir !== "rtl") this.showSelectionHandles(doc); }, renderAllHighlights() { for (const hl of this.highlightItems) { @@ -2393,12 +2312,7 @@ document.addEventListener("alpine:init", () => { if (cfi.startsWith("{")) { try { const a = JSON.parse(cfi); - if ( - a && - typeof a.page === "number" && - Array.isArray(a.rects) && - a.rects.length - ) { + if (a && typeof a.page === "number" && Array.isArray(a.rects) && a.rects.length) { pdfPage = a.page; pdfRects = a.rects; cfi = ""; @@ -2445,14 +2359,11 @@ document.addEventListener("alpine:init", () => { toRangeCfi(start: string, end: string, text: string): string { if (!start) return end || start; if (start.includes(",")) return start; // already a range CFI - const spineRe = - /^(epubcfi\([^!]*!)((?:\/\d+(?:\[[^\]]*\])?)+)(?::(\d+))?\)$/; + const spineRe = /^(epubcfi\([^!]*!)((?:\/\d+(?:\[[^\]]*\])?)+)(?::(\d+))?\)$/; const parseCfi = (cfi: string) => { const m = spineRe.exec(cfi); if (!m) return null; - const steps = (m[2].match(/\/\d+(?:\[[^\]]*\])?/g) ?? []).map( - (s) => s, - ); + const steps = (m[2].match(/\/\d+(?:\[[^\]]*\])?/g) ?? []).map((s) => s); return { spine: m[1], steps, off: m[3] ? parseInt(m[3], 10) : -1 }; }; const s = parseCfi(start); @@ -2493,8 +2404,7 @@ document.addEventListener("alpine:init", () => { common === e.steps.length - 1 && s.steps[common] === e.steps[common] ) { - const degenerate = - e.off === 0 || (s.off >= 0 && e.off >= 0 && e.off < s.off); + const degenerate = e.off === 0 || (s.off >= 0 && e.off >= 0 && e.off < s.off); if (degenerate) { const adv = advanceEnd(); if (adv) return adv; @@ -2510,10 +2420,7 @@ document.addEventListener("alpine:init", () => { } } const parent = s.spine + s.steps.slice(0, common).join(""); - return `${parent},${rem(s.steps.slice(common), s.off)},${rem( - e.steps.slice(common), - e.off, - )})`; + return `${parent},${rem(s.steps.slice(common), s.off)},${rem(e.steps.slice(common), e.off)})`; }, async refreshAnnotations() { const token = getToken(); @@ -2533,10 +2440,7 @@ document.addEventListener("alpine:init", () => { .map((r) => this.mapHighlightRow(r)) // Comic-treated items show no highlights (bookmarks only), // and previously-created PDF highlights stop rendering. - .filter( - (hl: any) => - !this.treatAsComic && (hl.cfi || hl.pdfPage >= 0), - ); + .filter((hl: any) => !this.treatAsComic && (hl.cfi || hl.pdfPage >= 0)); this.renderAllHighlights(); } if (noteResp.ok) { @@ -2567,30 +2471,25 @@ document.addEventListener("alpine:init", () => { return; } const pdfAnchor = - p.pdfPage >= 0 - ? JSON.stringify({ v: 1, page: p.pdfPage, rects: p.pdfRects }) - : ""; + p.pdfPage >= 0 ? JSON.stringify({ v: 1, page: p.pdfPage, rects: p.pdfRects }) : ""; try { - const resp = await fetch( - `/api/media-items/${this.mediaItemId}/highlights`, - { - method: "POST", - headers: { - Authorization: `Bearer ${token}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - selection_text: p.text, - start_position: "", - end_position: "", - epubcfi_start: p.pdfPage >= 0 ? pdfAnchor : p.cfi, - epubcfi_end: p.pdfPage >= 0 ? pdfAnchor : p.cfiEnd, - color, - note_text: note, - percentage_start: this.lastRelocateDetail?.fraction ?? 0, - }), + const resp = await fetch(`/api/media-items/${this.mediaItemId}/highlights`, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", }, - ); + body: JSON.stringify({ + selection_text: p.text, + start_position: "", + end_position: "", + epubcfi_start: p.pdfPage >= 0 ? pdfAnchor : p.cfi, + epubcfi_end: p.pdfPage >= 0 ? pdfAnchor : p.cfiEnd, + color, + note_text: note, + percentage_start: this.lastRelocateDetail?.fraction ?? 0, + }), + }); if (!resp.ok) return; const row = await resp.json(); const hl = this.mapHighlightRow(row); @@ -2610,10 +2509,7 @@ document.addEventListener("alpine:init", () => { } } else { this.view?.addAnnotation({ - value: - p.pdfPage >= 0 - ? "" - : this.toRangeCfi(p.cfi, p.cfiEnd, p.text) || p.cfi, + value: p.pdfPage >= 0 ? "" : this.toRangeCfi(p.cfi, p.cfiEnd, p.text) || p.cfi, color, note, id: row.id, @@ -2643,29 +2539,24 @@ document.addEventListener("alpine:init", () => { return; } const anchor = - p.pdfPage >= 0 - ? JSON.stringify({ v: 1, page: p.pdfPage, rects: p.pdfRects }) - : p.cfi; + p.pdfPage >= 0 ? JSON.stringify({ v: 1, page: p.pdfPage, rects: p.pdfRects }) : p.cfi; try { - const resp = await fetch( - `/api/media-items/${this.mediaItemId}/highlights/${p.id}`, - { - method: "PUT", - headers: { - Authorization: `Bearer ${token}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - selection_text: p.text, - start_position: "", - end_position: "", - epubcfi_start: anchor, - epubcfi_end: p.pdfPage >= 0 ? anchor : p.cfiEnd, - color: p.color, - note_text: p.note, - }), + const resp = await fetch(`/api/media-items/${this.mediaItemId}/highlights/${p.id}`, { + method: "PUT", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", }, - ); + body: JSON.stringify({ + selection_text: p.text, + start_position: "", + end_position: "", + epubcfi_start: anchor, + epubcfi_end: p.pdfPage >= 0 ? anchor : p.cfiEnd, + color: p.color, + note_text: p.note, + }), + }); if (!resp.ok) return; const row = await resp.json(); const idx = this.highlightItems.findIndex((h) => h.id === p.id); @@ -2673,16 +2564,10 @@ document.addEventListener("alpine:init", () => { // change it (note/text edits change the synthesized range), so // remove the old paint before re-adding or it ghosts. const oldValue = - idx !== -1 - ? this.highlightItems[idx].renderCfi || - this.highlightItems[idx].cfi - : ""; + idx !== -1 ? this.highlightItems[idx].renderCfi || this.highlightItems[idx].cfi : ""; if (idx !== -1) this.highlightItems[idx] = this.mapHighlightRow(row); const newValue = - idx !== -1 - ? this.highlightItems[idx].renderCfi || - this.highlightItems[idx].cfi - : ""; + idx !== -1 ? this.highlightItems[idx].renderCfi || this.highlightItems[idx].cfi : ""; if (p.pdfPage < 0 && oldValue && oldValue !== newValue) { this.view?.deleteAnnotation({ value: oldValue }); } @@ -2714,10 +2599,10 @@ document.addEventListener("alpine:init", () => { if (!token || !this.mediaItemId) return; const hl = this.highlightItems.find((h) => h.id === id); try { - const resp = await fetch( - `/api/media-items/${this.mediaItemId}/highlights/${id}`, - { method: "DELETE", headers: { Authorization: `Bearer ${token}` } }, - ); + const resp = await fetch(`/api/media-items/${this.mediaItemId}/highlights/${id}`, { + method: "DELETE", + headers: { Authorization: `Bearer ${token}` }, + }); if (!resp.ok && resp.status !== 204) return; this.highlightItems = this.highlightItems.filter((h) => h.id !== id); if (hl?.pdfPage >= 0) { @@ -2859,10 +2744,8 @@ document.addEventListener("alpine:init", () => { return; } const range = sel.getRangeAt(0); - const startPos = this.caretHostPos(doc, range.startContainer, - range.startOffset, true); - const endPos = this.caretHostPos(doc, range.endContainer, - range.endOffset, false); + const startPos = this.caretHostPos(doc, range.startContainer, range.startOffset, true); + const endPos = this.caretHostPos(doc, range.endContainer, range.endOffset, false); if (!startPos || !endPos) { this.hideSelectionHandles(); return; @@ -2885,8 +2768,7 @@ document.addEventListener("alpine:init", () => { const right = r.start; const x = Math.min(Math.max(ev.clientX - off.left, left + 2), right - 2); const y = ev.clientY - off.top; - const caret = doc.caretRangeFromPoint?.(x, y) - ?? doc.caretPositionFromPoint?.(x, y); + const caret = doc.caretRangeFromPoint?.(x, y) ?? doc.caretPositionFromPoint?.(x, y); if (!caret) return; const node = caret.startContainer ?? caret.offsetNode; const offset = caret.startOffset ?? caret.offset; @@ -2895,16 +2777,10 @@ document.addEventListener("alpine:init", () => { const range = sel.getRangeAt(0); if (h.drag === "start") sel.setBaseAndExtent(node, offset, range.endContainer, range.endOffset); - else - sel.setBaseAndExtent(range.startContainer, range.startOffset, - node, offset); + else sel.setBaseAndExtent(range.startContainer, range.startOffset, node, offset); this.positionSelectionHandles(doc); }, - goToHighlight(hl: { - cfi: string; - renderCfi: string; - pdfPage: number; - }) { + goToHighlight(hl: { cfi: string; renderCfi: string; pdfPage: number }) { if (hl.pdfPage >= 0) { // Fixed-layout: a bare number navigates to the section (page) index. this.pushBackStack(); @@ -2912,9 +2788,7 @@ document.addEventListener("alpine:init", () => { this.closeDrawers(); } else if (hl.renderCfi || hl.cfi) { this.pushBackStack(); - this.view - ?.showAnnotation({ value: hl.renderCfi || hl.cfi }) - ?.catch?.(() => {}); + this.view?.showAnnotation({ value: hl.renderCfi || hl.cfi })?.catch?.(() => {}); this.closeDrawers(); } }, @@ -2950,22 +2824,19 @@ document.addEventListener("alpine:init", () => { return; } try { - const resp = await fetch( - `/api/media-items/${this.mediaItemId}/notes`, - { - method: "POST", - headers: { - Authorization: `Bearer ${token}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - content, - position: !this.isFixedLayout - ? `cfi:${this.view?.lastLocation?.cfi ?? ""}` - : `page:${(this.renderer?.index ?? 0) + 1}`, - }), + const resp = await fetch(`/api/media-items/${this.mediaItemId}/notes`, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", }, - ); + body: JSON.stringify({ + content, + position: !this.isFixedLayout + ? `cfi:${this.view?.lastLocation?.cfi ?? ""}` + : `page:${(this.renderer?.index ?? 0) + 1}`, + }), + }); if (!resp.ok) return; await this.refreshAnnotations(); } catch (_e) { @@ -2977,55 +2848,52 @@ document.addEventListener("alpine:init", () => { * the server hard-sets it, so an omitted position would strip the * jump anchor. */ startNoteEdit(note: any) { - note.editText = note.content ?? ""; - note.editOpen = true; + note.editText = note.content ?? ""; + note.editOpen = true; }, cancelNoteEdit(note: any) { - note.editOpen = false; - note.editText = ""; + note.editOpen = false; + note.editText = ""; }, async saveNoteEdit(note: any) { - const token = getToken(); - if (!token || !this.mediaItemId) return; - const content = String(note.editText ?? "").trim(); - if (!content) return; - try { - const resp = await fetch( - `/api/media-items/${this.mediaItemId}/notes/${note.id}`, - { - method: "PUT", - headers: { - Authorization: `Bearer ${token}`, - "Content-Type": "application/json", - }, - // The stored position MUST ride along: the server's PUT - // hard-sets the position column (an omitted position - // would strip the jump anchor). - body: JSON.stringify({ - content, - position: note.positionLabel || note.content && note.position || "", - }), - }, - ); - if (!resp.ok) return; - const row: any = await resp.json(); - note.content = row.content ?? content; - note.contentHtml = renderNoteHtml(note.content); - note.positionLabel = row.position ?? note.positionLabel; - note.editOpen = false; - note.editText = ""; - } catch (_e) { - /* note errors are non-critical */ - } + const token = getToken(); + if (!token || !this.mediaItemId) return; + const content = String(note.editText ?? "").trim(); + if (!content) return; + try { + const resp = await fetch(`/api/media-items/${this.mediaItemId}/notes/${note.id}`, { + method: "PUT", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + // The stored position MUST ride along: the server's PUT + // hard-sets the position column (an omitted position + // would strip the jump anchor). + body: JSON.stringify({ + content, + position: note.positionLabel || (note.content && note.position) || "", + }), + }); + if (!resp.ok) return; + const row: any = await resp.json(); + note.content = row.content ?? content; + note.contentHtml = renderNoteHtml(note.content); + note.positionLabel = row.position ?? note.positionLabel; + note.editOpen = false; + note.editText = ""; + } catch (_e) { + /* note errors are non-critical */ + } }, async deleteNoteById(id: string) { const token = getToken(); if (!token || !this.mediaItemId) return; try { - const resp = await fetch( - `/api/media-items/${this.mediaItemId}/notes/${id}`, - { method: "DELETE", headers: { Authorization: `Bearer ${token}` } }, - ); + const resp = await fetch(`/api/media-items/${this.mediaItemId}/notes/${id}`, { + method: "DELETE", + headers: { Authorization: `Bearer ${token}` }, + }); if (resp.ok || resp.status === 204) { this.noteItems = this.noteItems.filter((n) => n.id !== id); } @@ -3045,13 +2913,10 @@ document.addEventListener("alpine:init", () => { const token = getToken(); if (!token || !this.mediaItemId) return {}; try { - const resp = await fetch( - `/api/media-items/${this.mediaItemId}/progress`, - { - headers: { Authorization: `Bearer ${token}` }, - cache: "no-store", - }, - ); + const resp = await fetch(`/api/media-items/${this.mediaItemId}/progress`, { + headers: { Authorization: `Bearer ${token}` }, + cache: "no-store", + }); if (!resp.ok) return {}; const row: any = await resp.json(); const cfi: string = row?.epubcfi?.String ?? row?.epubcfi ?? ""; @@ -3114,11 +2979,10 @@ document.addEventListener("alpine:init", () => { percentage: fraction, current_page: this.isFixedLayout ? (this.renderer?.index ?? 0) + 1 - : location?.current ?? 0, + : (location?.current ?? 0), total_pages: this.isFixedLayout - ? this.book?.sections?.filter((s: any) => s.linear !== "no") - ?.length ?? 0 - : location?.total ?? 0, + ? (this.book?.sections?.filter((s: any) => s.linear !== "no")?.length ?? 0) + : (location?.total ?? 0), reading_mode: this.readingMode || undefined, }; if (!this.isFixedLayout) { @@ -3202,10 +3066,7 @@ document.addEventListener("alpine:init", () => { applyFxFilter(save = false) { const viewport = document.getElementById("reader-viewport"); if (!viewport) return; - const parts = [ - `brightness(${this.fxBrightness})`, - `contrast(${this.fxContrast})`, - ]; + const parts = [`brightness(${this.fxBrightness})`, `contrast(${this.fxContrast})`]; if (this.fxInvert) parts.push("invert(1)"); viewport.style.setProperty("--fx-filter", parts.join(" ")); if (save) { @@ -3238,10 +3099,7 @@ document.addEventListener("alpine:init", () => { }, applyDoublePageSpread() { if (!this.isFixedLayout || !this.renderer) return; - this.renderer.setAttribute( - "spread", - this.doublePageSpread ? "auto" : "none", - ); + this.renderer.setAttribute("spread", this.doublePageSpread ? "auto" : "none"); saveSettings({ double_page_spread: this.doublePageSpread }); }, goLeft() { @@ -3289,9 +3147,7 @@ document.addEventListener("alpine:init", () => { this.closeDrawers(); this.searchOpen = opening; if (opening) { - (this as any).$nextTick(() => - (this as any).$refs.searchInput?.focus(), - ); + (this as any).$nextTick(() => (this as any).$refs.searchInput?.focus()); } }, toggleTools() { @@ -3320,9 +3176,9 @@ document.addEventListener("alpine:init", () => { this._applyFullscreenLayout(true); const root = document.documentElement as any; if (typeof root.requestFullscreen === "function") { - root - .requestFullscreen({ navigationUI: "hide" }) - .catch(() => { /* denied or interrupted; the visual mode still holds */ }); + root.requestFullscreen({ navigationUI: "hide" }).catch(() => { + /* denied or interrupted; the visual mode still holds */ + }); } else if (typeof root.webkitRequestFullscreen === "function") { root.webkitRequestFullscreen(); } @@ -3333,11 +3189,10 @@ document.addEventListener("alpine:init", () => { this._applyFullscreenLayout(false); const doc = document as any; if (doc.fullscreenElement && typeof doc.exitFullscreen === "function") { - doc.exitFullscreen().catch(() => { /* ignore */ }); - } else if ( - doc.webkitFullscreenElement && - typeof doc.webkitExitFullscreen === "function" - ) { + doc.exitFullscreen().catch(() => { + /* ignore */ + }); + } else if (doc.webkitFullscreenElement && typeof doc.webkitExitFullscreen === "function") { doc.webkitExitFullscreen(); } }, @@ -3369,9 +3224,7 @@ document.addEventListener("alpine:init", () => { /* keep 0 */ } } - const comfortable = Math.round( - Math.min(window.innerWidth, window.innerHeight) * 0.06, - ); + const comfortable = Math.round(Math.min(window.innerWidth, window.innerHeight) * 0.06); const margin = Math.round(Math.max(insets, comfortable, 48)); renderer.setAttribute("margin", `${margin}px`); } @@ -3384,8 +3237,7 @@ document.addEventListener("alpine:init", () => { setupFullscreen() { const doc = document as any; const handler = () => { - const realActive = - !!doc.fullscreenElement || !!doc.webkitFullscreenElement; + const realActive = !!doc.fullscreenElement || !!doc.webkitFullscreenElement; if (realActive) { this._hadRealFullscreen = true; // Insets only settle after the fullscreen relayout (Android @@ -3480,9 +3332,7 @@ document.addEventListener("alpine:init", () => { // proxy wraps the PDFDocumentProxy too. pdf.js uses #private fields, // so calling getPage() on the proxy throws "cannot read private // member", which used to be swallowed as an empty result set. - const rawBook = (Alpine as any).raw - ? (Alpine as any).raw(this.book) - : this.book; + const rawBook = (Alpine as any).raw ? (Alpine as any).raw(this.book) : this.book; const pdf = rawBook?.pdf; if (!pdf) { this.searchError = "PDF text engine unavailable"; @@ -3617,19 +3467,14 @@ document.addEventListener("alpine:init", () => { for (const entry of entries) { if (!entry.isIntersecting) continue; io.unobserve(entry.target); - const page = parseInt( - (entry.target as HTMLElement).dataset.page ?? "", - 10, - ); + const page = parseInt((entry.target as HTMLElement).dataset.page ?? "", 10); if (!isNaN(page)) this.renderPageThumb(page, entry.target as HTMLElement); } }, { root: root instanceof Element ? root : null, rootMargin: "200px" }, ); this.thumbObserver = io; - for (const el of Array.from( - grid.querySelectorAll(".reader-thumb-img"), - )) { + for (const el of Array.from(grid.querySelectorAll(".reader-thumb-img"))) { io.observe(el); } this.scrollToCurrentThumb(); @@ -3639,9 +3484,7 @@ document.addEventListener("alpine:init", () => { const grid = document.getElementById("page-thumb-grid"); if (grid && this.thumbObserver) { for (const el of Array.from( - grid.querySelectorAll( - ".reader-thumb-img:not([data-done])", - ), + grid.querySelectorAll(".reader-thumb-img:not([data-done])"), )) { this.thumbObserver.observe(el); } @@ -3649,9 +3492,7 @@ document.addEventListener("alpine:init", () => { this.scrollToCurrentThumb(); }, scrollToCurrentThumb() { - const cur = document.querySelector( - "#page-thumb-grid .reader-thumb.active", - ); + const cur = document.querySelector("#page-thumb-grid .reader-thumb.active"); cur?.scrollIntoView({ block: "center" }); }, async renderPageThumb(page: number, container: HTMLElement) { @@ -3663,9 +3504,7 @@ document.addEventListener("alpine:init", () => { container.appendChild(cached); return; } - const rawBook = (Alpine as any).raw - ? (Alpine as any).raw(this.book) - : this.book; + const rawBook = (Alpine as any).raw ? (Alpine as any).raw(this.book) : this.book; if (this.isPDF && rawBook?.pdf) { const pdfPage = await rawBook.pdf.getPage(page + 1); const base = pdfPage.getViewport({ scale: 1 }); @@ -3736,8 +3575,7 @@ document.addEventListener("alpine:init", () => { applyTheme() { const viewport = document.getElementById("reader-viewport")!; const themeSet = THEME_COLORS[this.readingTheme] ?? THEME_COLORS.light; - const themeColors = - this.readingMode === "dark" ? themeSet.dark : themeSet.light; + const themeColors = this.readingMode === "dark" ? themeSet.dark : themeSet.light; viewport.style.backgroundColor = themeColors.bg; this.applyStyles(); saveSettings({ @@ -3808,10 +3646,9 @@ document.addEventListener("alpine:init", () => { const token = getToken(); if (!token || !this.mediaItemId) return; try { - const resp = await fetch( - `/api/media-items/${this.mediaItemId}/bookmarks`, - { headers: { Authorization: `Bearer ${token}` } }, - ); + 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) => ({ @@ -3819,10 +3656,7 @@ document.addEventListener("alpine:init", () => { 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, + page: r.page_number != null ? (r.page_number?.Int32 ?? r.page_number) : null, })); } catch (_e) { /* leave the existing list on fetch failure */ @@ -3835,48 +3669,32 @@ document.addEventListener("alpine:init", () => { if (!location) return; const cfi = (!this.isFixedLayout && location.cfi) || ""; - const page = this.isFixedLayout - ? (this.renderer?.index ?? 0) + 1 - : 0; + const page = this.isFixedLayout ? (this.renderer?.index ?? 0) + 1 : 0; // Auto-label mirrors KOReader's convention ("in ") // so every client names unnamed bookmarks identically; falls back // to a plain "Bookmark" for fixed-layout or TOC-less books. - const chapter = this.lastRelocateDetail?.tocItem?.label as - | string - | undefined; - const title = - !this.isFixedLayout && chapter ? `in ${chapter}` : "Bookmark"; + const chapter = this.lastRelocateDetail?.tocItem?.label as string | undefined; + const title = !this.isFixedLayout && chapter ? `in ${chapter}` : "Bookmark"; try { - const resp = await fetch( - `/api/media-items/${this.mediaItemId}/bookmarks`, - { - method: "POST", - headers: { - Authorization: `Bearer ${token}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - title, - position: this.isFixedLayout - ? `page:${page}` - : cfi - ? `cfi:${cfi}` - : "", - cfi_position: cfi, - page_number: page, - chapter_number: this.chapterNumberForProgress() || 0, - percentage: location.fraction ?? 0, - }), + const resp = await fetch(`/api/media-items/${this.mediaItemId}/bookmarks`, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", }, - ); + body: JSON.stringify({ + title, + position: this.isFixedLayout ? `page:${page}` : cfi ? `cfi:${cfi}` : "", + cfi_position: cfi, + page_number: page, + chapter_number: this.chapterNumberForProgress() || 0, + percentage: location.fraction ?? 0, + }), + }); if (resp.ok) { await this.refreshBookmarks(); - showToast( - `Bookmark added — ${this.progressText || "current position"}`, - "success", - 2500, - ); + showToast(`Bookmark added — ${this.progressText || "current position"}`, "success", 2500); } } catch (_e) { /* network failures surface via the toast fetch interceptor */ @@ -3886,10 +3704,10 @@ document.addEventListener("alpine:init", () => { 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}` } }, - ); + 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); } @@ -3906,21 +3724,18 @@ document.addEventListener("alpine:init", () => { const title = (bookmark.renameText ?? "").trim(); if (!token || !this.mediaItemId || !title) return; try { - const resp = await fetch( - `/api/media-items/${this.mediaItemId}/bookmarks/${bookmark.id}`, - { - method: "PUT", - headers: { - Authorization: `Bearer ${token}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - title, - notes: "", - position: bookmark.positionLabel || "", - }), + const resp = await fetch(`/api/media-items/${this.mediaItemId}/bookmarks/${bookmark.id}`, { + method: "PUT", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", }, - ); + body: JSON.stringify({ + title, + notes: "", + position: bookmark.positionLabel || "", + }), + }); if (resp.ok) { bookmark.title = title; } @@ -3932,9 +3747,7 @@ document.addEventListener("alpine:init", () => { chapterNumberForProgress(): number { const tocItem = this.lastRelocateDetail?.tocItem; if (!tocItem?.label) return 0; - const idx = this.chapterBoundaries.findIndex( - (b: any) => b.label === tocItem.label, - ); + const idx = this.chapterBoundaries.findIndex((b: any) => b.label === tocItem.label); return idx === -1 ? 0 : idx; }, formatProgressParts( @@ -3944,30 +3757,21 @@ document.addEventListener("alpine:init", () => { tocItem: FoliateTocItem | null, section: { current: number; total: number }, ): { label: string; main: string } { - const percent = new Intl.NumberFormat("en", { style: "percent" }).format( - fraction, - ); + const percent = new Intl.NumberFormat("en", { style: "percent" }).format(fraction); const chapterLabel = tocItem?.label ? `${tocItem.label} · ` : ""; switch (this.progressMode) { case "percentage": return { label: "", main: percent }; case "chapter": { if (this.isFixedLayout && tocItem && this.chapterBoundaries.length > 0) { - const totalSections = this.book?.sections?.filter( - (s: any) => s.linear !== "no", - )?.length ?? 0; + const totalSections = + this.book?.sections?.filter((s: any) => s.linear !== "no")?.length ?? 0; const currentPage = this.renderer?.index ?? 0; - const boundaryIdx = this.chapterBoundaries.findIndex( - (b) => b.id === tocItem.id, - ); + const boundaryIdx = this.chapterBoundaries.findIndex((b) => b.id === tocItem.id); if (boundaryIdx !== -1 && totalSections > 0) { - const chapterStart = - this.chapterBoundaries[boundaryIdx].startPage; - const nextBoundary = - this.chapterBoundaries[boundaryIdx + 1]; - const chapterEnd = nextBoundary - ? nextBoundary.startPage - : totalSections; + const chapterStart = this.chapterBoundaries[boundaryIdx].startPage; + const nextBoundary = this.chapterBoundaries[boundaryIdx + 1]; + const chapterEnd = nextBoundary ? nextBoundary.startPage : totalSections; const chapterPages = Math.max(1, chapterEnd - chapterStart); const currentInChapter = Math.max( 1, @@ -3980,15 +3784,11 @@ document.addEventListener("alpine:init", () => { } } if (tocItem && this.chapterBoundaries.length > 0) { - const boundaryIdx = this.chapterBoundaries.findIndex( - (b) => b.id === tocItem.id, - ); + const boundaryIdx = this.chapterBoundaries.findIndex((b) => b.id === tocItem.id); if (boundaryIdx !== -1) { const currentPage = this.renderer?.page ?? 1; - const chapterStart = - this.chapterBoundaries[boundaryIdx].startPage; - const nextBoundary = - this.chapterBoundaries[boundaryIdx + 1]; + const chapterStart = this.chapterBoundaries[boundaryIdx].startPage; + const nextBoundary = this.chapterBoundaries[boundaryIdx + 1]; const totalPages = this.renderer?.pages ?? 0; const chapterEnd = nextBoundary ? nextBoundary.startPage @@ -4014,14 +3814,8 @@ document.addEventListener("alpine:init", () => { const endFrac = this.sectionFractionsArr[idx + 1] ?? 1; const sectionFrac = endFrac - startFrac; if (sectionFrac > 0 && pageBase > 0) { - const totalInSec = Math.max( - 1, - Math.round(sectionFrac * pageBase), - ); - const currentInSec = Math.max( - 1, - Math.round((fraction - startFrac) * pageBase), - ); + const totalInSec = Math.max(1, Math.round(sectionFrac * pageBase)); + const currentInSec = Math.max(1, Math.round((fraction - startFrac) * pageBase)); const clamped = Math.min(currentInSec, totalInSec); return { label: chapterLabel, @@ -4077,11 +3871,7 @@ document.addEventListener("alpine:init", () => { // The reflowable and fixed-layout bottom rows each have their own slider // (only one is displayed); helpers below target whichever exists. progressSliders(): HTMLInputElement[] { - const ids = [ - "progress-slider", - "progress-slider-fx", - "progress-slider-fx-compact", - ]; + const ids = ["progress-slider", "progress-slider-fx", "progress-slider-fx-compact"]; return ids .map((id) => document.getElementById(id) as HTMLInputElement | null) .filter((el): el is HTMLInputElement => !!el); @@ -4100,17 +3890,8 @@ document.addEventListener("alpine:init", () => { this.progressMode = modes[(idx + 1) % modes.length]; this.applyProgressMode(); if (this.lastRelocateDetail) { - const { fraction, location, pageItem, tocItem, section } = - this.lastRelocateDetail; - this.setProgress( - this.formatProgressParts( - fraction, - location, - pageItem, - tocItem, - section, - ), - ); + const { fraction, location, pageItem, tocItem, section } = this.lastRelocateDetail; + this.setProgress(this.formatProgressParts(fraction, location, pageItem, tocItem, section)); } }, async fetchReadingSpeed() { @@ -4131,17 +3912,8 @@ document.addEventListener("alpine:init", () => { applyProgressMode() { saveSettings({ progress_mode: this.progressMode as any }); if (this.lastRelocateDetail) { - const { fraction, location, pageItem, tocItem, section } = - this.lastRelocateDetail; - this.setProgress( - this.formatProgressParts( - fraction, - location, - pageItem, - tocItem, - section, - ), - ); + const { fraction, location, pageItem, tocItem, section } = this.lastRelocateDetail; + this.setProgress(this.formatProgressParts(fraction, location, pageItem, tocItem, section)); } }, computeFixedLayoutChapterBoundaries() { @@ -4161,9 +3933,7 @@ document.addEventListener("alpine:init", () => { // skip unresolvable anchors } } - this.chapterBoundaries.sort( - (a, b) => a.startPage - b.startPage, - ); + this.chapterBoundaries.sort((a, b) => a.startPage - b.startPage); }, computeChapterPageBoundaries(doc: Document) { this.chapterBoundaries = []; @@ -4192,10 +3962,7 @@ document.addEventListener("alpine:init", () => { }, getRenderedPageInfo(): { total: number; current: number } | null { if (this.isFixedLayout) { - const total = - this.book?.sections?.filter( - (s: any) => s.linear !== "no", - )?.length ?? 0; + const total = this.book?.sections?.filter((s: any) => s.linear !== "no")?.length ?? 0; if (total === 0) return null; const index = this.renderer?.index ?? 0; return { total, current: index + 1 }; @@ -4214,10 +3981,7 @@ document.addEventListener("alpine:init", () => { "time-left": "Time Left", }; const label = labels[this.progressMode] ?? "Pages"; - if ( - this.progressMode === "chapter" && - this.chapterBoundaries.length === 0 - ) { + if (this.progressMode === "chapter" && this.chapterBoundaries.length === 0) { return `${label} · no chapter data · Click to switch`; } return `${label} · Click to switch`; @@ -4271,12 +4035,10 @@ document.addEventListener("alpine:init", () => { else if (k === "b") this.addBookmark(); else if (k === "f") { // Ctrl/Cmd+F stays the browser's find-in-page. - if (!event.ctrlKey && !event.metaKey && !event.altKey) - this.toggleFullscreen(); + if (!event.ctrlKey && !event.metaKey && !event.altKey) this.toggleFullscreen(); } else if (k === "a") { if (!event.ctrlKey && !event.metaKey && !event.altKey) this.toggleBookmarks(); - } - else if (k === "?") this.toggleHelp(); + } else if (k === "?") this.toggleHelp(); else if (k === "/") { event.preventDefault(); this.toggleSearch(); diff --git a/web/src/reader/settings-manager.ts b/web/src/reader/settings-manager.ts index 97a0f9db..911751c1 100644 --- a/web/src/reader/settings-manager.ts +++ b/web/src/reader/settings-manager.ts @@ -21,9 +21,7 @@ export async function loadSettings(): Promise { return local ? JSON.parse(local) : getDefaultSettings(); } } -export async function saveSettings( - settings: Partial, -): Promise { +export async function saveSettings(settings: Partial): Promise { const token = getToken(); const current = await loadSettings(); const updated = { ...current, ...settings }; diff --git a/web/src/search.ts b/web/src/search.ts index 4719c89f..d285b3d8 100644 --- a/web/src/search.ts +++ b/web/src/search.ts @@ -5,9 +5,7 @@ const SEARCH_DEBOUNCE_MS = 300; const SEARCH_MIN_CHARS = 2; function initializeSearch(): void { - const searchInput = document.getElementById( - "header-search", - ) as HTMLInputElement | null; + const searchInput = document.getElementById("header-search") as HTMLInputElement | null; const mobileSearchInput = document.getElementById( "header-search-mobile", ) as HTMLInputElement | null; @@ -107,11 +105,9 @@ function selectSearchResult(items: NodeListOf, index: number): void { } function getActiveSearchInput(): HTMLInputElement | null { - return ( - document.activeElement?.id === "header-search-mobile" - ? (document.getElementById("header-search-mobile") as HTMLInputElement) - : (document.getElementById("header-search") as HTMLInputElement) - ); + return document.activeElement?.id === "header-search-mobile" + ? (document.getElementById("header-search-mobile") as HTMLInputElement) + : (document.getElementById("header-search") as HTMLInputElement); } function performSearch(query: string): void { @@ -138,27 +134,21 @@ function performSearch(query: string): void { } return response.json(); }) - .then( - ( - data: - | { error?: string; results?: MediaItemSummary[] } - | MediaItemSummary[], - ) => { - hideSearchLoading(); + .then((data: { error?: string; results?: MediaItemSummary[] } | MediaItemSummary[]) => { + hideSearchLoading(); - const resultActiveId = getActiveSearchInput()?.id; + const resultActiveId = getActiveSearchInput()?.id; - if (data && "error" in data && data.error === "no results found") { - showNoResults(query, resultActiveId); - } else if (Array.isArray(data) && data.length > 0) { - showSearchResults(data, query, resultActiveId); - } else if (Array.isArray(data)) { - showNoResults(query, resultActiveId); - } else { - showNoResults(query, resultActiveId); - } - }, - ) + if (data && "error" in data && data.error === "no results found") { + showNoResults(query, resultActiveId); + } else if (Array.isArray(data) && data.length > 0) { + showSearchResults(data, query, resultActiveId); + } else if (Array.isArray(data)) { + showNoResults(query, resultActiveId); + } else { + showNoResults(query, resultActiveId); + } + }) .catch((error) => { hideSearchLoading(); console.error("Search error:", error); @@ -292,9 +282,7 @@ function createSearchResultsContainer(activeId?: string): void { searchResults.style.cssText = "background-color: var(--bg-secondary); border-color: var(--border)"; - const searchInput = document.getElementById( - activeId || "header-search", - ); + const searchInput = document.getElementById(activeId || "header-search"); if (searchInput) { const searchContainer = searchInput.closest(".relative"); if (searchContainer) { diff --git a/web/src/setup.ts b/web/src/setup.ts index dae5b595..58fa23a2 100644 --- a/web/src/setup.ts +++ b/web/src/setup.ts @@ -36,7 +36,8 @@ function initSetup(): void { if (savedTheme && savedTheme !== "tokyo-night") { applyTheme(savedTheme); } -}Alpine.data("setupWizard", () => ({ +} +Alpine.data("setupWizard", () => ({ currentStep: 1 as number, loading: false as boolean, errorMsg: "" as string, @@ -110,7 +111,9 @@ function initSetup(): void { } if ( - (errMsg.includes("already exists") || errMsg.includes("email already") || errMsg.includes("username already")) && + (errMsg.includes("already exists") || + errMsg.includes("email already") || + errMsg.includes("username already")) && getToken() ) { this.currentStep = 2; @@ -140,11 +143,12 @@ function initSetup(): void { async saveBaseUrl() { if (!this.admin.baseUrl) return; try { - await handleVoidResponse( - await apiPut("/system/config", { base_url: this.admin.baseUrl }) - ); + await handleVoidResponse(await apiPut("/system/config", { base_url: this.admin.baseUrl })); } catch { - showToast("Warning: could not save server URL. You can set it later in Admin Settings.", "warning"); + showToast( + "Warning: could not save server URL. You can set it later in Admin Settings.", + "warning", + ); } }, @@ -299,7 +303,8 @@ function initSetup(): void { `; if (data.directories.length === 0) { - html += '

No subdirectories

'; + html += + '

No subdirectories

'; } else { data.directories.forEach((dir) => { const fullPath = data.current_path === "/" ? `/${dir}` : `${data.current_path}/${dir}`; diff --git a/web/src/shims.d.ts b/web/src/shims.d.ts index 646904ed..1fd7bea0 100644 --- a/web/src/shims.d.ts +++ b/web/src/shims.d.ts @@ -12,11 +12,7 @@ interface HtmxAjaxOptions { interface Window { htmx: { - ajax( - method: string, - url: string, - options?: HtmxAjaxOptions, - ): Promise; + ajax(method: string, url: string, options?: HtmxAjaxOptions): Promise; process(element: Element): void; }; } diff --git a/web/src/stats.ts b/web/src/stats.ts index 0fc06aab..a94b4f0a 100644 --- a/web/src/stats.ts +++ b/web/src/stats.ts @@ -137,9 +137,13 @@ let libraryDataLoaded = false; // Page-scoped helpers ----------------------------------------------------- -async function chartCtor(): Promise ChartLike> { +async function chartCtor(): Promise< + new (el: HTMLCanvasElement, cfg: ChartConfiguration) => ChartLike +> { const mod = await import("chart.js/auto"); - return (mod as unknown as { default: new (el: HTMLCanvasElement, cfg: ChartConfiguration) => ChartLike }).default; + return ( + mod as unknown as { default: new (el: HTMLCanvasElement, cfg: ChartConfiguration) => ChartLike } + ).default; } function text(id: string, value: string): void { @@ -302,10 +306,14 @@ function renderSummary(summary: Summary): void { const hasData = summary.total_minutes > 0 || summary.session_count > 0; document.getElementById("stats-empty")?.classList.toggle("hidden", hasData); const windowLabel = document.getElementById("stats-window-label"); - if (windowLabel) windowLabel.textContent = `${summary.start_date} → ${summary.end_date} (${summary.days} days)`; + if (windowLabel) + windowLabel.textContent = `${summary.start_date} → ${summary.end_date} (${summary.days} days)`; const mins = summary.total_minutes; - text("stats-total-minutes", mins >= 60 ? `${Math.floor(mins / 60)} h ${mins % 60} min` : `${mins} min`); + text( + "stats-total-minutes", + mins >= 60 ? `${Math.floor(mins / 60)} h ${mins % 60} min` : `${mins} min`, + ); renderPagesCard(summary); text( "stats-streaks", @@ -315,7 +323,9 @@ function renderSummary(summary: Summary): void { ); text( "stats-books-finished", - summary.books_with_activity > 0 ? `${summary.books_finished} of ${summary.books_with_activity}` : "–", + summary.books_with_activity > 0 + ? `${summary.books_finished} of ${summary.books_with_activity}` + : "–", ); const mostActive = document.getElementById("stats-most-active"); @@ -357,7 +367,11 @@ async function renderDailyCharts(daily: DailyEntry[]): Promise { }, scales: { x: { grid: { display: false }, ticks: { color: secondary, maxTicksLimit: 10 } }, - y: { grid: { color: "rgba(128,128,128,.15)" }, ticks: { color: secondary }, beginAtZero: true }, + y: { + grid: { color: "rgba(128,128,128,.15)" }, + ticks: { color: secondary }, + beginAtZero: true, + }, }, }); @@ -368,7 +382,9 @@ async function renderDailyCharts(daily: DailyEntry[]): Promise { pagesChart = null; listeningChart = null; - const minutesCanvas = document.getElementById("stats-daily-minutes-chart") as HTMLCanvasElement | null; + const minutesCanvas = document.getElementById( + "stats-daily-minutes-chart", + ) as HTMLCanvasElement | null; if (minutesCanvas) { minutesChart = new ChartCtor(minutesCanvas, { type: "bar", @@ -380,7 +396,9 @@ async function renderDailyCharts(daily: DailyEntry[]): Promise { }); } - const pagesCanvas = document.getElementById("stats-daily-pages-chart") as HTMLCanvasElement | null; + const pagesCanvas = document.getElementById( + "stats-daily-pages-chart", + ) as HTMLCanvasElement | null; if (pagesCanvas) { pagesChart = new ChartCtor(pagesCanvas, { type: "line", @@ -404,7 +422,9 @@ async function renderDailyCharts(daily: DailyEntry[]): Promise { // Listening per Day (hrs) — the audio half of the side-by-side pages // split; its panel is hidden by renderPagesCard unless the range has // audio minutes. - const listeningCanvas = document.getElementById("stats-daily-listening-chart") as HTMLCanvasElement | null; + const listeningCanvas = document.getElementById( + "stats-daily-listening-chart", + ) as HTMLCanvasElement | null; if (listeningCanvas) { listeningChart = new ChartCtor(listeningCanvas, { type: "bar", @@ -426,26 +446,32 @@ async function renderDailyCharts(daily: DailyEntry[]): Promise { function renderLibraries(rows: LibraryRow[]): void { const container = document.getElementById("stats-library-breakdown"); if (!container) return; - renderBars(container, rows.map((r) => ({ - label: r.name, - sub: `${fmtMinutes(r.minutes)} · ${r.sessions} sessions`, - value: r.minutes, - }))); + renderBars( + container, + rows.map((r) => ({ + label: r.name, + sub: `${fmtMinutes(r.minutes)} · ${r.sessions} sessions`, + value: r.minutes, + })), + ); } function renderDevices(rows: DeviceRow[]): void { const container = document.getElementById("stats-device-breakdown"); if (!container) return; - renderBars(container, rows.map((r) => ({ - label: r.name || "Web", - sub: `${r.books} ${r.books === 1 ? "book" : "books"} · ${r.sessions} sessions`, - value: r.minutes, - }))); + renderBars( + container, + rows.map((r) => ({ + label: r.name || "Web", + sub: `${r.books} ${r.books === 1 ? "book" : "books"} · ${r.sessions} sessions`, + value: r.minutes, + })), + ); } function fmtMinutes(minutes: number): string { return minutes >= 60 - ? `${Math.floor(minutes / 60)}h${minutes % 60 ? ` ${(minutes % 60)}m` : ""}` + ? `${Math.floor(minutes / 60)}h${minutes % 60 ? ` ${minutes % 60}m` : ""}` : `${minutes} min`; } @@ -522,10 +548,9 @@ async function loadLibraries(): Promise { function fillLibrarySelector(rows: VisibleLibrary[]): void { const select = document.getElementById("stats-library-select") as HTMLSelectElement | null; if (!select) return; - select.innerHTML = `` + - rows - .map((r) => ``) - .join(""); + select.innerHTML = + `` + + rows.map((r) => ``).join(""); // Honor a URL-seeded selection initStatsPage may have set. if (currentLibraryID) select.value = currentLibraryID; } @@ -545,9 +570,15 @@ async function loadLibraryUsage(): Promise { "lib-stat-narrators", ]; targets.forEach((id) => text(id, "–")); - for (const id of ["stats-lib-genres", "stats-lib-authors", "stats-lib-longest", "stats-lib-largest"]) { + for (const id of [ + "stats-lib-genres", + "stats-lib-authors", + "stats-lib-longest", + "stats-lib-largest", + ]) { const el = document.getElementById(id); - if (el) el.innerHTML = `
Loading…
`; + if (el) + el.innerHTML = `
Loading…
`; } try { @@ -598,7 +629,10 @@ function renderLibraryUsage(payload: LibraryUsage): void { } const gb = payload.total_size_bytes / 1e9; - text("lib-stat-size", gb >= 0.1 ? gb.toFixed(1) : (payload.total_size_bytes / 1e6).toFixed(1) + " MB"); + text( + "lib-stat-size", + gb >= 0.1 ? gb.toFixed(1) : (payload.total_size_bytes / 1e6).toFixed(1) + " MB", + ); const itemTotal = Math.max(payload.items, 1); renderBars( @@ -619,7 +653,12 @@ function renderLibraryUsage(payload: LibraryUsage): void { })), "No authors recorded in this pool yet.", ); - renderUsageItems("stats-lib-longest", "stats-lib-longest-label", payload.longest_items, payload.hours_mode); + renderUsageItems( + "stats-lib-longest", + "stats-lib-longest-label", + payload.longest_items, + payload.hours_mode, + ); renderUsageItems("stats-lib-largest", null, payload.largest_items, payload.hours_mode, true); } diff --git a/web/src/storage.ts b/web/src/storage.ts index 45c1f701..5ae0e573 100644 --- a/web/src/storage.ts +++ b/web/src/storage.ts @@ -61,5 +61,5 @@ export { setRefreshToken, setSelectedLibrary, setTheme, - setToken + setToken, }; diff --git a/web/src/theme.ts b/web/src/theme.ts index c08ed192..b2012b8a 100644 --- a/web/src/theme.ts +++ b/web/src/theme.ts @@ -68,9 +68,7 @@ const applyTheme = (theme: string): void => { // Load theme from localStorage or use default const loadTheme = (): void => { - const storedTheme = localStorage.getItem( - THEME_STORAGE_KEY, - ) as ThemeType | null; + const storedTheme = localStorage.getItem(THEME_STORAGE_KEY) as ThemeType | null; const theme = storedTheme || DEFAULT_THEME; applyTheme(theme); }; @@ -145,9 +143,7 @@ const updateThemeIndicators = (): void => { const revealCurrentTheme = (mode: string): void => { const match = document.body.className.match(/theme-([\w-]+)/); const current = match ? match[1] : DEFAULT_THEME; - const btn = document.querySelector( - `.theme-btn[data-theme="${current}"]`, - ); + const btn = document.querySelector(`.theme-btn[data-theme="${current}"]`); if (btn && btn.dataset.mode === mode) { btn.scrollIntoView({ block: "nearest" }); } @@ -160,12 +156,9 @@ const initializeTheme = (): void => { updateThemeIndicators(); // Set theme select value to current theme - const themeSelect = document.getElementById( - "theme-select", - ) as HTMLSelectElement; + const themeSelect = document.getElementById("theme-select") as HTMLSelectElement; if (themeSelect) { - const currentTheme = - localStorage.getItem(THEME_STORAGE_KEY) || DEFAULT_THEME; + const currentTheme = localStorage.getItem(THEME_STORAGE_KEY) || DEFAULT_THEME; themeSelect.value = currentTheme; } @@ -205,11 +198,7 @@ const applyWoodPaneling = (paneling: WoodPanelingType): void => { if (!container || !body) return; // Remove all wood background classes from container - container.classList.remove( - "bg-wood-light", - "bg-wood-dark", - "bg-wood-mahogany", - ); + container.classList.remove("bg-wood-light", "bg-wood-dark", "bg-wood-mahogany"); container.removeAttribute("data-wood"); // Remove from body @@ -231,9 +220,7 @@ const applyWoodPaneling = (paneling: WoodPanelingType): void => { // Load wood paneling from localStorage on page load const loadWoodPaneling = (): void => { - const stored = localStorage.getItem( - WOOD_STORAGE_KEY, - ) as WoodPanelingType | null; + const stored = localStorage.getItem(WOOD_STORAGE_KEY) as WoodPanelingType | null; if (stored) { applyWoodPaneling(stored); } else { diff --git a/web/src/toast.ts b/web/src/toast.ts index 1a8a7349..37f85526 100644 --- a/web/src/toast.ts +++ b/web/src/toast.ts @@ -11,8 +11,7 @@ const createToastContainer = (): HTMLElement => { if (!container) { container = document.createElement("div"); container.id = "toast-container"; - container.className = - "fixed top-5 right-5 z-[9999] flex flex-col gap-2.5 pointer-events-none"; + container.className = "fixed top-5 right-5 z-[9999] flex flex-col gap-2.5 pointer-events-none"; document.body.appendChild(container); } return container; @@ -141,11 +140,7 @@ const setupHTMXListeners = (): void => { if (customEvent.detail.xhr) { const xhr = customEvent.detail.xhr; - if ( - customEvent.detail.succeeded === false && - xhr.status >= 400 && - xhr.status < 600 - ) { + if (customEvent.detail.succeeded === false && xhr.status >= 400 && xhr.status < 600) { const errorMessage = parseXHRError(xhr); showToast(errorMessage, "error"); } else if (xhr.status >= 200 && xhr.status < 300) { @@ -188,7 +183,10 @@ function isSuggestionFetch(input: Parameters[0]): boolean { const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; // The builder's live-count endpoint is a background hint too — a // failed count must never interrupt with a toast. - if (!url.includes("/api/media-items/search?") && !url.includes("/api/media-items/filter-count?")) { + if ( + !url.includes("/api/media-items/search?") && + !url.includes("/api/media-items/filter-count?") + ) { return false; } if (url.includes("/api/media-items/filter-count?")) return true; @@ -198,9 +196,7 @@ function isSuggestionFetch(input: Parameters[0]): boolean { // Setup fetch interceptor const setupFetchInterceptor = (): void => { const originalFetch = window.fetch; - window.fetch = async ( - ...args: Parameters - ): Promise => { + window.fetch = async (...args: Parameters): Promise => { try { const response = await originalFetch(...args); @@ -233,9 +229,7 @@ const setupFetchInterceptor = (): void => { if (!response.ok) { const errorMessage = await parseFetchError(response); if (isSuggestionFetch(args[0])) { - console.warn( - `Suggestion fetch failed (${response.status}): ${errorMessage}`, - ); + console.warn(`Suggestion fetch failed (${response.status}): ${errorMessage}`); } else { showToast(errorMessage, "error"); } diff --git a/web/src/types/api.d.ts b/web/src/types/api.d.ts index d5edd339..bd40c9a8 100644 --- a/web/src/types/api.d.ts +++ b/web/src/types/api.d.ts @@ -59,8 +59,8 @@ interface MediaItemSummary { library_type_name: string; // NEW: Reading direction fields for manga/comics - manga_type?: 'unknown' | 'no' | 'yes' | 'yes_and_right_to_left'; - reading_direction?: 'auto' | 'ltr' | 'rtl' | 'vertical'; + manga_type?: "unknown" | "no" | "yes" | "yes_and_right_to_left"; + reading_direction?: "auto" | "ltr" | "rtl" | "vertical"; // NEW: Universal metadata fields (apply to ebooks, audiobooks, comics) series_count?: number; @@ -74,7 +74,11 @@ interface MediaItemSummary { // NEW: Comic-specific fields story_arc?: string; is_black_and_white?: boolean; - alternate_info?: { alternate_series?: string; alternate_number?: number; alternate_count?: number }; + alternate_info?: { + alternate_series?: string; + alternate_number?: number; + alternate_count?: number; + }; scan_information?: string; summary?: string; } @@ -162,14 +166,7 @@ interface PotentialMatchData { // Used in: collection_rules.ts interface CollectionRule { id: string; - field: - | "genre" - | "series" - | "author" - | "language" - | "publisher" - | "copyright_year" - | "tags"; + field: "genre" | "series" | "author" | "language" | "publisher" | "copyright_year" | "tags"; operator: | "equals" | "not_equals" diff --git a/web/src/types/reader.d.ts b/web/src/types/reader.d.ts index 5500d11d..4d0abab2 100644 --- a/web/src/types/reader.d.ts +++ b/web/src/types/reader.d.ts @@ -51,15 +51,7 @@ interface TOCNode { // Parser Types (Procedural, not OOP) // ============================================================ -type ParserFormat = - | "epub" - | "fb2" - | "txt" - | "html" - | "mobi" - | "azw3" - | "docx" - | "rtf"; +type ParserFormat = "epub" | "fb2" | "txt" | "html" | "mobi" | "azw3" | "docx" | "rtf"; interface ParserCapabilities { canParse(mimeType: string, extension: string): boolean; diff --git a/web/src/unlinked_books.ts b/web/src/unlinked_books.ts index d127cc34..22588bf6 100644 --- a/web/src/unlinked_books.ts +++ b/web/src/unlinked_books.ts @@ -4,11 +4,7 @@ import { getToken } from "./storage"; let selectedMediaItem: string | null = null; -function searchMatches( - progressId: string, - sha256: string, - title: string, -): void { +function searchMatches(progressId: string, sha256: string, title: string): void { const container = document.getElementById(`matches-${progressId}`); const matchesList = document.getElementById(`matches-list-${progressId}`); @@ -38,18 +34,8 @@ function searchMatches( }); } -function autoLinkBook( - progressId: string, - mediaItemId: string, - confidence: number, -): void { - if ( - !confirm( - "Link this book? The confidence score is " + - Math.round(confidence * 100) + - "%", - ) - ) { +function autoLinkBook(progressId: string, mediaItemId: string, confidence: number): void { + if (!confirm("Link this book? The confidence score is " + Math.round(confidence * 100) + "%")) { return; } @@ -88,12 +74,8 @@ function autoLinkBook( function showManualLinkModal(progressId: string, bookTitle: string): void { const modal = document.getElementById("manual-link-modal"); - const progressIdInput = document.getElementById( - "link-progress-id", - ) as HTMLInputElement; - const bookTitleInput = document.getElementById( - "link-book-title", - ) as HTMLInputElement; + const progressIdInput = document.getElementById("link-progress-id") as HTMLInputElement; + const bookTitleInput = document.getElementById("link-book-title") as HTMLInputElement; const searchResults = document.getElementById("link-search-results"); if (modal) modal.classList.remove("hidden"); @@ -107,9 +89,7 @@ function showManualLinkModal(progressId: string, bookTitle: string): void { function hideManualLinkModal(): void { const modal = document.getElementById("manual-link-modal"); - const searchInput = document.getElementById( - "link-search-input", - ) as HTMLInputElement; + const searchInput = document.getElementById("link-search-input") as HTMLInputElement; if (modal) modal.classList.add("hidden"); if (searchInput) searchInput.value = ""; @@ -117,9 +97,7 @@ function hideManualLinkModal(): void { } function searchBooksForLink(): void { - const searchInput = document.getElementById( - "link-search-input", - ) as HTMLInputElement; + const searchInput = document.getElementById("link-search-input") as HTMLInputElement; const resultsContainer = document.getElementById("link-search-results"); if (!searchInput || !resultsContainer) return; @@ -195,18 +173,10 @@ function confirmManualLink(): void { return; } - const progressIdInput = document.getElementById( - "link-progress-id", - ) as HTMLInputElement; - const confidenceInput = document.getElementById( - "link-confidence", - ) as HTMLInputElement; - const bookTitleInput = document.getElementById( - "link-book-title", - ) as HTMLInputElement; - const sha256Input = document.getElementById( - "link-book-sha256", - ) as HTMLInputElement; + const progressIdInput = document.getElementById("link-progress-id") as HTMLInputElement; + const confidenceInput = document.getElementById("link-confidence") as HTMLInputElement; + const bookTitleInput = document.getElementById("link-book-title") as HTMLInputElement; + const sha256Input = document.getElementById("link-book-sha256") as HTMLInputElement; if (!progressIdInput) return; @@ -245,9 +215,7 @@ function confirmManualLink(): void { } function toggleAllUnlinked(): void { - const selectAll = document.getElementById( - "select-all-unlinked", - ) as HTMLInputElement; + const selectAll = document.getElementById("select-all-unlinked") as HTMLInputElement; if (!selectAll) return; document.querySelectorAll(".unlinked-checkbox").forEach((cb) => { @@ -257,9 +225,7 @@ function toggleAllUnlinked(): void { } function getSelectedUnlinked(): { progressId: string; title: string }[] { - return Array.from( - document.querySelectorAll(".unlinked-checkbox:checked"), - ).map((cb) => ({ + return Array.from(document.querySelectorAll(".unlinked-checkbox:checked")).map((cb) => ({ progressId: cb.getAttribute("data-progress-id") || "", title: cb.getAttribute("data-title") || "", })); @@ -280,11 +246,7 @@ async function bulkAutoLink(): Promise { return; } - if ( - !confirm( - `Auto-link ${selected.length} books with high confidence matches (≥80%)?`, - ) - ) { + if (!confirm(`Auto-link ${selected.length} books with high confidence matches (≥80%)?`)) { return; } @@ -303,10 +265,7 @@ async function bulkAutoLink(): Promise { }); const result = await response.json(); - showToast( - `Auto-linked ${result.auto_linked} books successfully`, - "success", - ); + showToast(`Auto-linked ${result.auto_linked} books successfully`, "success"); setTimeout(() => window.location.reload(), 1500); } catch (error) { console.error("Auto-link failed", error); @@ -324,14 +283,11 @@ async function bulkGetSuggestions(): Promise { const token = getToken(); for (const book of selected) { try { - const response = await fetch( - `/sync/unlinked-books/${book.progressId}/suggestions`, - { - headers: { - Authorization: `Bearer ${token}`, - }, + const response = await fetch(`/sync/unlinked-books/${book.progressId}/suggestions`, { + headers: { + Authorization: `Bearer ${token}`, }, - ); + }); const result = await response.json(); displaySuggestions(book.progressId, result.suggestions, result.action); @@ -341,11 +297,7 @@ async function bulkGetSuggestions(): Promise { } } -function displaySuggestions( - progressId: string, - suggestions: any[], - _action: string, -): void { +function displaySuggestions(progressId: string, suggestions: any[], _action: string): void { const container = document.getElementById(`matches-${progressId}`); if (!container) return; @@ -356,15 +308,13 @@ function displaySuggestions( listContainer.innerHTML = ""; if (suggestions.length === 0) { - listContainer.innerHTML = - '

No matches found

'; + listContainer.innerHTML = '

No matches found

'; return; } suggestions.forEach((match) => { const div = document.createElement("div"); - div.className = - "p-3 border rounded cursor-pointer hover:bg-opacity-80 transition-colors"; + div.className = "p-3 border rounded cursor-pointer hover:bg-opacity-80 transition-colors"; div.style.cssText = `background-color: var(--bg-primary); border-color: var(--border);`; div.innerHTML = `
@@ -400,8 +350,7 @@ function showBulkManualLink(): void { "info", ); window.location.href = - "/library?mode=link&unlinked=" + - selected.map((s) => s.progressId).join(","); + "/library?mode=link&unlinked=" + selected.map((s) => s.progressId).join(","); } function setupEventDelegation(): void { @@ -420,10 +369,7 @@ function setupEventDelegation(): void { parseFloat(card.dataset.confidence || "0"), ); } else if (action === "select-book") { - selectBookForLink( - card.dataset.mediaItemId || "", - card.dataset.cover || "", - ); + selectBookForLink(card.dataset.mediaItemId || "", card.dataset.cover || ""); } else if (action === "select-match") { autoLinkBook( card.dataset.progressId || "", diff --git a/web/src/websocket.ts b/web/src/websocket.ts index b0c7539d..db6ce069 100644 --- a/web/src/websocket.ts +++ b/web/src/websocket.ts @@ -120,9 +120,4 @@ function createWebSocket(config: { return ws; } -export { - addListener, - createWebSocket, - disconnectWebSocket, - removeListener, -}; +export { addListener, createWebSocket, disconnectWebSocket, removeListener };