import { Alpine } from "./alpine"; import { applyTheme } from "./theme"; import { setToken, setRefreshToken, getToken, setSelectedLibrary } from "./storage"; import { apiPost, apiGet, apiPut, apiDelete, handleResponse, handleVoidResponse, handleError, } from "./api"; import { showToast } from "./toast"; import { initPasswordValidation } from "./password_validation"; interface LibraryEntry { id: string; name: string; type: string; description: string; folders: string[]; } interface ScanJob { jobId: string; libraryId: string; libraryName: string; progress: number; statusText: string; } let scanPollInterval: ReturnType | undefined; function initSetup(): void { const savedTheme = localStorage.getItem("theme"); if (savedTheme && savedTheme !== "tokyo-night") { applyTheme(savedTheme); } }Alpine.data("setupWizard", () => ({ currentStep: 1 as number, loading: false as boolean, errorMsg: "" as string, admin: { baseUrl: "", email: "", username: "", password: "", confirmPassword: "", firstName: "", lastName: "", }, libraries: [] as LibraryEntry[], newLibrary: { name: "", type: "ebooks", description: "", }, scanStarted: false as boolean, scanComplete: false as boolean, scanJobs: [] as ScanJob[], // Folder browser state currentBrowsePath: "" as string, currentBrowseInputId: "" as string, initSetup, init() { initSetup(); this.admin.baseUrl = window.location.origin; setTimeout(() => { initPasswordValidation(); }, 100); const modal = document.getElementById("folder-browser-modal"); if (modal) { modal.addEventListener("click", (e: Event) => this.handleBrowseClick(e)); } }, clearError() { this.errorMsg = ""; }, async submitAdmin() { this.clearError(); this.loading = true; try { const response = await fetch("/api/auth/register", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email: this.admin.email, username: this.admin.username, password: this.admin.password, first_name: this.admin.firstName, last_name: this.admin.lastName, }), }); if (!response.ok) { let errMsg = `Registration failed (${response.status})`; try { const errData = await response.clone().json(); errMsg = errData.error || errMsg; } catch { // Can't parse body } if ( (errMsg.includes("already exists") || errMsg.includes("email already") || errMsg.includes("username already")) && getToken() ) { this.currentStep = 2; this.errorMsg = ""; return; } this.errorMsg = errMsg; return; } const data = await response.json(); setToken(data.token || data.access_token); setRefreshToken(data.refresh_token); await this.saveBaseUrl(); showToast("Admin account created successfully!", "success"); this.currentStep = 2; } catch (err) { this.errorMsg = (err as Error).message || "Network error during registration"; } finally { this.loading = false; } }, async saveBaseUrl() { if (!this.admin.baseUrl) return; try { await handleVoidResponse( await apiPut("/system/config", { base_url: this.admin.baseUrl }) ); } catch { showToast("Warning: could not save server URL. You can set it later in Admin Settings.", "warning"); } }, async addLibrary() { this.clearError(); this.loading = true; try { const response = await apiPost("/libraries", { name: this.newLibrary.name, type: this.newLibrary.type, description: this.newLibrary.description, }); const result = (await handleResponse(response)) as { id: string; name: string }; this.libraries.push({ id: result.id, name: result.name, type: this.newLibrary.type, description: this.newLibrary.description, folders: [], }); this.newLibrary = { name: "", type: "ebooks", description: "" }; showToast("Library created", "success"); } catch (err) { handleError(err, "Failed to create library"); } finally { this.loading = false; } }, async removeLibrary(idx: number) { const lib = this.libraries[idx]; if (!lib) return; if (!confirm(`Remove library "${lib.name}"?`)) return; try { const response = await apiDelete(`/libraries/${lib.id}`); await handleVoidResponse(response); this.libraries.splice(idx, 1); showToast("Library removed", "success"); } catch (err) { handleError(err, "Failed to remove library"); } }, async addFolderToLibrary(lib: LibraryEntry) { const inputId = `folderInput-${lib.id}`; const input = document.getElementById(inputId) as HTMLInputElement; if (!input) return; const folderPath = input.value.trim(); if (!folderPath) return; this.clearError(); this.loading = true; try { const response = await apiPost(`/libraries/${lib.id}/folders`, { folder_path: folderPath, }); await handleVoidResponse(response); if (!lib.folders) lib.folders = []; lib.folders.push(folderPath); input.value = ""; showToast("Folder added", "success"); } catch (err) { handleError(err, "Failed to add folder"); } finally { this.loading = false; } }, async removeFolder(libraryId: string, folderPath: string, index: number) { if (!confirm(`Remove folder "${folderPath}"?`)) return; try { const response = await apiDelete(`/libraries/${libraryId}/folders`, { folder_path: folderPath, }); await handleVoidResponse(response); const lib = this.libraries.find((l) => l.id === libraryId); if (lib && lib.folders) { lib.folders.splice(index, 1); } showToast("Folder removed", "success"); } catch (err) { handleError(err, "Failed to remove folder"); } }, // Folder browser showFolderBrowser(inputId: string) { this.currentBrowseInputId = inputId; this.currentBrowsePath = "/"; const modal = document.getElementById("folder-browser-modal"); if (modal) { modal.classList.remove("hidden"); this.loadBrowseDirectories("/"); } }, hideFolderBrowser() { const modal = document.getElementById("folder-browser-modal"); if (modal) { modal.classList.add("hidden"); } }, async loadBrowseDirectories(path: string) { try { const response = await apiGet(`/libraries/browse?path=${encodeURIComponent(path)}`); const data = (await handleResponse(response)) as { current_path: string; parent_path: string; directories: string[]; }; this.currentBrowsePath = data.current_path; this.renderBrowseDirectories(data); } catch (err) { handleError(err, "Failed to load directories"); } }, escapeHtml(text: string): string { const div = document.createElement("div"); div.textContent = text; return div.innerHTML; }, renderBrowseDirectories(data: { current_path: string; parent_path: string; directories: string[]; }) { const container = document.getElementById("folder-browser-content"); if (!container) return; let html = `
${ data.parent_path ? `` : "" } ${this.escapeHtml(data.current_path)}
`; if (data.directories.length === 0) { html += '

No subdirectories

'; } else { data.directories.forEach((dir) => { const fullPath = data.current_path === "/" ? `/${dir}` : `${data.current_path}/${dir}`; html += `
📁 ${this.escapeHtml(dir)}
`; }); } html += `
`; container.innerHTML = html; }, handleBrowseClick(e: Event) { const target = e.target as HTMLElement; const button = target.closest("button") as HTMLElement; const div = target.closest("div[data-action]") as HTMLElement; if (button) { const action = button.dataset.action; const path = button.dataset.path; if (action === "browse-parent" && path) { this.loadBrowseDirectories(path); } else if (action === "browse-cancel") { this.hideFolderBrowser(); } else if (action === "browse-select" && path) { this.selectBrowseFolder(path); } } if (div && div.dataset.action === "browse-navigate") { const path = div.dataset.path; if (path) this.loadBrowseDirectories(path); } }, selectBrowseFolder(path: string) { if (this.currentBrowseInputId) { const input = document.getElementById(this.currentBrowseInputId) as HTMLInputElement; if (input) { input.value = path; } } this.hideFolderBrowser(); }, async startScan() { this.clearError(); this.loading = true; this.scanStarted = true; if (this.libraries.length === 0) { this.scanComplete = true; this.loading = false; return; } try { this.scanJobs = []; for (const lib of this.libraries) { const response = await apiPost(`/libraries/${lib.id}/scan`, { force: true }); if (response.ok) { const result = await response.json(); this.scanJobs.push({ jobId: result.job_id, libraryId: lib.id, libraryName: lib.name, progress: 0, statusText: "Pending...", }); } else { this.scanJobs.push({ jobId: "", libraryId: lib.id, libraryName: lib.name, progress: 0, statusText: "Failed to start", }); } } if (this.scanJobs.length === 0) { this.errorMsg = "Failed to start any scans"; return; } showToast("Scan started", "success"); this.pollScanProgress(); } catch (err) { this.errorMsg = (err as Error).message; showToast("Failed to start scan", "error"); } finally { this.loading = false; } }, pollScanProgress() { if (scanPollInterval !== undefined) { clearInterval(scanPollInterval); } scanPollInterval = setInterval(async () => { let allComplete = true; for (const job of this.scanJobs) { if (!job.jobId) continue; try { const response = await apiGet(`/scanner/status/${job.jobId}`); if (response.ok) { const status = await response.json(); job.progress = Math.round((status.progress || 0) * 100); const statusMessages: Record = { pending: "Pending...", running: `Scanning... ${Math.round((status.progress || 0) * 100)}%`, completed: `✓ Complete (${status.new_items || 0} items)`, failed: "✗ Failed", }; job.statusText = statusMessages[status.status] || status.status; if (status.status !== "completed" && status.status !== "failed") { allComplete = false; } } else { allComplete = false; } } catch { allComplete = false; } } if (allComplete) { clearInterval(scanPollInterval); scanPollInterval = undefined; this.scanComplete = true; showToast("Scan complete!", "success"); } }, 2000); }, async finishSetup() { this.loading = true; try { // Setup completion is derived from the existence of an admin user, so // there is no separate "complete" endpoint to call. The admin account // created in submitAdmin already marks setup as done server-side. if (this.libraries.length > 0) { setSelectedLibrary(this.libraries[0].id); } window.location.href = "/dashboard"; } finally { this.loading = false; } }, }));