184 lines
6.5 KiB
TypeScript
184 lines
6.5 KiB
TypeScript
import { FormEvent, useEffect, useState } from "react";
|
||
import { Play, Plus, Trash2 } from "lucide-react";
|
||
import type { JobDto, LibraryDto, UserDto } from "@readabook/shared";
|
||
import { api, getApiFallback } from "../api/client";
|
||
import { EmptyState, ErrorRibbon, LoadingState, Panel } from "../components/ui";
|
||
|
||
export function AdminPage() {
|
||
const [libraries, setLibraries] = useState<LibraryDto[]>([]);
|
||
const [jobs, setJobs] = useState<JobDto[]>([]);
|
||
const [users, setUsers] = useState<UserDto[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [name, setName] = useState("Bibliotheque locale");
|
||
const [path, setPath] = useState("/library");
|
||
const [error, setError] = useState<string>();
|
||
const [success, setSuccess] = useState<string>();
|
||
|
||
async function refresh() {
|
||
setLoading(true);
|
||
setError(undefined);
|
||
const [libraryResult, jobResult, userResult] = await Promise.allSettled([api.libraries(), api.jobs(), api.users()]);
|
||
const errors: string[] = [];
|
||
|
||
if (libraryResult.status === "fulfilled") {
|
||
setLibraries(libraryResult.value);
|
||
} else {
|
||
setLibraries(getApiFallback<LibraryDto[]>(libraryResult.reason) ?? []);
|
||
errors.push("bibliotheques");
|
||
}
|
||
|
||
if (jobResult.status === "fulfilled") {
|
||
setJobs(jobResult.value);
|
||
} else {
|
||
setJobs(getApiFallback<JobDto[]>(jobResult.reason) ?? []);
|
||
errors.push("travaux");
|
||
}
|
||
|
||
if (userResult.status === "fulfilled") {
|
||
setUsers(userResult.value);
|
||
} else {
|
||
setUsers(getApiFallback<UserDto[]>(userResult.reason) ?? []);
|
||
errors.push("comptes");
|
||
}
|
||
|
||
setError(errors.length ? `Donnees admin degradees : ${errors.join(", ")}.` : undefined);
|
||
setLoading(false);
|
||
}
|
||
|
||
useEffect(() => {
|
||
void refresh();
|
||
}, []);
|
||
|
||
async function createLibrary(event: FormEvent) {
|
||
event.preventDefault();
|
||
setError(undefined);
|
||
setSuccess(undefined);
|
||
try {
|
||
await api.createLibrary({ name, path, enabled: true });
|
||
await refresh();
|
||
setSuccess(`Bibliothèque "${name}" ajoutée.`);
|
||
} catch (createError) {
|
||
const fallback = getApiFallback<LibraryDto>(createError);
|
||
if (fallback) setLibraries((current) => [fallback, ...current]);
|
||
setError(fallback ? "Creation en mode secours, synchronisation a retenter." : "Creation impossible");
|
||
}
|
||
}
|
||
|
||
async function scan(id: number) {
|
||
setError(undefined);
|
||
setSuccess(undefined);
|
||
try {
|
||
await api.scanLibrary(id);
|
||
await refresh();
|
||
setSuccess("Scan demandé.");
|
||
} catch (scanError) {
|
||
const fallback = getApiFallback<JobDto>(scanError);
|
||
if (fallback) setJobs((current) => [fallback, ...current]);
|
||
setError(fallback ? "Scan place en file de secours, statut a verifier." : "Scan impossible");
|
||
}
|
||
}
|
||
|
||
async function deleteLibrary(library: LibraryDto) {
|
||
const confirmed = window.confirm(
|
||
`Supprimer la bibliothèque "${library.name}" ?\n\nLes livres restent sur le disque. ReadaBook supprimera seulement cette bibliothèque du catalogue.`
|
||
);
|
||
if (!confirmed) return;
|
||
|
||
setError(undefined);
|
||
setSuccess(undefined);
|
||
try {
|
||
await api.deleteLibrary(library.id);
|
||
setLibraries((current) => current.filter((item) => item.id !== library.id));
|
||
setSuccess(`Bibliothèque "${library.name}" supprimée. Les fichiers disque n'ont pas été supprimés.`);
|
||
} catch (deleteError) {
|
||
setError(deleteError instanceof Error ? deleteError.message : "Suppression impossible");
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="page-grid">
|
||
<Panel className="span-2">
|
||
<div className="section-heading">
|
||
<h1>Administration</h1>
|
||
<span>{loading ? "chargement" : `${users.length} comptes`}</span>
|
||
</div>
|
||
<ErrorRibbon message={error} />
|
||
{success && <div className="success-ribbon">{success}</div>}
|
||
{error && (
|
||
<div className="retry-row">
|
||
<span>Les formulaires restent disponibles.</span>
|
||
<button className="ghost-button" onClick={() => void refresh()}>
|
||
Reessayer
|
||
</button>
|
||
</div>
|
||
)}
|
||
<form className="admin-form" onSubmit={createLibrary}>
|
||
<label>
|
||
Nom de la bibliothèque
|
||
<input value={name} onChange={(event) => setName(event.target.value)} required />
|
||
<small>Nom affiché dans l’application, par exemple : Romans, BD, Documentation.</small>
|
||
</label>
|
||
<label>
|
||
Chemin du dossier
|
||
<input value={path} onChange={(event) => setPath(event.target.value)} required />
|
||
<small>Emplacement réel sur le serveur où ReadaBook lit les livres.</small>
|
||
</label>
|
||
<button className="primary-button" type="submit">
|
||
<Plus size={17} />
|
||
Ajouter
|
||
</button>
|
||
</form>
|
||
</Panel>
|
||
|
||
<Panel>
|
||
<div className="section-heading">
|
||
<h2>Travaux</h2>
|
||
<span>{jobs.length}</span>
|
||
</div>
|
||
{loading && !jobs.length ? (
|
||
<LoadingState label="Lecture des travaux" />
|
||
) : jobs.length ? (
|
||
<div className="job-list">
|
||
{jobs.map((job) => (
|
||
<div key={job.id}>
|
||
<strong>{job.type}</strong>
|
||
<span>{job.status}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<EmptyState title="Aucun travail" detail="Les scans apparaitront ici." />
|
||
)}
|
||
</Panel>
|
||
|
||
<Panel className="span-3">
|
||
{loading && !libraries.length ? (
|
||
<LoadingState label="Lecture des rayons" />
|
||
) : libraries.length ? (
|
||
<div className="library-table">
|
||
{libraries.map((library) => (
|
||
<div key={library.id}>
|
||
<div className="library-copy">
|
||
<strong>{library.name}</strong>
|
||
<span>Chemin : {library.path}</span>
|
||
</div>
|
||
<span>{library.enabled ? "actif" : "pause"}</span>
|
||
<button className="ghost-button" onClick={() => scan(library.id)}>
|
||
<Play size={16} />
|
||
Scanner
|
||
</button>
|
||
<button className="ghost-button danger-button" onClick={() => void deleteLibrary(library)}>
|
||
<Trash2 size={16} />
|
||
Supprimer
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<EmptyState title="Aucun rayon" detail="Ajoute un chemin puis relance la synchronisation." />
|
||
)}
|
||
</Panel>
|
||
</div>
|
||
);
|
||
}
|