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:
1 parent
8aeecaa0ef
commit
77cd268f4e
37 files changed
+1151
-1853
No files matched your search
+16
-53
@@ -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");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -43,7 +43,10 @@ interface AnnotationSource {
|
||||
}
|
||||
|
||||
/** Friendly human location label; mirrors the app's positionHint(). */
|
||||
function friendlyAnnotationLabel(position: string | null | undefined, fallbackText: string): string {
|
||||
function friendlyAnnotationLabel(
|
||||
position: string | null | undefined,
|
||||
fallbackText: string,
|
||||
): string {
|
||||
const raw = (position ?? "").trim();
|
||||
if (raw) {
|
||||
// PDF/comic anchors: "page:N"
|
||||
@@ -61,9 +64,12 @@ function friendlyAnnotationLabel(position: string | null | undefined, fallbackTe
|
||||
}
|
||||
}
|
||||
// Spec fallback: the text's first few words (never a raw locator).
|
||||
const words = stripNote(fallbackText || "").replace(/\s+/g, " ").trim();
|
||||
const words = stripNote(fallbackText || "")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
if (words) {
|
||||
const cut = words.length > 42 ? words.slice(0, words.lastIndexOf(" ", 42)) || words.slice(0, 42) : words;
|
||||
const cut =
|
||||
words.length > 42 ? words.slice(0, words.lastIndexOf(" ", 42)) || words.slice(0, 42) : words;
|
||||
return cut;
|
||||
}
|
||||
return "Location";
|
||||
@@ -214,10 +220,7 @@ function buildAnnotationRows(
|
||||
}
|
||||
|
||||
function escapeHtmlForRich(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
/** The export item text — identical in single and multi modes. */
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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");
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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
@@ -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
@@ -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}`;
|
||||
|
||||
Vendored
+1
-5
@@ -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
@@ -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
@@ -61,5 +61,5 @@ export {
|
||||
setRefreshToken,
|
||||
setSelectedLibrary,
|
||||
setTheme,
|
||||
setToken
|
||||
setToken,
|
||||
};
|
||||
+6
-19
@@ -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
@@ -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");
|
||||
}
|
||||
|
||||
Vendored
+8
-11
@@ -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"
|
||||
|
||||
Vendored
+1
-9
@@ -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
@@ -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 || "",
|
||||
|
||||
@@ -120,9 +120,4 @@ function createWebSocket(config: {
|
||||
return ws;
|
||||
}
|
||||
|
||||
export {
|
||||
addListener,
|
||||
createWebSocket,
|
||||
disconnectWebSocket,
|
||||
removeListener,
|
||||
};
|
||||
export { addListener, createWebSocket, disconnectWebSocket, removeListener };
|
||||
Reference in new issue
Block a user