From b78aacd3207e2f3341f25d0ae5477f41b1df010c Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Sun, 8 Mar 2026 21:35:47 -0400 Subject: [PATCH] feat: Add new Alpine.js component TypeScript files Extracted inline JavaScript from templates into proper TypeScript modules: - api-explorer-docs.ts: API explorer page functionality - collection-rules.ts: Collection rules management page - index.ts: Homepage theme and auth redirect - login.ts: Login page theme initialization - profile-modal.ts: Profile modal close and escape key - profile.ts: Profile page delete account - register.ts: Registration page theme init - toast-error.ts: Error toast with retry button - unlinked_books.ts: Unlinked books management page Each file: - Uses ES imports (showToast, getToken, etc.) - Has proper TypeScript types - Registers with Alpine.js via Alpine.global() - Uses async/await for API calls --- web/src/api-explorer-docs.ts | 142 +++++++++++ web/src/collection-rules.ts | 425 +++++++++++++++++++++++++++++++++ web/src/index.ts | 50 ++++ web/src/login.ts | 28 +++ web/src/profile-modal.ts | 32 +++ web/src/profile.ts | 46 ++++ web/src/register.ts | 16 ++ web/src/toast-error.ts | 37 +++ web/src/unlinked_books.ts | 443 +++++++++++++++++++++++++++++++++++ 9 files changed, 1219 insertions(+) create mode 100644 web/src/api-explorer-docs.ts create mode 100644 web/src/collection-rules.ts create mode 100644 web/src/index.ts create mode 100644 web/src/login.ts create mode 100644 web/src/profile-modal.ts create mode 100644 web/src/profile.ts create mode 100644 web/src/register.ts create mode 100644 web/src/toast-error.ts create mode 100644 web/src/unlinked_books.ts diff --git a/web/src/api-explorer-docs.ts b/web/src/api-explorer-docs.ts new file mode 100644 index 0000000..4f4f5d7 --- /dev/null +++ b/web/src/api-explorer-docs.ts @@ -0,0 +1,142 @@ +import { Alpine } from "./alpine"; +import { getToken } from "./storage"; + +let endpointPath = ""; +let exampleResponse: unknown = null; + +function initAPIExplorerDoc(path: string, request: string, response: string): void { + endpointPath = path; + exampleResponse = JSON.parse(response); +} + +function showDocMode(mode: "mock" | "real"): void { + const mockBtn = document.getElementById("mock-btn"); + const realBtn = document.getElementById("real-btn"); + const responseBody = document.getElementById("response-body"); + const responseStatus = document.getElementById("response-status"); + const responseTime = document.getElementById("response-time"); + const apiResponse = document.querySelector(".api-response"); + const requestBody = document.getElementById("request-body") as HTMLTextAreaElement; + const tryItOut = document.getElementById("try-it-out"); + + if (!mockBtn || !realBtn || !responseBody || !responseStatus || !responseTime || !apiResponse || !requestBody || !tryItOut) return; + + if (mode === "mock") { + mockBtn.classList.add("bg-accent", "text-white"); + mockBtn.classList.remove("bg-background-primary", "text-text-primary"); + realBtn.classList.remove("bg-accent", "text-white"); + realBtn.classList.add("bg-background-primary", "text-text-primary"); + + apiResponse.classList.remove("hidden"); + requestBody.readOnly = true; + tryItOut.classList.add("hidden"); + responseBody.textContent = JSON.stringify(exampleResponse, null, 2); + responseStatus.textContent = "200 OK"; + responseTime.textContent = "Mock"; + } else { + realBtn.classList.add("bg-accent", "text-white"); + realBtn.classList.remove("bg-background-primary", "text-text-primary"); + mockBtn.classList.remove("bg-accent", "text-white"); + mockBtn.classList.add("bg-background-primary", "text-text-primary"); + + apiResponse.classList.add("hidden"); + requestBody.readOnly = false; + tryItOut.classList.remove("hidden"); + } +} + +async function tryDocEndpoint(): Promise { + const methodSelect = document.getElementById("http-method") as HTMLSelectElement; + const requestBody = document.getElementById("request-body") as HTMLTextAreaElement; + const responseBody = document.getElementById("response-body"); + const responseStatus = document.getElementById("response-status"); + const responseTime = document.getElementById("response-time"); + const apiResponse = document.querySelector(".api-response"); + + if (!methodSelect || !requestBody || !responseBody || !responseStatus || !responseTime || !apiResponse) return; + + const method = methodSelect.value; + const body = requestBody.value; + + const startTime = Date.now(); + try { + const token = getToken(); + if (!token) { + throw new Error("No authentication token found"); + } + + const response = await fetch(endpointPath, { + method: method, + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: ["GET", "DELETE"].includes(method) ? undefined : body, + }); + + const duration = Date.now() - startTime; + const data = await response.json(); + + responseStatus.textContent = `${response.status} (${response.statusText})`; + responseTime.textContent = `${duration}ms`; + responseBody.textContent = JSON.stringify(data, null, 2); + apiResponse.classList.remove("hidden"); + } catch (error) { + responseStatus.textContent = "Error"; + responseBody.textContent = (error as Error).message; + apiResponse.classList.remove("hidden"); + } +} + +function copyDocRequest(): void { + const requestBody = document.getElementById("request-body") as HTMLTextAreaElement; + if (requestBody) { + navigator.clipboard.writeText(requestBody.value); + } +} + +function copyDocResponse(): void { + const responseBody = document.getElementById("response-body"); + if (responseBody) { + navigator.clipboard.writeText(responseBody.textContent || ""); + } +} + +function generateDocCURL(): void { + const methodSelect = document.getElementById("http-method") as HTMLSelectElement; + const requestBody = document.getElementById("request-body") as HTMLTextAreaElement; + + if (!methodSelect || !requestBody) return; + + const method = methodSelect.value; + const body = requestBody.value; + const token = getToken(); + + let curl = `curl -X ${method} \\n -H "Content-Type: application/json" \\n -H "Authorization: Bearer ${token}"`; + + if (!["GET", "DELETE"].includes(method) && body.trim()) { + curl += ` \\n -d '${body}'`; + } + + curl += ` \\n ${endpointPath}`; + + navigator.clipboard.writeText(curl); +} + +export { + copyDocRequest, + copyDocResponse, + generateDocCURL, + initAPIExplorerDoc, + showDocMode, + tryDocEndpoint, +}; + +Alpine.global("apiExplorerDoc", { + copyDocRequest, + copyDocResponse, + generateDocCURL, + initAPIExplorerDoc, + showDocMode, + tryDocEndpoint, +}); diff --git a/web/src/collection-rules.ts b/web/src/collection-rules.ts new file mode 100644 index 0000000..66331a6 --- /dev/null +++ b/web/src/collection-rules.ts @@ -0,0 +1,425 @@ +import { Alpine } from "./alpine"; +import { showToast } from "./toast"; + +// ============================================================ +// Collection Rules Page - Auto-Assign Rule Management +// ============================================================ + +let collectionId = ""; + +function initCollectionRules(id: string): void { + collectionId = id; + loadRules(); + setupEventDelegation(); +} + +function setupEventDelegation(): void { + const container = document.getElementById("rules-container"); + if (!container) return; + + container.addEventListener("click", (e) => { + const target = e.target as HTMLElement; + const button = target.closest("button") as HTMLButtonElement; + + if (!button) return; + + const action = button.dataset.action; + const ruleId = button.dataset.ruleId; + + if (action === "toggle" && ruleId) { + const enabled = button.dataset.enabled === "true"; + toggleRule(ruleId, enabled); + } else if (action === "delete" && ruleId) { + deleteRule(ruleId); + } + }); +} + +function backToCollection(): void { + if (!collectionId) return; + window.location.href = `/collections/${collectionId}`; +} + +function getFieldLabel(field: string): string { + const labels: Record = { + genre: "Genre", + series: "Series", + author: "Author", + language: "Language", + publisher: "Publisher", + copyright_year: "Copyright Year", + tags: "Tags", + }; + return labels[field] || field; +} + +function getOperatorLabel(operator: string): string { + const labels: Record = { + equals: "equals", + not_equals: "does not equal", + contains: "contains", + not_contains: "does not contain", + starts_with: "starts with", + ends_with: "ends with", + greater_than: "greater than", + less_than: "less than", + }; + return labels[operator] || operator; +} + +async function loadRules(): Promise { + const token = localStorage.getItem("token"); + if (!token || !collectionId) return; + + try { + const response = await fetch(`/api/collections/${collectionId}`, { + headers: { Authorization: `Bearer ${token}` }, + }); + + if (response.ok) { + const data = await response.json(); + renderRules(data.auto_assign_rules || []); + } + } catch (error) { + console.error("Failed to load rules", error); + } +} + +function renderRules(rules: CollectionRule[]): void { + const container = document.getElementById("rules-container"); + const noRulesDiv = document.getElementById("no-rules"); + if (!container || !noRulesDiv) return; + + if (rules && rules.length > 0) { + noRulesDiv.classList.add("hidden"); + + container.innerHTML = rules + .map( + (rule) => ` +
+
+
+
+ + ${getFieldLabel(rule.field)} + + + ${rule.enabled ? "Enabled" : "Disabled"} + + + Priority ${rule.priority} + +
+ + ${getOperatorLabel(rule.operator)} "${rule.value}" + +
+
+ + +
+
+
+ `, + ) + .join(""); + } else { + noRulesDiv.classList.remove("hidden"); + } +} + +async function handleCreateRule(event: Event): Promise { + event.preventDefault(); + + const token = localStorage.getItem("token"); + if (!token || !collectionId) return; + + const fieldInput = document.getElementById("rule-field") as HTMLSelectElement; + const operatorInput = document.getElementById( + "rule-operator", + ) as HTMLSelectElement; + const valueInput = document.getElementById("rule-value") as HTMLInputElement; + const enabledInput = document.getElementById( + "rule-enabled", + ) as HTMLInputElement; + const priorityInput = document.querySelector( + 'input[name="priority"]:checked', + ) as HTMLInputElement; + + if ( + !fieldInput || + !operatorInput || + !valueInput || + !enabledInput || + !priorityInput + ) { + showToast("Missing form fields", "error"); + return; + } + + const data = { + field: fieldInput.value, + operator: operatorInput.value, + value: valueInput.value, + enabled: enabledInput.checked, + priority: parseInt(priorityInput.value, 10), + }; + + try { + const response = await fetch(`/api/collections/${collectionId}/rules`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(data), + }); + + if (response.ok) { + showToast("Rule created successfully", "success"); + clearForm(); + loadRules(); + } else { + showToast("Failed to create rule", "error"); + } + } catch (error) { + console.error("Failed to create rule", error); + showToast("Failed to create rule", "error"); + } +} + +async function testRule(): Promise { + const token = localStorage.getItem("token"); + if (!token) return; + + const fieldInput = document.getElementById("rule-field") as HTMLSelectElement; + const operatorInput = document.getElementById( + "rule-operator", + ) as HTMLSelectElement; + const valueInput = document.getElementById("rule-value") as HTMLInputElement; + + if (!fieldInput || !operatorInput || !valueInput) { + showToast("Missing form fields", "error"); + return; + } + + const field = fieldInput.value; + const operator = operatorInput.value; + const value = valueInput.value; + + if (!field || !operator || !value) { + showToast("Please fill in all rule fields", "error"); + return; + } + + const testResultsDiv = document.getElementById("test-results"); + const resultsList = document.getElementById("test-results-list"); + if (!testResultsDiv || !resultsList) return; + + testResultsDiv.classList.remove("hidden"); + resultsList.innerHTML = + '

Testing rule...

'; + + const rules = [ + { + field: field, + operator: operator, + value: value, + }, + ]; + + try { + const response = await fetch("/api/collections/test-rules", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ rules: rules }), + }); + + if (response.ok) { + const result = await response.json(); + if (result.matches && result.matches.length > 0) { + let html = + '

Found ' + + result.matches.length + + " matching books:

"; + html += '
'; + + result.matches.slice(0, 20).forEach((book: TestRuleMatch) => { + html += + '
'; + html += + 'Cover'; + html += '
'; + html += + '
' + + book.title + + "
"; + if (book.author) { + html += + '
' + + book.author + + "
"; + } + if (book.match_reason) { + html += + '
' + + book.match_reason + + "
"; + } + html += "
"; + html += "
"; + }); + + if (result.matches.length > 20) { + html += + '

...and ' + + (result.matches.length - 20) + + " more

"; + } + + html += "
"; + resultsList.innerHTML = html; + } else { + resultsList.innerHTML = + '

No books match this rule

'; + } + } else { + resultsList.innerHTML = + '

Failed to test rule

'; + } + } catch (error) { + console.error("Failed to test rule", error); + resultsList.innerHTML = + '

Failed to test rule

'; + } +} + +async function toggleRule(ruleId: string, enabled: boolean): Promise { + const token = localStorage.getItem("token"); + if (!token || !collectionId) return; + + try { + const response = await fetch( + `/api/collections/${collectionId}/rules/${ruleId}`, + { + method: "PUT", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ enabled }), + }, + ); + + if (response.ok) { + showToast("Rule updated", "success"); + loadRules(); + } else { + showToast("Failed to update rule", "error"); + } + } catch (error) { + console.error("Failed to update rule", error); + showToast("Failed to update rule", "error"); + } +} + +async function deleteRule(ruleId: string): Promise { + if (!confirm("Are you sure you want to delete this rule?")) return; + + const token = localStorage.getItem("token"); + if (!token || !collectionId) return; + + try { + const response = await fetch( + `/api/collections/${collectionId}/rules/${ruleId}`, + { + method: "DELETE", + headers: { Authorization: `Bearer ${token}` }, + }, + ); + + if (response.ok) { + showToast("Rule deleted", "success"); + loadRules(); + } else { + showToast("Failed to delete rule", "error"); + } + } catch (error) { + console.error("Failed to delete rule", error); + showToast("Failed to delete rule", "error"); + } +} + +function clearForm(): void { + const fieldInput = document.getElementById("rule-field") as HTMLSelectElement; + const operatorInput = document.getElementById( + "rule-operator", + ) as HTMLSelectElement; + const valueInput = document.getElementById("rule-value") as HTMLInputElement; + const enabledInput = document.getElementById( + "rule-enabled", + ) as HTMLInputElement; + const priorityInput = document.querySelector( + 'input[name="priority"][value="2"]', + ) as HTMLInputElement; + const testResultsDiv = document.getElementById("test-results"); + + if (fieldInput) fieldInput.value = ""; + if (operatorInput) operatorInput.value = ""; + if (valueInput) valueInput.value = ""; + if (enabledInput) enabledInput.checked = true; + if (priorityInput) priorityInput.checked = true; + if (testResultsDiv) testResultsDiv.classList.add("hidden"); +} + +function logout(): void { + localStorage.removeItem("token"); + window.location.href = "/login"; +} + +// ============================================================ +// Exports +// ============================================================ + +export { + backToCollection, + clearForm, + deleteRule, + getFieldLabel, + getOperatorLabel, + handleCreateRule, + initCollectionRules, + loadRules, + logout, + renderRules, + setupEventDelegation, + testRule, + toggleRule, +}; + +Alpine.global("collectionRules", { + backToCollection, + clearForm, + deleteRule, + getFieldLabel, + getOperatorLabel, + handleCreateRule, + initCollectionRules, + loadRules, + logout, + renderRules, + setupEventDelegation, + testRule, + toggleRule, +}); diff --git a/web/src/index.ts b/web/src/index.ts new file mode 100644 index 0000000..60beae6 --- /dev/null +++ b/web/src/index.ts @@ -0,0 +1,50 @@ +import { Alpine } from "./alpine"; +import { applyTheme } from "./theme"; +import { getToken } from "./storage"; + +function initIndexTheme(): void { + // Progressive enhancement: check localStorage immediately + const savedTheme = localStorage.getItem("theme"); + if (savedTheme && savedTheme !== "tokyo-night") { + applyTheme(savedTheme); + } +} + +function changeTheme(): void { + const select = document.getElementById("theme-select") as HTMLSelectElement; + if (!select) return; + + const newTheme = select.value; + applyTheme(newTheme); + + // Save to localStorage + localStorage.setItem("theme", newTheme); +} + +async function checkAuthRedirect(): Promise { + const token = getToken(); + if (!token) return; + + try { + const response = await fetch("/api/auth/profile", { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + if (response.ok) { + // Token is valid, redirect to dashboard + window.location.href = "/dashboard"; + } + } catch (error) { + console.error("Failed to check auth", error); + } +} + +export { changeTheme, checkAuthRedirect, initIndexTheme }; + +Alpine.global("index", { + changeTheme, + checkAuthRedirect, + initIndexTheme, +}); diff --git a/web/src/login.ts b/web/src/login.ts new file mode 100644 index 0000000..837980c --- /dev/null +++ b/web/src/login.ts @@ -0,0 +1,28 @@ +import { Alpine } from "./alpine"; +import { applyTheme } from "./theme"; + +function initLoginTheme(): void { + // Progressive enhancement: check localStorage immediately + const savedTheme = localStorage.getItem("theme"); + if (savedTheme && savedTheme !== "tokyo-night") { + applyTheme(savedTheme); + } +} + +function changeTheme(): void { + const select = document.getElementById("theme-select") as HTMLSelectElement; + if (!select) return; + + const newTheme = select.value; + applyTheme(newTheme); + + // Save to localStorage + localStorage.setItem("theme", newTheme); +} + +export { changeTheme, initLoginTheme }; + +Alpine.global("login", { + changeTheme, + initLoginTheme, +}); diff --git a/web/src/profile-modal.ts b/web/src/profile-modal.ts new file mode 100644 index 0000000..f7e6c2c --- /dev/null +++ b/web/src/profile-modal.ts @@ -0,0 +1,32 @@ +import { Alpine } from "./alpine"; + +let escapeHandler: ((e: KeyboardEvent) => void) | null = null; + +function closeProfileModal(): void { + const modal = document.getElementById("profile-modal"); + if (modal) { + modal.remove(); + } + // Remove escape key listener when modal closes + if (escapeHandler) { + document.removeEventListener("keydown", escapeHandler); + escapeHandler = null; + } +} + +function setupProfileModal(): void { + // Set up escape key listener to close modal + escapeHandler = (e: KeyboardEvent) => { + if (e.key === "Escape") { + closeProfileModal(); + } + }; + document.addEventListener("keydown", escapeHandler); +} + +export { closeProfileModal, setupProfileModal }; + +Alpine.global("profileModal", { + closeProfileModal, + setupProfileModal, +}); diff --git a/web/src/profile.ts b/web/src/profile.ts new file mode 100644 index 0000000..6f9cab3 --- /dev/null +++ b/web/src/profile.ts @@ -0,0 +1,46 @@ +import { Alpine } from "./alpine"; +import { removeToken } from "./storage"; +import { showToast } from "./toast"; + +async function confirmDeleteAccount(): Promise { + if ( + !confirm( + "Are you sure? All preferences and devices will be deleted. This action cannot be undone.", + ) + ) { + return; + } + + const token = localStorage.getItem("token"); + if (!token) { + window.location.href = "/login"; + return; + } + + try { + const response = await fetch("/api/auth/profile", { + method: "DELETE", + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + if (response.ok) { + removeToken(); + localStorage.removeItem("user"); + window.location.href = "/login?deleted=true"; + } else { + const error = await response.json(); + showToast(error.error || "Failed to delete account", "error"); + } + } catch (error) { + console.error("Failed to delete account", error); + showToast("Failed to delete account", "error"); + } +} + +export { confirmDeleteAccount }; + +Alpine.global("profile", { + confirmDeleteAccount, +}); diff --git a/web/src/register.ts b/web/src/register.ts new file mode 100644 index 0000000..06f1e10 --- /dev/null +++ b/web/src/register.ts @@ -0,0 +1,16 @@ +import { Alpine } from "./alpine"; +import { applyTheme } from "./theme"; + +function initRegisterTheme(): void { + // Progressive enhancement: check localStorage immediately + const savedTheme = localStorage.getItem("theme"); + if (savedTheme && savedTheme !== "tokyo-night") { + applyTheme(savedTheme); + } +} + +export { initRegisterTheme }; + +Alpine.global("register", { + initRegisterTheme, +}); diff --git a/web/src/toast-error.ts b/web/src/toast-error.ts new file mode 100644 index 0000000..7efd62d --- /dev/null +++ b/web/src/toast-error.ts @@ -0,0 +1,37 @@ +import { Alpine } from "./alpine"; + +function showErrorToast(message: string): void { + // Use the global showToast from toast.ts + if (typeof (window as any).showToast !== "undefined") { + (window as any).showToast.error(message, 8000); + + // Add retry button to the toast + setTimeout(() => { + const toastContainer = document.getElementById("toast-container"); + if (toastContainer && toastContainer.lastElementChild) { + const toast = toastContainer.lastElementChild as HTMLElement; + const retryBtn = document.createElement("button"); + retryBtn.className = + "ml-4 px-3 py-1 bg-white/20 hover:bg-white/30 rounded text-sm font-medium transition-colors"; + retryBtn.textContent = "Retry"; + retryBtn.onclick = function () { + window.location.reload(); + }; + + // Insert before the close button + const closeBtn = toast.querySelector(".toast-close"); + if (closeBtn && closeBtn.parentElement) { + closeBtn.parentElement.insertBefore(retryBtn, closeBtn); + } else { + toast.appendChild(retryBtn); + } + } + }, 100); + } +} + +export { showErrorToast }; + +Alpine.global("toastError", { + showErrorToast, +}); diff --git a/web/src/unlinked_books.ts b/web/src/unlinked_books.ts new file mode 100644 index 0000000..7a5a662 --- /dev/null +++ b/web/src/unlinked_books.ts @@ -0,0 +1,443 @@ +import { Alpine } from "./alpine"; +import { showToast } from "./toast"; +import { getToken } from "./storage"; + +let selectedMediaItem: string | null = null; + +function searchMatches(progressId: string, sha256: string, title: string): void { + const container = document.getElementById(`matches-${progressId}`); + const matchesList = document.getElementById(`matches-list-${progressId}`); + + if (!container || !matchesList) return; + + container.classList.remove("hidden"); + matchesList.innerHTML = '

Searching...

'; + + const token = getToken(); + const url = sha256 + ? `/api/books/match?sha256=${sha256}` + : `/api/books/match?title=${encodeURIComponent(title)}`; + + fetch(url, { + headers: { + Authorization: `Bearer ${token}`, + }, + }) + .then((response) => response.json()) + .then((result) => { + if (result.matches && result.matches.length > 0) { + matchesList.innerHTML = result.matches + .map( + (match: any) => ` +
+
+ Cover +
+
${match.title}
+

by ${match.author || "Unknown"}

+
+ + ${Math.round(match.confidence * 100)}% confidence + + ${match.match_method} +
+
+
+
+ `, + ) + .join(""); + } else { + matchesList.innerHTML = '

No matches found. Try manual linking.

'; + } + }) + .catch((error) => { + console.error("Failed to search", error); + matchesList.innerHTML = '

Failed to search

'; + }); +} + +function autoLinkBook(progressId: string, mediaItemId: string, confidence: number): void { + if ( + !confirm( + "Link this book? The confidence score is " + Math.round(confidence * 100) + "%", + ) + ) { + return; + } + + const progressElement = document.getElementById(`matches-${progressId}`); + const codeElement = progressElement?.querySelector("code"); + const sha256Element = progressElement?.querySelector('[title="SHA-256"]'); + + const data = { + device_file: { + file_path: codeElement?.textContent || "", + sha256: sha256Element?.textContent || "", + }, + media_item_id: mediaItemId, + confidence_score: confidence, + }; + + const token = getToken(); + fetch(`/api/devices/sync/link-book`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(data), + }) + .then((response) => response.json()) + .then((result) => { + showToast("Book linked successfully", "success"); + window.location.reload(); + }) + .catch((error) => { + console.error("Failed to link book", error); + showToast("Failed to link book", "error"); + }); +} + +function showManualLinkModal(progressId: string, bookTitle: string): void { + const modal = document.getElementById("manual-link-modal"); + const progressIdInput = document.getElementById("link-progress-id") as HTMLInputElement; + const bookTitleInput = document.getElementById("link-book-title") as HTMLInputElement; + const searchResults = document.getElementById("link-search-results"); + + if (modal) modal.classList.remove("hidden"); + if (progressIdInput) progressIdInput.value = progressId; + if (bookTitleInput) bookTitleInput.value = bookTitle; + if (searchResults) searchResults.innerHTML = '

Search for books to link

'; + selectedMediaItem = null; +} + +function hideManualLinkModal(): void { + const modal = document.getElementById("manual-link-modal"); + const searchInput = document.getElementById("link-search-input") as HTMLInputElement; + + if (modal) modal.classList.add("hidden"); + if (searchInput) searchInput.value = ""; + selectedMediaItem = null; +} + +function searchBooksForLink(): void { + const searchInput = document.getElementById("link-search-input") as HTMLInputElement; + const resultsContainer = document.getElementById("link-search-results"); + + if (!searchInput || !resultsContainer) return; + + const searchTerm = searchInput.value; + + if (searchTerm.length < 2) { + resultsContainer.innerHTML = '

Enter at least 2 characters

'; + return; + } + + resultsContainer.innerHTML = '

Searching...

'; + + const token = getToken(); + fetch(`/api/books/match?title=${encodeURIComponent(searchTerm)}`, { + headers: { + Authorization: `Bearer ${token}`, + }, + }) + .then((response) => response.json()) + .then((result) => { + if (result.matches && result.matches.length > 0) { + resultsContainer.innerHTML = result.matches + .map( + (match: any) => ` +
+
+ Cover +
+
${match.title}
+

by ${match.author || "Unknown"}

+

${Math.round(match.confidence * 100)}% confidence

+
+
+
+ `, + ) + .join(""); + } else { + resultsContainer.innerHTML = '

No matches found

'; + } + }) + .catch((error) => { + console.error("Failed to search", error); + resultsContainer.innerHTML = '

Failed to search

'; + }); +} + +function selectBookForLink(mediaItemId: string, title: string, _coverPath: string): void { + selectedMediaItem = mediaItemId; + const resultsContainer = document.getElementById("link-search-results"); + if (!resultsContainer) return; + + const cards = resultsContainer.querySelectorAll(".card"); + cards.forEach((card) => { + card.classList.remove("border-2", "border-blue-500"); + if ((card as HTMLElement).dataset.mediaItemId === mediaItemId) { + card.classList.add("border-2", "border-blue-500"); + } + }); +} + +function confirmManualLink(): void { + if (!selectedMediaItem) { + showToast("Please select a book to link", "error"); + return; + } + + const progressIdInput = document.getElementById("link-progress-id") as HTMLInputElement; + const confidenceInput = document.getElementById("link-confidence") as HTMLInputElement; + const bookTitleInput = document.getElementById("link-book-title") as HTMLInputElement; + const sha256Input = document.getElementById("link-book-sha256") as HTMLInputElement; + + if (!progressIdInput) return; + + const progressId = progressIdInput.value; + const confidence = parseFloat(confidenceInput?.value || "0"); + const bookTitle = bookTitleInput?.value || ""; + const sha256 = sha256Input?.value || ""; + + const data = { + device_file: { + file_path: bookTitle, + sha256: sha256, + }, + media_item_id: selectedMediaItem, + confidence_score: confidence, + }; + + const token = getToken(); + fetch(`/api/devices/sync/link-book`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(data), + }) + .then((response) => response.json()) + .then((result) => { + showToast("Book linked successfully", "success"); + hideManualLinkModal(); + window.location.reload(); + }) + .catch((error) => { + console.error("Failed to link book", error); + showToast("Failed to link book", "error"); + }); +} + +function toggleAllUnlinked(): void { + const selectAll = document.getElementById("select-all-unlinked") as HTMLInputElement; + if (!selectAll) return; + + document.querySelectorAll(".unlinked-checkbox").forEach((cb) => { + (cb as HTMLInputElement).checked = selectAll.checked; + }); + updateSelectedCount(); +} + +function getSelectedUnlinked(): { progressId: string; title: string }[] { + return Array.from(document.querySelectorAll(".unlinked-checkbox:checked")).map((cb) => ({ + progressId: cb.getAttribute("data-progress-id") || "", + title: cb.getAttribute("data-title") || "", + })); +} + +function updateSelectedCount(): void { + const count = document.querySelectorAll(".unlinked-checkbox:checked").length; + const countElement = document.getElementById("selected-count"); + if (countElement) { + countElement.textContent = `${count} selected`; + } +} + +async function bulkAutoLink(): Promise { + const selected = getSelectedUnlinked(); + if (selected.length === 0) { + showToast("Please select at least one book", "error"); + return; + } + + if (!confirm(`Auto-link ${selected.length} books with high confidence matches (≥80%)?`)) { + return; + } + + const token = getToken(); + try { + const response = await fetch("/sync/auto-link-books", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + confidence_threshold: 0.8, + limit: selected.length, + }), + }); + + const result = await response.json(); + showToast(`Auto-linked ${result.auto_linked} books successfully`, "success"); + setTimeout(() => window.location.reload(), 1500); + } catch (error) { + console.error("Auto-link failed", error); + showToast(`Auto-link failed: ${(error as Error).message}`, "error"); + } +} + +async function bulkGetSuggestions(): Promise { + const selected = getSelectedUnlinked(); + if (selected.length === 0) { + showToast("Please select at least one book", "error"); + return; + } + + const token = getToken(); + for (const book of selected) { + try { + const response = await fetch(`/sync/unlinked-books/${book.progressId}/suggestions`, { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + const result = await response.json(); + displaySuggestions(book.progressId, result.suggestions, result.action); + } catch (error) { + console.error("Failed to get suggestions:", error); + } + } +} + +function displaySuggestions(progressId: string, suggestions: any[], _action: string): void { + const container = document.getElementById(`matches-${progressId}`); + if (!container) return; + + container.classList.remove("hidden"); + const listContainer = container.querySelector(".matches-list"); + if (!listContainer) return; + + listContainer.innerHTML = ""; + + if (suggestions.length === 0) { + listContainer.innerHTML = '

No matches found

'; + return; + } + + suggestions.forEach((match) => { + const div = document.createElement("div"); + div.className = "p-3 border rounded cursor-pointer hover:bg-opacity-80 transition-colors"; + div.style.cssText = `background-color: var(--bg-primary); border-color: var(--border);`; + div.innerHTML = ` +
+
+

${match.title}

+

Author: ${match.author || "Unknown"}

+
+
+
+ ${(match.confidence * 100).toFixed(0)}% confidence +
+
${match.match_method}
+
+
+ `; + (div as HTMLElement).dataset.action = "select-match"; + (div as HTMLElement).dataset.progressId = progressId; + (div as HTMLElement).dataset.mediaItemId = match.media_item_id; + (div as HTMLElement).dataset.confidence = String(match.confidence); + listContainer.appendChild(div); + }); +} + +function showBulkManualLink(): void { + const selected = getSelectedUnlinked(); + if (selected.length === 0) { + showToast("Please select at least one book", "error"); + return; + } + + showToast(`Bulk manual link for ${selected.length} books - select target book in library`, "info"); + window.location.href = "/library?mode=link&unlinked=" + selected.map((s) => s.progressId).join(","); +} + +function setupEventDelegation(): void { + document.addEventListener("click", (e) => { + const target = e.target as HTMLElement; + const card = target.closest("[data-action]") as HTMLElement; + + if (!card) return; + + const action = card.dataset.action; + + if (action === "auto-link") { + autoLinkBook( + card.dataset.progressId || "", + card.dataset.mediaItemId || "", + parseFloat(card.dataset.confidence || "0"), + ); + } else if (action === "select-book") { + selectBookForLink( + card.dataset.mediaItemId || "", + card.dataset.title || "", + card.dataset.cover || "", + ); + } else if (action === "select-match") { + autoLinkBook( + card.dataset.progressId || "", + card.dataset.mediaItemId || "", + parseFloat(card.dataset.confidence || "0"), + ); + } + }); + + document.addEventListener("change", (e) => { + const target = e.target as HTMLElement; + if (target.classList.contains("unlinked-checkbox")) { + updateSelectedCount(); + } + }); +} + +export { + bulkAutoLink, + bulkGetSuggestions, + confirmManualLink, + displaySuggestions, + hideManualLinkModal, + searchBooksForLink, + searchMatches, + selectBookForLink, + setupEventDelegation, + showBulkManualLink, + showManualLinkModal, + toggleAllUnlinked, +}; + +Alpine.global("unlinkedBooks", { + bulkAutoLink, + bulkGetSuggestions, + confirmManualLink, + displaySuggestions, + hideManualLinkModal, + searchBooksForLink, + searchMatches, + selectBookForLink, + setupEventDelegation, + showBulkManualLink, + showManualLinkModal, + toggleAllUnlinked, +});