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

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