feat(ui): add first-run setup wizard with 4 guided steps

Add a single-page multi-step setup wizard that guides new users
through initial configuration:

Step 1 - Admin Registration: Creates the first user (auto-admin)
  using the existing POST /api/auth/register endpoint, with
  real-time password validation and confirmation matching.

Step 2 - Library Creation: Create one or more libraries (Ebooks,
  Audiobooks, Comics, Manga) using POST /api/libraries. Libraries
  list updates inline as they're added.

Step 3 - Folder Configuration: Add filesystem folders to each
  library using the existing GET /api/libraries/browse endpoint for
  a visual directory browser. Folders are attached via POST
  /api/libraries/:id/folders.

Step 4 - Initial Scan: Triggers a manual scan of all libraries via
  POST /api/libraries/scan with real-time progress polling using the
  existing scan status endpoint.

On completion, the wizard calls PUT /api/setup/complete and sets the
selectedLibrary cookie to the first library's UUID, ensuring the
dashboard loads with populated content instead of an empty 'All
Libraries' view. Handles the edge case where a stale JWT from a
previous database instance triggers an 'already exists' error by
auto-advancing to step 2.

The wizard reuses all existing API calls, Alpine.js utilities, and
form validation functions — no backend logic was duplicated.
This commit is contained in:
2026-06-06 00:04:09 -04:00
parent f37de6c07c
commit f75d68bf66
4 changed files with 889 additions and 0 deletions
+385
View File
@@ -0,0 +1,385 @@
package templates
templ Setup() {
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Setup - Bookhoard</title>
<script src="/static/htmx.min.js"></script>
<script type="module" src="/static/main.js"></script>
<link href="/static/style.css" rel="stylesheet"/>
</head>
<body class="theme-tokyo-night min-h-screen" x-data="setupWizard" x-init="initSetup()">
<div class="container mx-auto px-4 py-8 max-w-2xl">
<div class="text-center mb-8">
<h1 class="text-4xl font-bold mb-2" style="color: var(--text-primary)">Welcome to Bookhoard</h1>
<p class="text-lg" style="color: var(--text-secondary)">Let's get your library set up.</p>
</div>
<!-- Step indicator -->
<div class="flex items-center justify-center mb-8 gap-2">
<template x-for="n in 4" :key="n">
<div class="flex items-center gap-2">
<div
class="w-10 h-10 rounded-full flex items-center justify-center font-bold text-sm transition-all"
:class="currentStep >= n ? 'bg-[var(--accent)] text-[var(--bg-primary)]' : 'bg-[var(--bg-secondary)] text-[var(--text-secondary)] border border-[var(--border)]'"
>
<span x-text="currentStep > n ? '✓' : n"></span>
</div>
<div
x-text="['Admin', 'Libraries', 'Folders', 'Scan'][n-1]"
class="text-sm hidden sm:inline"
:style="currentStep >= n ? 'color: var(--text-primary)' : 'color: var(--text-secondary)'"
></div>
<div x-show="n < 4" class="w-8 h-0.5 mx-1" :class="currentStep > n ? 'bg-[var(--accent)]' : 'bg-[var(--border)]'"></div>
</div>
</template>
</div>
<!-- Error display -->
<div x-show="errorMsg" class="mb-4 p-4 rounded-lg border border-red-500/50 bg-red-500/10 text-red-400 text-sm">
<span x-text="errorMsg"></span>
</div>
<!-- ==================== STEP 1: Create Admin ==================== -->
<div x-show="currentStep === 1" class="card p-6 rounded-lg shadow-md border" style="background-color: var(--bg-secondary); border-color: var(--border)">
<h2 class="text-2xl font-bold mb-2" style="color: var(--text-primary)">Create Administrator Account</h2>
<p class="mb-6" style="color: var(--text-secondary)">This first account will have full admin privileges.</p>
<form @submit.prevent="submitAdmin()">
<div class="mb-4">
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Email</label>
<input
type="email"
id="email"
x-model="admin.email"
class="w-full px-3 py-2 border rounded"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)"
required
/>
</div>
<div class="mb-4">
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Username</label>
<input
type="text"
x-model="admin.username"
id="username"
class="w-full px-3 py-2 border rounded"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)"
required
/>
</div>
<div class="grid grid-cols-2 gap-4 mb-4">
<div>
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">First Name</label>
<input
type="text"
x-model="admin.firstName"
placeholder="Optional"
class="w-full px-3 py-2 border rounded"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)"
/>
</div>
<div>
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Last Name</label>
<input
type="text"
x-model="admin.lastName"
placeholder="Optional"
class="w-full px-3 py-2 border rounded"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)"
/>
</div>
</div>
<div id="password-requirements" class="mb-4 p-4 rounded-lg border text-sm" style="background-color: var(--bg-primary); border-color: var(--border)">
<p class="font-semibold mb-2" style="color: var(--text-primary)">Password Requirements:</p>
<ul class="space-y-1" style="color: var(--text-secondary)">
<li id="req-length" class="flex items-center gap-2">
<span class="requirement-icon"></span> At least 8 characters
</li>
<li id="req-upper" class="flex items-center gap-2">
<span class="requirement-icon"></span> One uppercase letter
</li>
<li id="req-lower" class="flex items-center gap-2">
<span class="requirement-icon"></span> One lowercase letter
</li>
<li id="req-number" class="flex items-center gap-2">
<span class="requirement-icon"></span> One number
</li>
<li id="req-special" class="flex items-center gap-2">
<span class="requirement-icon"></span> One special character
</li>
<li id="req-match" class="flex items-center gap-2">
<span class="requirement-icon"></span> Passwords match
</li>
</ul>
</div>
<div class="mb-4">
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Password</label>
<input
type="password"
x-model="admin.password"
id="password"
class="w-full px-3 py-2 border rounded"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)"
required
/>
</div>
<div class="mb-6">
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Confirm Password</label>
<input
type="password"
x-model="admin.confirmPassword"
id="confirm-password"
class="w-full px-3 py-2 border rounded"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)"
required
/>
</div>
<button
type="submit"
id="register-btn"
class="btn-primary w-full py-2 rounded opacity-50 cursor-not-allowed"
:disabled="loading"
x-text="loading ? 'Creating...' : 'Create Admin Account'"
></button>
</form>
</div>
<!-- ==================== STEP 2: Create Libraries ==================== -->
<div x-show="currentStep === 2" class="card p-6 rounded-lg shadow-md border" style="background-color: var(--bg-secondary); border-color: var(--border)">
<h2 class="text-2xl font-bold mb-2" style="color: var(--text-primary)">Create Libraries</h2>
<p class="mb-6" style="color: var(--text-secondary)">Add one or more media libraries. You can always add more later.</p>
<!-- Created libraries list -->
<div x-show="libraries.length > 0" class="mb-6 space-y-2">
<template x-for="(lib, idx) in libraries" :key="lib.id">
<div class="p-3 rounded-lg border flex items-center justify-between" style="background-color: var(--bg-primary); border-color: var(--border)">
<div class="flex items-center gap-3">
<span class="text-xl" x-text="lib.type === 'ebooks' ? '📚' : lib.type === 'comics' ? '📖' : '🗾'"></span>
<div>
<p class="font-medium" style="color: var(--text-primary)" x-text="lib.name"></p>
<p class="text-xs capitalize" style="color: var(--text-secondary)" x-text="lib.type"></p>
</div>
</div>
<button
type="button"
@click="removeLibrary(idx)"
class="text-xs text-red-500 hover:underline"
>Remove</button>
</div>
</template>
</div>
<!-- Add library form -->
<form @submit.prevent="addLibrary()" class="space-y-4 p-4 rounded-lg border" style="background-color: var(--bg-primary); border-color: var(--border)">
<div>
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Library Name</label>
<input
type="text"
x-model="newLibrary.name"
placeholder="My Ebook Library"
class="w-full px-3 py-2 border rounded"
style="background-color: var(--bg-secondary); color: var(--text-primary); border-color: var(--border)"
required
/>
</div>
<div>
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Library Type</label>
<select
x-model="newLibrary.type"
class="w-full px-3 py-2 border rounded"
style="background-color: var(--bg-secondary); color: var(--text-primary); border-color: var(--border)"
>
<option value="ebooks">📚 Ebooks</option>
<option value="comics">📖 Comics</option>
<option value="manga">🗾 Manga</option>
</select>
</div>
<div>
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Description (optional)</label>
<input
type="text"
x-model="newLibrary.description"
placeholder="Optional"
class="w-full px-3 py-2 border rounded"
style="background-color: var(--bg-secondary); color: var(--text-primary); border-color: var(--border)"
/>
</div>
<button
type="submit"
class="btn-secondary w-full py-2 rounded"
:disabled="loading || !newLibrary.name.trim()"
x-text="loading ? 'Creating...' : '+ Add This Library'"
></button>
</form>
<div class="flex justify-between mt-6">
<button type="button" @click="currentStep = 1" class="btn-secondary px-4 py-2 rounded">Back</button>
<button type="button" @click="currentStep = 3" class="btn-primary px-6 py-2 rounded">
<span x-text="libraries.length > 0 ? 'Next: Configure Folders' : 'Skip Continue'"></span>
</button>
</div>
</div>
<!-- ==================== STEP 3: Configure Folders ==================== -->
<div x-show="currentStep === 3" class="card p-6 rounded-lg shadow-md border" style="background-color: var(--bg-secondary); border-color: var(--border)">
<h2 class="text-2xl font-bold mb-2" style="color: var(--text-primary)">Configure Library Folders</h2>
<p class="mb-6" style="color: var(--text-secondary)">Add the filesystem folders where your media files are stored.</p>
<div x-show="libraries.length === 0" class="text-center py-8" style="color: var(--text-secondary)">
<p>No libraries created. You can add folders later from the admin panel.</p>
</div>
<div class="space-y-6">
<template x-for="lib in libraries" :key="lib.id">
<div class="p-4 rounded-lg border" style="background-color: var(--bg-primary); border-color: var(--border)">
<div class="flex items-center gap-2 mb-3">
<span class="text-xl" x-text="lib.type === 'ebooks' ? '📚' : lib.type === 'comics' ? '📖' : '🗾'"></span>
<h4 class="font-semibold" style="color: var(--text-primary)" x-text="lib.name"></h4>
</div>
<!-- Existing folders for this library -->
<div class="space-y-1 mb-3" x-show="lib.folders && lib.folders.length > 0">
<template x-for="(folder, fIdx) in (lib.folders || [])" :key="folder">
<div class="flex items-center justify-between p-2 rounded text-sm" style="background-color: var(--bg-secondary); border-color: var(--border)">
<span style="color: var(--text-primary)" x-text="folder"></span>
<button type="button" @click="removeFolder(lib.id, folder, fIdx)" class="text-xs text-red-500 hover:underline">Remove</button>
</div>
</template>
</div>
<!-- Add folder -->
<div class="flex gap-2">
<input
type="text"
:placeholder="'/path/to/' + lib.type"
:id="'folderInput-' + lib.id"
class="flex-1 px-3 py-2 border rounded text-sm"
style="background-color: var(--bg-secondary); color: var(--text-primary); border-color: var(--border)"
/>
<button
type="button"
@click="showFolderBrowser('folderInput-' + lib.id)"
class="btn-secondary px-3 py-2 text-xs rounded"
>Browse</button>
<button
type="button"
@click="addFolderToLibrary(lib)"
class="btn-primary px-3 py-2 text-xs rounded"
>Add</button>
</div>
</div>
</template>
</div>
<div class="flex justify-between mt-6">
<button type="button" @click="currentStep = 2" class="btn-secondary px-4 py-2 rounded">Back</button>
<button type="button" @click="currentStep = 4" class="btn-primary px-6 py-2 rounded">Next: Scan</button>
</div>
</div>
<!-- ==================== STEP 4: Scan & Finish ==================== -->
<div x-show="currentStep === 4" class="card p-6 rounded-lg shadow-md border" style="background-color: var(--bg-secondary); border-color: var(--border)">
<h2 class="text-2xl font-bold mb-2" style="color: var(--text-primary)">Scan & Finish</h2>
<p class="mb-6" style="color: var(--text-secondary)">Review your configuration and start the initial scan.</p>
<!-- Summary -->
<div class="space-y-3 mb-6">
<div class="p-4 rounded-lg border" style="background-color: var(--bg-primary); border-color: var(--border)">
<p class="text-sm font-medium mb-1" style="color: var(--text-secondary)">Administrator</p>
<p style="color: var(--text-primary)" x-text="admin.username + ' (' + admin.email + ')'"></p>
</div>
<div class="p-4 rounded-lg border" style="background-color: var(--bg-primary); border-color: var(--border)">
<p class="text-sm font-medium mb-2" style="color: var(--text-secondary)">
Libraries (<span x-text="libraries.length"></span>)
</p>
<template x-for="lib in libraries" :key="lib.id">
<div class="flex items-start justify-between text-sm py-1">
<div>
<span style="color: var(--text-primary)" x-text="lib.name"></span>
<span class="ml-2 text-xs px-2 py-0.5 rounded" style="background-color: var(--accent); color: var(--bg-primary)" x-text="lib.type"></span>
</div>
<div class="text-right" style="color: var(--text-secondary)">
<span x-text="(lib.folders || []).length + ' folder(s)'"></span>
</div>
</div>
</template>
</div>
</div>
<!-- Scan progress -->
<div x-show="scanStarted" class="mb-6 space-y-3">
<h3 class="font-semibold" style="color: var(--text-primary)">Scan Progress</h3>
<div id="library-progress-list" class="space-y-3">
<template x-for="job in scanJobs" :key="job.jobId">
<div 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)" x-text="job.libraryName"></span>
<span class="text-sm" style="color: var(--text-secondary)" x-text="job.statusText"></span>
</div>
<div class="w-full rounded-full h-2" style="background-color: var(--bg-secondary)">
<div
class="h-2 rounded-full transition-all duration-500"
:style="'width: ' + job.progress + '%; background-color: var(--accent);'"
></div>
</div>
</div>
</template>
</div>
<div x-show="scanComplete" class="p-4 rounded-lg border border-green-500/50 bg-green-500/10 text-green-400 text-sm">
Initial scan complete! Your libraries are ready to use.
</div>
</div>
<div class="flex justify-between mt-6">
<button type="button" @click="currentStep = 3" class="btn-secondary px-4 py-2 rounded" :disabled="loading">Back</button>
<div class="flex gap-3">
<button
x-show="!scanStarted"
type="button"
@click="startScan()"
class="btn-primary px-6 py-2 rounded"
:disabled="loading"
>Start Scan</button>
<button
x-show="scanStarted && !scanComplete"
type="button"
@click="finishSetup()"
class="btn-secondary px-6 py-2 rounded"
:disabled="loading"
>Skip to Dashboard</button>
<button
x-show="scanComplete"
type="button"
@click="finishSetup()"
class="btn-primary px-6 py-2 rounded"
:disabled="loading"
x-text="loading ? 'Finishing...' : 'Go to Dashboard'"
></button>
</div>
</div>
</div>
</div>
<!-- Folder Browser Modal (reused from admin library) -->
<div id="folder-browser-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center" style="background-color: rgba(0, 0, 0, 0.7)">
<div class="card rounded-lg p-6 w-full max-w-md mx-4" style="background-color: var(--bg-secondary); border-color: var(--border)">
<div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Browse Folders</h2>
<button type="button" @click="hideFolderBrowser()" class="p-2 hover:opacity-80 rounded" style="color: var(--text-primary)"></button>
</div>
<div id="folder-browser-content">
<!-- Directory listings rendered by Alpine -->
</div>
</div>
</div>
</body>
</html>
}
File diff suppressed because one or more lines are too long
+1
View File
@@ -31,6 +31,7 @@ import "./queue";
import "./register";
import "./search";
import "./series";
import "./setup";
import "./storage";
import "./theme";
import "./toast";
+463
View File
@@ -0,0 +1,463 @@
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<typeof setInterval> | 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: {
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();
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);
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 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 = `
<div class="flex items-center gap-2 mb-4">
${
data.parent_path
? `<button type="button" data-action="browse-parent" data-path="${this.escapeHtml(data.parent_path)}" class="btn-secondary px-3 py-1 rounded">↑ Parent</button>`
: ""
}
<span class="text-sm" style="color: var(--text-secondary)">${this.escapeHtml(data.current_path)}</span>
</div>
<div class="max-h-64 overflow-y-auto space-y-1">
`;
if (data.directories.length === 0) {
html += '<p style="color: var(--text-secondary)" class="text-center py-4">No subdirectories</p>';
} else {
data.directories.forEach((dir) => {
const fullPath = data.current_path === "/" ? `/${dir}` : `${data.current_path}/${dir}`;
html += `
<div class="p-2 rounded cursor-pointer hover:opacity-80"
style="background-color: var(--bg-primary); color: var(--text-primary)"
data-action="browse-navigate"
data-path="${this.escapeHtml(fullPath)}">
📁 ${this.escapeHtml(dir)}
</div>
`;
});
}
html += `
</div>
<div class="mt-4 flex justify-end gap-2">
<button type="button" data-action="browse-cancel" class="btn-secondary px-4 py-2 rounded">Cancel</button>
<button type="button" data-action="browse-select" data-path="${this.escapeHtml(data.current_path)}" class="btn-primary px-4 py-2 rounded">Select This Folder</button>
</div>
`;
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<string, string> = {
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 {
const response = await apiPut("/setup/complete");
await handleVoidResponse(response);
if (this.libraries.length > 0) {
setSelectedLibrary(this.libraries[0].id);
}
window.location.href = "/dashboard";
} catch (err) {
handleError(err, "Failed to complete setup");
window.location.href = "/dashboard";
} finally {
this.loading = false;
}
},
}));