From f75d68bf6634b44640ef08d82d1a769dcd3021c2 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Sat, 6 Jun 2026 00:04:09 -0400 Subject: [PATCH] feat(ui): add first-run setup wizard with 4 guided steps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- templates/setup.templ | 385 ++++++++++++++++++++++++++++++++ templates/setup_templ.go | 40 ++++ web/src/main.ts | 1 + web/src/setup.ts | 463 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 889 insertions(+) create mode 100644 templates/setup.templ create mode 100644 templates/setup_templ.go create mode 100644 web/src/setup.ts diff --git a/templates/setup.templ b/templates/setup.templ new file mode 100644 index 0000000..ee8279b --- /dev/null +++ b/templates/setup.templ @@ -0,0 +1,385 @@ +package templates + +templ Setup() { + + + + + + Setup - Bookhoard + + + + + +
+
+

Welcome to Bookhoard

+

Let's get your library set up.

+
+ + +
+ +
+ + +
+ +
+ + +
+

Create Administrator Account

+

This first account will have full admin privileges.

+ +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+ +
+

Password Requirements:

+
    +
  • + At least 8 characters +
  • +
  • + One uppercase letter +
  • +
  • + One lowercase letter +
  • +
  • + One number +
  • +
  • + One special character +
  • +
  • + Passwords match +
  • +
+
+
+ + +
+
+ + +
+ +
+
+ + +
+

Create Libraries

+

Add one or more media libraries. You can always add more later.

+ + +
+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ +
+ + +
+
+ + +
+

Configure Library Folders

+

Add the filesystem folders where your media files are stored.

+ +
+

No libraries created. You can add folders later from the admin panel.

+
+ +
+ +
+ +
+ + +
+
+ + +
+

Scan & Finish

+

Review your configuration and start the initial scan.

+ + +
+
+

Administrator

+

+
+
+

+ Libraries () +

+ +
+
+ + +
+

Scan Progress

+
+ +
+
+ Initial scan complete! Your libraries are ready to use. +
+
+ +
+ + +
+ + + + + +
+
+
+
+ + + + + +} diff --git a/templates/setup_templ.go b/templates/setup_templ.go new file mode 100644 index 0000000..fd6daec --- /dev/null +++ b/templates/setup_templ.go @@ -0,0 +1,40 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.3.1020 +package templates + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +func Setup() templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "Setup - Bookhoard

Welcome to Bookhoard

Let's get your library set up.

Create Administrator Account

This first account will have full admin privileges.

Password Requirements:

  • At least 8 characters
  • One uppercase letter
  • One lowercase letter
  • One number
  • One special character
  • Passwords match

Create Libraries

Add one or more media libraries. You can always add more later.

0\" class=\"mb-6 space-y-2\">

Configure Library Folders

Add the filesystem folders where your media files are stored.

No libraries created. You can add folders later from the admin panel.

Scan & Finish

Review your configuration and start the initial scan.

Administrator

Libraries ()

Scan Progress

Initial scan complete! Your libraries are ready to use.

Browse Folders

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +var _ = templruntime.GeneratedTemplate diff --git a/web/src/main.ts b/web/src/main.ts index 582d6b2..5af70b9 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -31,6 +31,7 @@ import "./queue"; import "./register"; import "./search"; import "./series"; +import "./setup"; import "./storage"; import "./theme"; import "./toast"; diff --git a/web/src/setup.ts b/web/src/setup.ts new file mode 100644 index 0000000..18aeef2 --- /dev/null +++ b/web/src/setup.ts @@ -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 | 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 = ` +
+ ${ + 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 { + 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; + } + }, +}));