Files
ReadaBook/apps/web/src/pages/AdminPage.tsx
Git Agent d79f502dd2 feat(api,web): providers ComicVine + MangaDex et pilotage de l'enrichissement
- 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>
2026-08-24 09:11:18 +02:00

215 lines
7.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 lapplication, 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 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>
);
}