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:
21
apps/backoffice/app/components/ExternalLinkBadge.tsx
Normal file
21
apps/backoffice/app/components/ExternalLinkBadge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
|
||||
242
apps/backoffice/app/components/ReadingStatusModal.tsx
Normal file
242
apps/backoffice/app/components/ReadingStatusModal.tsx
Normal 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
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>> = {
|
||||
|
||||
Reference in New Issue
Block a user