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
This commit is contained in:
@@ -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<void> {
|
||||||
|
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,
|
||||||
|
});
|
||||||
@@ -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<string, string> = {
|
||||||
|
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<string, string> = {
|
||||||
|
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<void> {
|
||||||
|
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) => `
|
||||||
|
<div class="card p-4 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border);">
|
||||||
|
<div class="flex justify-between items-start">
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="flex items-center gap-3 mb-2">
|
||||||
|
<span class="font-semibold" style="color: var(--text-primary)">
|
||||||
|
${getFieldLabel(rule.field)}
|
||||||
|
</span>
|
||||||
|
<span class="px-2 py-1 text-xs rounded" style="background-color: ${rule.enabled ? "var(--accent)" : "var(--text-secondary)"}; color: var(--bg-primary);">
|
||||||
|
${rule.enabled ? "Enabled" : "Disabled"}
|
||||||
|
</span>
|
||||||
|
<span class="px-2 py-1 text-xs rounded" style="background-color: var(--bg-secondary); color: var(--text-primary);">
|
||||||
|
Priority ${rule.priority}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<code class="block text-sm" style="color: var(--text-secondary);">
|
||||||
|
${getOperatorLabel(rule.operator)} "${rule.value}"
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
<div class="flex space-x-2">
|
||||||
|
<button data-action="toggle" data-rule-id="${rule.id}" data-enabled="${!rule.enabled}"
|
||||||
|
class="p-2 hover:opacity-80 rounded" style="color: var(--text-secondary); background-color: var(--bg-primary);">
|
||||||
|
${rule.enabled ? "⏸️" : "▶️"}
|
||||||
|
</button>
|
||||||
|
<button data-action="delete" data-rule-id="${rule.id}"
|
||||||
|
class="p-2 hover:opacity-80 rounded" style="color: var(--text-secondary); background-color: var(--bg-primary);">
|
||||||
|
🗑️
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
.join("");
|
||||||
|
} else {
|
||||||
|
noRulesDiv.classList.remove("hidden");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCreateRule(event: Event): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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 =
|
||||||
|
'<p class="text-sm" style="color: var(--text-secondary)">Testing rule...</p>';
|
||||||
|
|
||||||
|
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 =
|
||||||
|
'<p style="color: var(--text-secondary); font-size: 0.875rem;">Found ' +
|
||||||
|
result.matches.length +
|
||||||
|
" matching books:</p>";
|
||||||
|
html += '<div style="display: flex; flex-direction: column; gap: 0.5rem;">';
|
||||||
|
|
||||||
|
result.matches.slice(0, 20).forEach((book: TestRuleMatch) => {
|
||||||
|
html +=
|
||||||
|
'<div style="display: flex; align-items: center; gap: 0.75rem; padding: 0.5rem; border-radius: 0.25rem; background-color: var(--bg-secondary);">';
|
||||||
|
html +=
|
||||||
|
'<img src="' +
|
||||||
|
(book.cover_image_path || "/static/placeholder-book.svg") +
|
||||||
|
'" alt="Cover" style="width: 2rem; height: 3rem; object-fit: cover; border-radius: 0.25rem;">';
|
||||||
|
html += '<div style="flex: 1;">';
|
||||||
|
html +=
|
||||||
|
'<div style="font-size: 0.875rem; font-weight: 500; color: var(--text-primary);">' +
|
||||||
|
book.title +
|
||||||
|
"</div>";
|
||||||
|
if (book.author) {
|
||||||
|
html +=
|
||||||
|
'<div style="font-size: 0.75rem; color: var(--text-secondary);">' +
|
||||||
|
book.author +
|
||||||
|
"</div>";
|
||||||
|
}
|
||||||
|
if (book.match_reason) {
|
||||||
|
html +=
|
||||||
|
'<div style="font-size: 0.75rem; color: var(--accent);">' +
|
||||||
|
book.match_reason +
|
||||||
|
"</div>";
|
||||||
|
}
|
||||||
|
html += "</div>";
|
||||||
|
html += "</div>";
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.matches.length > 20) {
|
||||||
|
html +=
|
||||||
|
'<p style="color: var(--text-secondary); font-size: 0.875rem;">...and ' +
|
||||||
|
(result.matches.length - 20) +
|
||||||
|
" more</p>";
|
||||||
|
}
|
||||||
|
|
||||||
|
html += "</div>";
|
||||||
|
resultsList.innerHTML = html;
|
||||||
|
} else {
|
||||||
|
resultsList.innerHTML =
|
||||||
|
'<p style="color: var(--text-secondary); font-size: 0.875rem;">No books match this rule</p>';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
resultsList.innerHTML =
|
||||||
|
'<p class="text-sm" style="color: var(--error)">Failed to test rule</p>';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to test rule", error);
|
||||||
|
resultsList.innerHTML =
|
||||||
|
'<p class="text-sm" style="color: var(--error)">Failed to test rule</p>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleRule(ruleId: string, enabled: boolean): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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,
|
||||||
|
});
|
||||||
@@ -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<void> {
|
||||||
|
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,
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { Alpine } from "./alpine";
|
||||||
|
import { removeToken } from "./storage";
|
||||||
|
import { showToast } from "./toast";
|
||||||
|
|
||||||
|
async function confirmDeleteAccount(): Promise<void> {
|
||||||
|
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,
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
@@ -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 = '<p class="text-sm" style="color: var(--text-secondary)">Searching...</p>';
|
||||||
|
|
||||||
|
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) => `
|
||||||
|
<div class="card p-4 rounded-lg border cursor-pointer hover:shadow-lg transition-shadow"
|
||||||
|
style="background-color: var(--bg-primary); border-color: var(--border);"
|
||||||
|
data-action="auto-link" data-progress-id="${progressId}" data-media-item-id="${match.media_item_id}" data-confidence="${match.confidence}">
|
||||||
|
<div class="flex gap-4">
|
||||||
|
<img src="${match.cover_image_path || "/static/placeholder-book.svg"}"
|
||||||
|
alt="Cover" class="w-16 h-24 object-cover rounded">
|
||||||
|
<div>
|
||||||
|
<h5 class="font-semibold" style="color: var(--text-primary)">${match.title}</h5>
|
||||||
|
<p class="text-sm" style="color: var(--text-secondary)">by ${match.author || "Unknown"}</p>
|
||||||
|
<div class="mt-2 flex items-center gap-2">
|
||||||
|
<span class="text-xs px-2 py-1 rounded" style="background-color: var(--accent); color: var(--bg-primary);">
|
||||||
|
${Math.round(match.confidence * 100)}% confidence
|
||||||
|
</span>
|
||||||
|
<span class="text-xs" style="color: var(--text-secondary)">${match.match_method}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
.join("");
|
||||||
|
} else {
|
||||||
|
matchesList.innerHTML = '<p class="text-sm" style="color: var(--text-secondary)">No matches found. Try manual linking.</p>';
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error("Failed to search", error);
|
||||||
|
matchesList.innerHTML = '<p class="text-sm" style="color: var(--error)">Failed to search</p>';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = '<p style="color: var(--text-secondary)">Search for books to link</p>';
|
||||||
|
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 = '<p class="text-sm" style="color: var(--text-secondary)">Enter at least 2 characters</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
resultsContainer.innerHTML = '<p class="text-sm" style="color: var(--text-secondary)">Searching...</p>';
|
||||||
|
|
||||||
|
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) => `
|
||||||
|
<div class="card p-3 rounded-lg border cursor-pointer ${selectedMediaItem === match.media_item_id ? "border-2 border-blue-500" : ""}"
|
||||||
|
style="background-color: var(--bg-primary); border-color: var(--border);"
|
||||||
|
data-action="select-book" data-media-item-id="${match.media_item_id}" data-title="${match.title}" data-cover="${match.cover_image_path || ""}">
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<img src="${match.cover_image_path || "/static/placeholder-book.svg"}"
|
||||||
|
alt="Cover" class="w-12 h-16 object-cover rounded">
|
||||||
|
<div>
|
||||||
|
<h5 class="font-semibold text-sm" style="color: var(--text-primary)">${match.title}</h5>
|
||||||
|
<p class="text-xs" style="color: var(--text-secondary)">by ${match.author || "Unknown"}</p>
|
||||||
|
<p class="text-xs" style="color: var(--accent)">${Math.round(match.confidence * 100)}% confidence</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
.join("");
|
||||||
|
} else {
|
||||||
|
resultsContainer.innerHTML = '<p class="text-sm" style="color: var(--text-secondary)">No matches found</p>';
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error("Failed to search", error);
|
||||||
|
resultsContainer.innerHTML = '<p class="text-sm" style="color: var(--error)">Failed to search</p>';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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 = '<p style="color: var(--text-secondary)">No matches found</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
suggestions.forEach((match) => {
|
||||||
|
const div = document.createElement("div");
|
||||||
|
div.className = "p-3 border rounded cursor-pointer hover:bg-opacity-80 transition-colors";
|
||||||
|
div.style.cssText = `background-color: var(--bg-primary); border-color: var(--border);`;
|
||||||
|
div.innerHTML = `
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<h4 class="font-semibold" style="color: var(--text-primary)">${match.title}</h4>
|
||||||
|
<p class="text-sm" style="color: var(--text-secondary)">Author: ${match.author || "Unknown"}</p>
|
||||||
|
</div>
|
||||||
|
<div class="text-right">
|
||||||
|
<div class="text-sm font-semibold" style="color: var(--text-primary)">
|
||||||
|
${(match.confidence * 100).toFixed(0)}% confidence
|
||||||
|
</div>
|
||||||
|
<div class="text-xs" style="color: var(--text-secondary)">${match.match_method}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
(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,
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user