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