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:
@@ -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
@@ -31,6 +31,7 @@ import "./queue";
|
||||
import "./register";
|
||||
import "./search";
|
||||
import "./series";
|
||||
import "./setup";
|
||||
import "./storage";
|
||||
import "./theme";
|
||||
import "./toast";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
}));
|
||||
Reference in New Issue
Block a user