feat: add series status, improve providers & e2e tests
- Add series status concept (ongoing/ended/hiatus/cancelled/upcoming) with normalization across all providers - Add status field to series_metadata table (migration 0033) - AniList: use chapters as fallback for volume count on ongoing series, add books_message when both volumes and chapters are null - Bedetheque: extract description from meta tag, genres, parution status, origin/language; rewrite book parsing with itemprop microdata for clean ISBN, dates, page counts, covers; filter placeholder authors - Add comprehensive e2e provider tests with field coverage reporting - Wire status into EditSeriesForm, MetadataSearchModal, and series page Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -39,6 +39,15 @@ function LockButton({
|
||||
);
|
||||
}
|
||||
|
||||
const SERIES_STATUSES = [
|
||||
{ value: "", label: "Non défini" },
|
||||
{ value: "ongoing", label: "En cours" },
|
||||
{ value: "ended", label: "Terminée" },
|
||||
{ value: "hiatus", label: "Hiatus" },
|
||||
{ value: "cancelled", label: "Annulée" },
|
||||
{ value: "upcoming", label: "À paraître" },
|
||||
] as const;
|
||||
|
||||
interface EditSeriesFormProps {
|
||||
libraryId: string;
|
||||
seriesName: string;
|
||||
@@ -49,6 +58,7 @@ interface EditSeriesFormProps {
|
||||
currentDescription: string | null;
|
||||
currentStartYear: number | null;
|
||||
currentTotalVolumes: number | null;
|
||||
currentStatus: string | null;
|
||||
currentLockedFields: Record<string, boolean>;
|
||||
}
|
||||
|
||||
@@ -62,6 +72,7 @@ export function EditSeriesForm({
|
||||
currentDescription,
|
||||
currentStartYear,
|
||||
currentTotalVolumes,
|
||||
currentStatus,
|
||||
currentLockedFields,
|
||||
}: EditSeriesFormProps) {
|
||||
const router = useRouter();
|
||||
@@ -80,6 +91,7 @@ export function EditSeriesForm({
|
||||
const [description, setDescription] = useState(currentDescription ?? "");
|
||||
const [startYear, setStartYear] = useState(currentStartYear?.toString() ?? "");
|
||||
const [totalVolumes, setTotalVolumes] = useState(currentTotalVolumes?.toString() ?? "");
|
||||
const [status, setStatus] = useState(currentStatus ?? "");
|
||||
|
||||
// Lock states
|
||||
const [lockedFields, setLockedFields] = useState<Record<string, boolean>>(currentLockedFields);
|
||||
@@ -142,6 +154,7 @@ export function EditSeriesForm({
|
||||
setDescription(currentDescription ?? "");
|
||||
setStartYear(currentStartYear?.toString() ?? "");
|
||||
setTotalVolumes(currentTotalVolumes?.toString() ?? "");
|
||||
setStatus(currentStatus ?? "");
|
||||
setLockedFields(currentLockedFields);
|
||||
setShowApplyToBooks(false);
|
||||
setBookAuthor(currentBookAuthor ?? "");
|
||||
@@ -182,6 +195,7 @@ export function EditSeriesForm({
|
||||
description: description.trim() || null,
|
||||
start_year: startYear.trim() ? parseInt(startYear.trim(), 10) : null,
|
||||
total_volumes: totalVolumes.trim() ? parseInt(totalVolumes.trim(), 10) : null,
|
||||
status: status || null,
|
||||
locked_fields: lockedFields,
|
||||
};
|
||||
if (showApplyToBooks) {
|
||||
@@ -285,6 +299,23 @@ export function EditSeriesForm({
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField>
|
||||
<div className="flex items-center gap-1">
|
||||
<FormLabel>Statut</FormLabel>
|
||||
<LockButton locked={!!lockedFields.status} onToggle={() => toggleLock("status")} disabled={isPending} />
|
||||
</div>
|
||||
<select
|
||||
value={status}
|
||||
onChange={(e) => setStatus(e.target.value)}
|
||||
disabled={isPending}
|
||||
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/40"
|
||||
>
|
||||
{SERIES_STATUSES.map((s) => (
|
||||
<option key={s.value} value={s.value}>{s.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
|
||||
{/* Auteurs — multi-valeur */}
|
||||
<FormField className="sm:col-span-2">
|
||||
<div className="flex items-center gap-1">
|
||||
|
||||
@@ -13,6 +13,7 @@ const FIELD_LABELS: Record<string, string> = {
|
||||
publishers: "Éditeurs",
|
||||
start_year: "Année",
|
||||
total_volumes: "Nb volumes",
|
||||
status: "Statut",
|
||||
summary: "Résumé",
|
||||
isbn: "ISBN",
|
||||
publish_date: "Date de publication",
|
||||
@@ -338,7 +339,14 @@ export function MetadataSearchModal({
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
{c.publishers.length > 0 && <span>{c.publishers[0]}</span>}
|
||||
{c.start_year != null && <span>{c.start_year}</span>}
|
||||
{c.total_volumes != null && <span>{c.total_volumes} vol.</span>}
|
||||
{c.total_volumes != null && (
|
||||
<span>
|
||||
{c.total_volumes} {c.metadata_json?.volume_source === "chapters" ? "ch." : "vol."}
|
||||
</span>
|
||||
)}
|
||||
{c.metadata_json?.status === "RELEASING" && (
|
||||
<span className="italic text-amber-500">en cours</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -366,8 +374,11 @@ export function MetadataSearchModal({
|
||||
{selectedCandidate.authors.length > 0 && (
|
||||
<p className="text-sm text-muted-foreground">{selectedCandidate.authors.join(", ")}</p>
|
||||
)}
|
||||
{selectedCandidate.total_volumes && (
|
||||
<p className="text-sm text-muted-foreground">{selectedCandidate.total_volumes} volumes</p>
|
||||
{selectedCandidate.total_volumes != null && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{selectedCandidate.total_volumes} {selectedCandidate.metadata_json?.volume_source === "chapters" ? "chapitres" : "volumes"}
|
||||
{selectedCandidate.metadata_json?.status === "RELEASING" && <span className="italic text-amber-500 ml-1">(en cours)</span>}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground mt-1 inline-flex items-center gap-1">
|
||||
via <ProviderIcon provider={selectedCandidate.provider} size={12} /> <span className="font-medium">{providerLabel(selectedCandidate.provider)}</span>
|
||||
@@ -458,8 +469,15 @@ export function MetadataSearchModal({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Books message (e.g. provider has no volume data) */}
|
||||
{syncReport.books_message && (
|
||||
<div className="p-3 rounded-lg bg-amber-500/10 border border-amber-500/30">
|
||||
<p className="text-xs text-amber-600">{syncReport.books_message}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Books report */}
|
||||
{(syncReport.books.length > 0 || syncReport.books_unmatched > 0) && (
|
||||
{!syncReport.books_message && (syncReport.books.length > 0 || syncReport.books_unmatched > 0) && (
|
||||
<div className="p-3 rounded-lg bg-muted/30 border border-border/50">
|
||||
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-2">
|
||||
Livres — {syncReport.books_matched} matched{syncReport.books_unmatched > 0 && `, ${syncReport.books_unmatched} unmatched`}
|
||||
|
||||
@@ -100,9 +100,27 @@ export default async function SeriesDetailPage({
|
||||
<div className="flex-1 space-y-4">
|
||||
<h1 className="text-3xl font-bold text-foreground">{displayName}</h1>
|
||||
|
||||
{seriesMeta && seriesMeta.authors.length > 0 && (
|
||||
<p className="text-base text-muted-foreground">{seriesMeta.authors.join(", ")}</p>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{seriesMeta && seriesMeta.authors.length > 0 && (
|
||||
<p className="text-base text-muted-foreground">{seriesMeta.authors.join(", ")}</p>
|
||||
)}
|
||||
{seriesMeta?.status && (
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||
seriesMeta.status === "ongoing" ? "bg-blue-500/15 text-blue-600" :
|
||||
seriesMeta.status === "ended" ? "bg-green-500/15 text-green-600" :
|
||||
seriesMeta.status === "hiatus" ? "bg-amber-500/15 text-amber-600" :
|
||||
seriesMeta.status === "cancelled" ? "bg-red-500/15 text-red-600" :
|
||||
"bg-muted text-muted-foreground"
|
||||
}`}>
|
||||
{seriesMeta.status === "ongoing" ? "En cours" :
|
||||
seriesMeta.status === "ended" ? "Terminée" :
|
||||
seriesMeta.status === "hiatus" ? "Hiatus" :
|
||||
seriesMeta.status === "cancelled" ? "Annulée" :
|
||||
seriesMeta.status === "upcoming" ? "À paraître" :
|
||||
seriesMeta.status}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{seriesMeta?.description && (
|
||||
<SafeHtml html={seriesMeta.description} className="text-sm text-muted-foreground leading-relaxed" />
|
||||
@@ -153,6 +171,7 @@ export default async function SeriesDetailPage({
|
||||
currentDescription={seriesMeta?.description ?? null}
|
||||
currentStartYear={seriesMeta?.start_year ?? null}
|
||||
currentTotalVolumes={seriesMeta?.total_volumes ?? null}
|
||||
currentStatus={seriesMeta?.status ?? null}
|
||||
currentLockedFields={seriesMeta?.locked_fields ?? {}}
|
||||
/>
|
||||
<MetadataSearchModal
|
||||
|
||||
Reference in New Issue
Block a user