Files
bookhoard/web/src/admin.ts
T
john-okeefe 41e7445524 refactor: remove inline WebSocket code from templates
collections.templ:
- Removed ~75 lines of inline WebSocket JS
- Added initializeCollectionWebSocket using websocket.ts utility
- Updated template to use x-init for WebSocket init

admin.templ:
- Removed ~55 lines of inline WebSocket JS
- Added initializeScanWebSocket using websocket.ts utility
- Updated template to use x-init for WebSocket init

Both now use the shared websocket.ts createWebSocket function
2026-03-13 12:51:11 -04:00

402 lines
12 KiB
TypeScript

import { Alpine } from "./alpine";
import { showToast } from "./toast";
import { createWebSocket } from "./websocket";
function initializeScanWebSocket(): void {
createWebSocket({
onMessage: (message) => {
switch (message.type) {
case "scan_progress":
updateScanProgress(message.data);
break;
case "scan_complete":
showScanComplete(message.data);
break;
case "scan_error":
showScanError(message.data);
break;
}
},
enableReconnect: true,
reconnectDelay: 5000,
});
}
function updateScanProgress(data: { progress: number; files_scanned: number; new_items: number }): void {
const progressBar = document.getElementById("scan-progress-bar");
if (progressBar) {
progressBar.style.width = (data.progress * 100) + "%";
}
const progressText = document.getElementById("scan-progress-text");
if (progressText) {
progressText.textContent = `${data.files_scanned} files scanned (${data.new_items} new)`;
}
}
function showScanComplete(_data: unknown): void {
console.log("Scan complete:", _data);
}
function showScanError(_data: unknown): void {
console.error("Scan error:", _data);
}
async function triggerLibraryScan(): Promise<void> {
const token = localStorage.getItem("token");
if (!token) return;
try {
const response = await fetch("/api/libraries/scan", {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
});
if (response.ok) {
showToast("Library scan started", "success");
} else {
const error = await response.json();
showToast(error.error || "Failed to start scan", "error");
}
} catch (error) {
console.error("Scan error:", error);
showToast("Failed to start library scan", "error");
}
}
async function triggerQuickScan(): Promise<void> {
const token = localStorage.getItem("token");
if (!token) return;
try {
const response = await fetch("/api/libraries/quick-scan", {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
});
if (response.ok) {
showToast("Quick scan started", "success");
} else {
const error = await response.json();
showToast(error.error || "Failed to start quick scan", "error");
}
} catch (error) {
console.error("Quick scan error:", error);
showToast("Failed to start quick scan", "error");
}
}
async function loadSystemStats(): Promise<void> {
const token = localStorage.getItem("token");
if (!token) return;
try {
const response = await fetch("/api/admin/stats", {
headers: { Authorization: `Bearer ${token}` },
});
if (response.ok) {
const stats = await response.json();
renderSystemStats(stats);
}
} catch (error) {
console.error("Failed to load stats:", error);
}
}
function renderSystemStats(stats: Record<string, unknown>): void {
const container = document.getElementById("system-stats");
if (!container) return;
container.innerHTML = `
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
<div class="p-4 rounded-lg" style="background-color: var(--bg-secondary)">
<p class="text-2xl font-bold" style="color: var(--text-primary)">${stats.total_books || 0}</p>
<p class="text-sm" style="color: var(--text-secondary)">Total Books</p>
</div>
<div class="p-4 rounded-lg" style="background-color: var(--bg-secondary)">
<p class="text-2xl font-bold" style="color: var(--text-primary)">${stats.total_users || 0}</p>
<p class="text-sm" style="color: var(--text-secondary)">Users</p>
</div>
<div class="p-4 rounded-lg" style="background-color: var(--bg-secondary)">
<p class="text-2xl font-bold" style="color: var(--text-primary)">${stats.total_devices || 0}</p>
<p class="text-sm" style="color: var(--text-secondary)">Devices</p>
</div>
<div class="p-4 rounded-lg" style="background-color: var(--bg-secondary)">
<p class="text-2xl font-bold" style="color: var(--text-primary)">${stats.total_libraries || 0}</p>
<p class="text-sm" style="color: var(--text-secondary)">Libraries</p>
</div>
</div>
`;
}
async function scanAllLibraries(): Promise<void> {
const token = localStorage.getItem("token");
if (!token) return;
try {
const libsResp = await fetch("/api/libraries", {
headers: { Authorization: `Bearer ${token}` },
});
if (!libsResp.ok) {
throw new Error("Failed to get libraries");
}
const libsData = await libsResp.json();
if (!libsData.data || libsData.data.length === 0) {
showToast("No libraries found. Please create a library first.", "error");
return;
}
const libraries = libsData.data;
const jobs: string[] = [];
const libraryNames: Record<string, string> = {};
for (const lib of libraries) {
const scanResp = await fetch(`/api/libraries/${lib.id}/scan`, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ force: true }),
});
if (scanResp.ok) {
const result = await scanResp.json();
jobs.push(result.job_id);
libraryNames[result.job_id] = lib.name;
} else {
console.error(`Failed to scan library: ${lib.name}`);
}
}
if (jobs.length === 0) {
showToast("Failed to start scan for any library", "error");
return;
}
showScanProgress(jobs, libraryNames);
} catch (error) {
console.error("Scan error:", error);
showToast("Failed to start scan: " + (error as Error).message, "error");
}
}
function showScanProgress(
jobIds: string[],
libraryNames: Record<string, string>,
): void {
const container = document.getElementById(
"scan-progress-container",
) as HTMLElement;
const list = document.getElementById("library-progress-list") as HTMLElement;
if (!container || !list) return;
container.classList.remove("hidden");
container.classList.remove("opacity-0", "-translate-y-2.5");
list.innerHTML = jobIds
.map(
(jobId) => `
<div id="progress-${jobId}" class="p-3 rounded border"
style="background-color: var(--bg-primary); border-color: var(--border);">
<div class="flex justify-between items-center mb-2">
<span class="font-medium" style="color: var(--text-primary)">
${libraryNames[jobId]}
</span>
<span id="status-${jobId}" class="text-sm" style="color: var(--text-secondary)">
Pending...
</span>
</div>
<div class="w-full bg-gray-700 rounded-full h-2">
<div id="bar-${jobId}"
class="h-2 rounded-full transition-all duration-500"
style="width: 0%; background-color: var(--accent);">
</div>
</div>
</div>
`,
)
.join("");
pollScanProgress(jobIds, libraryNames);
}
function pollScanProgress(
jobIds: string[],
_libraryNames: Record<string, string>,
): void {
const token = localStorage.getItem("token");
const startTime = Date.now();
scanPollInterval = setInterval(async () => {
let allComplete = true;
let totalProgress = 0;
let totalFiles = 0;
let totalNewItems = 0;
let totalErrors = 0;
for (const jobId of jobIds) {
try {
const resp = await fetch(`/api/scanner/status/${jobId}`, {
headers: { Authorization: `Bearer ${token}` },
});
if (resp.ok) {
const status = await resp.json();
updateLibraryProgress(jobId, status);
totalProgress += status.progress || 0;
totalFiles += status.files_scanned || 0;
totalNewItems += status.new_items || 0;
totalErrors += status.errors || 0;
if (status.status !== "completed" && status.status !== "failed") {
allComplete = false;
}
}
} catch (error) {
console.error(`Failed to poll job ${jobId}:`, error);
}
}
const overallProgress = Math.round(totalProgress / jobIds.length);
const progressBar = document.getElementById(
"scan-progress-bar",
) as HTMLElement;
const progressText = document.getElementById(
"scan-progress-text",
) as HTMLElement;
const statusText = document.getElementById("scan-status") as HTMLElement;
if (progressBar) progressBar.style.width = overallProgress + "%";
if (progressText) progressText.textContent = overallProgress + "%";
const elapsed = Math.round((Date.now() - startTime) / 1000);
if (!allComplete && statusText) {
statusText.textContent = `Scanning... ${elapsed}s elapsed • ${totalFiles} files processed`;
}
if (allComplete) {
clearInterval(scanPollInterval);
showScanResults(
jobIds.length,
totalFiles,
totalNewItems,
totalErrors,
elapsed,
);
}
}, 2000);
}
function updateLibraryProgress(jobId: string, status: any): void {
const bar = document.getElementById(`bar-${jobId}`) as HTMLElement;
const statusText = document.getElementById(`status-${jobId}`) as HTMLElement;
if (bar) {
bar.style.width = (status.progress || 0) + "%";
}
if (statusText) {
const statusMessages: Record<string, string> = {
pending: "Pending...",
running: `Scanning... ${status.progress || 0}%`,
completed: `✓ Complete (${status.new_items || 0} items)`,
failed: `✗ Failed`,
};
statusText.textContent = statusMessages[status.status] || status.status;
}
}
function showScanResults(
libCount: number,
files: number,
items: number,
errors: number,
elapsed: number,
): void {
const resultsDiv = document.getElementById("scan-results") as HTMLElement;
const contentDiv = document.getElementById(
"scan-results-content",
) as HTMLElement;
if (!resultsDiv || !contentDiv) return;
contentDiv.innerHTML = `
<p>• ${libCount} librar${libCount === 1 ? "y" : "ies"} scanned</p>
<p>• ${files} files processed</p>
<p>• ${items} new items added</p>
${errors > 0 ? `<p style="color: var(--accent);">• ${errors} errors</p>` : ""}
<p style="color: var(--text-secondary)">Completed in ${elapsed} seconds</p>
`;
resultsDiv.classList.remove("hidden");
const statusText = document.getElementById("scan-status") as HTMLElement;
if (statusText) statusText.textContent = "Scan complete!";
}
function hideScanProgress(): void {
const container = document.getElementById(
"scan-progress-container",
) as HTMLElement;
if (container) container.classList.add("hidden");
}
async function loadWatchStatus(): Promise<void> {
const token = localStorage.getItem("token");
if (!token) return;
try {
const response = await fetch("/api/scanner/watch/status", {
headers: { Authorization: `Bearer ${token}` },
});
if (response.ok) {
const data = await response.json();
const countEl = document.getElementById("watch-count");
if (countEl) {
countEl.textContent = data.total_watching?.toString() || "0";
}
}
} catch (error) {
console.error("Failed to load watch status:", error);
}
}
let scanPollInterval: ReturnType<typeof setInterval> | undefined = undefined;
function stopScanStatusPolling(): void {
if (scanPollInterval !== undefined) {
clearInterval(scanPollInterval);
scanPollInterval = undefined;
}
}
export {
hideScanProgress,
initializeScanWebSocket,
loadSystemStats,
loadWatchStatus,
scanAllLibraries,
stopScanStatusPolling,
triggerLibraryScan,
triggerQuickScan,
};
Alpine.data("admin", () => ({
hideScanProgress,
initializeScanWebSocket,
loadSystemStats,
loadWatchStatus,
scanAllLibraries,
stopScanStatusPolling,
triggerLibraryScan,
triggerQuickScan,
}));