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:
@@ -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