Files
bookhoard/web/src/admin.ts
T
john-okeefe ea5ad7a41b feat(web): update frontend TypeScript modules and API types
This commit updates the web frontend TypeScript modules:

Core modules:
- admin.ts: Admin panel functionality and user management
- analytics.ts: Analytics dashboard and data visualization
- api-explorer.ts: Interactive API documentation explorer
- api.ts: Core API client with request/response handling
- collections.ts: Book collection management UI
- conflicts.ts: Sync conflict resolution interface
- custom-section-builder.ts: Dynamic section builder for UI
- docs.ts: Documentation viewer and navigation
- dom.ts: DOM manipulation utilities and helpers
- header.ts: Application header with navigation
- library.ts: Library view and book grid management
- linking.ts: Device-book linking interface
- password_validation.ts: Client-side password strength validation
- queue.ts: Device sync queue management UI
- search.ts: Full-text search with Lunr integration
- storage.ts: Local storage and cache management
- theme.ts: Theme management and CSS variable updates
- themeDropdown.ts: Theme selector dropdown component
- toast.ts: Toast notification system
- woodPaneling.ts: Visual theme effects
- woodPanelingInit.ts: Visual effects initialization

Type definitions:
- api.d.ts: Updated TypeScript definitions for API responses

These updates enhance the frontend with improved functionality
for book management, device synchronization, and user experience.
2026-02-27 17:06:48 -05:00

363 lines
11 KiB
TypeScript

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) {
if ((window as any).showToast?.success) {
(window as any).showToast.success("Library scan started");
}
} else {
const error = await response.json();
if ((window as any).showToast?.error) {
(window as any).showToast.error(error.error || "Failed to start scan");
}
}
} catch (error) {
console.error("Scan error:", error);
if ((window as any).showToast?.error) {
(window as any).showToast.error("Failed to start library scan");
}
}
}
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) {
if ((window as any).showToast?.success) {
(window as any).showToast.success("Quick scan started");
}
} else {
const error = await response.json();
if ((window as any).showToast?.error) {
(window as any).showToast.error(
error.error || "Failed to start quick scan",
);
}
}
} catch (error) {
console.error("Quick scan error:", error);
if ((window as any).showToast?.error) {
(window as any).showToast.error("Failed to start quick scan");
}
}
}
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) {
if ((window as any).showToast?.error) {
(window as any).showToast.error(
"No libraries found. Please create a library first.",
);
}
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) {
if ((window as any).showToast?.error) {
(window as any).showToast.error("Failed to start scan for any library");
}
return;
}
showScanProgress(jobs, libraryNames);
} catch (error) {
console.error("Scan error:", error);
if ((window as any).showToast?.error) {
(window as any).showToast.error(
"Failed to start scan: " + (error as Error).message,
);
}
}
}
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();
const interval = 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(interval);
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);
}
}
document.addEventListener("DOMContentLoaded", function () {
loadWatchStatus();
});
(window as any).triggerLibraryScan = triggerLibraryScan;
(window as any).triggerQuickScan = triggerQuickScan;
(window as any).loadSystemStats = loadSystemStats;
(window as any).scanAllLibraries = scanAllLibraries;
(window as any).loadWatchStatus = loadWatchStatus;
(window as any).hideScanProgress = hideScanProgress;