feat: AniList reading status integration

- Add full AniList integration: OAuth connect, series linking, push/pull sync
- Push: PLANNING/CURRENT/COMPLETED based on books read vs total_volumes (never auto-complete from owned books alone)
- Pull: update local reading progress from AniList list (per-user)
- Detailed sync/pull reports with per-series status and progress
- Local user selector in settings to scope sync to a specific user
- Rename "AniList" tab/buttons to generic "État de lecture" / "Reading status"
- Make Bédéthèque and AniList badges clickable links on series detail page
- Fix ON CONFLICT error on series link (provider column in PK)
- Migration 0054: fix series_metadata missing columns (authors, publishers, locked_fields, total_volumes, status)
- Align button heights on series detail page; move MarkSeriesReadButton to action row

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-24 17:08:11 +01:00
parent 2a7881ac6e
commit e94a4a0b13
29 changed files with 2352 additions and 40 deletions

View File

@@ -0,0 +1,21 @@
"use client";
interface ExternalLinkBadgeProps {
href: string;
className?: string;
children: React.ReactNode;
}
export function ExternalLinkBadge({ href, className, children }: ExternalLinkBadgeProps) {
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className={className}
onClick={(e) => e.stopPropagation()}
>
{children}
</a>
);
}

View File

@@ -14,6 +14,7 @@ interface LibraryActionsProps {
metadataProvider: string | null;
fallbackMetadataProvider: string | null;
metadataRefreshMode: string;
readingStatusProvider: string | null;
onUpdate?: () => void;
}
@@ -25,6 +26,7 @@ export function LibraryActions({
metadataProvider,
fallbackMetadataProvider,
metadataRefreshMode,
readingStatusProvider,
}: LibraryActionsProps) {
const { t } = useTranslation();
const [isOpen, setIsOpen] = useState(false);
@@ -40,6 +42,7 @@ export function LibraryActions({
const newMetadataProvider = (formData.get("metadata_provider") as string) || null;
const newFallbackProvider = (formData.get("fallback_metadata_provider") as string) || null;
const newMetadataRefreshMode = formData.get("metadata_refresh_mode") as string;
const newReadingStatusProvider = (formData.get("reading_status_provider") as string) || null;
try {
const [response] = await Promise.all([
@@ -58,6 +61,11 @@ export function LibraryActions({
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ metadata_provider: newMetadataProvider, fallback_metadata_provider: newFallbackProvider }),
}),
fetch(`/api/libraries/${libraryId}/reading-status-provider`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ reading_status_provider: newReadingStatusProvider }),
}),
]);
if (response.ok) {
@@ -255,6 +263,34 @@ export function LibraryActions({
</div>
</div>
<hr className="border-border/40" />
{/* Section: État de lecture */}
<div className="space-y-5">
<h3 className="flex items-center gap-2 text-sm font-semibold text-foreground uppercase tracking-wide">
<svg className="w-4 h-4 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{t("libraryActions.sectionReadingStatus")}
</h3>
<div>
<div className="flex items-center justify-between gap-4">
<label className="text-sm font-medium text-foreground">
{t("libraryActions.readingStatusProvider")}
</label>
<select
name="reading_status_provider"
defaultValue={readingStatusProvider || ""}
className="text-sm border border-border rounded-lg px-3 py-1.5 bg-background min-w-[160px] shrink-0"
>
<option value="">{t("libraryActions.none")}</option>
<option value="anilist">AniList</option>
</select>
</div>
<p className="text-xs text-muted-foreground mt-1.5">{t("libraryActions.readingStatusProviderDesc")}</p>
</div>
</div>
{saveError && (
<p className="text-sm text-destructive bg-destructive/10 px-3 py-2 rounded-lg break-all">
{saveError}

View File

@@ -45,27 +45,27 @@ export function MarkSeriesReadButton({ seriesName, bookCount, booksReadCount }:
<button
onClick={handleClick}
disabled={loading}
className={`inline-flex items-center gap-1 text-xs px-2 py-1 rounded-full font-medium transition-colors ${
className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg border text-sm font-medium transition-colors disabled:opacity-50 ${
allRead
? "bg-green-500/15 text-green-600 dark:text-green-400 hover:bg-green-500/25"
: "bg-muted/50 text-muted-foreground hover:bg-primary/10 hover:text-primary"
} disabled:opacity-50`}
? "border-green-500/30 bg-green-500/10 text-green-600 hover:bg-green-500/20"
: "border-border bg-card text-muted-foreground hover:text-foreground hover:border-primary"
}`}
>
{loading ? (
<svg className="w-3.5 h-3.5 animate-spin" fill="none" viewBox="0 0 24 24">
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
) : allRead ? (
<>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 15 3 9m0 0 6-6M3 9h12a6 6 0 0 1 0 12h-3" />
</svg>
{label}
</>
) : (
<>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0z" />
</svg>
{label}

View File

@@ -683,13 +683,6 @@ export function MetadataSearchModal({
{existingLink && existingLink.status === "approved" ? t("metadata.metadataButton") : t("metadata.searchButton")}
</button>
{existingLink && existingLink.status === "approved" && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-primary/10 text-primary text-xs border border-primary/30">
<ProviderIcon provider={existingLink.provider} size={12} />
<span>{providerLabel(existingLink.provider)}</span>
</span>
)}
{modal}
</>
);

View File

@@ -0,0 +1,242 @@
"use client";
import { useState, useCallback } from "react";
import { createPortal } from "react-dom";
import { useRouter } from "next/navigation";
import { Button } from "./ui";
import { useTranslation } from "../../lib/i18n/context";
import type { AnilistMediaResultDto, AnilistSeriesLinkDto } from "../../lib/api";
interface ReadingStatusModalProps {
libraryId: string;
seriesName: string;
readingStatusProvider: string | null;
existingLink: AnilistSeriesLinkDto | null;
}
type ModalStep = "idle" | "searching" | "results" | "linked";
export function ReadingStatusModal({
libraryId,
seriesName,
readingStatusProvider,
existingLink,
}: ReadingStatusModalProps) {
const { t } = useTranslation();
const router = useRouter();
const [isOpen, setIsOpen] = useState(false);
const [step, setStep] = useState<ModalStep>(existingLink ? "linked" : "idle");
const [query, setQuery] = useState(seriesName);
const [candidates, setCandidates] = useState<AnilistMediaResultDto[]>([]);
const [error, setError] = useState<string | null>(null);
const [link, setLink] = useState<AnilistSeriesLinkDto | null>(existingLink);
const [isLinking, setIsLinking] = useState(false);
const [isUnlinking, setIsUnlinking] = useState(false);
const handleOpen = useCallback(() => {
setIsOpen(true);
setStep(link ? "linked" : "idle");
setQuery(seriesName);
setCandidates([]);
setError(null);
}, [link, seriesName]);
const handleClose = useCallback(() => setIsOpen(false), []);
async function handleSearch() {
setStep("searching");
setError(null);
try {
const resp = await fetch("/api/anilist/search", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ query }),
});
const data = await resp.json();
if (!resp.ok) throw new Error(data.error || "Search failed");
setCandidates(data);
setStep("results");
} catch (e) {
setError(e instanceof Error ? e.message : "Search failed");
setStep("idle");
}
}
async function handleLink(candidate: AnilistMediaResultDto) {
setIsLinking(true);
setError(null);
try {
const resp = await fetch(
`/api/anilist/series/${libraryId}/${encodeURIComponent(seriesName)}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ anilist_id: candidate.id }),
}
);
const data = await resp.json();
if (!resp.ok) throw new Error(data.error || "Link failed");
setLink(data);
setStep("linked");
router.refresh();
} catch (e) {
setError(e instanceof Error ? e.message : "Link failed");
} finally {
setIsLinking(false);
}
}
async function handleUnlink() {
setIsUnlinking(true);
setError(null);
try {
const resp = await fetch(
`/api/anilist/series/${libraryId}/${encodeURIComponent(seriesName)}`,
{ method: "DELETE" }
);
if (!resp.ok) throw new Error("Unlink failed");
setLink(null);
setStep("idle");
router.refresh();
} catch (e) {
setError(e instanceof Error ? e.message : "Unlink failed");
} finally {
setIsUnlinking(false);
}
}
if (!readingStatusProvider) return null;
const providerLabel = readingStatusProvider === "anilist" ? "AniList" : readingStatusProvider;
return (
<>
<button
type="button"
onClick={handleOpen}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-border bg-card text-sm font-medium text-muted-foreground hover:text-foreground hover:border-primary transition-colors"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
</svg>
{t("readingStatus.button")}
</button>
{isOpen && createPortal(
<>
<div className="fixed inset-0 bg-black/30 backdrop-blur-sm z-50" onClick={handleClose} />
<div className="fixed inset-0 flex items-center justify-center z-50 p-4">
<div className="bg-card border border-border/50 rounded-xl shadow-2xl w-full max-w-lg overflow-hidden animate-in fade-in zoom-in-95 duration-200">
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-border/50 bg-muted/30">
<div className="flex items-center gap-2.5">
<svg className="w-5 h-5 text-cyan-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
</svg>
<span className="font-semibold text-lg">{providerLabel} {seriesName}</span>
</div>
<button type="button" onClick={handleClose} className="text-muted-foreground hover:text-foreground transition-colors p-1.5 hover:bg-accent rounded-lg">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="p-6 space-y-4 max-h-[70vh] overflow-y-auto">
{/* Linked state */}
{step === "linked" && link && (
<div className="space-y-4">
<div className="flex items-start gap-3 p-3 rounded-lg bg-muted/40">
<div className="flex-1 min-w-0">
<p className="font-medium">{link.anilist_title ?? seriesName}</p>
{link.anilist_url && (
<a href={link.anilist_url} target="_blank" rel="noopener noreferrer" className="text-xs text-primary hover:underline">
{link.anilist_url}
</a>
)}
<p className="text-xs text-muted-foreground mt-1">
ID: {link.anilist_id} · {t(`readingStatus.status.${link.status}` as any) || link.status}
</p>
</div>
<span className="text-[10px] px-1.5 py-0.5 rounded-full font-medium bg-cyan-500/15 text-cyan-600 shrink-0">
{providerLabel}
</span>
</div>
<div className="flex gap-2">
<Button variant="secondary" size="sm" onClick={() => setStep("idle")}>
{t("readingStatus.changeLink")}
</Button>
<Button variant="ghost" size="sm" onClick={handleUnlink} disabled={isUnlinking} className="text-destructive hover:text-destructive">
{isUnlinking ? t("common.loading") : t("readingStatus.unlink")}
</Button>
</div>
</div>
)}
{/* Search form */}
{(step === "idle" || step === "results") && (
<div className="space-y-3">
<div className="flex gap-2">
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") handleSearch(); }}
placeholder={t("readingStatus.searchPlaceholder")}
className="flex-1 text-sm border border-border rounded-lg px-3 py-2 bg-background focus:outline-none focus:ring-2 focus:ring-ring"
/>
<Button onClick={handleSearch} size="sm">
{t("readingStatus.search")}
</Button>
</div>
{step === "results" && candidates.length === 0 && (
<p className="text-sm text-muted-foreground">{t("readingStatus.noResults")}</p>
)}
{step === "results" && candidates.length > 0 && (
<div className="space-y-2">
{candidates.map((c) => (
<div key={c.id} className="flex items-center gap-3 p-3 rounded-lg border border-border/60 hover:bg-muted/30 transition-colors">
<div className="flex-1 min-w-0">
<p className="font-medium text-sm truncate">{c.title_romaji ?? c.title_english}</p>
{c.title_english && c.title_english !== c.title_romaji && (
<p className="text-xs text-muted-foreground truncate">{c.title_english}</p>
)}
<div className="flex items-center gap-2 mt-0.5">
{c.volumes && <span className="text-xs text-muted-foreground">{c.volumes} vol.</span>}
{c.status && <span className="text-xs text-muted-foreground">{c.status}</span>}
<a href={c.site_url} target="_blank" rel="noopener noreferrer" className="text-xs text-primary hover:underline"></a>
</div>
</div>
<Button size="sm" onClick={() => handleLink(c)} disabled={isLinking} className="shrink-0">
{t("readingStatus.link")}
</Button>
</div>
))}
</div>
)}
</div>
)}
{/* Searching spinner */}
{step === "searching" && (
<div className="flex items-center gap-2 py-4 text-muted-foreground">
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
<span className="text-sm">{t("readingStatus.searching")}</span>
</div>
)}
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
</div>
</div>
</>,
document.body
)}
</>
);
}

View File

@@ -35,7 +35,9 @@ type IconName =
| "tag"
| "document"
| "authors"
| "bell";
| "bell"
| "link"
| "eye";
type IconSize = "sm" | "md" | "lg" | "xl";
@@ -90,6 +92,8 @@ const icons: Record<IconName, string> = {
document: "M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z",
authors: "M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z",
bell: "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9",
link: "M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1",
eye: "M15 12a3 3 0 11-6 0 3 3 0 016 0zm-3-9C7.477 3 3.268 6.11 1.5 12c1.768 5.89 5.977 9 10.5 9s8.732-3.11 10.5-9C20.732 6.11 16.523 3 12 3z",
};
const colorClasses: Partial<Record<IconName, string>> = {