style: format web/src with oxfmt defaults (via vp fmt)

One-time mechanical sweep, isolated from any functional change.
- oxfmt defaults: double quotes, semicolons, 2-space indent, LF,
  printWidth 100, trailing commas, bracket spacing, arrow parens.
- vite.config.ts gains an fmt.ignorePatterns block for web/static:
  vendored/minified assets (htmx.min.js, compiled css) must never
  be reformatted.
- Verified: rebuild after the sweep produces byte-identical JS/CSS
  bundles; only sourcemaps changed (they embed sourcesContent).
This commit is contained in:
John O'Keefe committed 2026-10-21 13:18:24 -04:00
1 parent 8aeecaa0ef
commit 77cd268f4e
37 files changed
+1151 -1853

No files matched your search

+5
View File
@@ -39,6 +39,11 @@ const cleanStaleChunks = () => {
}; };
export default defineConfig({ 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: { resolve: {
alias: { alias: {
"@": "/web/src", "@": "/web/src",
+16 -53
View File
@@ -57,13 +57,8 @@ async function scanAllLibraries(): Promise<void> {
} }
} }
function showScanProgress( function showScanProgress(jobIds: string[], libraryNames: Record<string, string>): void {
jobIds: string[], const container = document.getElementById("scan-progress-container") as HTMLElement;
libraryNames: Record<string, string>,
): void {
const container = document.getElementById(
"scan-progress-container",
) as HTMLElement;
const list = document.getElementById("library-progress-list") as HTMLElement; const list = document.getElementById("library-progress-list") as HTMLElement;
if (!container || !list) return; if (!container || !list) return;
@@ -113,10 +108,7 @@ function showScanProgress(
let scanPollInterval: ReturnType<typeof setInterval> | undefined = undefined; let scanPollInterval: ReturnType<typeof setInterval> | undefined = undefined;
function pollScanProgress( function pollScanProgress(jobIds: string[], _libraryNames: Record<string, string>): void {
jobIds: string[],
_libraryNames: Record<string, string>,
): void {
const token = localStorage.getItem("token"); const token = localStorage.getItem("token");
const startTime = Date.now(); const startTime = Date.now();
const cancelledJobs = new Set<string>(); const cancelledJobs = new Set<string>();
@@ -168,14 +160,9 @@ function pollScanProgress(
} }
// status.progress arrives 0-1 from the server; render as percent. // status.progress arrives 0-1 from the server; render as percent.
const overallProgress = const overallProgress = progressJobs > 0 ? Math.round((totalProgress / progressJobs) * 100) : 0;
progressJobs > 0 ? Math.round((totalProgress / progressJobs) * 100) : 0; const progressBar = document.getElementById("scan-progress-bar") as HTMLElement;
const progressBar = document.getElementById( const progressText = document.getElementById("scan-progress-text") as HTMLElement;
"scan-progress-bar",
) as HTMLElement;
const progressText = document.getElementById(
"scan-progress-text",
) as HTMLElement;
const statusText = document.getElementById("scan-status") as HTMLElement; const statusText = document.getElementById("scan-status") as HTMLElement;
if (progressBar) progressBar.style.width = overallProgress + "%"; if (progressBar) progressBar.style.width = overallProgress + "%";
@@ -241,9 +228,7 @@ async function cancelScan(jobId: string): Promise<void> {
const token = localStorage.getItem("token"); const token = localStorage.getItem("token");
if (!token) return; if (!token) return;
const btn = document.querySelector<HTMLButtonElement>( const btn = document.querySelector<HTMLButtonElement>(`[data-cancel-job="${jobId}"]`);
`[data-cancel-job="${jobId}"]`,
);
if (btn) { if (btn) {
btn.disabled = true; btn.disabled = true;
btn.textContent = "Cancelling..."; btn.textContent = "Cancelling...";
@@ -260,10 +245,7 @@ async function cancelScan(jobId: string): Promise<void> {
} }
showToast("Scan cancellation requested", "success"); showToast("Scan cancellation requested", "success");
} catch (e) { } catch (e) {
showToast( showToast(e instanceof Error ? e.message : "Failed to cancel scan", "error");
e instanceof Error ? e.message : "Failed to cancel scan",
"error",
);
if (btn) { if (btn) {
btn.disabled = false; btn.disabled = false;
btn.textContent = "Cancel"; btn.textContent = "Cancel";
@@ -291,10 +273,7 @@ async function cancelAllScans(): Promise<void> {
await Promise.allSettled(jobs.map((job) => cancelScan(job.job_id))); await Promise.allSettled(jobs.map((job) => cancelScan(job.job_id)));
} catch (e) { } catch (e) {
showToast( showToast(e instanceof Error ? e.message : "Failed to cancel scans", "error");
e instanceof Error ? e.message : "Failed to cancel scans",
"error",
);
} }
} }
@@ -361,9 +340,7 @@ function showScanResults(
cancelled: number, cancelled: number,
): void { ): void {
const resultsDiv = document.getElementById("scan-results") as HTMLElement; const resultsDiv = document.getElementById("scan-results") as HTMLElement;
const contentDiv = document.getElementById( const contentDiv = document.getElementById("scan-results-content") as HTMLElement;
"scan-results-content",
) as HTMLElement;
if (!resultsDiv || !contentDiv) return; if (!resultsDiv || !contentDiv) return;
@@ -381,16 +358,12 @@ function showScanResults(
const statusText = document.getElementById("scan-status") as HTMLElement; const statusText = document.getElementById("scan-status") as HTMLElement;
if (statusText) { if (statusText) {
statusText.textContent = statusText.textContent =
cancelled > 0 cancelled > 0 ? "Scan finished (some scans were cancelled)" : "Scan complete!";
? "Scan finished (some scans were cancelled)"
: "Scan complete!";
} }
} }
function hideScanProgress(): void { function hideScanProgress(): void {
const container = document.getElementById( const container = document.getElementById("scan-progress-container") as HTMLElement;
"scan-progress-container",
) as HTMLElement;
if (container) container.classList.add("hidden"); if (container) container.classList.add("hidden");
setCancelAllVisible(false); setCancelAllVisible(false);
} }
@@ -447,16 +420,10 @@ async function purgeArchivedItems(): Promise<void> {
throw new Error(err.error || "Failed to purge archived items"); throw new Error(err.error || "Failed to purge archived items");
} }
const data = await resp.json(); const data = await resp.json();
showToast( showToast(`Purged ${data.purged} archived item${data.purged === 1 ? "" : "s"}`, "success");
`Purged ${data.purged} archived item${data.purged === 1 ? "" : "s"}`,
"success",
);
setTimeout(() => window.location.reload(), 700); setTimeout(() => window.location.reload(), 700);
} catch (e) { } catch (e) {
showToast( showToast(e instanceof Error ? e.message : "Failed to purge archived items", "error");
e instanceof Error ? e.message : "Failed to purge archived items",
"error",
);
} }
} }
@@ -551,9 +518,7 @@ document.querySelectorAll<HTMLElement>("[data-unarchive]").forEach((el) => {
el.addEventListener("click", () => unarchiveItem(el.dataset.unarchive || "")); el.addEventListener("click", () => unarchiveItem(el.dataset.unarchive || ""));
}); });
document.querySelectorAll<HTMLElement>("[data-delete-archived]").forEach((el) => { document.querySelectorAll<HTMLElement>("[data-delete-archived]").forEach((el) => {
el.addEventListener("click", () => el.addEventListener("click", () => deleteArchivedItem(el.dataset.deleteArchived || ""));
deleteArchivedItem(el.dataset.deleteArchived || ""),
);
}); });
document.querySelectorAll<HTMLElement>("[data-merge-source]").forEach((el) => { document.querySelectorAll<HTMLElement>("[data-merge-source]").forEach((el) => {
el.addEventListener("click", () => el.addEventListener("click", () =>
@@ -631,9 +596,7 @@ function bindDeleteLibraryModalClose(): void {
?.addEventListener("htmx:afterRequest", function (evt) { ?.addEventListener("htmx:afterRequest", function (evt) {
const detail = (evt as CustomEvent).detail as { successful?: boolean }; const detail = (evt as CustomEvent).detail as { successful?: boolean };
if (detail.successful) { if (detail.successful) {
document document.getElementById("delete-library-modal")?.classList.add("hidden");
.getElementById("delete-library-modal")
?.classList.add("hidden");
} }
}); });
} }
+10 -7
View File
@@ -43,7 +43,10 @@ interface AnnotationSource {
} }
/** Friendly human location label; mirrors the app's positionHint(). */ /** 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(); const raw = (position ?? "").trim();
if (raw) { if (raw) {
// PDF/comic anchors: "page:N" // 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). // 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) { 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 cut;
} }
return "Location"; return "Location";
@@ -214,10 +220,7 @@ function buildAnnotationRows(
} }
function escapeHtmlForRich(text: string): string { function escapeHtmlForRich(text: string): string {
return text return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
} }
/** The export item text — identical in single and multi modes. */ /** The export item text — identical in single and multi modes. */
+6 -18
View File
@@ -16,9 +16,7 @@ function showDocMode(mode: "mock" | "real"): void {
const responseStatus = document.getElementById("response-status"); const responseStatus = document.getElementById("response-status");
const responseTime = document.getElementById("response-time"); const responseTime = document.getElementById("response-time");
const apiResponse = document.querySelector(".api-response"); const apiResponse = document.querySelector(".api-response");
const requestBody = document.getElementById( const requestBody = document.getElementById("request-body") as HTMLTextAreaElement;
"request-body",
) as HTMLTextAreaElement;
const tryItOut = document.getElementById("try-it-out"); const tryItOut = document.getElementById("try-it-out");
if ( if (
@@ -58,12 +56,8 @@ function showDocMode(mode: "mock" | "real"): void {
} }
async function tryDocEndpoint(): Promise<void> { async function tryDocEndpoint(): Promise<void> {
const methodSelect = document.getElementById( const methodSelect = document.getElementById("http-method") as HTMLSelectElement;
"http-method", const requestBody = document.getElementById("request-body") as HTMLTextAreaElement;
) as HTMLSelectElement;
const requestBody = document.getElementById(
"request-body",
) as HTMLTextAreaElement;
const responseBody = document.getElementById("response-body"); const responseBody = document.getElementById("response-body");
const responseStatus = document.getElementById("response-status"); const responseStatus = document.getElementById("response-status");
const responseTime = document.getElementById("response-time"); const responseTime = document.getElementById("response-time");
@@ -113,9 +107,7 @@ async function tryDocEndpoint(): Promise<void> {
} }
function copyDocRequest(): void { function copyDocRequest(): void {
const requestBody = document.getElementById( const requestBody = document.getElementById("request-body") as HTMLTextAreaElement;
"request-body",
) as HTMLTextAreaElement;
if (requestBody) { if (requestBody) {
navigator.clipboard.writeText(requestBody.value); navigator.clipboard.writeText(requestBody.value);
} }
@@ -129,12 +121,8 @@ function copyDocResponse(): void {
} }
function generateDocCURL(): void { function generateDocCURL(): void {
const methodSelect = document.getElementById( const methodSelect = document.getElementById("http-method") as HTMLSelectElement;
"http-method", const requestBody = document.getElementById("request-body") as HTMLTextAreaElement;
) as HTMLSelectElement;
const requestBody = document.getElementById(
"request-body",
) as HTMLTextAreaElement;
if (!methodSelect || !requestBody) return; if (!methodSelect || !requestBody) return;
+6 -18
View File
@@ -10,13 +10,9 @@ interface ApiExplorerRequest {
const requestHistory: ApiExplorerRequest[] = []; const requestHistory: ApiExplorerRequest[] = [];
function sendApiRequest(): void { function sendApiRequest(): void {
const method = const method = (document.getElementById("api-method") as HTMLSelectElement)?.value || "GET";
(document.getElementById("api-method") as HTMLSelectElement)?.value || const endpoint = (document.getElementById("api-endpoint") as HTMLInputElement)?.value || "";
"GET"; const bodyText = (document.getElementById("api-body") as HTMLTextAreaElement)?.value || "";
const endpoint =
(document.getElementById("api-endpoint") as HTMLInputElement)?.value || "";
const bodyText =
(document.getElementById("api-body") as HTMLTextAreaElement)?.value || "";
const token = localStorage.getItem("token"); const token = localStorage.getItem("token");
@@ -64,11 +60,7 @@ function sendApiRequest(): void {
}); });
} }
function displayResponse( function displayResponse(response: Response, data: unknown, duration: number): void {
response: Response,
data: unknown,
duration: number,
): void {
const container = document.getElementById("api-response"); const container = document.getElementById("api-response");
if (!container) return; if (!container) return;
@@ -150,12 +142,8 @@ function loadFromHistory(index: number): void {
const request = requestHistory[index]; const request = requestHistory[index];
if (!request) return; if (!request) return;
const methodSelect = document.getElementById( const methodSelect = document.getElementById("api-method") as HTMLSelectElement;
"api-method", const endpointInput = document.getElementById("api-endpoint") as HTMLInputElement;
) as HTMLSelectElement;
const endpointInput = document.getElementById(
"api-endpoint",
) as HTMLInputElement;
const bodyInput = document.getElementById("api-body") as HTMLTextAreaElement; const bodyInput = document.getElementById("api-body") as HTMLTextAreaElement;
if (methodSelect) methodSelect.value = request.method; if (methodSelect) methodSelect.value = request.method;
+6 -19
View File
@@ -37,10 +37,7 @@ async function apiPut(url: string, data?: unknown): Promise<Response> {
}); });
} }
async function apiDelete<T extends object>( async function apiDelete<T extends object>(url: string, data?: T): Promise<Response> {
url: string,
data?: T,
): Promise<Response> {
return fetch(`/api${url}`, { return fetch(`/api${url}`, {
method: "DELETE", method: "DELETE",
headers: { headers: {
@@ -64,9 +61,7 @@ async function apiPatch(url: string, data?: unknown): Promise<Response> {
async function handleResponse<T>(response: Response): Promise<T> { async function handleResponse<T>(response: Response): Promise<T> {
if (!response.ok) { if (!response.ok) {
const errorData = await response const errorData = await response.json().catch(() => ({ error: "Unknown error" }));
.json()
.catch(() => ({ error: "Unknown error" }));
throw new Error(errorData.error || `HTTP ${response.status}`); throw new Error(errorData.error || `HTTP ${response.status}`);
} }
return response.json(); return response.json();
@@ -74,17 +69,14 @@ async function handleResponse<T>(response: Response): Promise<T> {
async function handleVoidResponse(response: Response): Promise<void> { async function handleVoidResponse(response: Response): Promise<void> {
if (!response.ok) { if (!response.ok) {
const errorData = await response const errorData = await response.json().catch(() => ({ error: "Unknown error" }));
.json()
.catch(() => ({ error: "Unknown error" }));
throw new Error(errorData.error || `HTTP ${response.status}`); throw new Error(errorData.error || `HTTP ${response.status}`);
} }
} }
function handleError(error: unknown, context: string): void { function handleError(error: unknown, context: string): void {
console.error(`${context}:`, error); console.error(`${context}:`, error);
const message = const message = error instanceof Error ? error.message : "An unexpected error occurred";
error instanceof Error ? error.message : "An unexpected error occurred";
showToast(message, "error"); showToast(message, "error");
} }
@@ -106,16 +98,11 @@ async function updateReadingProgress(
mediaItemId: string, mediaItemId: string,
progress: ReadingProgress, progress: ReadingProgress,
): Promise<void> { ): Promise<void> {
const response = await apiPut( const response = await apiPut(`/media-items/${mediaItemId}/progress`, progress);
`/media-items/${mediaItemId}/progress`,
progress,
);
await handleVoidResponse(response); await handleVoidResponse(response);
} }
async function getReadingProgress( async function getReadingProgress(mediaItemId: string): Promise<ReadingProgress | null> {
mediaItemId: string,
): Promise<ReadingProgress | null> {
const response = await apiGet(`/media-items/${mediaItemId}/progress`); const response = await apiGet(`/media-items/${mediaItemId}/progress`);
if (response.status === 404) { if (response.status === 404) {
+17 -18
View File
@@ -43,7 +43,11 @@ export function localOffsetFor(tracks: AudioTrack[], index: number, globalSecond
// globalSecondsFor converts (track i, local seconds) back to the global // globalSecondsFor converts (track i, local seconds) back to the global
// position — the inverse of localOffsetFor. // 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]; const track = tracks[index];
return (track ? track.start : 0) + Math.max(0, localSeconds); return (track ? track.start : 0) + Math.max(0, localSeconds);
} }
@@ -309,10 +313,7 @@ Alpine.data("audioReader", () => ({
try { try {
const [chaptersResp, progress] = await Promise.all([ const [chaptersResp, progress] = await Promise.all([
fetch(readConfig().chapterEndpoint, { headers: getAuthHeaders() }), fetch(readConfig().chapterEndpoint, { headers: getAuthHeaders() }),
fetch( fetch(`/api/media-items/${this.mediaItemId}/progress`, { headers: getAuthHeaders() }),
`/api/media-items/${this.mediaItemId}/progress`,
{ headers: getAuthHeaders() },
),
]); ]);
if (chaptersResp.ok) { if (chaptersResp.ok) {
const data = await chaptersResp.json(); const data = await chaptersResp.json();
@@ -375,9 +376,7 @@ Alpine.data("audioReader", () => ({
chapterTitle(): string { chapterTitle(): string {
const ch = this.chapters[this.currentChapterIdx]; const ch = this.chapters[this.currentChapterIdx];
return ch return ch ? ch.title || `Chapter ${ch.index + 1}` : this.title;
? ch.title || `Chapter ${ch.index + 1}`
: this.title;
}, },
currentChapter(): number { currentChapter(): number {
@@ -407,7 +406,10 @@ Alpine.data("audioReader", () => ({
}, },
skip(direction: 1 | -1) { 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); this.seekTo(target);
}, },
@@ -494,8 +496,7 @@ Alpine.data("audioReader", () => ({
.filter((b) => String(b.position || "").startsWith("audio:")) .filter((b) => String(b.position || "").startsWith("audio:"))
.sort( .sort(
(a, b) => (a, b) =>
this.bookmarkSeconds(String(a.position)) - this.bookmarkSeconds(String(a.position)) - this.bookmarkSeconds(String(b.position)),
this.bookmarkSeconds(String(b.position)),
); );
} catch { } catch {
// bookmarks are non-critical UI // bookmarks are non-critical UI
@@ -558,9 +559,7 @@ Alpine.data("audioReader", () => ({
}, },
async saveNote() { async saveNote() {
const b = this.bookmarks.find( const b = this.bookmarks.find((x) => String(x.id) === this.editingBookmarkId);
(x) => String(x.id) === this.editingBookmarkId,
);
if (!b) return; if (!b) return;
try { try {
const resp = await fetch( const resp = await fetch(
@@ -585,10 +584,10 @@ Alpine.data("audioReader", () => ({
async deleteBookmark(b: Record<string, unknown>) { async deleteBookmark(b: Record<string, unknown>) {
try { try {
const resp = await fetch( const resp = await fetch(`/api/media-items/${this.mediaItemId}/bookmarks/${b.id}`, {
`/api/media-items/${this.mediaItemId}/bookmarks/${b.id}`, method: "DELETE",
{ method: "DELETE", headers: getAuthHeaders() }, headers: getAuthHeaders(),
); });
if (!resp.ok) throw new Error("delete failed"); if (!resp.ok) throw new Error("delete failed");
await this.loadBookmarks(); await this.loadBookmarks();
} catch { } catch {
+47 -90
View File
@@ -169,9 +169,7 @@ Alpine.data("bookDetail", () => {
const coverImg = document.querySelector( const coverImg = document.querySelector(
"#metadata-editor-modal img#metadata-cover-preview", "#metadata-editor-modal img#metadata-cover-preview",
) as HTMLImageElement | null; ) as HTMLImageElement | null;
const hasCover = const hasCover = coverImg?.src && !coverImg.src.includes("placeholder-book.svg");
coverImg?.src &&
!coverImg.src.includes("placeholder-book.svg");
const initialTags: string[] = []; const initialTags: string[] = [];
const tagBadges = document.querySelectorAll("#metadata-editor-modal [data-editor-tag]"); 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. // HTML — no \n\n separators in it) and came up empty every time.
this.bannerEditRow = row; this.bannerEditRow = row;
this.bannerEditText = String( 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; this.bannerEditOpen = true;
}, },
@@ -308,7 +306,7 @@ Alpine.data("bookDetail", () => {
// Full-replace PUT: title + position ride along from the row's // Full-replace PUT: title + position ride along from the row's
// stored values; the note is the editable part. // stored values; the note is the editable part.
const bmId = row.key.slice("bookmark-".length); 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}`, { const resp = await fetch(`/api/media-items/${mediaId}/bookmarks/${bmId}`, {
method: "PUT", method: "PUT",
headers: { Authorization: getAuthHeader(), "Content-Type": "application/json" }, headers: { Authorization: getAuthHeader(), "Content-Type": "application/json" },
@@ -323,8 +321,10 @@ Alpine.data("bookDetail", () => {
const noteId = row.key.slice("note-".length); const noteId = row.key.slice("note-".length);
// The stored position rides along: the server's PUT hard-sets the // The stored position rides along: the server's PUT hard-sets the
// position column. // position column.
const position = (row.loc || "").startsWith("cfi:") || (row.loc || "").startsWith("page:") const position =
? row.loc : undefined; (row.loc || "").startsWith("cfi:") || (row.loc || "").startsWith("page:")
? row.loc
: undefined;
const resp = await fetch(`/api/media-items/${mediaId}/notes/${noteId}`, { const resp = await fetch(`/api/media-items/${mediaId}/notes/${noteId}`, {
method: "PUT", method: "PUT",
headers: { Authorization: getAuthHeader(), "Content-Type": "application/json" }, headers: { Authorization: getAuthHeader(), "Content-Type": "application/json" },
@@ -335,7 +335,7 @@ Alpine.data("bookDetail", () => {
// Highlight-attached note: the full-replace highlight PUT; empty // Highlight-attached note: the full-replace highlight PUT; empty
// content clears the note (allowed by the validator). // content clears the note (allowed by the validator).
const hlId = row.key.slice("hl-".length); 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; if (content === String(src?.source ?? "")) return;
// The banner only ever changes note_text: read the highlight list, // The banner only ever changes note_text: read the highlight list,
// then full-replace the row with the new note text. // then full-replace the row with the new note text.
@@ -352,7 +352,8 @@ Alpine.data("bookDetail", () => {
headers: { Authorization: getAuthHeader(), "Content-Type": "application/json" }, headers: { Authorization: getAuthHeader(), "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
selection_text: typeof hl.selection_text === "string" ? hl.selection_text : "", 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 : "", end_position: typeof (hl.end_position ?? "") === "string" ? hl.end_position : "",
epubcfi_start: hl.epubcfi_start ?? "", epubcfi_start: hl.epubcfi_start ?? "",
epubcfi_end: hl.epubcfi_end ?? "", epubcfi_end: hl.epubcfi_end ?? "",
@@ -479,10 +480,7 @@ Alpine.data("bookDetail", () => {
showToast("Conflict resolved successfully", "success"); showToast("Conflict resolved successfully", "success");
setTimeout(() => window.location.reload(), 500); setTimeout(() => window.location.reload(), 500);
} catch (e) { } catch (e) {
showToast( showToast(e instanceof Error ? e.message : "Failed to resolve conflict", "error");
e instanceof Error ? e.message : "Failed to resolve conflict",
"error",
);
} }
}, },
@@ -507,10 +505,7 @@ Alpine.data("bookDetail", () => {
showToast("Annotation restored", "success"); showToast("Annotation restored", "success");
setTimeout(() => window.location.reload(), 500); setTimeout(() => window.location.reload(), 500);
} catch (e) { } catch (e) {
showToast( showToast(e instanceof Error ? e.message : "Failed to restore annotation", "error");
e instanceof Error ? e.message : "Failed to restore annotation",
"error",
);
} }
}, },
@@ -538,10 +533,7 @@ Alpine.data("bookDetail", () => {
showToast("Annotation permanently deleted", "success"); showToast("Annotation permanently deleted", "success");
setTimeout(() => window.location.reload(), 500); setTimeout(() => window.location.reload(), 500);
} catch (e) { } catch (e) {
showToast( showToast(e instanceof Error ? e.message : "Failed to delete annotation", "error");
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 conflictId = document.body.getAttribute("data-conflict-id");
const conflictWinner = document.body.getAttribute("data-conflict-winner"); const conflictWinner = document.body.getAttribute("data-conflict-winner");
this.conflictId = conflictId && conflictId !== "null" ? conflictId : ""; this.conflictId = conflictId && conflictId !== "null" ? conflictId : "";
this.conflictWinner = this.conflictWinner = conflictWinner && conflictWinner !== "null" ? conflictWinner : "";
conflictWinner && conflictWinner !== "null" ? conflictWinner : "";
const link = document.getElementById("back-link"); const link = document.getElementById("back-link");
if (!link) return; if (!link) return;
@@ -628,10 +619,7 @@ Alpine.data("bookDetail", () => {
this.ratingHover = 0; this.ratingHover = 0;
showToast("Rating saved", "success"); showToast("Rating saved", "success");
} catch (e) { } catch (e) {
showToast( showToast(e instanceof Error ? e.message : "Failed to save rating", "error");
e instanceof Error ? e.message : "Failed to save rating",
"error",
);
} finally { } finally {
this.ratingSaving = false; this.ratingSaving = false;
} }
@@ -654,10 +642,7 @@ Alpine.data("bookDetail", () => {
this.ratingHover = 0; this.ratingHover = 0;
showToast("Rating cleared", "success"); showToast("Rating cleared", "success");
} catch (e) { } catch (e) {
showToast( showToast(e instanceof Error ? e.message : "Failed to clear rating", "error");
e instanceof Error ? e.message : "Failed to clear rating",
"error",
);
} finally { } finally {
this.ratingSaving = false; this.ratingSaving = false;
} }
@@ -691,10 +676,7 @@ Alpine.data("bookDetail", () => {
// Reload so the server-rendered card (or its absence) matches. // Reload so the server-rendered card (or its absence) matches.
window.location.reload(); window.location.reload();
} catch (e) { } catch (e) {
showToast( showToast(e instanceof Error ? e.message : "Failed to save review", "error");
e instanceof Error ? e.message : "Failed to save review",
"error",
);
this.reviewSaving = false; this.reviewSaving = false;
} }
}, },
@@ -714,10 +696,7 @@ Alpine.data("bookDetail", () => {
} }
window.location.reload(); window.location.reload();
} catch (e) { } catch (e) {
showToast( showToast(e instanceof Error ? e.message : "Failed to delete review", "error");
e instanceof Error ? e.message : "Failed to delete review",
"error",
);
this.reviewSaving = false; this.reviewSaving = false;
} }
}, },
@@ -732,23 +711,18 @@ Alpine.data("bookDetail", () => {
// conflict. The winner only needs to be a valid source key; the final // conflict. The winner only needs to be a valid source key; the final
// read/unread state is set by the progress write that follows. // read/unread state is set by the progress write that follows.
if (this.conflictId && this.conflictWinner) { if (this.conflictId && this.conflictWinner) {
const cr = await fetch( const cr = await fetch(`/api/conflicts/${this.conflictId}/resolve`, {
`/api/conflicts/${this.conflictId}/resolve`, method: "POST",
{ headers: {
method: "POST", Authorization: getAuthHeader(),
headers: { "Content-Type": "application/json",
Authorization: getAuthHeader(),
"Content-Type": "application/json",
},
body: JSON.stringify({ winner: this.conflictWinner }),
}, },
); body: JSON.stringify({ winner: this.conflictWinner }),
});
// 400 means it was already resolved - treat as no conflict. // 400 means it was already resolved - treat as no conflict.
if (!cr.ok && cr.status !== 400) { if (!cr.ok && cr.status !== 400) {
const err = await cr.json().catch(() => ({})); const err = await cr.json().catch(() => ({}));
throw new Error( throw new Error(err.error || err.message || "Failed to clear sync conflict");
err.error || err.message || "Failed to clear sync conflict",
);
} }
} }
@@ -780,16 +754,10 @@ Alpine.data("bookDetail", () => {
} }
} }
showToast( showToast(read ? "Marked as read" : "Marked as unread", "success");
read ? "Marked as read" : "Marked as unread",
"success",
);
setTimeout(() => window.location.reload(), 500); setTimeout(() => window.location.reload(), 500);
} catch (e) { } catch (e) {
showToast( showToast(e instanceof Error ? e.message : "Failed to update read state", "error");
e instanceof Error ? e.message : "Failed to update read state",
"error",
);
} finally { } finally {
this.readSaving = false; this.readSaving = false;
} }
@@ -805,9 +773,7 @@ Alpine.data("bookDetail", () => {
const reader = new FileReader(); const reader = new FileReader();
reader.onload = (e) => { reader.onload = (e) => {
const preview = document.getElementById( const preview = document.getElementById("metadata-cover-preview") as HTMLImageElement;
"metadata-cover-preview",
) as HTMLImageElement;
if (preview && e.target?.result) { if (preview && e.target?.result) {
preview.src = e.target.result as string; preview.src = e.target.result as string;
} }
@@ -820,9 +786,7 @@ Alpine.data("bookDetail", () => {
this.coverAction = "remove"; this.coverAction = "remove";
this.coverFile = null; this.coverFile = null;
this.newCoverPreview = ""; this.newCoverPreview = "";
const preview = document.getElementById( const preview = document.getElementById("metadata-cover-preview") as HTMLImageElement;
"metadata-cover-preview",
) as HTMLImageElement;
if (preview) { if (preview) {
preview.src = "/static/placeholder-book.svg"; preview.src = "/static/placeholder-book.svg";
} }
@@ -880,10 +844,7 @@ Alpine.data("bookDetail", () => {
showToast("Metadata saved successfully", "success"); showToast("Metadata saved successfully", "success");
setTimeout(() => window.location.reload(), 500); setTimeout(() => window.location.reload(), 500);
} catch (e) { } catch (e) {
showToast( showToast(e instanceof Error ? e.message : "Failed to save metadata", "error");
e instanceof Error ? e.message : "Failed to save metadata",
"error",
);
} finally { } finally {
this.saving = false; this.saving = false;
} }
@@ -905,10 +866,7 @@ Alpine.data("bookDetail", () => {
showToast("Book rescanned successfully", "success"); showToast("Book rescanned successfully", "success");
setTimeout(() => window.location.reload(), 500); setTimeout(() => window.location.reload(), 500);
} catch (e) { } catch (e) {
showToast( showToast(e instanceof Error ? e.message : "Failed to rescan book", "error");
e instanceof Error ? e.message : "Failed to rescan book",
"error",
);
} finally { } finally {
this.rescanning = false; this.rescanning = false;
} }
@@ -926,13 +884,10 @@ Alpine.data("bookDetail", () => {
this.resetting = true; this.resetting = true;
const mediaId = getMediaId(); const mediaId = getMediaId();
try { try {
const resp = await fetch( const resp = await fetch(`/api/media-items/${mediaId}/rescan?reset_overrides=true`, {
`/api/media-items/${mediaId}/rescan?reset_overrides=true`, method: "POST",
{ headers: { Authorization: getAuthHeader() },
method: "POST", });
headers: { Authorization: getAuthHeader() },
},
);
if (!resp.ok) { if (!resp.ok) {
const err = await resp.json().catch(() => ({})); const err = await resp.json().catch(() => ({}));
throw new Error(err.error || "Failed to reset metadata"); throw new Error(err.error || "Failed to reset metadata");
@@ -940,10 +895,7 @@ Alpine.data("bookDetail", () => {
showToast("Metadata reset to scanned defaults", "success"); showToast("Metadata reset to scanned defaults", "success");
setTimeout(() => window.location.reload(), 500); setTimeout(() => window.location.reload(), 500);
} catch (e) { } catch (e) {
showToast( showToast(e instanceof Error ? e.message : "Failed to reset metadata", "error");
e instanceof Error ? e.message : "Failed to reset metadata",
"error",
);
} finally { } finally {
this.resetting = false; this.resetting = false;
} }
@@ -958,7 +910,9 @@ Alpine.data("bookDetail", () => {
} }
const results = await searchTagSuggestions(this.tagSearch, libraryId); const results = await searchTagSuggestions(this.tagSearch, libraryId);
const current = this.editorTags.map((t: string) => t.toLowerCase()); 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.showTagDropdown = this.tagSuggestions.length > 0;
this.highlightedTagIndex = -1; this.highlightedTagIndex = -1;
}, },
@@ -984,7 +938,9 @@ Alpine.data("bookDetail", () => {
}, },
hideEditorTagDropdown() { hideEditorTagDropdown() {
setTimeout(() => { this.showTagDropdown = false; }, 200); setTimeout(() => {
this.showTagDropdown = false;
}, 200);
}, },
onTagKeydown(event: KeyboardEvent) { onTagKeydown(event: KeyboardEvent) {
@@ -996,9 +952,10 @@ Alpine.data("bookDetail", () => {
} else if (event.key === "ArrowUp") { } else if (event.key === "ArrowUp") {
event.preventDefault(); event.preventDefault();
if (this.showTagDropdown && this.tagSuggestions.length > 0) { if (this.showTagDropdown && this.tagSuggestions.length > 0) {
this.highlightedTagIndex = this.highlightedTagIndex <= 0 this.highlightedTagIndex =
? this.tagSuggestions.length - 1 this.highlightedTagIndex <= 0
: this.highlightedTagIndex - 1; ? this.tagSuggestions.length - 1
: this.highlightedTagIndex - 1;
} }
} else if (event.key === "Enter") { } else if (event.key === "Enter") {
event.preventDefault(); event.preventDefault();
+2 -7
View File
@@ -51,9 +51,7 @@ Alpine.store("bookPicker", {
const filterDiv = document.getElementById("book-picker-filters"); const filterDiv = document.getElementById("book-picker-filters");
if (!filterDiv) return; if (!filterDiv) return;
const inputs = filterDiv.querySelectorAll( const inputs = filterDiv.querySelectorAll('input[type="text"]') as NodeListOf<HTMLInputElement>;
'input[type="text"]',
) as NodeListOf<HTMLInputElement>;
inputs.forEach((input) => { inputs.forEach((input) => {
input.value = ""; input.value = "";
}); });
@@ -90,10 +88,7 @@ Alpine.store("bookPicker", {
}); });
if (response.ok) { if (response.ok) {
showToast( showToast(`Added ${this.selectedBooks.size} books to collection`, "success");
`Added ${this.selectedBooks.size} books to collection`,
"success",
);
this.close(); this.close();
location.reload(); location.reload();
} else { } else {
+58 -81
View File
@@ -2,10 +2,7 @@
import { Alpine } from "./alpine"; import { Alpine } from "./alpine";
import { showToast } from "./toast"; import { showToast } from "./toast";
import type { import type { FilterGroupState, FilterRuleState } from "./filter_builder";
FilterGroupState,
FilterRuleState,
} from "./filter_builder";
function clearFilters(): void { function clearFilters(): void {
// Clear all form state (reuses helper) // Clear all form state (reuses helper)
@@ -65,12 +62,8 @@ function clearFormWithoutSubmit(): void {
cleanupDynamicHiddenInputs(); cleanupDynamicHiddenInputs();
// Reset pagination to defaults // Reset pagination to defaults
const limitInput = filterForm.querySelector( const limitInput = filterForm.querySelector('input[name="limit"]') as HTMLInputElement;
'input[name="limit"]', const offsetInput = filterForm.querySelector('input[name="offset"]') as HTMLInputElement;
) as HTMLInputElement;
const offsetInput = filterForm.querySelector(
'input[name="offset"]',
) as HTMLInputElement;
if (limitInput) limitInput.value = "50"; if (limitInput) limitInput.value = "50";
if (offsetInput) offsetInput.value = "0"; if (offsetInput) offsetInput.value = "0";
@@ -100,22 +93,21 @@ Alpine.data("bookshelf", () => ({
// inputs are dropped outright) and re-apply the form. Chips refresh // inputs are dropped outright) and re-apply the form. Chips refresh
// via the shared htmx:afterRequest hook. // via the shared htmx:afterRequest hook.
removeFilterChip(param: string): void { removeFilterChip(param: string): void {
const filterForm = document.getElementById( const filterForm = document.getElementById("filter-form") as HTMLFormElement;
"filter-form",
) as HTMLFormElement;
if (!filterForm) return; if (!filterForm) return;
// Tree chips (qf mode): "rule:<path>" — remove that rule from the // Tree chips (qf mode): "rule:<path>" — remove that rule from the
// builder tree, resync the wire inputs, re-apply. // builder tree, resync the wire inputs, re-apply.
if (param.startsWith("rule:")) { if (param.startsWith("rule:")) {
const builder = builderData(); 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); builder?.removeNode(path);
window.htmx.trigger(filterForm, "submit"); window.htmx.trigger(filterForm, "submit");
return; return;
} }
const input = filterForm.querySelector( const input = filterForm.querySelector(`[name="${param}"]`) as HTMLInputElement | null;
`[name="${param}"]`,
) as HTMLInputElement | null;
if (input) { if (input) {
if (input.type === "hidden") { if (input.type === "hidden") {
input.remove(); input.remove();
@@ -225,14 +217,10 @@ Alpine.data("bookshelf", () => ({
// Parse filters JSON (string → object) // Parse filters JSON (string → object)
const filterData: Record<string, string> = const filterData: Record<string, string> =
typeof filter.filters === "string" typeof filter.filters === "string" ? JSON.parse(filter.filters) : filter.filters;
? JSON.parse(filter.filters)
: filter.filters;
// Get the hidden filter form // Get the hidden filter form
const filterForm = document.getElementById( const filterForm = document.getElementById("filter-form") as HTMLFormElement;
"filter-form",
) as HTMLFormElement;
if (!filterForm) { if (!filterForm) {
showToast("Filter form not found", "error"); showToast("Filter form not found", "error");
return; return;
@@ -250,9 +238,7 @@ Alpine.data("bookshelf", () => ({
// (qf wins over flat, the pinned precedence). // (qf wins over flat, the pinned precedence).
Object.entries(filterData).forEach(([key, value]) => { Object.entries(filterData).forEach(([key, value]) => {
if (!value) return; // Skip empty values if (!value) return; // Skip empty values
const visibleField = filterForm.querySelector( const visibleField = filterForm.querySelector(`[name="${key}"]`) as HTMLInputElement;
`[name="${key}"]`,
) as HTMLInputElement;
if (visibleField) { if (visibleField) {
if (visibleField.type === "checkbox") { if (visibleField.type === "checkbox") {
visibleField.checked = value === "true"; visibleField.checked = value === "true";
@@ -336,9 +322,7 @@ function applyURLFilters(filterForm: HTMLFormElement | null): boolean {
// ignored — the tree IS the query. // ignored — the tree IS the query.
const qf = params.get("qf"); const qf = params.get("qf");
if (qf) { if (qf) {
const input = filterForm.querySelector( const input = filterForm.querySelector('[name="qf"]') as HTMLInputElement | null;
'[name="qf"]',
) as HTMLInputElement | null;
if (input) { if (input) {
input.value = qf; input.value = qf;
} else { } else {
@@ -376,9 +360,7 @@ function applyURLFilters(filterForm: HTMLFormElement | null): boolean {
for (const name of filterNames) { for (const name of filterNames) {
const value = params.get(name); const value = params.get(name);
if (!value) continue; if (!value) continue;
const input = filterForm.querySelector( const input = filterForm.querySelector(`[name="${name}"]`) as HTMLInputElement | null;
`[name="${name}"]`,
) as HTMLInputElement | null;
if (input) { if (input) {
input.value = value; input.value = value;
applied = true; applied = true;
@@ -413,14 +395,10 @@ interface FilterChip {
function prettifyGenreLabel(value: string): string { function prettifyGenreLabel(value: string): string {
const trimmed = value.trim(); const trimmed = value.trim();
if (!trimmed || trimmed !== trimmed.toLowerCase()) return trimmed; if (!trimmed || trimmed !== trimmed.toLowerCase()) return trimmed;
const small = new Set([ const small = new Set(["a", "an", "and", "of", "the", "in", "on", "for", "to", "or"]);
"a", "an", "and", "of", "the", "in", "on", "for", "to", "or",
]);
return trimmed return trimmed
.split(/\s+/) .split(/\s+/)
.map((w, i) => .map((w, i) => (i > 0 && small.has(w) ? w : w.charAt(0).toUpperCase() + w.slice(1)))
i > 0 && small.has(w) ? w : w.charAt(0).toUpperCase() + w.slice(1),
)
.join(" "); .join(" ");
} }
@@ -450,8 +428,7 @@ function facetChipLabel(name: string, value: string): string {
abridged_filter: "Abridged", abridged_filter: "Abridged",
}; };
const label = names[name] ?? name; const label = names[name] ?? name;
const shown = const shown = name === "genre_filter" ? prettifyGenreLabel(value) : value;
name === "genre_filter" ? prettifyGenreLabel(value) : value;
return `${label}: ${shown}`; return `${label}: ${shown}`;
} }
@@ -459,9 +436,7 @@ function facetChipLabel(name: string, value: string): string {
// the shareable URL (history.replaceState) — the single sync point for // the shareable URL (history.replaceState) — the single sync point for
// form applies, URL deep-links, sort changes, and clears. // form applies, URL deep-links, sort changes, and clears.
function refreshFilterChips(): void { function refreshFilterChips(): void {
const filterForm = document.getElementById( const filterForm = document.getElementById("filter-form") as HTMLFormElement;
"filter-form",
) as HTMLFormElement;
const bookshelfElem = document.querySelector('[x-data*="bookshelf"]'); const bookshelfElem = document.querySelector('[x-data*="bookshelf"]');
if (!filterForm || !bookshelfElem || !(bookshelfElem as any)._x_dataStack) { if (!filterForm || !bookshelfElem || !(bookshelfElem as any)._x_dataStack) {
return; return;
@@ -470,9 +445,7 @@ function refreshFilterChips(): void {
if (typeof data.activeFilters === "undefined") return; if (typeof data.activeFilters === "undefined") return;
const chips: FilterChip[] = []; const chips: FilterChip[] = [];
const qfInput = filterForm.querySelector( const qfInput = filterForm.querySelector('[name="qf"]') as HTMLInputElement | null;
'[name="qf"]',
) as HTMLInputElement | null;
if (qfInput && qfInput.value) { if (qfInput && qfInput.value) {
// Tree mode: one chip per rule (red for negations — the chips // Tree mode: one chip per rule (red for negations — the chips
@@ -482,24 +455,20 @@ function refreshFilterChips(): void {
try { try {
const tree = JSON.parse(qfInput.value) as FilterGroupState; const tree = JSON.parse(qfInput.value) as FilterGroupState;
const walk = (group: FilterGroupState, path: number[]) => { const walk = (group: FilterGroupState, path: number[]) => {
(group.children as (FilterRuleState | FilterGroupState)[]).forEach( (group.children as (FilterRuleState | FilterGroupState)[]).forEach((child, idx) => {
(child, idx) => { const childPath = [...path, idx];
const childPath = [...path, idx]; if ("children" in (child as FilterGroupState)) {
if ("children" in (child as FilterGroupState)) { walk(child as FilterGroupState, childPath);
walk(child as FilterGroupState, childPath); return;
return; }
} const rule = child as FilterRuleState;
const rule = child as FilterRuleState; const label = builder ? builder.chipLabelFor(rule) : `${rule.field} ${rule.op}`;
const label = builder chips.push({
? builder.chipLabelFor(rule) param: "rule:" + childPath.join("-"),
: `${rule.field} ${rule.op}`; label,
chips.push({ negated: builder?.isNegated(rule) ?? false,
param: "rule:" + childPath.join("-"), });
label, });
negated: builder?.isNegated(rule) ?? false,
});
},
);
}; };
walk(tree, []); walk(tree, []);
} catch { } catch {
@@ -527,12 +496,27 @@ function refreshFilterChips(): void {
// shareable (plan: chips bar is URL-synced). // shareable (plan: chips bar is URL-synced).
const url = new URL(window.location.href); const url = new URL(window.location.href);
const keep = new Set([ const keep = new Set([
"author_filter", "series_filter", "genre_filter", "publisher_filter", "author_filter",
"tags_filter", "narrator_filter", "language_filter", "story_arc_filter", "series_filter",
"imprint_filter", "edition_filter", "age_rating_filter", "genre_filter",
"format_group_filter", "manga_type_filter", "reading_direction_filter", "publisher_filter",
"explicit_filter", "abridged_filter", "year_min", "year_max", "tags_filter",
"has_cover", "library_id", "qf", "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(); const next = new URLSearchParams();
url.searchParams.forEach((value, key) => { url.searchParams.forEach((value, key) => {
@@ -551,9 +535,7 @@ function refreshFilterChips(): void {
} }
function initBookshelf(): void { function initBookshelf(): void {
const filterForm = document.getElementById( const filterForm = document.getElementById("filter-form") as HTMLFormElement;
"filter-form",
) as HTMLFormElement;
// Active-filter chips refresh after every grid refetch — drawer // Active-filter chips refresh after every grid refetch — drawer
// applies, sort changes, and pagination all include the form's // 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 // The live match count is scope-aware: switching the top library
// picker re-runs it (the count fetch reads the select at fetch time — // picker re-runs it (the count fetch reads the select at fetch time —
// this is what makes it fire). // this is what makes it fire).
const librarySelect = document.getElementById( const librarySelect = document.getElementById("library-select") as HTMLSelectElement | null;
"library-select",
) as HTMLSelectElement | null;
librarySelect?.addEventListener("change", () => { librarySelect?.addEventListener("change", () => {
builderData()?.scheduleMatchCount(); builderData()?.scheduleMatchCount();
}); });
@@ -587,13 +567,10 @@ function initBookshelf(): void {
// Check if books were already rendered server-side // Check if books were already rendered server-side
const booksGrid = document.getElementById("books-grid"); const booksGrid = document.getElementById("books-grid");
const hasServerBooks = const hasServerBooks = booksGrid && booksGrid.querySelector("[data-book-id]") !== null;
booksGrid && booksGrid.querySelector("[data-book-id]") !== null;
if (!hasServerBooks || urlApplied) { if (!hasServerBooks || urlApplied) {
const librarySelect = document.getElementById( const librarySelect = document.getElementById("library-select") as HTMLSelectElement;
"library-select",
) as HTMLSelectElement;
librarySelect?.addEventListener("change", () => { librarySelect?.addEventListener("change", () => {
localStorage.setItem("selectedLibrary", librarySelect.value); localStorage.setItem("selectedLibrary", librarySelect.value);
}); });
+18 -41
View File
@@ -146,24 +146,14 @@ async function handleCreateRule(event: Event): Promise<void> {
if (!token || !collectionId) return; if (!token || !collectionId) return;
const fieldInput = document.getElementById("rule-field") as HTMLSelectElement; const fieldInput = document.getElementById("rule-field") as HTMLSelectElement;
const operatorInput = document.getElementById( const operatorInput = document.getElementById("rule-operator") as HTMLSelectElement;
"rule-operator",
) as HTMLSelectElement;
const valueInput = document.getElementById("rule-value") as HTMLInputElement; const valueInput = document.getElementById("rule-value") as HTMLInputElement;
const enabledInput = document.getElementById( const enabledInput = document.getElementById("rule-enabled") as HTMLInputElement;
"rule-enabled",
) as HTMLInputElement;
const priorityInput = document.querySelector( const priorityInput = document.querySelector(
'input[name="priority"]:checked', 'input[name="priority"]:checked',
) as HTMLInputElement; ) as HTMLInputElement;
if ( if (!fieldInput || !operatorInput || !valueInput || !enabledInput || !priorityInput) {
!fieldInput ||
!operatorInput ||
!valueInput ||
!enabledInput ||
!priorityInput
) {
showToast("Missing form fields", "error"); showToast("Missing form fields", "error");
return; return;
} }
@@ -204,9 +194,7 @@ async function testRule(): Promise<void> {
if (!token) return; if (!token) return;
const fieldInput = document.getElementById("rule-field") as HTMLSelectElement; const fieldInput = document.getElementById("rule-field") as HTMLSelectElement;
const operatorInput = document.getElementById( const operatorInput = document.getElementById("rule-operator") as HTMLSelectElement;
"rule-operator",
) as HTMLSelectElement;
const valueInput = document.getElementById("rule-value") as HTMLInputElement; const valueInput = document.getElementById("rule-value") as HTMLInputElement;
if (!fieldInput || !operatorInput || !valueInput) { if (!fieldInput || !operatorInput || !valueInput) {
@@ -256,8 +244,7 @@ async function testRule(): Promise<void> {
'<p style="color: var(--text-secondary); font-size: 0.875rem;">Found ' + '<p style="color: var(--text-secondary); font-size: 0.875rem;">Found ' +
result.matches.length + result.matches.length +
" matching books:</p>"; " matching books:</p>";
html += html += '<div style="display: flex; flex-direction: column; gap: 0.5rem;">';
'<div style="display: flex; flex-direction: column; gap: 0.5rem;">';
result.matches.slice(0, 20).forEach((book: TestRuleMatch) => { result.matches.slice(0, 20).forEach((book: TestRuleMatch) => {
html += html +=
@@ -316,17 +303,14 @@ async function toggleRule(ruleId: string, enabled: boolean): Promise<void> {
if (!token || !collectionId) return; if (!token || !collectionId) return;
try { try {
const response = await fetch( const response = await fetch(`/api/collections/${collectionId}/rules/${ruleId}`, {
`/api/collections/${collectionId}/rules/${ruleId}`, method: "PUT",
{ headers: {
method: "PUT", "Content-Type": "application/json",
headers: { Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ enabled }),
}, },
); body: JSON.stringify({ enabled }),
});
if (response.ok) { if (response.ok) {
showToast("Rule updated", "success"); showToast("Rule updated", "success");
@@ -347,13 +331,10 @@ async function deleteRule(ruleId: string): Promise<void> {
if (!token || !collectionId) return; if (!token || !collectionId) return;
try { try {
const response = await fetch( const response = await fetch(`/api/collections/${collectionId}/rules/${ruleId}`, {
`/api/collections/${collectionId}/rules/${ruleId}`, method: "DELETE",
{ headers: { Authorization: `Bearer ${token}` },
method: "DELETE", });
headers: { Authorization: `Bearer ${token}` },
},
);
if (response.ok) { if (response.ok) {
showToast("Rule deleted", "success"); showToast("Rule deleted", "success");
@@ -369,13 +350,9 @@ async function deleteRule(ruleId: string): Promise<void> {
function clearForm(): void { function clearForm(): void {
const fieldInput = document.getElementById("rule-field") as HTMLSelectElement; const fieldInput = document.getElementById("rule-field") as HTMLSelectElement;
const operatorInput = document.getElementById( const operatorInput = document.getElementById("rule-operator") as HTMLSelectElement;
"rule-operator",
) as HTMLSelectElement;
const valueInput = document.getElementById("rule-value") as HTMLInputElement; const valueInput = document.getElementById("rule-value") as HTMLInputElement;
const enabledInput = document.getElementById( const enabledInput = document.getElementById("rule-enabled") as HTMLInputElement;
"rule-enabled",
) as HTMLInputElement;
const priorityInput = document.querySelector( const priorityInput = document.querySelector(
'input[name="priority"][value="2"]', 'input[name="priority"][value="2"]',
) as HTMLInputElement; ) as HTMLInputElement;
+53 -114
View File
@@ -1,10 +1,7 @@
import { Alpine } from "./alpine"; import { Alpine } from "./alpine";
import { showToast } from "./toast"; import { showToast } from "./toast";
import { createWebSocket } from "./websocket"; import { createWebSocket } from "./websocket";
import { import { initLibrarySwitcher, switchWithTransition } from "./library-switcher";
initLibrarySwitcher,
switchWithTransition,
} from "./library-switcher";
let collectionId: string | null = null; let collectionId: string | null = null;
@@ -18,10 +15,7 @@ function initializeCollectionWebSocket(): void {
createWebSocket({ createWebSocket({
onMessage: (message) => { onMessage: (message) => {
if ( if (message.type === "collection_updated" && message.data.collection_id === collectionId) {
message.type === "collection_updated" &&
message.data.collection_id === collectionId
) {
const actionText = const actionText =
message.data.action === "books_added" message.data.action === "books_added"
? `Added ${message.data.count || 0} book(s)` ? `Added ${message.data.count || 0} book(s)`
@@ -100,10 +94,7 @@ function renderRules(rules: CollectionRule[]): void {
.join(""); .join("");
} }
async function createRule( async function createRule(collectionId: string, rule: Partial<CollectionRule>): Promise<void> {
collectionId: string,
rule: Partial<CollectionRule>,
): Promise<void> {
const token = localStorage.getItem("token"); const token = localStorage.getItem("token");
if (!token) return; if (!token) return;
@@ -137,13 +128,10 @@ async function deleteRule(collectionId: string, ruleId: string): Promise<void> {
if (!confirm("Are you sure you want to delete this rule?")) return; if (!confirm("Are you sure you want to delete this rule?")) return;
try { try {
const response = await fetch( const response = await fetch(`/api/collections/${collectionId}/rules/${ruleId}`, {
`/api/collections/${collectionId}/rules/${ruleId}`, method: "DELETE",
{ headers: { Authorization: `Bearer ${token}` },
method: "DELETE", });
headers: { Authorization: `Bearer ${token}` },
},
);
if (response.ok) { if (response.ok) {
showToast("Rule deleted", "success"); showToast("Rule deleted", "success");
@@ -155,25 +143,19 @@ async function deleteRule(collectionId: string, ruleId: string): Promise<void> {
} }
} }
async function testRule( async function testRule(collectionId: string, rule: Partial<CollectionRule>): Promise<void> {
collectionId: string,
rule: Partial<CollectionRule>,
): Promise<void> {
const token = localStorage.getItem("token"); const token = localStorage.getItem("token");
if (!token) return; if (!token) return;
try { try {
const response = await fetch( const response = await fetch(`/api/collections/${collectionId}/rules/test`, {
`/api/collections/${collectionId}/rules/test`, method: "POST",
{ headers: {
method: "POST", Authorization: `Bearer ${token}`,
headers: { "Content-Type": "application/json",
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify(rule),
}, },
); body: JSON.stringify(rule),
});
if (response.ok) { if (response.ok) {
const results = await response.json(); const results = await response.json();
@@ -229,9 +211,7 @@ const borderClasses: Record<string, string> = {
}; };
function selectColor(color: string): void { function selectColor(color: string): void {
const colorInput = document.getElementById( const colorInput = document.getElementById("collection-color") as HTMLInputElement;
"collection-color",
) as HTMLInputElement;
if (colorInput) { if (colorInput) {
colorInput.value = color; colorInput.value = color;
} }
@@ -239,9 +219,7 @@ function selectColor(color: string): void {
(btn as HTMLElement).style.outline = "none"; (btn as HTMLElement).style.outline = "none";
(btn as HTMLElement).style.outlineOffset = "0"; (btn as HTMLElement).style.outlineOffset = "0";
}); });
const selectedBtn = document.querySelector( const selectedBtn = document.querySelector(`.color-option[data-color="${color}"]`) as HTMLElement;
`.color-option[data-color="${color}"]`,
) as HTMLElement;
if (selectedBtn) { if (selectedBtn) {
selectedBtn.style.outline = "3px solid var(--text-primary)"; selectedBtn.style.outline = "3px solid var(--text-primary)";
selectedBtn.style.outlineOffset = "3px"; selectedBtn.style.outlineOffset = "3px";
@@ -256,9 +234,7 @@ function closeCollectionModal(): void {
} }
function initColorSelection(): void { function initColorSelection(): void {
const colorInput = document.getElementById( const colorInput = document.getElementById("collection-color") as HTMLInputElement;
"collection-color",
) as HTMLInputElement;
if (colorInput && colorInput.value) { if (colorInput && colorInput.value) {
selectColor(colorInput.value); selectColor(colorInput.value);
} }
@@ -291,9 +267,7 @@ function setupHTMXModalInit(): void {
function showAllIcons(): void { function showAllIcons(): void {
populateIconGrid(); populateIconGrid();
const searchInput = document.getElementById( const searchInput = document.getElementById("icon-search") as HTMLInputElement;
"icon-search",
) as HTMLInputElement;
if (searchInput) { if (searchInput) {
searchInput.value = ""; searchInput.value = "";
} }
@@ -359,12 +333,8 @@ function populateIconGrid(): void {
} }
function selectIcon(icon: string): void { function selectIcon(icon: string): void {
const iconInput = document.getElementById( const iconInput = document.getElementById("collection-icon") as HTMLInputElement;
"collection-icon", const searchInput = document.getElementById("icon-search") as HTMLInputElement;
) as HTMLInputElement;
const searchInput = document.getElementById(
"icon-search",
) as HTMLInputElement;
if (iconInput) iconInput.value = icon; if (iconInput) iconInput.value = icon;
if (searchInput) searchInput.value = icon; if (searchInput) searchInput.value = icon;
@@ -374,9 +344,7 @@ function selectIcon(icon: string): void {
(btn as HTMLElement).style.backgroundColor = ""; (btn as HTMLElement).style.backgroundColor = "";
}); });
const selectedBtn = document.querySelector( const selectedBtn = document.querySelector(`.icon-btn[data-emoji="${icon}"]`) as HTMLElement;
`.icon-btn[data-emoji="${icon}"]`,
) as HTMLElement;
if (selectedBtn) { if (selectedBtn) {
selectedBtn.style.outline = "2px solid var(--text-primary)"; selectedBtn.style.outline = "2px solid var(--text-primary)";
selectedBtn.style.backgroundColor = "var(--bg-secondary)"; selectedBtn.style.backgroundColor = "var(--bg-secondary)";
@@ -385,9 +353,7 @@ function selectIcon(icon: string): void {
function filterIcons(searchTerm: string): void { function filterIcons(searchTerm: string): void {
const iconGrid = document.getElementById("icon-grid"); const iconGrid = document.getElementById("icon-grid");
const iconInput = document.getElementById( const iconInput = document.getElementById("collection-icon") as HTMLInputElement;
"collection-icon",
) as HTMLInputElement;
if (iconInput) iconInput.value = searchTerm; if (iconInput) iconInput.value = searchTerm;
if (!iconGrid) return; if (!iconGrid) return;
const buttons = iconGrid.querySelectorAll(".icon-btn"); const buttons = iconGrid.querySelectorAll(".icon-btn");
@@ -400,9 +366,7 @@ function filterIcons(searchTerm: string): void {
const matches = const matches =
searchTerm === "" || searchTerm === "" ||
emoji.includes(searchTerm) || emoji.includes(searchTerm) ||
keywords.some((keyword) => keywords.some((keyword) => keyword.toLowerCase().includes(lowerSearchTerm));
keyword.toLowerCase().includes(lowerSearchTerm),
);
(btn as HTMLElement).style.display = matches ? "" : "none"; (btn as HTMLElement).style.display = matches ? "" : "none";
}); });
@@ -427,11 +391,10 @@ function initCollectionsPage(): void {
// Rendering ownership: the grid is server-rendered markup. Swap // Rendering ownership: the grid is server-rendered markup. Swap
// in the templ partial instead of re-rendering from JSON. // in the templ partial instead of re-rendering from JSON.
const param = libraryId ? `?library_id=${encodeURIComponent(libraryId)}` : ""; const param = libraryId ? `?library_id=${encodeURIComponent(libraryId)}` : "";
await window.htmx.ajax( await window.htmx.ajax("GET", `/collections/${collId}/books-grid${param}`, {
"GET", target: "#books-container",
`/collections/${collId}/books-grid${param}`, swap: "innerHTML",
{ target: "#books-container", swap: "innerHTML" }, });
);
const container = document.getElementById("books-container"); const container = document.getElementById("books-container");
if (container) Alpine.initTree(container); if (container) Alpine.initTree(container);
}); });
@@ -461,13 +424,10 @@ async function doRemoveBook(id: string): Promise<void> {
const token = localStorage.getItem("token"); const token = localStorage.getItem("token");
if (!token) return; if (!token) return;
try { try {
const response = await fetch( const response = await fetch(`/api/collections/${getCollectionId()}/books/${id}`, {
`/api/collections/${getCollectionId()}/books/${id}`, method: "DELETE",
{ headers: { Authorization: `Bearer ${token}` },
method: "DELETE", });
headers: { Authorization: `Bearer ${token}` },
},
);
if (response.ok) { if (response.ok) {
showToast("Removed from collection", "success"); showToast("Removed from collection", "success");
setTimeout(() => location.reload(), 500); setTimeout(() => location.reload(), 500);
@@ -483,17 +443,14 @@ async function doBulkRemove(ids: string[]): Promise<void> {
const token = localStorage.getItem("token"); const token = localStorage.getItem("token");
if (!token) return; if (!token) return;
try { try {
const response = await fetch( const response = await fetch(`/api/collections/${getCollectionId()}/books/bulk-remove`, {
`/api/collections/${getCollectionId()}/books/bulk-remove`, method: "POST",
{ headers: {
method: "POST", "Content-Type": "application/json",
headers: { Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ book_ids: ids }),
}, },
); body: JSON.stringify({ book_ids: ids }),
});
if (response.ok) { if (response.ok) {
showToast(`Removed ${ids.length} book(s)`, "success"); showToast(`Removed ${ids.length} book(s)`, "success");
setTimeout(() => location.reload(), 500); setTimeout(() => location.reload(), 500);
@@ -506,19 +463,14 @@ async function doBulkRemove(ids: string[]): Promise<void> {
} }
function filterCollectionBooks(): void { function filterCollectionBooks(): void {
const input = document.getElementById( const input = document.getElementById("collection-search") as HTMLInputElement;
"collection-search",
) as HTMLInputElement;
if (!input) return; if (!input) return;
const query = input.value.toLowerCase(); const query = input.value.toLowerCase();
const cards = document.querySelectorAll<HTMLElement>( const cards = document.querySelectorAll<HTMLElement>(".collection-book-card");
".collection-book-card",
);
cards.forEach((card) => { cards.forEach((card) => {
const title = (card.dataset.title || "").toLowerCase(); const title = (card.dataset.title || "").toLowerCase();
const author = (card.dataset.author || "").toLowerCase(); const author = (card.dataset.author || "").toLowerCase();
card.style.display = card.style.display = title.includes(query) || author.includes(query) ? "" : "none";
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 // 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 // order; the API is the truth for SAVED order. On failure the grid refresh
// from the templ partial so the UI never lies. // from the templ partial so the UI never lies.
async function moveCollectionBook( async function moveCollectionBook(this: any, mediaId: string, direction: number): Promise<void> {
this: any,
mediaId: string,
direction: number,
): Promise<void> {
const container = document.getElementById("books-container"); const container = document.getElementById("books-container");
if (!container) return; if (!container) return;
const cards = Array.from( const cards = Array.from(container.querySelectorAll<HTMLElement>(".collection-book-card"));
container.querySelectorAll<HTMLElement>(".collection-book-card"), const index = cards.findIndex((c) => c.dataset.mediaId === mediaId);
);
const index = cards.findIndex(
(c) => c.dataset.mediaId === mediaId,
);
const target = index + direction; const target = index + direction;
if (index < 0 || target < 0 || target >= cards.length) return; if (index < 0 || target < 0 || target >= cards.length) return;
@@ -624,26 +568,21 @@ async function moveCollectionBook(
container.insertBefore(cards[target], cards[index]); container.insertBefore(cards[target], cards[index]);
} }
const orderedIds = Array.from( const orderedIds = Array.from(container.querySelectorAll<HTMLElement>(".collection-book-card"))
container.querySelectorAll<HTMLElement>(".collection-book-card"),
)
.map((c) => c.dataset.mediaId) .map((c) => c.dataset.mediaId)
.filter((id): id is string => Boolean(id)); .filter((id): id is string => Boolean(id));
const token = localStorage.getItem("token"); const token = localStorage.getItem("token");
if (!token) return; if (!token) return;
try { try {
const response = await fetch( const response = await fetch(`/api/collections/${getCollectionId()}/items/order`, {
`/api/collections/${getCollectionId()}/items/order`, method: "PUT",
{ headers: {
method: "PUT", Authorization: `Bearer ${token}`,
headers: { "Content-Type": "application/json",
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ item_ids: orderedIds }),
}, },
); body: JSON.stringify({ item_ids: orderedIds }),
});
if (!response.ok) { if (!response.ok) {
showToast("Failed to save the new order", "error"); showToast("Failed to save the new order", "error");
// Rendering ownership: the templ partial re-renders the true order. // Rendering ownership: the templ partial re-renders the true order.
+8 -24
View File
@@ -24,9 +24,7 @@ async function refreshConflicts(): Promise<void> {
// ownership: JS decides what shows; templ rendered what they look like). // ownership: JS decides what shows; templ rendered what they look like).
function filterConflicts(): void { function filterConflicts(): void {
const select = document.getElementById( const select = document.getElementById("status-filter") as HTMLSelectElement | null;
"status-filter",
) as HTMLSelectElement | null;
const filter = select?.value ?? "unresolved"; const filter = select?.value ?? "unresolved";
let total = 0; let total = 0;
let visible = 0; let visible = 0;
@@ -44,9 +42,7 @@ function filterConflicts(): void {
} }
function toggleAllConflicts(): void { function toggleAllConflicts(): void {
const selectAll = document.getElementById( const selectAll = document.getElementById("select-all-conflicts") as HTMLInputElement | null;
"select-all-conflicts",
) as HTMLInputElement | null;
if (!selectAll) return; if (!selectAll) return;
document.querySelectorAll<HTMLElement>(".conflict-item").forEach((item) => { document.querySelectorAll<HTMLElement>(".conflict-item").forEach((item) => {
if (item.style.display === "none") return; if (item.style.display === "none") return;
@@ -57,9 +53,7 @@ function toggleAllConflicts(): void {
} }
function getSelectedConflictIds(): string[] { function getSelectedConflictIds(): string[] {
return Array.from( return Array.from(document.querySelectorAll<HTMLInputElement>(".conflict-checkbox:checked"))
document.querySelectorAll<HTMLInputElement>(".conflict-checkbox:checked"),
)
.map((box) => box.dataset.conflictId || "") .map((box) => box.dataset.conflictId || "")
.filter((id) => id !== ""); .filter((id) => id !== "");
} }
@@ -75,12 +69,8 @@ function updateBulkActions(): void {
function resolveSelected(): void { function resolveSelected(): void {
const ids = getSelectedConflictIds(); const ids = getSelectedConflictIds();
if (ids.length === 0) return; if (ids.length === 0) return;
const strategyEl = document.getElementById( const strategyEl = document.getElementById("bulk-strategy") as HTMLSelectElement | null;
"bulk-strategy", const strategy = (strategyEl?.value ?? "most_recent") as "most_recent" | "highest_progress";
) as HTMLSelectElement | null;
const strategy = (strategyEl?.value ?? "most_recent") as
| "most_recent"
| "highest_progress";
void bulkResolve(strategy, ids); void bulkResolve(strategy, ids);
} }
@@ -195,9 +185,7 @@ async function dismissAllResolved(): Promise<void> {
function showResolveModal(conflictId: string): void { function showResolveModal(conflictId: string): void {
const modal = document.getElementById("resolve-modal"); const modal = document.getElementById("resolve-modal");
const conflictIdInput = document.getElementById( const conflictIdInput = document.getElementById("resolve-conflict-id") as HTMLInputElement;
"resolve-conflict-id",
) as HTMLInputElement;
if (modal && conflictIdInput) { if (modal && conflictIdInput) {
conflictIdInput.value = conflictId; conflictIdInput.value = conflictId;
@@ -216,12 +204,8 @@ function handleResolveSubmit(event: Event): void {
event.preventDefault(); event.preventDefault();
const form = event.target as HTMLFormElement; const form = event.target as HTMLFormElement;
const conflictId = ( const conflictId = (form.querySelector("#resolve-conflict-id") as HTMLInputElement)?.value;
form.querySelector("#resolve-conflict-id") as HTMLInputElement const winner = (form.querySelector('input[name="winner"]:checked') as HTMLInputElement)?.value;
)?.value;
const winner = (
form.querySelector('input[name="winner"]:checked') as HTMLInputElement
)?.value;
if (!conflictId || !winner) { if (!conflictId || !winner) {
showToast("Please select a winner", "error"); showToast("Please select a winner", "error");
+22 -76
View File
@@ -174,16 +174,7 @@ const FILTER_FIELDS: FilterField[] = [
{ id: "in", label: "In", requiresValue: true }, { id: "in", label: "In", requiresValue: true },
], ],
valueType: "select", valueType: "select",
options: [ options: ["English", "Spanish", "French", "German", "Japanese", "Chinese", "Russian", "Other"],
"English",
"Spanish",
"French",
"German",
"Japanese",
"Chinese",
"Russian",
"Other",
],
}, },
{ {
id: "format", id: "format",
@@ -300,45 +291,28 @@ function addFilterRule(): void {
container.appendChild(ruleElement); container.appendChild(ruleElement);
const fieldSelect = ruleElement.querySelector( const fieldSelect = ruleElement.querySelector(".field-select") as HTMLSelectElement;
".field-select", const removeBtn = ruleElement.querySelector(".remove-rule-btn") as HTMLButtonElement;
) as HTMLSelectElement;
const removeBtn = ruleElement.querySelector(
".remove-rule-btn",
) as HTMLButtonElement;
fieldSelect.addEventListener("change", () => onFieldChange(ruleElement)); fieldSelect.addEventListener("change", () => onFieldChange(ruleElement));
removeBtn.addEventListener("click", () => removeFilterRule(ruleId)); removeBtn.addEventListener("click", () => removeFilterRule(ruleId));
} }
function onFieldChange(ruleElement: HTMLElement): void { function onFieldChange(ruleElement: HTMLElement): void {
const fieldSelect = ruleElement.querySelector( const fieldSelect = ruleElement.querySelector(".field-select") as HTMLSelectElement;
".field-select", const operatorSelect = ruleElement.querySelector(".operator-select") as HTMLSelectElement;
) as HTMLSelectElement; const valueInput = ruleElement.querySelector(".value-input") as HTMLInputElement;
const operatorSelect = ruleElement.querySelector(
".operator-select",
) as HTMLSelectElement;
const valueInput = ruleElement.querySelector(
".value-input",
) as HTMLInputElement;
const fieldId = fieldSelect.value; const fieldId = fieldSelect.value;
const field = FILTER_FIELDS.find((f) => f.id === fieldId); const field = FILTER_FIELDS.find((f) => f.id === fieldId);
operatorSelect.innerHTML = field operatorSelect.innerHTML = field
? field.operators ? field.operators.map((op) => `<option value="${op.id}">${op.label}</option>`).join("")
.map((op) => `<option value="${op.id}">${op.label}</option>`)
.join("")
: '<option value="">Select field first...</option>'; : '<option value="">Select field first...</option>';
operatorSelect.disabled = !field; operatorSelect.disabled = !field;
if ( if (field && field.operators.some((op) => op.id === operatorSelect.value && op.requiresValue)) {
field &&
field.operators.some(
(op) => op.id === operatorSelect.value && op.requiresValue,
)
) {
valueInput.classList.remove("hidden"); valueInput.classList.remove("hidden");
if (field.valueType === "select" && field.options) { if (field.valueType === "select" && field.options) {
@@ -356,9 +330,7 @@ function onFieldChange(ruleElement: HTMLElement): void {
} }
operatorSelect.addEventListener("change", () => { operatorSelect.addEventListener("change", () => {
const selectedOp = field?.operators.find( const selectedOp = field?.operators.find((op) => op.id === operatorSelect.value);
(op) => op.id === operatorSelect.value,
);
if (selectedOp?.requiresValue) { if (selectedOp?.requiresValue) {
valueInput.classList.remove("hidden"); valueInput.classList.remove("hidden");
} else { } else {
@@ -384,15 +356,9 @@ function onBookSearchInput(): void {
} }
async function searchBooks(): Promise<void> { async function searchBooks(): Promise<void> {
const searchInput = document.getElementById( const searchInput = document.getElementById("book-search") as HTMLInputElement;
"book-search", const librarySelect = document.getElementById("section-library") as HTMLSelectElement;
) as HTMLInputElement; const resultsContainer = document.getElementById("search-results") as HTMLElement;
const librarySelect = document.getElementById(
"section-library",
) as HTMLSelectElement;
const resultsContainer = document.getElementById(
"search-results",
) as HTMLElement;
const query = searchInput?.value.trim(); const query = searchInput?.value.trim();
const libraryId = librarySelect?.value; const libraryId = librarySelect?.value;
@@ -426,9 +392,7 @@ async function searchBooks(): Promise<void> {
} }
function displaySearchResults(books: BookInfo[]): void { function displaySearchResults(books: BookInfo[]): void {
const resultsContainer = document.getElementById( const resultsContainer = document.getElementById("search-results") as HTMLElement;
"search-results",
) as HTMLElement;
if (!resultsContainer) return; if (!resultsContainer) return;
if (books.length === 0) { if (books.length === 0) {
@@ -458,11 +422,7 @@ function displaySearchResults(books: BookInfo[]): void {
resultsContainer.classList.remove("hidden"); resultsContainer.classList.remove("hidden");
} }
const addBookToSelection = ( const addBookToSelection = (bookId: string, title: string, author: string): void => {
bookId: string,
title: string,
author: string,
): void => {
if (selectedBooks.has(bookId)) { if (selectedBooks.has(bookId)) {
showToast("Book already selected", "error"); showToast("Book already selected", "error");
return; return;
@@ -508,12 +468,8 @@ function updateSelectedBooksDisplay(): void {
} }
async function loadPreview(): Promise<void> { async function loadPreview(): Promise<void> {
const previewContainer = document.getElementById( const previewContainer = document.getElementById("preview-container") as HTMLElement;
"preview-container", const librarySelect = document.getElementById("section-library") as HTMLSelectElement;
) as HTMLElement;
const librarySelect = document.getElementById(
"section-library",
) as HTMLSelectElement;
const libraryId = librarySelect?.value; const libraryId = librarySelect?.value;
if (!libraryId) { if (!libraryId) {
@@ -543,8 +499,7 @@ async function loadPreview(): Promise<void> {
} }
} catch (error) { } catch (error) {
console.error("Preview error:", error); console.error("Preview error:", error);
previewContainer.innerHTML = previewContainer.innerHTML = '<p class="text-center text-red-500">Failed to load preview</p>';
'<p class="text-center text-red-500">Failed to load preview</p>';
} }
} }
@@ -556,15 +511,9 @@ function gatherFilterRules(): FilterRule[] {
const rules: FilterRule[] = []; const rules: FilterRule[] = [];
ruleElements.forEach((element, index) => { ruleElements.forEach((element, index) => {
const fieldSelect = element.querySelector( const fieldSelect = element.querySelector(".field-select") as HTMLSelectElement;
".field-select", const operatorSelect = element.querySelector(".operator-select") as HTMLSelectElement;
) as HTMLSelectElement; const valueInput = element.querySelector(".value-input") as HTMLInputElement;
const operatorSelect = element.querySelector(
".operator-select",
) as HTMLSelectElement;
const valueInput = element.querySelector(
".value-input",
) as HTMLInputElement;
if (fieldSelect.value && operatorSelect.value) { if (fieldSelect.value && operatorSelect.value) {
rules.push({ rules.push({
@@ -581,9 +530,7 @@ function gatherFilterRules(): FilterRule[] {
} }
function displayPreview(items: BookInfo[]): void { function displayPreview(items: BookInfo[]): void {
const previewContainer = document.getElementById( const previewContainer = document.getElementById("preview-container") as HTMLElement;
"preview-container",
) as HTMLElement;
if (!previewContainer) return; if (!previewContainer) return;
if (items.length === 0) { if (items.length === 0) {
@@ -626,8 +573,7 @@ async function saveCustomSection(event: Event): Promise<void> {
const name = formData.get("name") as string; const name = formData.get("name") as string;
const icon = formData.get("icon") as string; const icon = formData.get("icon") as string;
const description = formData.get("description") as string; const description = formData.get("description") as string;
const matchType = (document.getElementById("match-type") as HTMLSelectElement) const matchType = (document.getElementById("match-type") as HTMLSelectElement).value;
.value;
if (!libraryId || !name) { if (!libraryId || !name) {
showToast("Please fill in required fields", "error"); showToast("Please fill in required fields", "error");
+46 -115
View File
@@ -7,9 +7,7 @@ const SCROLL_AMOUNT = 300;
let scanListenerRegistered = false; let scanListenerRegistered = false;
function scrollCarousel(collectionId: string, direction: number): void { function scrollCarousel(collectionId: string, direction: number): void {
const track = document.getElementById( const track = document.getElementById(`carousel-track-${collectionId}`) as HTMLElement;
`carousel-track-${collectionId}`,
) as HTMLElement;
if (!track) return; if (!track) return;
const scrollAmount = direction * SCROLL_AMOUNT; const scrollAmount = direction * SCROLL_AMOUNT;
@@ -17,20 +15,15 @@ function scrollCarousel(collectionId: string, direction: number): void {
} }
async function openDashboardSettings(): Promise<void> { async function openDashboardSettings(): Promise<void> {
const librarySelect = document.getElementById( const librarySelect = document.getElementById("library-select") as HTMLSelectElement;
"library-select",
) as HTMLSelectElement;
const libraryId = librarySelect?.value || ""; const libraryId = librarySelect?.value || "";
// Empty select = the All Libraries view: its preferences are the GLOBAL // Empty select = the All Libraries view: its preferences are the GLOBAL
// scope — the server returns that row when library_id is absent. // scope — the server returns that row when library_id is absent.
const query = libraryId ? `?library_id=${encodeURIComponent(libraryId)}` : ""; const query = libraryId ? `?library_id=${encodeURIComponent(libraryId)}` : "";
const response = await fetch( const response = await fetch(`/api/dashboard/preferences${query}`, {
`/api/dashboard/preferences${query}`, headers: { Authorization: `Bearer ${localStorage.getItem("token")}` },
{ });
headers: { Authorization: `Bearer ${localStorage.getItem("token")}` },
},
);
if (response.ok) { if (response.ok) {
const prefs = await response.json(); const prefs = await response.json();
@@ -38,11 +31,8 @@ async function openDashboardSettings(): Promise<void> {
// Scope indicator: make it unmistakable what a save will customize. // Scope indicator: make it unmistakable what a save will customize.
const scopeEl = document.getElementById("dashboard-scope-label"); const scopeEl = document.getElementById("dashboard-scope-label");
if (scopeEl) { if (scopeEl) {
const optionText = const optionText = librarySelect?.selectedOptions[0]?.textContent || libraryId;
librarySelect?.selectedOptions[0]?.textContent || libraryId; scopeEl.textContent = (libraryId ? optionText : optionText || "All Libraries")
scopeEl.textContent = (
libraryId ? optionText : optionText || "All Libraries"
)
.replace(/\s*\(\d+\)$/, "") .replace(/\s*\(\d+\)$/, "")
.trim(); .trim();
} }
@@ -52,16 +42,12 @@ async function openDashboardSettings(): Promise<void> {
return; return;
} }
const modal = document.getElementById( const modal = document.getElementById("dashboard-settings-modal") as HTMLElement;
"dashboard-settings-modal",
) as HTMLElement;
modal?.classList.remove("hidden"); modal?.classList.remove("hidden");
} }
function applyPreferencesToModal(prefs: any): void { function applyPreferencesToModal(prefs: any): void {
const collectionList = document.getElementById( const collectionList = document.getElementById("collection-list") as HTMLElement;
"collection-list",
) as HTMLElement;
if (!collectionList) return; if (!collectionList) return;
const slider = document.querySelector( const slider = document.querySelector(
@@ -73,16 +59,12 @@ function applyPreferencesToModal(prefs: any): void {
display.textContent = String(prefs.items_per_section); display.textContent = String(prefs.items_per_section);
} }
const items = collectionList.querySelectorAll( const items = collectionList.querySelectorAll("[data-collection-id]") as NodeListOf<HTMLElement>;
"[data-collection-id]",
) as NodeListOf<HTMLElement>;
const orderedItems: HTMLElement[] = []; const orderedItems: HTMLElement[] = [];
items.forEach((item) => { items.forEach((item) => {
const collectionId = item.dataset.collectionId; const collectionId = item.dataset.collectionId;
const checkbox = item.querySelector( const checkbox = item.querySelector('input[type="checkbox"]') as HTMLInputElement;
'input[type="checkbox"]',
) as HTMLInputElement;
const isHidden = prefs.hidden_collections.includes(collectionId); const isHidden = prefs.hidden_collections.includes(collectionId);
if (checkbox) checkbox.checked = !isHidden; if (checkbox) checkbox.checked = !isHidden;
@@ -99,32 +81,25 @@ function applyPreferencesToModal(prefs: any): void {
} }
function closeDashboardSettings(): void { function closeDashboardSettings(): void {
const modal = document.getElementById( const modal = document.getElementById("dashboard-settings-modal") as HTMLElement;
"dashboard-settings-modal",
) as HTMLElement;
modal?.classList.add("hidden"); modal?.classList.add("hidden");
} }
async function fetchAndRenderSections(libraryId: string): Promise<void> { async function fetchAndRenderSections(libraryId: string): Promise<void> {
const param = libraryId ? `library_id=${libraryId}` : ""; const param = libraryId ? `library_id=${libraryId}` : "";
const response = await fetch( const response = await fetch(`/api/dashboard/sections?${param}`, {
`/api/dashboard/sections?${param}`, headers: {
{ Authorization: `Bearer ${localStorage.getItem("token")}`,
headers: { "Content-Type": "application/json",
Authorization: `Bearer ${localStorage.getItem("token")}`,
"Content-Type": "application/json",
},
}, },
); });
if (!response.ok) throw new Error("Failed to load sections"); if (!response.ok) throw new Error("Failed to load sections");
const data = await response.json(); const data = await response.json();
renderDashboardCollections(data.sections); renderDashboardCollections(data.sections);
} }
async function saveDashboardSettings(): Promise<void> { async function saveDashboardSettings(): Promise<void> {
const collectionList = document.getElementById( const collectionList = document.getElementById("collection-list") as HTMLElement;
"collection-list",
) as HTMLElement;
if (!collectionList) return; if (!collectionList) return;
const collectionItems = collectionList.querySelectorAll( const collectionItems = collectionList.querySelectorAll(
"[data-collection-id]", "[data-collection-id]",
@@ -133,9 +108,7 @@ async function saveDashboardSettings(): Promise<void> {
const collectionOrder: string[] = []; const collectionOrder: string[] = [];
collectionItems.forEach((item) => { collectionItems.forEach((item) => {
const collectionId = item.dataset.collectionId; const collectionId = item.dataset.collectionId;
const checkbox = item.querySelector( const checkbox = item.querySelector('input[type="checkbox"]') as HTMLInputElement;
'input[type="checkbox"]',
) as HTMLInputElement;
if (collectionId) { if (collectionId) {
collectionOrder.push(collectionId); collectionOrder.push(collectionId);
if (checkbox && !checkbox.checked) { if (checkbox && !checkbox.checked) {
@@ -144,12 +117,9 @@ async function saveDashboardSettings(): Promise<void> {
} }
}); });
const itemsPerCollection = const itemsPerCollection =
(document.querySelector("#items-count-display") as HTMLElement) (document.querySelector("#items-count-display") as HTMLElement)?.textContent || "20";
?.textContent || "20";
try { try {
const librarySelect = document.getElementById( const librarySelect = document.getElementById("library-select") as HTMLSelectElement;
"library-select",
) as HTMLSelectElement;
const libraryId = librarySelect?.value || ""; const libraryId = librarySelect?.value || "";
const response = await apiPut("/dashboard/preferences", { const response = await apiPut("/dashboard/preferences", {
// Empty select = the All Libraries view: an explicit JSON null is // 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;"> style="scrollbar-width: none; -ms-overflow-style: none;">
${ ${
section.items.length > 0 section.items.length > 0
? section.items ? section.items.map((item) => renderBookCard(item)).join("")
.map((item) => renderBookCard(item))
.join("")
: '<div class="text-center py-8 w-full" style="color: var(--text-secondary);"><p>No items in this collection</p></div>' : '<div class="text-center py-8 w-full" style="color: var(--text-secondary);"><p>No items in this collection</p></div>'
} }
</div> </div>
@@ -279,9 +247,7 @@ function renderSectionHTML(section: SectionData): string {
} }
function renderDashboardCollections(sections: SectionData[]): void { function renderDashboardCollections(sections: SectionData[]): void {
const container = document.getElementById( const container = document.getElementById("collections-container") as HTMLElement;
"collections-container",
) as HTMLElement;
if (!container) return; if (!container) return;
const currentWood = container.getAttribute("data-wood"); const currentWood = container.getAttribute("data-wood");
@@ -342,7 +308,6 @@ function badgeTitleFor(badge: string): string {
return ""; return "";
} }
function renderBookCard(book: BookInfo): string { function renderBookCard(book: BookInfo): string {
const coverUrl = book.cover_image_path || "/static/placeholder-book.svg"; const coverUrl = book.cover_image_path || "/static/placeholder-book.svg";
const actionHref = book.has_conflict const actionHref = book.has_conflict
@@ -354,11 +319,7 @@ function renderBookCard(book: BookInfo): string {
: isAudio : isAudio
? `Listen to ${book.title}` ? `Listen to ${book.title}`
: `Read ${book.title}`; : `Read ${book.title}`;
const actionTitle = book.has_conflict const actionTitle = book.has_conflict ? "Resolve progress conflict" : isAudio ? "Listen" : "Read";
? "Resolve progress conflict"
: isAudio
? "Listen"
: "Read";
const actionIcon = isAudio ? HEADPHONES_ICON : BOOK_OPEN_ICON; const actionIcon = isAudio ? HEADPHONES_ICON : BOOK_OPEN_ICON;
const badgeIcon = badgeIconFor(book.badge || ""); const badgeIcon = badgeIconFor(book.badge || "");
@@ -393,20 +354,13 @@ function renderBookCard(book: BookInfo): string {
} }
async function reloadPage(): Promise<void> { async function reloadPage(): Promise<void> {
const librarySelect = document.getElementById( const librarySelect = document.getElementById("library-select") as HTMLSelectElement;
"library-select",
) as HTMLSelectElement;
const libraryId = librarySelect?.value || ""; const libraryId = librarySelect?.value || "";
await switchWithTransition("collections-container", () => await switchWithTransition("collections-container", () => fetchAndRenderSections(libraryId));
fetchAndRenderSections(libraryId),
);
} }
function updateItemsCount( function updateItemsCount(input: HTMLInputElement, displayTarget: string): void {
input: HTMLInputElement,
displayTarget: string,
): void {
const displayElement = document.getElementById(displayTarget) as HTMLElement; const displayElement = document.getElementById(displayTarget) as HTMLElement;
if (displayElement) { if (displayElement) {
displayElement.textContent = input.value; displayElement.textContent = input.value;
@@ -414,9 +368,7 @@ function updateItemsCount(
} }
function initDragAndDrop(): void { function initDragAndDrop(): void {
const collectionList = document.getElementById( const collectionList = document.getElementById("collection-list") as HTMLElement;
"collection-list",
) as HTMLElement;
if (!collectionList) return; if (!collectionList) return;
let draggedItem: HTMLElement | null = null; let draggedItem: HTMLElement | null = null;
@@ -440,11 +392,7 @@ function initDragAndDrop(): void {
collectionList.addEventListener("dragover", (e: Event) => { collectionList.addEventListener("dragover", (e: Event) => {
e.preventDefault(); e.preventDefault();
const target = e.target as HTMLElement; const target = e.target as HTMLElement;
if ( if (target.classList.contains("collection-item") && target !== draggedItem && draggedItem) {
target.classList.contains("collection-item") &&
target !== draggedItem &&
draggedItem
) {
const rect = target.getBoundingClientRect(); const rect = target.getBoundingClientRect();
const midY = rect.top + rect.height / 2; const midY = rect.top + rect.height / 2;
if ((e as DragEvent).clientY < midY) { if ((e as DragEvent).clientY < midY) {
@@ -509,8 +457,7 @@ function initDashboard() {
switch (action) { switch (action) {
case "scroll-carousel": { case "scroll-carousel": {
const collectionId = const collectionId = target.dataset.collectionId || actionElem?.dataset.collectionId;
target.dataset.collectionId || actionElem?.dataset.collectionId;
const direction = parseInt( const direction = parseInt(
target.dataset.direction || actionElem?.dataset.direction || "0", target.dataset.direction || actionElem?.dataset.direction || "0",
); );
@@ -531,8 +478,7 @@ function initDashboard() {
break; break;
case "restore-system-collection": { case "restore-system-collection": {
const colName = const colName = actionElem?.dataset.collectionName || target.dataset.collectionName;
actionElem?.dataset.collectionName || target.dataset.collectionName;
const colTitle = const colTitle =
actionElem?.dataset.collectionTitle || actionElem?.dataset.collectionTitle ||
target.dataset.collectionTitle || target.dataset.collectionTitle ||
@@ -565,55 +511,40 @@ function initDashboard() {
if (!scanListenerRegistered) { if (!scanListenerRegistered) {
scanListenerRegistered = true; scanListenerRegistered = true;
window.addEventListener("bookhoard:scan-complete", async () => { window.addEventListener("bookhoard:scan-complete", async () => {
const librarySelect = document.getElementById( const librarySelect = document.getElementById("library-select") as HTMLSelectElement;
"library-select",
) as HTMLSelectElement;
const libraryId = librarySelect?.value || ""; const libraryId = librarySelect?.value || "";
try { try {
const param = libraryId ? `library_id=${libraryId}` : ""; const param = libraryId ? `library_id=${libraryId}` : "";
const response = await fetch( const response = await fetch(`/api/dashboard/sections?${param}`, {
`/api/dashboard/sections?${param}`, headers: {
{ Authorization: `Bearer ${localStorage.getItem("token")}`,
headers: { "Content-Type": "application/json",
Authorization: `Bearer ${localStorage.getItem("token")}`,
"Content-Type": "application/json",
},
}, },
); });
if (!response.ok) return; if (!response.ok) return;
const data = await response.json(); const data = await response.json();
const container = document.getElementById( const container = document.getElementById("collections-container") as HTMLElement;
"collections-container",
) as HTMLElement;
if (!container) return; if (!container) return;
const freshSectionIds = new Set( const freshSectionIds = new Set((data.sections as SectionData[]).map((s) => s.id));
(data.sections as SectionData[]).map((s) => s.id),
);
for (const section of data.sections as SectionData[]) { for (const section of data.sections as SectionData[]) {
const track = document.getElementById( const track = document.getElementById(`carousel-track-${section.id}`);
`carousel-track-${section.id}`,
);
if (!track) { if (!track) {
container.insertAdjacentHTML( container.insertAdjacentHTML("beforeend", renderSectionHTML(section));
"beforeend",
renderSectionHTML(section),
);
continue; continue;
} }
track.innerHTML = section.items.length > 0 track.innerHTML =
? section.items.map((item) => renderBookCard(item)).join("") section.items.length > 0
: '<div class="text-center py-8 w-full" style="color: var(--text-secondary);"><p>No items in this collection</p></div>'; ? section.items.map((item) => renderBookCard(item)).join("")
: '<div class="text-center py-8 w-full" style="color: var(--text-secondary);"><p>No items in this collection</p></div>';
} }
const existingSections = container.querySelectorAll<HTMLElement>( const existingSections = container.querySelectorAll<HTMLElement>(".dashboard-collection");
".dashboard-collection",
);
existingSections.forEach((sec) => { existingSections.forEach((sec) => {
const secId = sec.getAttribute("data-collection-id"); const secId = sec.getAttribute("data-collection-id");
if (secId && !freshSectionIds.has(secId)) { if (secId && !freshSectionIds.has(secId)) {
+52 -147
View File
@@ -30,15 +30,9 @@ async function handleAddDevice(event: Event): Promise<void> {
const token = getToken(); const token = getToken();
if (!token) return; if (!token) return;
const deviceNameInput = document.getElementById( const deviceNameInput = document.getElementById("device-name") as HTMLInputElement;
"device-name", const deviceTypeInput = document.getElementById("device-type") as HTMLSelectElement;
) as HTMLInputElement; const deviceIdentifierInput = document.getElementById("device-identifier") as HTMLInputElement;
const deviceTypeInput = document.getElementById(
"device-type",
) as HTMLSelectElement;
const deviceIdentifierInput = document.getElementById(
"device-identifier",
) as HTMLInputElement;
if (!deviceNameInput || !deviceTypeInput || !deviceIdentifierInput) return; if (!deviceNameInput || !deviceTypeInput || !deviceIdentifierInput) return;
@@ -59,10 +53,7 @@ async function handleAddDevice(event: Event): Promise<void> {
}); });
if (response.ok) { if (response.ok) {
showToast( showToast("Device registered! Check your device for sync instructions.", "success");
"Device registered! Check your device for sync instructions.",
"success",
);
hideAddDeviceModal(); hideAddDeviceModal();
window.location.reload(); window.location.reload();
} else { } else {
@@ -76,24 +67,12 @@ async function handleAddDevice(event: Event): Promise<void> {
function showDeviceSettings(deviceId: string): void { function showDeviceSettings(deviceId: string): void {
const modal = document.getElementById("device-settings-modal"); const modal = document.getElementById("device-settings-modal");
const deviceIdInput = document.getElementById( const deviceIdInput = document.getElementById("settings-device-id") as HTMLInputElement;
"settings-device-id", const deviceTypeInput = document.getElementById("settings-device-type") as HTMLInputElement;
) as HTMLInputElement; const deviceNameInput = document.getElementById("settings-device-name") as HTMLInputElement;
const deviceTypeInput = document.getElementById( const syncEnabledInput = document.getElementById("settings-sync-enabled") as HTMLInputElement;
"settings-device-type", const autoSyncInput = document.getElementById("settings-auto-sync") as HTMLInputElement;
) as HTMLInputElement; const syncFrequencyInput = document.getElementById("settings-sync-frequency") 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; if (!modal || !deviceIdInput) return;
@@ -111,8 +90,7 @@ function showDeviceSettings(deviceId: string): void {
.then((device) => { .then((device) => {
if (deviceTypeInput) deviceTypeInput.value = device.device_type || ""; if (deviceTypeInput) deviceTypeInput.value = device.device_type || "";
if (deviceNameInput) deviceNameInput.value = device.device_name || ""; if (deviceNameInput) deviceNameInput.value = device.device_name || "";
if (syncEnabledInput) if (syncEnabledInput) syncEnabledInput.checked = device.sync_enabled || false;
syncEnabledInput.checked = device.sync_enabled || false;
if (autoSyncInput) autoSyncInput.checked = device.auto_sync || false; if (autoSyncInput) autoSyncInput.checked = device.auto_sync || false;
if (syncFrequencyInput) if (syncFrequencyInput)
syncFrequencyInput.value = String(device.sync_frequency_minutes || 60); syncFrequencyInput.value = String(device.sync_frequency_minutes || 60);
@@ -129,32 +107,22 @@ function showDeviceSettings(deviceId: string): void {
const viewSettings = firstCollection.view_settings || {}; const viewSettings = firstCollection.view_settings || {};
const deviceSettings = viewSettings[deviceType] || {}; const deviceSettings = viewSettings[deviceType] || {};
const viewModeInput = document.getElementById( const viewModeInput = document.getElementById("settings-view-mode") as HTMLInputElement;
"settings-view-mode", const sortOrderInput = document.getElementById("settings-sort-order") as HTMLInputElement;
) as HTMLInputElement;
const sortOrderInput = document.getElementById(
"settings-sort-order",
) as HTMLInputElement;
const itemsPerPageInput = document.getElementById( const itemsPerPageInput = document.getElementById(
"settings-items-per-page", "settings-items-per-page",
) as HTMLInputElement; ) as HTMLInputElement;
const showCoversInput = document.getElementById( const showCoversInput = document.getElementById("settings-show-covers") as HTMLInputElement;
"settings-show-covers",
) as HTMLInputElement;
const showProgressInput = document.getElementById( const showProgressInput = document.getElementById(
"settings-show-progress", "settings-show-progress",
) as HTMLInputElement; ) as HTMLInputElement;
if (viewModeInput) if (viewModeInput) viewModeInput.value = deviceSettings.view_mode || "grid";
viewModeInput.value = deviceSettings.view_mode || "grid"; if (sortOrderInput) sortOrderInput.value = deviceSettings.sort_order || "name";
if (sortOrderInput)
sortOrderInput.value = deviceSettings.sort_order || "name";
if (itemsPerPageInput) if (itemsPerPageInput)
itemsPerPageInput.value = String(deviceSettings.items_per_page || 24); itemsPerPageInput.value = String(deviceSettings.items_per_page || 24);
if (showCoversInput) if (showCoversInput) showCoversInput.checked = deviceSettings.show_covers !== false;
showCoversInput.checked = deviceSettings.show_covers !== false; if (showProgressInput) showProgressInput.checked = deviceSettings.show_progress || false;
if (showProgressInput)
showProgressInput.checked = deviceSettings.show_progress || false;
} }
}) })
.catch((error) => { .catch((error) => {
@@ -165,9 +133,7 @@ function showDeviceSettings(deviceId: string): void {
} }
function showShelfMappings(deviceId: string): void { function showShelfMappings(deviceId: string): void {
const mappingsDeviceIdInput = document.getElementById( const mappingsDeviceIdInput = document.getElementById("mappings-device-id") as HTMLInputElement;
"mappings-device-id",
) as HTMLInputElement;
const modal = document.getElementById("shelf-mappings-modal"); const modal = document.getElementById("shelf-mappings-modal");
if (mappingsDeviceIdInput) mappingsDeviceIdInput.value = deviceId; if (mappingsDeviceIdInput) mappingsDeviceIdInput.value = deviceId;
@@ -226,41 +192,28 @@ async function loadShelfMappings(deviceId: string): Promise<void> {
'<p style="color: var(--text-secondary)">No shelf mappings configured. Click "Add Mapping" to create one.</p>'; '<p style="color: var(--text-secondary)">No shelf mappings configured. Click "Add Mapping" to create one.</p>';
} }
} catch (error) { } catch (error) {
container.innerHTML = container.innerHTML = '<p style="color: var(--error)">Failed to load mappings</p>';
'<p style="color: var(--error)">Failed to load mappings</p>';
} }
} }
function showAddMappingModal(): void { function showAddMappingModal(): void {
const mappingsDeviceIdInput = document.getElementById( const mappingsDeviceIdInput = document.getElementById("mappings-device-id") as HTMLInputElement;
"mappings-device-id", const mappingIdInput = document.getElementById("mapping-id") as HTMLInputElement;
) as HTMLInputElement; const mappingCollectionInput = document.getElementById("mapping-collection") as HTMLSelectElement;
const mappingIdInput = document.getElementById( const mappingShelfNameInput = document.getElementById("mapping-shelf-name") as HTMLInputElement;
"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( const mappingSyncDirectionInput = document.getElementById(
"mapping-sync-direction", "mapping-sync-direction",
) as HTMLSelectElement; ) as HTMLSelectElement;
const modal = document.getElementById("add-mapping-modal"); const modal = document.getElementById("add-mapping-modal");
if (mappingsDeviceIdInput) { if (mappingsDeviceIdInput) {
const mappingDeviceIdInput = document.getElementById( const mappingDeviceIdInput = document.getElementById("mapping-device-id") as HTMLInputElement;
"mapping-device-id", if (mappingDeviceIdInput) mappingDeviceIdInput.value = mappingsDeviceIdInput.value;
) as HTMLInputElement;
if (mappingDeviceIdInput)
mappingDeviceIdInput.value = mappingsDeviceIdInput.value;
} }
if (mappingIdInput) mappingIdInput.value = ""; if (mappingIdInput) mappingIdInput.value = "";
if (mappingCollectionInput) mappingCollectionInput.value = ""; if (mappingCollectionInput) mappingCollectionInput.value = "";
if (mappingShelfNameInput) mappingShelfNameInput.value = ""; if (mappingShelfNameInput) mappingShelfNameInput.value = "";
if (mappingSyncDirectionInput) if (mappingSyncDirectionInput) mappingSyncDirectionInput.value = "bidirectional";
mappingSyncDirectionInput.value = "bidirectional";
loadCollections(); loadCollections();
if (modal) modal.classList.remove("hidden"); if (modal) modal.classList.remove("hidden");
@@ -274,9 +227,7 @@ function hideAddMappingModal(): void {
} }
async function loadCollections(): Promise<void> { async function loadCollections(): Promise<void> {
const select = document.getElementById( const select = document.getElementById("mapping-collection") as HTMLSelectElement;
"mapping-collection",
) as HTMLSelectElement;
const token = getToken(); const token = getToken();
if (!select || !token) return; if (!select || !token) return;
@@ -304,15 +255,9 @@ function editMapping(
shelfName: string, shelfName: string,
syncDirection: string, syncDirection: string,
): void { ): void {
const mappingIdInput = document.getElementById( const mappingIdInput = document.getElementById("mapping-id") as HTMLInputElement;
"mapping-id", const mappingCollectionInput = document.getElementById("mapping-collection") as HTMLSelectElement;
) as HTMLInputElement; const mappingShelfNameInput = document.getElementById("mapping-shelf-name") as HTMLInputElement;
const mappingCollectionInput = document.getElementById(
"mapping-collection",
) as HTMLSelectElement;
const mappingShelfNameInput = document.getElementById(
"mapping-shelf-name",
) as HTMLInputElement;
const mappingSyncDirectionInput = document.getElementById( const mappingSyncDirectionInput = document.getElementById(
"mapping-sync-direction", "mapping-sync-direction",
) as HTMLSelectElement; ) as HTMLSelectElement;
@@ -321,8 +266,7 @@ function editMapping(
if (mappingIdInput) mappingIdInput.value = mappingId; if (mappingIdInput) mappingIdInput.value = mappingId;
if (mappingCollectionInput) mappingCollectionInput.value = collectionId; if (mappingCollectionInput) mappingCollectionInput.value = collectionId;
if (mappingShelfNameInput) mappingShelfNameInput.value = shelfName; if (mappingShelfNameInput) mappingShelfNameInput.value = shelfName;
if (mappingSyncDirectionInput) if (mappingSyncDirectionInput) mappingSyncDirectionInput.value = syncDirection;
mappingSyncDirectionInput.value = syncDirection;
if (modal) modal.classList.remove("hidden"); if (modal) modal.classList.remove("hidden");
} }
@@ -331,18 +275,10 @@ async function handleSaveMapping(event: Event): Promise<void> {
event.preventDefault(); event.preventDefault();
const token = getToken(); const token = getToken();
const mappingsDeviceIdInput = document.getElementById( const mappingsDeviceIdInput = document.getElementById("mapping-device-id") as HTMLInputElement;
"mapping-device-id", const mappingIdInput = document.getElementById("mapping-id") as HTMLInputElement;
) as HTMLInputElement; const mappingCollectionInput = document.getElementById("mapping-collection") as HTMLSelectElement;
const mappingIdInput = document.getElementById( const mappingShelfNameInput = document.getElementById("mapping-shelf-name") as HTMLInputElement;
"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( const mappingSyncDirectionInput = document.getElementById(
"mapping-sync-direction", "mapping-sync-direction",
) as HTMLSelectElement; ) as HTMLSelectElement;
@@ -398,21 +334,16 @@ async function deleteMapping(mappingId: string): Promise<void> {
if (!confirm("Are you sure you want to delete this mapping?")) return; if (!confirm("Are you sure you want to delete this mapping?")) return;
const token = getToken(); const token = getToken();
const mappingsDeviceIdInput = document.getElementById( const mappingsDeviceIdInput = document.getElementById("mappings-device-id") as HTMLInputElement;
"mappings-device-id",
) as HTMLInputElement;
if (!token || !mappingsDeviceIdInput) return; if (!token || !mappingsDeviceIdInput) return;
const deviceId = mappingsDeviceIdInput.value; const deviceId = mappingsDeviceIdInput.value;
try { try {
const response = await fetch( const response = await fetch(`/api/devices/${deviceId}/collections/${mappingId}`, {
`/api/devices/${deviceId}/collections/${mappingId}`, method: "DELETE",
{ headers: { Authorization: `Bearer ${token}` },
method: "DELETE", });
headers: { Authorization: `Bearer ${token}` },
},
);
if (response.ok) { if (response.ok) {
showToast("Mapping deleted", "success"); showToast("Mapping deleted", "success");
@@ -435,9 +366,7 @@ async function handleSaveDeviceSettings(event: Event): Promise<void> {
event.preventDefault(); event.preventDefault();
const token = getToken(); const token = getToken();
const settingsDeviceIdInput = document.getElementById( const settingsDeviceIdInput = document.getElementById("settings-device-id") as HTMLInputElement;
"settings-device-id",
) as HTMLInputElement;
const settingsDeviceTypeInput = document.getElementById( const settingsDeviceTypeInput = document.getElementById(
"settings-device-type", "settings-device-type",
) as HTMLInputElement; ) as HTMLInputElement;
@@ -447,9 +376,7 @@ async function handleSaveDeviceSettings(event: Event): Promise<void> {
const settingsSyncEnabledInput = document.getElementById( const settingsSyncEnabledInput = document.getElementById(
"settings-sync-enabled", "settings-sync-enabled",
) as HTMLInputElement; ) as HTMLInputElement;
const settingsAutoSyncInput = document.getElementById( const settingsAutoSyncInput = document.getElementById("settings-auto-sync") as HTMLInputElement;
"settings-auto-sync",
) as HTMLInputElement;
const settingsSyncFrequencyInput = document.getElementById( const settingsSyncFrequencyInput = document.getElementById(
"settings-sync-frequency", "settings-sync-frequency",
) as HTMLInputElement; ) as HTMLInputElement;
@@ -463,27 +390,14 @@ async function handleSaveDeviceSettings(event: Event): Promise<void> {
device_name: settingsDeviceNameInput.value, device_name: settingsDeviceNameInput.value,
sync_enabled: settingsSyncEnabledInput?.checked || false, sync_enabled: settingsSyncEnabledInput?.checked || false,
auto_sync: settingsAutoSyncInput?.checked || false, auto_sync: settingsAutoSyncInput?.checked || false,
sync_frequency_minutes: parseInt( sync_frequency_minutes: parseInt(settingsSyncFrequencyInput?.value || "60", 10),
settingsSyncFrequencyInput?.value || "60",
10,
),
}; };
const viewModeInput = document.getElementById( const viewModeInput = document.getElementById("settings-view-mode") as HTMLSelectElement;
"settings-view-mode", const sortOrderInput = document.getElementById("settings-sort-order") as HTMLSelectElement;
) as HTMLSelectElement; const itemsPerPageInput = document.getElementById("settings-items-per-page") as HTMLInputElement;
const sortOrderInput = document.getElementById( const showCoversInput = document.getElementById("settings-show-covers") as HTMLInputElement;
"settings-sort-order", const showProgressInput = document.getElementById("settings-show-progress") as HTMLInputElement;
) 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 = { const viewSettings = {
view_mode: viewModeInput?.value || "grid", view_mode: viewModeInput?.value || "grid",
@@ -536,10 +450,7 @@ async function handleSaveDeviceSettings(event: Event): Promise<void> {
} }
} }
async function copyToClipboard( async function copyToClipboard(text: string, description: string): Promise<void> {
text: string,
description: string,
): Promise<void> {
try { try {
await navigator.clipboard.writeText(text); await navigator.clipboard.writeText(text);
showToast(`${description} copied!`, "success"); showToast(`${description} copied!`, "success");
@@ -551,16 +462,10 @@ async function copyToClipboard(
async function handleRevokeDevice(): Promise<void> { async function handleRevokeDevice(): Promise<void> {
const token = getToken(); const token = getToken();
const settingsDeviceIdInput = document.getElementById( const settingsDeviceIdInput = document.getElementById("settings-device-id") as HTMLInputElement;
"settings-device-id",
) as HTMLInputElement;
if (!token || !settingsDeviceIdInput) return; if (!token || !settingsDeviceIdInput) return;
if ( if (!confirm("Are you sure you want to revoke this device? It will no longer be able to sync."))
!confirm(
"Are you sure you want to revoke this device? It will no longer be able to sync.",
)
)
return; return;
const deviceId = settingsDeviceIdInput.value; const deviceId = settingsDeviceIdInput.value;
+12 -7
View File
@@ -15,7 +15,7 @@ function toggleSection(el: HTMLElement): void {
const items = el.nextElementSibling as HTMLElement; const items = el.nextElementSibling as HTMLElement;
const arrow = el.querySelector(".section-arrow") as HTMLElement; const arrow = el.querySelector(".section-arrow") as HTMLElement;
if (!items || !arrow) return; if (!items || !arrow) return;
const isCollapsed = items.getAttribute("data-collapsed") === "true"; const isCollapsed = items.getAttribute("data-collapsed") === "true";
if (isCollapsed) { if (isCollapsed) {
@@ -30,9 +30,7 @@ function toggleSection(el: HTMLElement): void {
} }
function initializeDocsSearch(): void { function initializeDocsSearch(): void {
const searchInput = document.getElementById( const searchInput = document.getElementById("docs-search") as HTMLInputElement;
"docs-search",
) as HTMLInputElement;
const searchResults = document.getElementById("docs-search-results"); const searchResults = document.getElementById("docs-search-results");
if (!searchInput || !searchResults) return; if (!searchInput || !searchResults) return;
@@ -125,14 +123,15 @@ function initializeCodeCopyButtons(): void {
document.querySelectorAll("pre code").forEach((block) => { document.querySelectorAll("pre code").forEach((block) => {
const pre = block.parentElement; const pre = block.parentElement;
if (!pre) return; if (!pre) return;
const wrapper = document.createElement("div"); const wrapper = document.createElement("div");
wrapper.className = "relative group"; wrapper.className = "relative group";
pre.parentNode.insertBefore(wrapper, pre); pre.parentNode.insertBefore(wrapper, pre);
wrapper.appendChild(pre); wrapper.appendChild(pre);
const button = document.createElement("button"); 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.textContent = "Copy";
button.onclick = async () => { button.onclick = async () => {
await navigator.clipboard.writeText(block.textContent || ""); await navigator.clipboard.writeText(block.textContent || "");
@@ -154,4 +153,10 @@ Alpine.data("docs", () => ({
initializeCodeCopyButtons, initializeCodeCopyButtons,
})); }));
export { toggleSidebar, toggleSection, initializeDocsSearch, highlightCurrentPage, initializeCodeCopyButtons }; export {
toggleSidebar,
toggleSection,
initializeDocsSearch,
highlightCurrentPage,
initializeCodeCopyButtons,
};
+7 -29
View File
@@ -50,61 +50,39 @@ function onDelegatedKeydown(
}); });
} }
function getDataAttribute( function getDataAttribute(element: HTMLElement, name: string): string | undefined {
element: HTMLElement,
name: string,
): string | undefined {
return element.dataset[name]; return element.dataset[name];
} }
function setDataAttribute( function setDataAttribute(element: HTMLElement, name: string, value: string): void {
element: HTMLElement,
name: string,
value: string,
): void {
element.dataset[name] = value; element.dataset[name] = value;
} }
function onClick( function onClick(element: HTMLElement | null, handler: (event: MouseEvent) => void): void {
element: HTMLElement | null,
handler: (event: MouseEvent) => void,
): void {
if (element) { if (element) {
element.addEventListener("click", handler); element.addEventListener("click", handler);
} }
} }
function onSubmit( function onSubmit(element: HTMLFormElement | null, handler: (event: Event) => void): void {
element: HTMLFormElement | null,
handler: (event: Event) => void,
): void {
if (element) { if (element) {
element.addEventListener("submit", handler); element.addEventListener("submit", handler);
} }
} }
function onChange( function onChange(element: HTMLElement | null, handler: (event: Event) => void): void {
element: HTMLElement | null,
handler: (event: Event) => void,
): void {
if (element) { if (element) {
element.addEventListener("change", handler); element.addEventListener("change", handler);
} }
} }
function onKeydown( function onKeydown(element: HTMLElement | null, handler: (event: KeyboardEvent) => void): void {
element: HTMLElement | null,
handler: (event: KeyboardEvent) => void,
): void {
if (element) { if (element) {
element.addEventListener("keydown", handler); element.addEventListener("keydown", handler);
} }
} }
function onInput( function onInput(element: HTMLElement | null, handler: (event: Event) => void): void {
element: HTMLElement | null,
handler: (event: Event) => void,
): void {
if (element) { if (element) {
element.addEventListener("input", handler); element.addEventListener("input", handler);
} }
+156 -56
View File
@@ -23,12 +23,12 @@ export interface OpMeta {
arity: "none" | "one" | "two" | "many"; arity: "none" | "one" | "two" | "many";
} }
const op = ( const op = (key: string, label: string, negated: boolean, arity: OpMeta["arity"]): OpMeta => ({
key: string, key,
label: string, label,
negated: boolean, negated,
arity: OpMeta["arity"], arity,
): OpMeta => ({ key, label, negated, arity }); });
// The map key DERIVES from meta.key — the wire value and the display // The map key DERIVES from meta.key — the wire value and the display
// label cannot drift apart again (a mis-keyed entry threw every // 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("isbn", "ISBN", "Book facts", "text", ["is", "is_not", "has", "missing"], "has"),
F("asin", "ASIN", "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_size", "File size", "Book facts", "number", NUM_OPS, "at_least"),
F("file_availability", "File availability", "Book facts", "fixed", F(
["is", "is_not"], "is", false, ["available", "missing"]), "file_availability",
"File availability",
"Book facts",
"fixed",
["is", "is_not"],
"is",
false,
["available", "missing"],
),
F("cover", "Cover", "Book facts", "none", ["has", "missing"], "has"), 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), 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("volume", "Volume", "Comics & manga", "number", NUM_OPS, "at_least"),
F("format_group", "Format group", "Comics & manga", "text", ["is", "is_not"], "is", true), 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("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(
F("has_fixed_layout", "Fixed layout", "Comics & manga", "none", ["is_true", "is_false"], "is_true"), "is_reflowable",
F("is_black_and_white", "Black & white", "Comics & manga", "none", ["is_true", "is_false"], "is_true"), "Reflowable",
F("manga_type", "Manga type", "Comics & manga", "text", ["is", "is_not", "empty"], "is", true), "Comics & manga",
F("reading_direction", "Reading direction", "Comics & manga", "text", ["is", "is_not", "empty"], "is", true), "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", group: "My reading",
fields: [ fields: [
F("status", "Reading status", "My reading", "fixed", ["is"], "is", false, F("status", "Reading status", "My reading", "fixed", ["is"], "is", false, [
["unread", "reading", "finished"]), "unread",
"reading",
"finished",
]),
F("progress_percent", "Progress percent", "My reading", "number", NUM_OPS, "at_least"), 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("last_read", "Last read", "My reading", "days", DATE_OPS, "within_days"),
F("my_rating", "My rating", "My reading", "fixed", F(
["is", "at_least", "at_most", "unrated"], "at_least", false, "my_rating",
["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"]), "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("review", "Has review", "My reading", "none", ["has", "missing"], "has"),
F("notes", "Notes", "My reading", "none", ["has", "missing", "within_days"], "has"), F("notes", "Notes", "My reading", "none", ["has", "missing", "within_days"], "has"),
F("highlights", "Highlights", "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(
F("highlight_text", "Highlight text", "My reading", "text", ["contains", "not_contains", "empty"], "contains", true), "note_text",
F("time_spent", "Time spent (minutes)", "My reading", "number", ["between", "at_least", "at_most"], "at_least"), "Note text",
F("pages_read", "Pages read", "My reading", "number", ["between", "at_least", "at_most"], "at_least"), "My reading",
F("collection", "In collection", "My reading", "id", "text",
["in", "not_in", "in_any", "in_none"], "in", true), ["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", group: "Library",
fields: [ fields: [F("library", "Library", "Library", "id", ["is", "is_not"], "is", true)],
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. // Grouped ops per field, precomputed in the field's op order.
const OPS_BY_FIELD = new Map<string, OpMeta[]>(); const OPS_BY_FIELD = new Map<string, OpMeta[]>();
for (const [, f] of FIELD_BY_KEY) { for (const [, f] of FIELD_BY_KEY) {
OPS_BY_FIELD.set( OPS_BY_FIELD.set(f.key, f.ops.map((k) => OPS[k]).filter(Boolean));
f.key,
f.ops.map((k) => OPS[k]).filter(Boolean),
);
} }
// Flat-expressible fields: key → the flat wire param a `contains`/`is` // Flat-expressible fields: key → the flat wire param a `contains`/`is`
@@ -237,8 +331,7 @@ export interface FilterGroupState {
implicit?: boolean; implicit?: boolean;
} }
export const isRule = (n: FilterRuleState | FilterGroupState): n is FilterRuleState => export const isRule = (n: FilterRuleState | FilterGroupState): n is FilterRuleState => "field" in n;
"field" in n;
// flatParamNames are the wire params the flat deep-link contract pins // flatParamNames are the wire params the flat deep-link contract pins
// (plus the two flat extras the builder owns). // (plus the two flat extras the builder owns).
@@ -257,12 +350,14 @@ function ruleToFlatParam(rule: FilterRuleState): Record<string, string> | null {
if (!joined) return null; if (!joined) return null;
const flat = FLAT_PARAMS[rule.field]; const flat = FLAT_PARAMS[rule.field];
if (flat && rule.op === "contains") return { [flat]: joined }; if (flat && rule.op === "contains") return { [flat]: joined };
if (flat && rule.op === "is" && if (
["age_rating", "format_group", "manga_type", "reading_direction"].includes(rule.field)) { flat &&
rule.op === "is" &&
["age_rating", "format_group", "manga_type", "reading_direction"].includes(rule.field)
) {
return { [flat]: joined }; return { [flat]: joined };
} }
if (flat && rule.op === "is" && if (flat && rule.op === "is" && ["story_arc", "imprint", "edition"].includes(rule.field)) {
["story_arc", "imprint", "edition"].includes(rule.field)) {
return { [flat]: joined }; return { [flat]: joined };
} }
if (rule.field === "cover") { if (rule.field === "cover") {
@@ -362,8 +457,7 @@ function projectRows(
const childPath = [...path, idx]; const childPath = [...path, idx];
const key = childPath.join("-"); const key = childPath.join("-");
// The slot chip: how this child joins the row above it. // The slot chip: how this child joins the row above it.
const slotChip: "AND" | "OR" = const slotChip: "AND" | "OR" = idx === 0 || group.mode === "all" ? "AND" : "OR";
idx === 0 || group.mode === "all" ? "AND" : "OR";
if (!isRule(child)) { if (!isRule(child)) {
const g = child as FilterGroupState; const g = child as FilterGroupState;
if (g.implicit) { if (g.implicit) {
@@ -412,10 +506,7 @@ function projectRows(
return; return;
} }
const prev = group.children[idx - 1]; const prev = group.children[idx - 1];
const clickable = const clickable = idx > 0 && !!prev && (isRule(prev) || !!(prev as FilterGroupState).implicit);
idx > 0 &&
!!prev &&
(isRule(prev) || !!(prev as FilterGroupState).implicit);
out.push({ out.push({
key, key,
path: childPath, path: childPath,
@@ -682,8 +773,7 @@ export function filterBuilderData() {
return FIELD_GROUPS.map((g) => ({ return FIELD_GROUPS.map((g) => ({
group: g.group, group: g.group,
fields: g.fields.filter( fields: g.fields.filter(
(f) => (f) => f.label.toLowerCase().includes(q) || f.key.toLowerCase().includes(q),
f.label.toLowerCase().includes(q) || f.key.toLowerCase().includes(q),
), ),
})).filter((g) => g.fields.length > 0); })).filter((g) => g.fields.length > 0);
}, },
@@ -719,9 +809,8 @@ export function filterBuilderData() {
const params = new URLSearchParams(); const params = new URLSearchParams();
params.set(rule.field, typed || " "); params.set(rule.field, typed || " ");
if (others) params.set("qf", JSON.stringify(others)); if (others) params.set("qf", JSON.stringify(others));
const currentLibraryId = ( const currentLibraryId = (document.getElementById("library-select") as HTMLSelectElement)
document.getElementById("library-select") as HTMLSelectElement ?.value;
)?.value;
if (currentLibraryId) params.set("library_id", currentLibraryId); if (currentLibraryId) params.set("library_id", currentLibraryId);
// Browse (no typing) shows a slightly wider window than filter- // Browse (no typing) shows a slightly wider window than filter-
// as-you-type. // as-you-type.
@@ -790,9 +879,8 @@ export function filterBuilderData() {
} }
const params = new URLSearchParams(); const params = new URLSearchParams();
params.set("qf", JSON.stringify(completed)); params.set("qf", JSON.stringify(completed));
const currentLibraryId = ( const currentLibraryId = (document.getElementById("library-select") as HTMLSelectElement)
document.getElementById("library-select") as HTMLSelectElement ?.value;
)?.value;
if (currentLibraryId) params.set("library_id", currentLibraryId); if (currentLibraryId) params.set("library_id", currentLibraryId);
this.matchCountLoading = true; this.matchCountLoading = true;
try { try {
@@ -911,13 +999,25 @@ export function filterBuilderData() {
if (cover && cover.value) { if (cover && cover.value) {
rules.push({ field: "cover", op: cover.value === "true" ? "has" : "missing", values: [] }); 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) { 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) { 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; const yearMin = form.querySelector('input[name="year_min"]') as HTMLInputElement | null;
if (yearMin && yearMin.value) { if (yearMin && yearMin.value) {
+1 -5
View File
@@ -21,11 +21,7 @@ const restoreLibrarySelection = (): void => {
const savedLibrary = getSelectedLibrary(); const savedLibrary = getSelectedLibrary();
if (!savedLibrary) return; if (!savedLibrary) return;
const dropdownSelectors = [ const dropdownSelectors = ["#library-select", "#selected-library", "#library_id"];
"#library-select",
"#selected-library",
"#library_id",
];
for (const selector of dropdownSelectors) { for (const selector of dropdownSelectors) {
const dropdown = document.querySelector(selector) as HTMLSelectElement; const dropdown = document.querySelector(selector) as HTMLSelectElement;
+4 -17
View File
@@ -43,11 +43,7 @@ export async function switchWithTransition(
} }
try { try {
container.classList.add( container.classList.add("opacity-0", "transition-opacity", "duration-150");
"opacity-0",
"transition-opacity",
"duration-150",
);
await new Promise((resolve) => setTimeout(resolve, 150)); await new Promise((resolve) => setTimeout(resolve, 150));
loading.classList.remove("hidden"); loading.classList.remove("hidden");
@@ -68,12 +64,7 @@ export async function switchWithTransition(
container.classList.remove("transition-opacity", "duration-300"); container.classList.remove("transition-opacity", "duration-300");
}, 300); }, 300);
} catch (error) { } catch (error) {
container.classList.remove( container.classList.remove("opacity-0", "transition-opacity", "duration-150", "duration-300");
"opacity-0",
"transition-opacity",
"duration-150",
"duration-300",
);
showToast("Failed to load library", "error"); showToast("Failed to load library", "error");
console.error("Switch library error:", error); console.error("Switch library error:", error);
} finally { } finally {
@@ -86,18 +77,14 @@ export interface LibrarySwitcherOptions {
} }
export function initLibrarySwitcher(options: LibrarySwitcherOptions): void { export function initLibrarySwitcher(options: LibrarySwitcherOptions): void {
const librarySelect = document.getElementById( const librarySelect = document.getElementById("library-select") as HTMLSelectElement;
"library-select",
) as HTMLSelectElement;
if (!librarySelect) return; if (!librarySelect) return;
const stored = localStorage.getItem("selectedLibrary"); const stored = localStorage.getItem("selectedLibrary");
if (stored === ALL_LIBRARIES) { if (stored === ALL_LIBRARIES) {
librarySelect.value = ""; librarySelect.value = "";
} else if (stored) { } else if (stored) {
const option = librarySelect.querySelector( const option = librarySelect.querySelector(`option[value="${stored}"]`);
`option[value="${stored}"]`,
);
if (option) { if (option) {
librarySelect.value = stored; librarySelect.value = stored;
} }
+2 -8
View File
@@ -73,10 +73,7 @@ function renderUnlinkedBooks(books: UnlinkedBookData[]): void {
.join(""); .join("");
} }
async function linkBook( async function linkBook(progressId: string, mediaItemId: string): Promise<void> {
progressId: string,
mediaItemId: string,
): Promise<void> {
const token = localStorage.getItem("token"); const token = localStorage.getItem("token");
if (!token) return; if (!token) return;
@@ -151,10 +148,7 @@ async function getSuggestions(progressId: string): Promise<void> {
} }
} }
function showSuggestionsModal( function showSuggestionsModal(progressId: string, suggestions: PotentialMatchData[]): void {
progressId: string,
suggestions: PotentialMatchData[],
): void {
const modal = document.getElementById("match-modal"); const modal = document.getElementById("match-modal");
const content = document.getElementById("match-modal-content"); const content = document.getElementById("match-modal-content");
+58 -58
View File
@@ -14,35 +14,35 @@ const md = new MarkdownIt({ html: false, linkify: false });
md.use(footnote); md.use(footnote);
export function renderNoteHtml(source: string): string { export function renderNoteHtml(source: string): string {
if (!source) return ""; if (!source) return "";
const raw = md.render(source); const raw = md.render(source);
// Disallowing img/input/style/form by tag and keeping DOMPurify's default // Disallowing img/input/style/form by tag and keeping DOMPurify's default
// safe-URI scheme check (http/https/mailto + same-page anchors only) // safe-URI scheme check (http/https/mailto + same-page anchors only)
// closes the link-injection hole, which is the one markdown pivot into // closes the link-injection hole, which is the one markdown pivot into
// script execution. // script execution.
const clean = DOMPurify.sanitize(raw, { const clean = DOMPurify.sanitize(raw, {
ALLOWED_ATTR: ["href", "class", "id", "role"], ALLOWED_ATTR: ["href", "class", "id", "role"],
FORBID_TAGS: ["img", "input", "style", "form"], FORBID_TAGS: ["img", "input", "style", "form"],
}); });
return clean; return clean;
} }
// Compact rows may show a plain line instead of rich markup. // Compact rows may show a plain line instead of rich markup.
export function stripNote(source: string): string { export function stripNote(source: string): string {
if (!source) return ""; if (!source) return "";
const holder = document.createElement("div"); const holder = document.createElement("div");
holder.innerHTML = renderNoteHtml(source); holder.innerHTML = renderNoteHtml(source);
return (holder.textContent || "").replace(/\s+/g, " ").trim(); return (holder.textContent || "").replace(/\s+/g, " ").trim();
} }
// ----- rich paste: HTML flavor -> markdown source ----- // ----- rich paste: HTML flavor -> markdown source -----
const turndown = new TurndownService({ const turndown = new TurndownService({
// Keep the converted output inside our supported syntax: headings, // Keep the converted output inside our supported syntax: headings,
// emphasis, lists, code, quotes, links, tables. Everything else degrades. // emphasis, lists, code, quotes, links, tables. Everything else degrades.
headingStyle: "atx", headingStyle: "atx",
codeBlockStyle: "fenced", codeBlockStyle: "fenced",
bulletListMarker: "-", bulletListMarker: "-",
hr: "---", hr: "---",
}); });
turndown.use(gfm); turndown.use(gfm);
turndown.use(tables); 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 // 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. // show as literal tags. Convert those to pipe syntax with a blank header.
turndown.addRule("tablesNoHeading", { turndown.addRule("tablesNoHeading", {
filter: (node: Node) => { filter: (node: Node) => {
const el = node as HTMLElement; const el = node as HTMLElement;
if (el.nodeName !== "TABLE" || !el.querySelector) return false; if (el.nodeName !== "TABLE" || !el.querySelector) return false;
return !el.querySelector("th") && el.querySelectorAll("tr").length > 0; return !el.querySelector("th") && el.querySelectorAll("tr").length > 0;
}, },
replacement: (_content: string, node: Node) => { replacement: (_content: string, node: Node) => {
const table = node as HTMLTableElement; const table = node as HTMLTableElement;
const rows = Array.from(table.querySelectorAll("tr")).map((tr) => const rows = Array.from(table.querySelectorAll("tr")).map((tr) =>
Array.from(tr.querySelectorAll("td, th")).map((cell) => Array.from(tr.querySelectorAll("td, th")).map((cell) =>
(cell.textContent ?? "").replace(/\s+/g, " ").trim().replace(/\|/g, "\\|"), (cell.textContent ?? "").replace(/\s+/g, " ").trim().replace(/\|/g, "\\|"),
), ),
); );
const cols = Math.max(1, ...rows.map((r) => r.length)); const cols = Math.max(1, ...rows.map((r) => r.length));
const pad = (r: string[]) => { const pad = (r: string[]) => {
const copy = [...r]; const copy = [...r];
while (copy.length < cols) copy.push(""); while (copy.length < cols) copy.push("");
return copy.map((c) => c || " "); return copy.map((c) => c || " ");
}; };
const head = pad(rows[0] ?? []); const head = pad(rows[0] ?? []);
const lines = [ const lines = [
"| " + head.join(" | ") + " |", "| " + head.join(" | ") + " |",
"|" + Array.from({ length: cols }, () => " --- ").join("|") + "|", "|" + Array.from({ length: cols }, () => " --- ").join("|") + "|",
...rows.slice(1).map((r) => "| " + pad(r).join(" | ") + " |"), ...rows.slice(1).map((r) => "| " + pad(r).join(" | ") + " |"),
]; ];
return "\n\n" + lines.join("\n") + "\n\n"; return "\n\n" + lines.join("\n") + "\n\n";
}, },
}); });
export function htmlToMarkdown(html: string): string { 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 /** 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 * rich (text/html) flavor, or null when the clipboard holds no usable rich
* content (plain pastes must fall through to the browser default). */ * content (plain pastes must fall through to the browser default). */
export function richPasteToMarkdown(event: ClipboardEvent): string | null { export function richPasteToMarkdown(event: ClipboardEvent): string | null {
const data = event.clipboardData; const data = event.clipboardData;
if (!data) return null; if (!data) return null;
const html = data.getData("text/html"); const html = data.getData("text/html");
if (!html || !html.trim()) return null; if (!html || !html.trim()) return null;
// A rich flavor that is just an HTML wrapper of plain text (some apps // A rich flavor that is just an HTML wrapper of plain text (some apps
// emit <p>...</p> wrappers for everything Chrome copies) still converts // emit <p>...</p> wrappers for everything Chrome copies) still converts
// harmlessly to the same text, so no content sniffing is needed here. // harmlessly to the same text, so no content sniffing is needed here.
const markdown = htmlToMarkdown(html); const markdown = htmlToMarkdown(html);
if (!markdown || !markdown.trim()) return null; if (!markdown || !markdown.trim()) return null;
return markdown; return markdown;
} }
+3 -8
View File
@@ -60,17 +60,14 @@ export function buildPageText(
const t = item.transform ?? [1, 0, 0, 1, 0, 0]; const t = item.transform ?? [1, 0, 0, 1, 0, 0];
const baseline = t[5] ?? 0; const baseline = t[5] ?? 0;
const x = t[4] ?? 0; const x = t[4] ?? 0;
const size = const size = Math.abs(item.height) || Math.abs(t[3]) || Math.abs(t[0]) || 10;
Math.abs(item.height) || Math.abs(t[3]) || Math.abs(t[0]) || 10;
const w = Math.abs(item.width) || 0; const w = Math.abs(item.width) || 0;
const h = size; const h = size;
let sep = ""; let sep = "";
if (text && !text.endsWith(" ") && prevRight != null) { if (text && !text.endsWith(" ") && prevRight != null) {
const newLine = const newLine =
item.hasEOL || item.hasEOL || prevBaseline == null || Math.abs(baseline - prevBaseline) > size * 0.5;
prevBaseline == null ||
Math.abs(baseline - prevBaseline) > size * 0.5;
const gap = x - prevRight; const gap = x - prevRight;
if (newLine || gap > size * 0.2) sep = " "; if (newLine || gap > size * 0.2) sep = " ";
} }
@@ -106,9 +103,7 @@ export async function extractPdfPages(
const page = await pdf.getPage(i + 1); const page = await pdf.getPage(i + 1);
const viewport = page.getViewport({ scale: 1 }); const viewport = page.getViewport({ scale: 1 });
const tc = await page.getTextContent(); const tc = await page.getTextContent();
pages.push( pages.push(buildPageText(tc.items, viewport.width, viewport.height, i));
buildPageText(tc.items, viewport.width, viewport.height, i),
);
onProgress?.((i + 1) / num); onProgress?.((i + 1) / num);
} }
return pages; return pages;
+388 -626
View File
File diff suppressed because it is too large. Load diff
+1 -3
View File
@@ -21,9 +21,7 @@ export async function loadSettings(): Promise<ReaderSettings> {
return local ? JSON.parse(local) : getDefaultSettings(); return local ? JSON.parse(local) : getDefaultSettings();
} }
} }
export async function saveSettings( export async function saveSettings(settings: Partial<ReaderSettings>): Promise<void> {
settings: Partial<ReaderSettings>,
): Promise<void> {
const token = getToken(); const token = getToken();
const current = await loadSettings(); const current = await loadSettings();
const updated = { ...current, ...settings }; const updated = { ...current, ...settings };
+18 -30
View File
@@ -5,9 +5,7 @@ const SEARCH_DEBOUNCE_MS = 300;
const SEARCH_MIN_CHARS = 2; const SEARCH_MIN_CHARS = 2;
function initializeSearch(): void { function initializeSearch(): void {
const searchInput = document.getElementById( const searchInput = document.getElementById("header-search") as HTMLInputElement | null;
"header-search",
) as HTMLInputElement | null;
const mobileSearchInput = document.getElementById( const mobileSearchInput = document.getElementById(
"header-search-mobile", "header-search-mobile",
) as HTMLInputElement | null; ) as HTMLInputElement | null;
@@ -107,11 +105,9 @@ function selectSearchResult(items: NodeListOf<Element>, index: number): void {
} }
function getActiveSearchInput(): HTMLInputElement | null { function getActiveSearchInput(): HTMLInputElement | null {
return ( return document.activeElement?.id === "header-search-mobile"
document.activeElement?.id === "header-search-mobile" ? (document.getElementById("header-search-mobile") as HTMLInputElement)
? (document.getElementById("header-search-mobile") as HTMLInputElement) : (document.getElementById("header-search") as HTMLInputElement);
: (document.getElementById("header-search") as HTMLInputElement)
);
} }
function performSearch(query: string): void { function performSearch(query: string): void {
@@ -138,27 +134,21 @@ function performSearch(query: string): void {
} }
return response.json(); return response.json();
}) })
.then( .then((data: { error?: string; results?: MediaItemSummary[] } | MediaItemSummary[]) => {
( hideSearchLoading();
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") { if (data && "error" in data && data.error === "no results found") {
showNoResults(query, resultActiveId); showNoResults(query, resultActiveId);
} else if (Array.isArray(data) && data.length > 0) { } else if (Array.isArray(data) && data.length > 0) {
showSearchResults(data, query, resultActiveId); showSearchResults(data, query, resultActiveId);
} else if (Array.isArray(data)) { } else if (Array.isArray(data)) {
showNoResults(query, resultActiveId); showNoResults(query, resultActiveId);
} else { } else {
showNoResults(query, resultActiveId); showNoResults(query, resultActiveId);
} }
}, })
)
.catch((error) => { .catch((error) => {
hideSearchLoading(); hideSearchLoading();
console.error("Search error:", error); console.error("Search error:", error);
@@ -292,9 +282,7 @@ function createSearchResultsContainer(activeId?: string): void {
searchResults.style.cssText = searchResults.style.cssText =
"background-color: var(--bg-secondary); border-color: var(--border)"; "background-color: var(--bg-secondary); border-color: var(--border)";
const searchInput = document.getElementById( const searchInput = document.getElementById(activeId || "header-search");
activeId || "header-search",
);
if (searchInput) { if (searchInput) {
const searchContainer = searchInput.closest(".relative"); const searchContainer = searchInput.closest(".relative");
if (searchContainer) { if (searchContainer) {
+12 -7
View File
@@ -36,7 +36,8 @@ function initSetup(): void {
if (savedTheme && savedTheme !== "tokyo-night") { if (savedTheme && savedTheme !== "tokyo-night") {
applyTheme(savedTheme); applyTheme(savedTheme);
} }
}Alpine.data("setupWizard", () => ({ }
Alpine.data("setupWizard", () => ({
currentStep: 1 as number, currentStep: 1 as number,
loading: false as boolean, loading: false as boolean,
errorMsg: "" as string, errorMsg: "" as string,
@@ -110,7 +111,9 @@ function initSetup(): void {
} }
if ( 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() getToken()
) { ) {
this.currentStep = 2; this.currentStep = 2;
@@ -140,11 +143,12 @@ function initSetup(): void {
async saveBaseUrl() { async saveBaseUrl() {
if (!this.admin.baseUrl) return; if (!this.admin.baseUrl) return;
try { try {
await handleVoidResponse( await handleVoidResponse(await apiPut("/system/config", { base_url: this.admin.baseUrl }));
await apiPut("/system/config", { base_url: this.admin.baseUrl })
);
} catch { } 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) { if (data.directories.length === 0) {
html += '<p style="color: var(--text-secondary)" class="text-center py-4">No subdirectories</p>'; html +=
'<p style="color: var(--text-secondary)" class="text-center py-4">No subdirectories</p>';
} else { } else {
data.directories.forEach((dir) => { data.directories.forEach((dir) => {
const fullPath = data.current_path === "/" ? `/${dir}` : `${data.current_path}/${dir}`; const fullPath = data.current_path === "/" ? `/${dir}` : `${data.current_path}/${dir}`;
+1 -5
View File
@@ -12,11 +12,7 @@ interface HtmxAjaxOptions {
interface Window { interface Window {
htmx: { htmx: {
ajax( ajax(method: string, url: string, options?: HtmxAjaxOptions): Promise<boolean>;
method: string,
url: string,
options?: HtmxAjaxOptions,
): Promise<boolean>;
process(element: Element): void; process(element: Element): void;
}; };
} }
+67 -28
View File
@@ -137,9 +137,13 @@ let libraryDataLoaded = false;
// Page-scoped helpers ----------------------------------------------------- // Page-scoped helpers -----------------------------------------------------
async function chartCtor(): Promise<new (el: HTMLCanvasElement, cfg: ChartConfiguration) => ChartLike> { async function chartCtor(): Promise<
new (el: HTMLCanvasElement, cfg: ChartConfiguration) => ChartLike
> {
const mod = await import("chart.js/auto"); 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 { 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; const hasData = summary.total_minutes > 0 || summary.session_count > 0;
document.getElementById("stats-empty")?.classList.toggle("hidden", hasData); document.getElementById("stats-empty")?.classList.toggle("hidden", hasData);
const windowLabel = document.getElementById("stats-window-label"); 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; 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); renderPagesCard(summary);
text( text(
"stats-streaks", "stats-streaks",
@@ -315,7 +323,9 @@ function renderSummary(summary: Summary): void {
); );
text( text(
"stats-books-finished", "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"); const mostActive = document.getElementById("stats-most-active");
@@ -357,7 +367,11 @@ async function renderDailyCharts(daily: DailyEntry[]): Promise<void> {
}, },
scales: { scales: {
x: { grid: { display: false }, ticks: { color: secondary, maxTicksLimit: 10 } }, 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<void> {
pagesChart = null; pagesChart = null;
listeningChart = 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) { if (minutesCanvas) {
minutesChart = new ChartCtor(minutesCanvas, { minutesChart = new ChartCtor(minutesCanvas, {
type: "bar", type: "bar",
@@ -380,7 +396,9 @@ async function renderDailyCharts(daily: DailyEntry[]): Promise<void> {
}); });
} }
const pagesCanvas = document.getElementById("stats-daily-pages-chart") as HTMLCanvasElement | null; const pagesCanvas = document.getElementById(
"stats-daily-pages-chart",
) as HTMLCanvasElement | null;
if (pagesCanvas) { if (pagesCanvas) {
pagesChart = new ChartCtor(pagesCanvas, { pagesChart = new ChartCtor(pagesCanvas, {
type: "line", type: "line",
@@ -404,7 +422,9 @@ async function renderDailyCharts(daily: DailyEntry[]): Promise<void> {
// Listening per Day (hrs) — the audio half of the side-by-side pages // Listening per Day (hrs) — the audio half of the side-by-side pages
// split; its panel is hidden by renderPagesCard unless the range has // split; its panel is hidden by renderPagesCard unless the range has
// audio minutes. // 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) { if (listeningCanvas) {
listeningChart = new ChartCtor(listeningCanvas, { listeningChart = new ChartCtor(listeningCanvas, {
type: "bar", type: "bar",
@@ -426,26 +446,32 @@ async function renderDailyCharts(daily: DailyEntry[]): Promise<void> {
function renderLibraries(rows: LibraryRow[]): void { function renderLibraries(rows: LibraryRow[]): void {
const container = document.getElementById("stats-library-breakdown"); const container = document.getElementById("stats-library-breakdown");
if (!container) return; if (!container) return;
renderBars(container, rows.map((r) => ({ renderBars(
label: r.name, container,
sub: `${fmtMinutes(r.minutes)} · ${r.sessions} sessions`, rows.map((r) => ({
value: r.minutes, label: r.name,
}))); sub: `${fmtMinutes(r.minutes)} · ${r.sessions} sessions`,
value: r.minutes,
})),
);
} }
function renderDevices(rows: DeviceRow[]): void { function renderDevices(rows: DeviceRow[]): void {
const container = document.getElementById("stats-device-breakdown"); const container = document.getElementById("stats-device-breakdown");
if (!container) return; if (!container) return;
renderBars(container, rows.map((r) => ({ renderBars(
label: r.name || "Web", container,
sub: `${r.books} ${r.books === 1 ? "book" : "books"} · ${r.sessions} sessions`, rows.map((r) => ({
value: r.minutes, label: r.name || "Web",
}))); sub: `${r.books} ${r.books === 1 ? "book" : "books"} · ${r.sessions} sessions`,
value: r.minutes,
})),
);
} }
function fmtMinutes(minutes: number): string { function fmtMinutes(minutes: number): string {
return minutes >= 60 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`; : `${minutes} min`;
} }
@@ -522,10 +548,9 @@ async function loadLibraries(): Promise<VisibleLibrary[]> {
function fillLibrarySelector(rows: VisibleLibrary[]): void { function fillLibrarySelector(rows: VisibleLibrary[]): void {
const select = document.getElementById("stats-library-select") as HTMLSelectElement | null; const select = document.getElementById("stats-library-select") as HTMLSelectElement | null;
if (!select) return; if (!select) return;
select.innerHTML = `<option value="">All libraries</option>` + select.innerHTML =
rows `<option value="">All libraries</option>` +
.map((r) => `<option value="${escapeHtml(r.id)}">${escapeHtml(r.name)}</option>`) rows.map((r) => `<option value="${escapeHtml(r.id)}">${escapeHtml(r.name)}</option>`).join("");
.join("");
// Honor a URL-seeded selection initStatsPage may have set. // Honor a URL-seeded selection initStatsPage may have set.
if (currentLibraryID) select.value = currentLibraryID; if (currentLibraryID) select.value = currentLibraryID;
} }
@@ -545,9 +570,15 @@ async function loadLibraryUsage(): Promise<void> {
"lib-stat-narrators", "lib-stat-narrators",
]; ];
targets.forEach((id) => text(id, "–")); 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); const el = document.getElementById(id);
if (el) el.innerHTML = `<div class="text-center py-6 text-sm" style="color: var(--text-secondary)">Loading…</div>`; if (el)
el.innerHTML = `<div class="text-center py-6 text-sm" style="color: var(--text-secondary)">Loading…</div>`;
} }
try { try {
@@ -598,7 +629,10 @@ function renderLibraryUsage(payload: LibraryUsage): void {
} }
const gb = payload.total_size_bytes / 1e9; 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); const itemTotal = Math.max(payload.items, 1);
renderBars( renderBars(
@@ -619,7 +653,12 @@ function renderLibraryUsage(payload: LibraryUsage): void {
})), })),
"No authors recorded in this pool yet.", "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); renderUsageItems("stats-lib-largest", null, payload.largest_items, payload.hours_mode, true);
} }
+1 -1
View File
@@ -61,5 +61,5 @@ export {
setRefreshToken, setRefreshToken,
setSelectedLibrary, setSelectedLibrary,
setTheme, setTheme,
setToken setToken,
}; };
+6 -19
View File
@@ -68,9 +68,7 @@ const applyTheme = (theme: string): void => {
// Load theme from localStorage or use default // Load theme from localStorage or use default
const loadTheme = (): void => { const loadTheme = (): void => {
const storedTheme = localStorage.getItem( const storedTheme = localStorage.getItem(THEME_STORAGE_KEY) as ThemeType | null;
THEME_STORAGE_KEY,
) as ThemeType | null;
const theme = storedTheme || DEFAULT_THEME; const theme = storedTheme || DEFAULT_THEME;
applyTheme(theme); applyTheme(theme);
}; };
@@ -145,9 +143,7 @@ const updateThemeIndicators = (): void => {
const revealCurrentTheme = (mode: string): void => { const revealCurrentTheme = (mode: string): void => {
const match = document.body.className.match(/theme-([\w-]+)/); const match = document.body.className.match(/theme-([\w-]+)/);
const current = match ? match[1] : DEFAULT_THEME; const current = match ? match[1] : DEFAULT_THEME;
const btn = document.querySelector<HTMLElement>( const btn = document.querySelector<HTMLElement>(`.theme-btn[data-theme="${current}"]`);
`.theme-btn[data-theme="${current}"]`,
);
if (btn && btn.dataset.mode === mode) { if (btn && btn.dataset.mode === mode) {
btn.scrollIntoView({ block: "nearest" }); btn.scrollIntoView({ block: "nearest" });
} }
@@ -160,12 +156,9 @@ const initializeTheme = (): void => {
updateThemeIndicators(); updateThemeIndicators();
// Set theme select value to current theme // Set theme select value to current theme
const themeSelect = document.getElementById( const themeSelect = document.getElementById("theme-select") as HTMLSelectElement;
"theme-select",
) as HTMLSelectElement;
if (themeSelect) { if (themeSelect) {
const currentTheme = const currentTheme = localStorage.getItem(THEME_STORAGE_KEY) || DEFAULT_THEME;
localStorage.getItem(THEME_STORAGE_KEY) || DEFAULT_THEME;
themeSelect.value = currentTheme; themeSelect.value = currentTheme;
} }
@@ -205,11 +198,7 @@ const applyWoodPaneling = (paneling: WoodPanelingType): void => {
if (!container || !body) return; if (!container || !body) return;
// Remove all wood background classes from container // Remove all wood background classes from container
container.classList.remove( container.classList.remove("bg-wood-light", "bg-wood-dark", "bg-wood-mahogany");
"bg-wood-light",
"bg-wood-dark",
"bg-wood-mahogany",
);
container.removeAttribute("data-wood"); container.removeAttribute("data-wood");
// Remove from body // Remove from body
@@ -231,9 +220,7 @@ const applyWoodPaneling = (paneling: WoodPanelingType): void => {
// Load wood paneling from localStorage on page load // Load wood paneling from localStorage on page load
const loadWoodPaneling = (): void => { const loadWoodPaneling = (): void => {
const stored = localStorage.getItem( const stored = localStorage.getItem(WOOD_STORAGE_KEY) as WoodPanelingType | null;
WOOD_STORAGE_KEY,
) as WoodPanelingType | null;
if (stored) { if (stored) {
applyWoodPaneling(stored); applyWoodPaneling(stored);
} else { } else {
+8 -14
View File
@@ -11,8 +11,7 @@ const createToastContainer = (): HTMLElement => {
if (!container) { if (!container) {
container = document.createElement("div"); container = document.createElement("div");
container.id = "toast-container"; container.id = "toast-container";
container.className = container.className = "fixed top-5 right-5 z-[9999] flex flex-col gap-2.5 pointer-events-none";
"fixed top-5 right-5 z-[9999] flex flex-col gap-2.5 pointer-events-none";
document.body.appendChild(container); document.body.appendChild(container);
} }
return container; return container;
@@ -141,11 +140,7 @@ const setupHTMXListeners = (): void => {
if (customEvent.detail.xhr) { if (customEvent.detail.xhr) {
const xhr = customEvent.detail.xhr; const xhr = customEvent.detail.xhr;
if ( if (customEvent.detail.succeeded === false && xhr.status >= 400 && xhr.status < 600) {
customEvent.detail.succeeded === false &&
xhr.status >= 400 &&
xhr.status < 600
) {
const errorMessage = parseXHRError(xhr); const errorMessage = parseXHRError(xhr);
showToast(errorMessage, "error"); showToast(errorMessage, "error");
} else if (xhr.status >= 200 && xhr.status < 300) { } else if (xhr.status >= 200 && xhr.status < 300) {
@@ -188,7 +183,10 @@ function isSuggestionFetch(input: Parameters<typeof fetch>[0]): boolean {
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; 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 // The builder's live-count endpoint is a background hint too — a
// failed count must never interrupt with a toast. // 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; return false;
} }
if (url.includes("/api/media-items/filter-count?")) return true; if (url.includes("/api/media-items/filter-count?")) return true;
@@ -198,9 +196,7 @@ function isSuggestionFetch(input: Parameters<typeof fetch>[0]): boolean {
// Setup fetch interceptor // Setup fetch interceptor
const setupFetchInterceptor = (): void => { const setupFetchInterceptor = (): void => {
const originalFetch = window.fetch; const originalFetch = window.fetch;
window.fetch = async ( window.fetch = async (...args: Parameters<typeof fetch>): Promise<Response> => {
...args: Parameters<typeof fetch>
): Promise<Response> => {
try { try {
const response = await originalFetch(...args); const response = await originalFetch(...args);
@@ -233,9 +229,7 @@ const setupFetchInterceptor = (): void => {
if (!response.ok) { if (!response.ok) {
const errorMessage = await parseFetchError(response); const errorMessage = await parseFetchError(response);
if (isSuggestionFetch(args[0])) { if (isSuggestionFetch(args[0])) {
console.warn( console.warn(`Suggestion fetch failed (${response.status}): ${errorMessage}`);
`Suggestion fetch failed (${response.status}): ${errorMessage}`,
);
} else { } else {
showToast(errorMessage, "error"); showToast(errorMessage, "error");
} }
+8 -11
View File
@@ -59,8 +59,8 @@ interface MediaItemSummary {
library_type_name: string; library_type_name: string;
// NEW: Reading direction fields for manga/comics // NEW: Reading direction fields for manga/comics
manga_type?: 'unknown' | 'no' | 'yes' | 'yes_and_right_to_left'; manga_type?: "unknown" | "no" | "yes" | "yes_and_right_to_left";
reading_direction?: 'auto' | 'ltr' | 'rtl' | 'vertical'; reading_direction?: "auto" | "ltr" | "rtl" | "vertical";
// NEW: Universal metadata fields (apply to ebooks, audiobooks, comics) // NEW: Universal metadata fields (apply to ebooks, audiobooks, comics)
series_count?: number; series_count?: number;
@@ -74,7 +74,11 @@ interface MediaItemSummary {
// NEW: Comic-specific fields // NEW: Comic-specific fields
story_arc?: string; story_arc?: string;
is_black_and_white?: boolean; 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; scan_information?: string;
summary?: string; summary?: string;
} }
@@ -162,14 +166,7 @@ interface PotentialMatchData {
// Used in: collection_rules.ts // Used in: collection_rules.ts
interface CollectionRule { interface CollectionRule {
id: string; id: string;
field: field: "genre" | "series" | "author" | "language" | "publisher" | "copyright_year" | "tags";
| "genre"
| "series"
| "author"
| "language"
| "publisher"
| "copyright_year"
| "tags";
operator: operator:
| "equals" | "equals"
| "not_equals" | "not_equals"
+1 -9
View File
@@ -51,15 +51,7 @@ interface TOCNode {
// Parser Types (Procedural, not OOP) // Parser Types (Procedural, not OOP)
// ============================================================ // ============================================================
type ParserFormat = type ParserFormat = "epub" | "fb2" | "txt" | "html" | "mobi" | "azw3" | "docx" | "rtf";
| "epub"
| "fb2"
| "txt"
| "html"
| "mobi"
| "azw3"
| "docx"
| "rtf";
interface ParserCapabilities { interface ParserCapabilities {
canParse(mimeType: string, extension: string): boolean; canParse(mimeType: string, extension: string): boolean;
+24 -78
View File
@@ -4,11 +4,7 @@ import { getToken } from "./storage";
let selectedMediaItem: string | null = null; let selectedMediaItem: string | null = null;
function searchMatches( function searchMatches(progressId: string, sha256: string, title: string): void {
progressId: string,
sha256: string,
title: string,
): void {
const container = document.getElementById(`matches-${progressId}`); const container = document.getElementById(`matches-${progressId}`);
const matchesList = document.getElementById(`matches-list-${progressId}`); const matchesList = document.getElementById(`matches-list-${progressId}`);
@@ -38,18 +34,8 @@ function searchMatches(
}); });
} }
function autoLinkBook( function autoLinkBook(progressId: string, mediaItemId: string, confidence: number): void {
progressId: string, if (!confirm("Link this book? The confidence score is " + Math.round(confidence * 100) + "%")) {
mediaItemId: string,
confidence: number,
): void {
if (
!confirm(
"Link this book? The confidence score is " +
Math.round(confidence * 100) +
"%",
)
) {
return; return;
} }
@@ -88,12 +74,8 @@ function autoLinkBook(
function showManualLinkModal(progressId: string, bookTitle: string): void { function showManualLinkModal(progressId: string, bookTitle: string): void {
const modal = document.getElementById("manual-link-modal"); const modal = document.getElementById("manual-link-modal");
const progressIdInput = document.getElementById( const progressIdInput = document.getElementById("link-progress-id") as HTMLInputElement;
"link-progress-id", const bookTitleInput = document.getElementById("link-book-title") as HTMLInputElement;
) as HTMLInputElement;
const bookTitleInput = document.getElementById(
"link-book-title",
) as HTMLInputElement;
const searchResults = document.getElementById("link-search-results"); const searchResults = document.getElementById("link-search-results");
if (modal) modal.classList.remove("hidden"); if (modal) modal.classList.remove("hidden");
@@ -107,9 +89,7 @@ function showManualLinkModal(progressId: string, bookTitle: string): void {
function hideManualLinkModal(): void { function hideManualLinkModal(): void {
const modal = document.getElementById("manual-link-modal"); const modal = document.getElementById("manual-link-modal");
const searchInput = document.getElementById( const searchInput = document.getElementById("link-search-input") as HTMLInputElement;
"link-search-input",
) as HTMLInputElement;
if (modal) modal.classList.add("hidden"); if (modal) modal.classList.add("hidden");
if (searchInput) searchInput.value = ""; if (searchInput) searchInput.value = "";
@@ -117,9 +97,7 @@ function hideManualLinkModal(): void {
} }
function searchBooksForLink(): void { function searchBooksForLink(): void {
const searchInput = document.getElementById( const searchInput = document.getElementById("link-search-input") as HTMLInputElement;
"link-search-input",
) as HTMLInputElement;
const resultsContainer = document.getElementById("link-search-results"); const resultsContainer = document.getElementById("link-search-results");
if (!searchInput || !resultsContainer) return; if (!searchInput || !resultsContainer) return;
@@ -195,18 +173,10 @@ function confirmManualLink(): void {
return; return;
} }
const progressIdInput = document.getElementById( const progressIdInput = document.getElementById("link-progress-id") as HTMLInputElement;
"link-progress-id", const confidenceInput = document.getElementById("link-confidence") as HTMLInputElement;
) as HTMLInputElement; const bookTitleInput = document.getElementById("link-book-title") as HTMLInputElement;
const confidenceInput = document.getElementById( const sha256Input = document.getElementById("link-book-sha256") as HTMLInputElement;
"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; if (!progressIdInput) return;
@@ -245,9 +215,7 @@ function confirmManualLink(): void {
} }
function toggleAllUnlinked(): void { function toggleAllUnlinked(): void {
const selectAll = document.getElementById( const selectAll = document.getElementById("select-all-unlinked") as HTMLInputElement;
"select-all-unlinked",
) as HTMLInputElement;
if (!selectAll) return; if (!selectAll) return;
document.querySelectorAll(".unlinked-checkbox").forEach((cb) => { document.querySelectorAll(".unlinked-checkbox").forEach((cb) => {
@@ -257,9 +225,7 @@ function toggleAllUnlinked(): void {
} }
function getSelectedUnlinked(): { progressId: string; title: string }[] { function getSelectedUnlinked(): { progressId: string; title: string }[] {
return Array.from( return Array.from(document.querySelectorAll(".unlinked-checkbox:checked")).map((cb) => ({
document.querySelectorAll(".unlinked-checkbox:checked"),
).map((cb) => ({
progressId: cb.getAttribute("data-progress-id") || "", progressId: cb.getAttribute("data-progress-id") || "",
title: cb.getAttribute("data-title") || "", title: cb.getAttribute("data-title") || "",
})); }));
@@ -280,11 +246,7 @@ async function bulkAutoLink(): Promise<void> {
return; return;
} }
if ( if (!confirm(`Auto-link ${selected.length} books with high confidence matches (≥80%)?`)) {
!confirm(
`Auto-link ${selected.length} books with high confidence matches (≥80%)?`,
)
) {
return; return;
} }
@@ -303,10 +265,7 @@ async function bulkAutoLink(): Promise<void> {
}); });
const result = await response.json(); const result = await response.json();
showToast( showToast(`Auto-linked ${result.auto_linked} books successfully`, "success");
`Auto-linked ${result.auto_linked} books successfully`,
"success",
);
setTimeout(() => window.location.reload(), 1500); setTimeout(() => window.location.reload(), 1500);
} catch (error) { } catch (error) {
console.error("Auto-link failed", error); console.error("Auto-link failed", error);
@@ -324,14 +283,11 @@ async function bulkGetSuggestions(): Promise<void> {
const token = getToken(); const token = getToken();
for (const book of selected) { for (const book of selected) {
try { try {
const response = await fetch( const response = await fetch(`/sync/unlinked-books/${book.progressId}/suggestions`, {
`/sync/unlinked-books/${book.progressId}/suggestions`, headers: {
{ Authorization: `Bearer ${token}`,
headers: {
Authorization: `Bearer ${token}`,
},
}, },
); });
const result = await response.json(); const result = await response.json();
displaySuggestions(book.progressId, result.suggestions, result.action); displaySuggestions(book.progressId, result.suggestions, result.action);
@@ -341,11 +297,7 @@ async function bulkGetSuggestions(): Promise<void> {
} }
} }
function displaySuggestions( function displaySuggestions(progressId: string, suggestions: any[], _action: string): void {
progressId: string,
suggestions: any[],
_action: string,
): void {
const container = document.getElementById(`matches-${progressId}`); const container = document.getElementById(`matches-${progressId}`);
if (!container) return; if (!container) return;
@@ -356,15 +308,13 @@ function displaySuggestions(
listContainer.innerHTML = ""; listContainer.innerHTML = "";
if (suggestions.length === 0) { if (suggestions.length === 0) {
listContainer.innerHTML = listContainer.innerHTML = '<p style="color: var(--text-secondary)">No matches found</p>';
'<p style="color: var(--text-secondary)">No matches found</p>';
return; return;
} }
suggestions.forEach((match) => { suggestions.forEach((match) => {
const div = document.createElement("div"); const div = document.createElement("div");
div.className = div.className = "p-3 border rounded cursor-pointer hover:bg-opacity-80 transition-colors";
"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.style.cssText = `background-color: var(--bg-primary); border-color: var(--border);`;
div.innerHTML = ` div.innerHTML = `
<div class="flex justify-between items-center"> <div class="flex justify-between items-center">
@@ -400,8 +350,7 @@ function showBulkManualLink(): void {
"info", "info",
); );
window.location.href = window.location.href =
"/library?mode=link&unlinked=" + "/library?mode=link&unlinked=" + selected.map((s) => s.progressId).join(",");
selected.map((s) => s.progressId).join(",");
} }
function setupEventDelegation(): void { function setupEventDelegation(): void {
@@ -420,10 +369,7 @@ function setupEventDelegation(): void {
parseFloat(card.dataset.confidence || "0"), parseFloat(card.dataset.confidence || "0"),
); );
} else if (action === "select-book") { } else if (action === "select-book") {
selectBookForLink( selectBookForLink(card.dataset.mediaItemId || "", card.dataset.cover || "");
card.dataset.mediaItemId || "",
card.dataset.cover || "",
);
} else if (action === "select-match") { } else if (action === "select-match") {
autoLinkBook( autoLinkBook(
card.dataset.progressId || "", card.dataset.progressId || "",
+1 -6
View File
@@ -120,9 +120,4 @@ function createWebSocket(config: {
return ws; return ws;
} }
export { export { addListener, createWebSocket, disconnectWebSocket, removeListener };
addListener,
createWebSocket,
disconnectWebSocket,
removeListener,
};