- Adaptateurs comic-vine et mangadex avec helper de fetch partagé, timeouts et fallback durcis sur les providers existants - Scoring des correspondances amélioré, normalisation de la date de publication, chaîne de résolution des providers étendue - Scanner : statuts par livre (scan/enrichissement) et page admin d'automatisation alignée Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
215 lines
7.7 KiB
TypeScript
215 lines
7.7 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 { jobDigestSummary } from "../book/metadata";
|
||
import { EmptyState, ErrorRibbon, LoadingState, Panel } from "../components/ui";
|
||
|
||
function formatJobTime(value: string) {
|
||
return new Intl.DateTimeFormat(undefined, { hour: "2-digit", minute: "2-digit" }).format(new Date(value));
|
||
}
|
||
|
||
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>();
|
||
const [scanRetryLibrary, setScanRetryLibrary] = useState<LibraryDto>();
|
||
|
||
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);
|
||
setScanRetryLibrary(undefined);
|
||
try {
|
||
const created = await api.createLibrary({ name, path, enabled: true });
|
||
await refresh();
|
||
setName("Bibliotheque locale");
|
||
setPath("/library");
|
||
try {
|
||
await api.scanLibrary(created.id);
|
||
await refresh();
|
||
setSuccess(`Bibliothèque "${created.name}" ajoutée. Scan initial demandé.`);
|
||
} catch (scanError) {
|
||
setScanRetryLibrary(created);
|
||
setSuccess(
|
||
`Bibliothèque "${created.name}" ajoutée, mais le scan initial n'a pas pu être demandé. Tu peux réessayer le scan.`
|
||
);
|
||
setError(scanError instanceof Error ? `Scan initial impossible : ${scanError.message}` : "Scan initial impossible.");
|
||
}
|
||
} catch (createError) {
|
||
setError(createError instanceof Error ? `Création impossible : ${createError.message}` : "Création impossible.");
|
||
}
|
||
}
|
||
|
||
async function scan(id: number) {
|
||
setError(undefined);
|
||
setSuccess(undefined);
|
||
setScanRetryLibrary(undefined);
|
||
try {
|
||
await api.scanLibrary(id);
|
||
await refresh();
|
||
setSuccess("Scan demandé.");
|
||
} catch (scanError) {
|
||
setError(scanError instanceof Error ? `Scan impossible : ${scanError.message}` : "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);
|
||
setScanRetryLibrary(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>}
|
||
{scanRetryLibrary ? (
|
||
<div className="retry-row">
|
||
<span>La bibliothèque est conservée dans la liste.</span>
|
||
<button className="ghost-button" onClick={() => scan(scanRetryLibrary.id)}>
|
||
<Play size={16} />
|
||
Réessayer le scan
|
||
</button>
|
||
</div>
|
||
) : error ? (
|
||
<div className="retry-row">
|
||
<span>Les formulaires restent disponibles.</span>
|
||
<button className="ghost-button" onClick={() => void refresh()}>
|
||
Reessayer
|
||
</button>
|
||
</div>
|
||
) : null}
|
||
<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}>
|
||
<div className="job-copy">
|
||
<div>
|
||
<strong>{job.type}</strong>
|
||
<small>{jobDigestSummary(job)}</small>
|
||
</div>
|
||
<time dateTime={job.updatedAt}>{formatJobTime(job.updatedAt)}</time>
|
||
</div>
|
||
<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>
|
||
);
|
||
}
|