diff --git a/tsconfig.json b/tsconfig.json index d99dbcf2..c605829e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,7 +2,7 @@ "compilerOptions": { "target": "ES2020", "module": "ES2020", - "lib": ["ES2020", "DOM"], + "lib": ["ES2021", "DOM", "DOM.Iterable"], "outDir": "./web/static", "rootDir": "./web/src", "moduleResolution": "bundler", diff --git a/web/src/alpine.ts b/web/src/alpine.ts index 75a73daf..7d6a4602 100644 --- a/web/src/alpine.ts +++ b/web/src/alpine.ts @@ -1,12 +1,11 @@ import Alpine from "alpinejs"; -// Extend Window interface to include Alpine +// Extend Window interface to include Alpine. The `htmx` member is declared +// once in shims.d.ts — redeclaring it here with a different shape is a +// compile error (TS2717) and split-brained typing. declare global { interface Window { Alpine: typeof Alpine; - htmx: { - trigger: (element: HTMLElement | string, event: string) => void; - }; } } diff --git a/web/src/book-detail.ts b/web/src/book-detail.ts index e7f0e9d8..8787e162 100644 --- a/web/src/book-detail.ts +++ b/web/src/book-detail.ts @@ -9,7 +9,6 @@ import { triggerDownload, bannerBookTitle, type AnnotationRow, - type AnnotationSource, } from "./annotations-banner"; function getMediaId(): string { @@ -69,8 +68,8 @@ function collectFormData(editorTags: string[]): Record { if (el.type === "number") { const val = parseFloat(el.value); data[el.name] = isNaN(val) ? 0 : val; - } else if (el.type === "checkbox") { - data[el.name] = (el as HTMLInputElement).checked; + } else if (el instanceof HTMLInputElement && el.type === "checkbox") { + data[el.name] = el.checked; } else { data[el.name] = el.value; } @@ -369,7 +368,7 @@ Alpine.data("bookDetail", () => { } await this.fetchBannerAnnotations(); showToast("Note saved", "success"); - } catch (e) { + } catch { showToast("Could not save the note", "error"); } }, @@ -386,7 +385,7 @@ Alpine.data("bookDetail", () => { // Tombstoned server-side: restorable from the Deleted modal. showToast("Note deleted — restorable from Deleted", "success"); await this.fetchBannerAnnotations(); - } catch (_e) { + } catch { showToast("Could not delete the note", "error"); } }, @@ -403,7 +402,7 @@ Alpine.data("bookDetail", () => { // Tombstoned server-side: restorable from the Deleted modal. showToast("Bookmark deleted — restorable from Deleted", "success"); await this.fetchBannerAnnotations(); - } catch (_e) { + } catch { showToast("Could not delete the bookmark", "error"); } }, @@ -420,7 +419,7 @@ Alpine.data("bookDetail", () => { // Tombstoned server-side: restorable from the Deleted modal. showToast("Highlight deleted — restorable from Deleted", "success"); await this.fetchBannerAnnotations(); - } catch (_e) { + } catch { showToast("Could not delete the highlight", "error"); } }, @@ -455,7 +454,7 @@ Alpine.data("bookDetail", () => { ); } showToast("Annotations exported", "success"); - } catch (_e) { + } catch { showToast("Export failed", "error"); } }, diff --git a/web/src/bookPicker.ts b/web/src/bookPicker.ts index 455c200a..eeaf7428 100644 --- a/web/src/bookPicker.ts +++ b/web/src/bookPicker.ts @@ -105,32 +105,30 @@ Alpine.store("bookPicker", { // Provides methods for template to access store Alpine.data("bookPicker", () => ({ get isOpen() { - return (window as any).Alpine?.$store.bookPicker.isOpen; + return window.Alpine?.$store.bookPicker.isOpen; }, get selectedCount() { - return (window as any).Alpine?.$store.bookPicker.selectedCount; + return window.Alpine?.$store.bookPicker.selectedCount; }, toggleBook(bookId: string) { - (window as any).Alpine?.$store.bookPicker.toggleBook(bookId); + window.Alpine?.$store.bookPicker.toggleBook(bookId); }, isSelected(bookId: string): boolean { - return (window as any).Alpine?.$store.bookPicker.isSelected(bookId); + return window.Alpine?.$store.bookPicker.isSelected(bookId); }, clearFilters() { - (window as any).Alpine?.$store.bookPicker.clearFilters(); + window.Alpine?.$store.bookPicker.clearFilters(); }, close() { - (window as any).Alpine?.$store.bookPicker.close(); + window.Alpine?.$store.bookPicker.close(); }, submit() { - (window as any).Alpine?.$store.bookPicker.submit(); + window.Alpine?.$store.bookPicker.submit(); }, })); - -export {}; diff --git a/web/src/collections.ts b/web/src/collections.ts index c617cb21..7375a64d 100644 --- a/web/src/collections.ts +++ b/web/src/collections.ts @@ -587,8 +587,8 @@ async function moveCollectionBook(this: any, mediaId: string, direction: number) showToast("Failed to save the new order", "error"); // Rendering ownership: the templ partial re-renders the true order. const gridTarget = document.getElementById("books-container"); - if (gridTarget && (window as any).htmx) { - (window as any).htmx.ajax( + if (gridTarget && window.htmx) { + window.htmx.ajax( "GET", `/collections/${getCollectionId()}/books-grid${window.location.search.startsWith("?") ? window.location.search : ""}`, { target: "#books-container", swap: "innerHTML" }, diff --git a/web/src/custom-section-builder.ts b/web/src/custom-section-builder.ts index e95208b2..e9ff6a5a 100644 --- a/web/src/custom-section-builder.ts +++ b/web/src/custom-section-builder.ts @@ -210,7 +210,11 @@ const FILTER_FIELDS: FilterField[] = [ ]; let ruleCounter = 0; -let selectedBooks: Map = new Map(); +// Books picked in the builder only ever carry identity fields; the API's +// BookInfo conflict/format/badge metadata is attached server-side later. +type SelectedBook = Pick; + +let selectedBooks: Map = new Map(); let customSectionTimeout: number | null = null; function initCustomSectionBuilder(): void { diff --git a/web/src/device-management.ts b/web/src/device-management.ts index a81879ff..5c260e78 100644 --- a/web/src/device-management.ts +++ b/web/src/device-management.ts @@ -191,7 +191,7 @@ async function loadShelfMappings(deviceId: string): Promise { container.innerHTML = '

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

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

Failed to load mappings

'; } } diff --git a/web/src/library-switcher.ts b/web/src/library-switcher.ts index 9f4d20a6..4104b53c 100644 --- a/web/src/library-switcher.ts +++ b/web/src/library-switcher.ts @@ -73,7 +73,9 @@ export async function switchWithTransition( } export interface LibrarySwitcherOptions { - onSwitch: (libraryId: string) => Promise; + // Async switchers (SSR navigation awaits nothing, API-backed switchers + // await their fetch) — hence void | Promise. + onSwitch: (libraryId: string) => void | Promise; } export function initLibrarySwitcher(options: LibrarySwitcherOptions): void { diff --git a/web/src/password_validation.ts b/web/src/password_validation.ts index b930841e..8c4bafa5 100644 --- a/web/src/password_validation.ts +++ b/web/src/password_validation.ts @@ -63,7 +63,7 @@ function hasNumber(password: string): boolean { return /[0-9]/.test(password); } function hasSpecialChar(password: string): boolean { - return /[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(password); + return /[!@#$%^&*()_+\-=[]{};':"\\|,.<>?]/.test(password); } function passwordsMatch(password: string, confirm: string): boolean { return password.length > 0 && password === confirm; @@ -107,7 +107,7 @@ function requirementRow(id: string, label: string): string { function ensureChecklist(containerId: string, rules: PasswordRules, hasConfirm: boolean): void { const container = document.getElementById(containerId); if (!container) return; - let list = container.querySelector("ul"); + let list: HTMLElement | null = container.querySelector("ul"); if (container.tagName === "UL") list = container; // container IS the list if (!list) { container.insertAdjacentHTML( diff --git a/web/src/reader/reader.ts b/web/src/reader/reader.ts index 08a6a804..020520f3 100644 --- a/web/src/reader/reader.ts +++ b/web/src/reader/reader.ts @@ -187,7 +187,7 @@ async function loadFontBlobUrls(): Promise { const blob = await resp.blob(); const blobUrl = URL.createObjectURL(blob); return { key: f.url, blobUrl }; - } catch (_e) { + } catch { return { key: f.url, blobUrl: f.url }; } }), @@ -1533,7 +1533,7 @@ document.addEventListener("alpine:init", () => { async adjustFinalize() { const ctx = this.adjustContext; if (!ctx) return; - const { index, doc, range, id } = ctx; + const { index, range, id } = ctx; const text = range.toString().replace(/\s+/g, " ").trim(); this.cancelAdjust(); if (!text) { @@ -2452,7 +2452,7 @@ document.addEventListener("alpine:init", () => { positionLabel: r.position ?? "", })); } - } catch (_e) { + } catch { /* annotations are non-critical; leave lists as-is */ } }, @@ -2523,7 +2523,7 @@ document.addEventListener("alpine:init", () => { } } this.hideSelectionPopover(); - } catch (_e) { + } catch { /* ignore highlight errors */ } }, @@ -2590,7 +2590,7 @@ document.addEventListener("alpine:init", () => { // Explicit saves (the Save button) close the whole popover; color // tweaks made while the note editor is open keep everything open. if (!keepNoteOpen) this.hideSelectionPopover(); - } catch (_e) { + } catch { /* ignore highlight errors */ } }, @@ -2615,7 +2615,7 @@ document.addEventListener("alpine:init", () => { }); } this.hideSelectionPopover(); - } catch (_e) { + } catch { /* ignore highlight errors */ } }, @@ -2839,7 +2839,7 @@ document.addEventListener("alpine:init", () => { }); if (!resp.ok) return; await this.refreshAnnotations(); - } catch (_e) { + } catch { /* ignore note errors */ } }, @@ -2882,7 +2882,7 @@ document.addEventListener("alpine:init", () => { note.positionLabel = row.position ?? note.positionLabel; note.editOpen = false; note.editText = ""; - } catch (_e) { + } catch { /* note errors are non-critical */ } }, @@ -2897,7 +2897,7 @@ document.addEventListener("alpine:init", () => { if (resp.ok || resp.status === 204) { this.noteItems = this.noteItems.filter((n) => n.id !== id); } - } catch (_e) { + } catch { /* ignore note errors */ } }, @@ -2928,7 +2928,7 @@ document.addEventListener("alpine:init", () => { page: typeof page === "number" ? page : 0, percentage: typeof pct === "number" ? pct : 0, }; - } catch (_e) { + } catch { return {}; } }, @@ -3017,7 +3017,7 @@ document.addEventListener("alpine:init", () => { }); this.lastSyncedCfi = cfi || ""; this.lastSyncedFraction = fraction; - } catch (_e) { + } catch { // silent fail — progress save is non-critical } }, @@ -3658,7 +3658,7 @@ document.addEventListener("alpine:init", () => { cfi: r.cfi_position?.String ?? r.cfi_position ?? "", page: r.page_number != null ? (r.page_number?.Int32 ?? r.page_number) : null, })); - } catch (_e) { + } catch { /* leave the existing list on fetch failure */ } }, @@ -3696,7 +3696,7 @@ document.addEventListener("alpine:init", () => { await this.refreshBookmarks(); showToast(`Bookmark added — ${this.progressText || "current position"}`, "success", 2500); } - } catch (_e) { + } catch { /* network failures surface via the toast fetch interceptor */ } }, @@ -3711,7 +3711,7 @@ document.addEventListener("alpine:init", () => { if (resp.ok || resp.status === 204) { this.bookmarkItems = this.bookmarkItems.filter((b) => b.id !== id); } - } catch (_e) { + } catch { /* ignore bookmark errors for now */ } }, @@ -3740,7 +3740,7 @@ document.addEventListener("alpine:init", () => { bookmark.title = title; } bookmark.renameOpen = false; - } catch (_e) { + } catch { bookmark.renameOpen = false; } }, @@ -3905,7 +3905,7 @@ document.addEventListener("alpine:init", () => { const data = await resp.json(); this.readingSpeedPpm = data.pages_per_minute || 0; } - } catch (_e) { + } catch { this.readingSpeedPpm = 0; } }, diff --git a/web/src/reader/settings-manager.ts b/web/src/reader/settings-manager.ts index 911751c1..aa5790fc 100644 --- a/web/src/reader/settings-manager.ts +++ b/web/src/reader/settings-manager.ts @@ -16,7 +16,7 @@ export async function loadSettings(): Promise { const settings = await response.json(); localStorage.setItem(LOCALSTORAGE_KEY, JSON.stringify(settings)); return settings; - } catch (error) { + } catch { const local = localStorage.getItem(LOCALSTORAGE_KEY); return local ? JSON.parse(local) : getDefaultSettings(); } @@ -39,7 +39,7 @@ export async function saveSettings(settings: Partial): Promise; } +interface Htmx { + ajax(method: string, url: string, options?: HtmxAjaxOptions): Promise; + process(element: Element): void; + trigger(element: Element | string, eventName: string, detail?: Record): void; +} + interface Window { - htmx: { - ajax(method: string, url: string, options?: HtmxAjaxOptions): Promise; - process(element: Element): void; - }; + htmx: Htmx; } diff --git a/web/src/stats.ts b/web/src/stats.ts index a94b4f0a..c26de780 100644 --- a/web/src/stats.ts +++ b/web/src/stats.ts @@ -151,11 +151,6 @@ function text(id: string, value: string): void { if (el) el.textContent = value; } -function getActiveRange(): string { - const btn = document.querySelector(".stats-range-btn.btn-active") as HTMLElement | null; - return btn?.dataset.range || "7"; -} - function markRangeButtons(active: string): void { document.querySelectorAll(".stats-range-btn").forEach((btn) => { const isActive = btn.dataset.range === active; @@ -359,7 +354,9 @@ async function renderDailyCharts(daily: DailyEntry[]): Promise { tooltip: { callbacks: { label: (ctx) => { - const v = typeof ctx.parsed === "number" ? ctx.parsed : ctx.parsed.y; + // Scalar for bar charts, {x, y} points for line charts. + const parsed: number | { y: number } = ctx.parsed; + const v = typeof parsed === "number" ? parsed : parsed.y; return `${Math.round(v * 10) / 10} ${unit}`; }, }, diff --git a/web/src/toast.ts b/web/src/toast.ts index 37f85526..9ce3d6d4 100644 --- a/web/src/toast.ts +++ b/web/src/toast.ts @@ -1,7 +1,7 @@ // Toast notification system for backend errors // Displays toast notifications at the top of the page -type ToastType = "error" | "success" | "info"; +type ToastType = "error" | "success" | "info" | "warning"; const TOAST_DEFAULT_DURATION = 5000; @@ -39,6 +39,12 @@ const getToastConfig = (type: ToastType) => { bgClass: "bg-blue-500/90", icon: "ℹ️", }, + warning: { + // bg-yellow-500 (no opacity modifier): it must already appear in the + // templates for the Tailwind build to emit the class. + bgClass: "bg-yellow-500", + icon: "⚠️", + }, }; return configs[type]; }; @@ -109,7 +115,7 @@ const parseXHRError = (xhr: XMLHttpRequest): string => { try { const response = JSON.parse(xhr.responseText); errorMessage = response.error || response.message || errorMessage; - } catch (e) { + } catch { errorMessage = xhr.responseText || errorMessage; } return errorMessage; @@ -154,7 +160,7 @@ const setupHTMXListeners = (): void => { customEvent.detail.target.innerHTML = ""; } } - } catch (e) { + } catch { // Not valid JSON — ignore } }