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,97 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
export default function AnilistCallbackPage() {
const router = useRouter();
const [status, setStatus] = useState<"loading" | "success" | "error">("loading");
const [message, setMessage] = useState("");
useEffect(() => {
async function handleCallback() {
const hash = window.location.hash.slice(1); // remove leading #
const params = new URLSearchParams(hash);
const accessToken = params.get("access_token");
if (!accessToken) {
setStatus("error");
setMessage("Aucun token trouvé dans l'URL de callback.");
return;
}
try {
// Read existing settings to preserve client_id
const existingResp = await fetch("/api/settings/anilist").catch(() => null);
const existing = existingResp?.ok ? await existingResp.json().catch(() => ({})) : {};
const save = (extra: Record<string, unknown>) =>
fetch("/api/settings/anilist", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ value: { ...existing, access_token: accessToken, ...extra } }),
});
const saveResp = await save({});
if (!saveResp.ok) throw new Error("Impossible de sauvegarder le token");
// Auto-fetch user info to populate user_id
const statusResp = await fetch("/api/anilist/status");
if (statusResp.ok) {
const data = await statusResp.json();
if (data.user_id) {
await save({ user_id: data.user_id });
}
setMessage(`Connecté en tant que ${data.username}`);
} else {
setMessage("Token sauvegardé.");
}
setStatus("success");
setTimeout(() => router.push("/settings?tab=anilist"), 2000);
} catch (e) {
setStatus("error");
setMessage(e instanceof Error ? e.message : "Erreur inconnue");
}
}
handleCallback();
}, [router]);
return (
<div className="min-h-screen flex items-center justify-center bg-background">
<div className="text-center space-y-4 p-8">
{status === "loading" && (
<>
<div className="w-8 h-8 border-2 border-primary border-t-transparent rounded-full animate-spin mx-auto" />
<p className="text-muted-foreground">Connexion AniList en cours</p>
</>
)}
{status === "success" && (
<>
<div className="w-12 h-12 rounded-full bg-success/15 flex items-center justify-center mx-auto">
<svg className="w-6 h-6 text-success" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<p className="text-success font-medium">{message}</p>
<p className="text-sm text-muted-foreground">Redirection vers les paramètres</p>
</>
)}
{status === "error" && (
<>
<div className="w-12 h-12 rounded-full bg-destructive/15 flex items-center justify-center mx-auto">
<svg className="w-6 h-6 text-destructive" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</div>
<p className="text-destructive font-medium">{message}</p>
<a href="/settings" className="text-sm text-primary hover:underline">
Retour aux paramètres
</a>
</>
)}
</div>
</div>
);
}

View File

@@ -1,7 +1,8 @@
import { fetchLibraries, fetchBooks, fetchSeriesMetadata, getBookCoverUrl, getMetadataLink, getMissingBooks, BookDto, SeriesMetadataDto, ExternalMetadataLinkDto, MissingBooksDto } from "@/lib/api";
import { fetchLibraries, fetchBooks, fetchSeriesMetadata, getBookCoverUrl, getMetadataLink, getMissingBooks, getReadingStatusLink, BookDto, SeriesMetadataDto, ExternalMetadataLinkDto, MissingBooksDto, AnilistSeriesLinkDto } from "@/lib/api";
import { BooksGrid, EmptyState } from "@/app/components/BookCard";
import { MarkSeriesReadButton } from "@/app/components/MarkSeriesReadButton";
import { MarkBookReadButton } from "@/app/components/MarkBookReadButton";
import { ProviderIcon, providerLabel } from "@/app/components/ProviderIcon";
import nextDynamic from "next/dynamic";
import { OffsetPagination } from "@/app/components/ui";
import { SafeHtml } from "@/app/components/SafeHtml";
@@ -14,6 +15,9 @@ const EditSeriesForm = nextDynamic(
const MetadataSearchModal = nextDynamic(
() => import("@/app/components/MetadataSearchModal").then(m => m.MetadataSearchModal)
);
const ReadingStatusModal = nextDynamic(
() => import("@/app/components/ReadingStatusModal").then(m => m.ReadingStatusModal)
);
const ProwlarrSearchModal = nextDynamic(
() => import("@/app/components/ProwlarrSearchModal").then(m => m.ProwlarrSearchModal)
);
@@ -37,7 +41,7 @@ export default async function SeriesDetailPage({
const seriesName = decodeURIComponent(name);
const [library, booksPage, seriesMeta, metadataLinks] = await Promise.all([
const [library, booksPage, seriesMeta, metadataLinks, readingStatusLink] = await Promise.all([
fetchLibraries().then((libs) => libs.find((l) => l.id === id)),
fetchBooks(id, seriesName, page, limit).catch(() => ({
items: [] as BookDto[],
@@ -47,6 +51,7 @@ export default async function SeriesDetailPage({
})),
fetchSeriesMetadata(id, seriesName).catch(() => null as SeriesMetadataDto | null),
getMetadataLink(id, seriesName).catch(() => [] as ExternalMetadataLinkDto[]),
getReadingStatusLink(id, seriesName).catch(() => null as AnilistSeriesLinkDto | null),
]);
const existingLink = metadataLinks.find((l) => l.status === "approved") ?? metadataLinks[0] ?? null;
@@ -126,6 +131,37 @@ export default async function SeriesDetailPage({
{t(`seriesStatus.${seriesMeta.status}` as any) || seriesMeta.status}
</span>
)}
{existingLink?.status === "approved" && (
existingLink.external_url ? (
<a
href={existingLink.external_url}
target="_blank"
rel="noopener noreferrer"
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 hover:bg-primary/20 transition-colors"
>
<ProviderIcon provider={existingLink.provider} size={12} />
{providerLabel(existingLink.provider)}
</a>
) : (
<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} />
{providerLabel(existingLink.provider)}
</span>
)
)}
{readingStatusLink && (
<a
href={readingStatusLink.anilist_url ?? `https://anilist.co/manga/${readingStatusLink.anilist_id}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-cyan-500/10 text-cyan-600 text-xs border border-cyan-500/30 hover:bg-cyan-500/20 transition-colors"
>
<svg className="w-3 h-3" viewBox="0 0 24 24" fill="currentColor">
<path d="M6.361 2.943 0 21.056h4.942l1.077-3.133H11.4l1.077 3.133H17.5L11.128 2.943H6.361zm1.58 11.152 1.84-5.354 1.84 5.354H7.941zM17.358 2.943v18.113h4.284V2.943h-4.284z"/>
</svg>
AniList
</a>
)}
</div>
{seriesMeta?.description && (
@@ -206,6 +242,12 @@ export default async function SeriesDetailPage({
existingLink={existingLink}
initialMissing={missingData}
/>
<ReadingStatusModal
libraryId={id}
seriesName={seriesName}
readingStatusProvider={library.reading_status_provider ?? null}
existingLink={readingStatusLink}
/>
</div>
</div>
</div>

View File

@@ -146,6 +146,7 @@ export default async function LibrariesPage() {
metadataProvider={lib.metadata_provider}
fallbackMetadataProvider={lib.fallback_metadata_provider}
metadataRefreshMode={lib.metadata_refresh_mode}
readingStatusProvider={lib.reading_status_provider}
/>
<form>
<input type="hidden" name="id" value={lib.id} />

View File

@@ -6,6 +6,7 @@ import { Card, CardContent, OffsetPagination } from "@/app/components/ui";
import Image from "next/image";
import Link from "next/link";
import { ProviderIcon } from "@/app/components/ProviderIcon";
import { ExternalLinkBadge } from "@/app/components/ExternalLinkBadge";
export const dynamic = "force-dynamic";
@@ -122,13 +123,9 @@ export default async function SeriesPage({
<>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
{series.map((s) => (
<Link
key={s.name}
href={`/libraries/${s.library_id}/series/${encodeURIComponent(s.name)}`}
className="group"
>
<div key={s.name} className="group relative">
<div
className={`bg-card rounded-xl shadow-sm border border-border/60 overflow-hidden hover:shadow-md hover:-translate-y-1 transition-all duration-200 ${
className={`bg-card rounded-xl shadow-sm border border-border/60 overflow-hidden group-hover:shadow-md group-hover:-translate-y-1 transition-all duration-200 ${
s.books_read_count >= s.book_count ? "opacity-50" : ""
}`}
>
@@ -149,13 +146,15 @@ export default async function SeriesPage({
<p className="text-xs text-muted-foreground">
{t("series.readCount", { read: String(s.books_read_count), total: String(s.book_count), plural: s.book_count !== 1 ? "s" : "" })}
</p>
<MarkSeriesReadButton
seriesName={s.name}
bookCount={s.book_count}
booksReadCount={s.books_read_count}
/>
<div className="relative z-20">
<MarkSeriesReadButton
seriesName={s.name}
bookCount={s.book_count}
booksReadCount={s.books_read_count}
/>
</div>
</div>
<div className="flex items-center gap-1 mt-1.5 flex-wrap">
<div className="relative z-20 flex items-center gap-1 mt-1.5 flex-wrap">
{s.series_status && (
<span className={`text-[10px] px-1.5 py-0.5 rounded-full font-medium ${
s.series_status === "ongoing" ? "bg-blue-500/15 text-blue-600" :
@@ -177,10 +176,24 @@ export default async function SeriesPage({
<ProviderIcon provider={s.metadata_provider} size={10} />
</span>
)}
{s.anilist_id && (
<ExternalLinkBadge
href={s.anilist_url ?? `https://anilist.co/manga/${s.anilist_id}`}
className="text-[10px] px-1.5 py-0.5 rounded-full font-medium bg-cyan-500/15 text-cyan-600 hover:bg-cyan-500/25"
>
AL
</ExternalLinkBadge>
)}
</div>
</div>
</div>
</Link>
{/* Link overlay covering the full card — below interactive elements */}
<Link
href={`/libraries/${s.library_id}/series/${encodeURIComponent(s.name)}`}
className="absolute inset-0 z-10 rounded-xl"
aria-label={s.name === "unclassified" ? t("books.unclassified") : s.name}
/>
</div>
))}
</div>

View File

@@ -3,7 +3,7 @@
import { useState, useEffect, useCallback, useMemo } from "react";
import { Card, CardHeader, CardTitle, CardDescription, CardContent, Button, FormField, FormInput, FormSelect, FormRow, Icon } from "@/app/components/ui";
import { ProviderIcon } from "@/app/components/ProviderIcon";
import { Settings, CacheStats, ClearCacheResponse, ThumbnailStats, KomgaSyncResponse, KomgaSyncReportSummary, StatusMappingDto, UserDto } from "@/lib/api";
import { Settings, CacheStats, ClearCacheResponse, ThumbnailStats, KomgaSyncResponse, KomgaSyncReportSummary, StatusMappingDto, UserDto, AnilistStatusDto, AnilistSyncReportDto, AnilistPullReportDto, AnilistSyncPreviewItemDto, AnilistSyncItemDto, AnilistPullItemDto } from "@/lib/api";
import { useTranslation } from "@/lib/i18n/context";
import type { Locale } from "@/lib/i18n/types";
@@ -12,9 +12,10 @@ interface SettingsPageProps {
initialCacheStats: CacheStats;
initialThumbnailStats: ThumbnailStats;
users: UserDto[];
initialTab?: string;
}
export default function SettingsPage({ initialSettings, initialCacheStats, initialThumbnailStats, users }: SettingsPageProps) {
export default function SettingsPage({ initialSettings, initialCacheStats, initialThumbnailStats, users, initialTab }: SettingsPageProps) {
const { t, locale, setLocale } = useTranslation();
const [settings, setSettings] = useState<Settings>({
...initialSettings,
@@ -153,11 +154,14 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
}
}
const [activeTab, setActiveTab] = useState<"general" | "integrations" | "notifications">("general");
const [activeTab, setActiveTab] = useState<"general" | "integrations" | "anilist" | "notifications">(
initialTab === "anilist" || initialTab === "integrations" || initialTab === "notifications" ? initialTab : "general"
);
const tabs = [
{ id: "general" as const, label: t("settings.general"), icon: "settings" as const },
{ id: "integrations" as const, label: t("settings.integrations"), icon: "refresh" as const },
{ id: "anilist" as const, label: t("settings.anilist"), icon: "link" as const },
{ id: "notifications" as const, label: t("settings.notifications"), icon: "bell" as const },
];
@@ -848,6 +852,10 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
{/* Telegram Notifications */}
<TelegramCard handleUpdateSetting={handleUpdateSetting} />
</>)}
{activeTab === "anilist" && (
<AnilistTab handleUpdateSetting={handleUpdateSetting} users={users} />
)}
</>
);
}
@@ -1753,3 +1761,407 @@ function TelegramCard({ handleUpdateSetting }: { handleUpdateSetting: (key: stri
</Card>
);
}
// ---------------------------------------------------------------------------
// AniList sub-component
// ---------------------------------------------------------------------------
function AnilistTab({
handleUpdateSetting,
users,
}: {
handleUpdateSetting: (key: string, value: unknown) => Promise<void>;
users: UserDto[];
}) {
const { t } = useTranslation();
const [clientId, setClientId] = useState("");
const [token, setToken] = useState("");
const [userId, setUserId] = useState("");
const [localUserId, setLocalUserId] = useState("");
const [isTesting, setIsTesting] = useState(false);
const [viewer, setViewer] = useState<AnilistStatusDto | null>(null);
const [testError, setTestError] = useState<string | null>(null);
const [isSyncing, setIsSyncing] = useState(false);
const [syncReport, setSyncReport] = useState<AnilistSyncReportDto | null>(null);
const [isPulling, setIsPulling] = useState(false);
const [pullReport, setPullReport] = useState<AnilistPullReportDto | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
const [isPreviewing, setIsPreviewing] = useState(false);
const [previewItems, setPreviewItems] = useState<AnilistSyncPreviewItemDto[] | null>(null);
useEffect(() => {
fetch("/api/settings/anilist")
.then((r) => r.ok ? r.json() : null)
.then((data) => {
if (data) {
if (data.client_id) setClientId(String(data.client_id));
if (data.access_token) setToken(data.access_token);
if (data.user_id) setUserId(String(data.user_id));
if (data.local_user_id) setLocalUserId(String(data.local_user_id));
}
})
.catch(() => {});
}, []);
function buildAnilistSettings() {
return {
client_id: clientId || undefined,
access_token: token || undefined,
user_id: userId ? Number(userId) : undefined,
local_user_id: localUserId || undefined,
};
}
function handleConnect() {
if (!clientId) return;
// Save client_id first, then open OAuth URL
handleUpdateSetting("anilist", buildAnilistSettings()).then(() => {
window.location.href = `https://anilist.co/api/v2/oauth/authorize?client_id=${encodeURIComponent(clientId)}&response_type=token`;
});
}
async function handleSaveToken() {
await handleUpdateSetting("anilist", buildAnilistSettings());
}
async function handleTestConnection() {
setIsTesting(true);
setViewer(null);
setTestError(null);
try {
// Save token first so the API reads the current value
await handleUpdateSetting("anilist", buildAnilistSettings());
const resp = await fetch("/api/anilist/status");
const data = await resp.json();
if (!resp.ok) throw new Error(data.error || "Connection failed");
setViewer(data);
if (!userId && data.user_id) setUserId(String(data.user_id));
} catch (e) {
setTestError(e instanceof Error ? e.message : "Connection failed");
} finally {
setIsTesting(false);
}
}
async function handlePreview() {
setIsPreviewing(true);
setPreviewItems(null);
setActionError(null);
try {
const resp = await fetch("/api/anilist/sync/preview");
const data = await resp.json();
if (!resp.ok) throw new Error(data.error || "Preview failed");
setPreviewItems(data);
} catch (e) {
setActionError(e instanceof Error ? e.message : "Preview failed");
} finally {
setIsPreviewing(false);
}
}
async function handleSync() {
setIsSyncing(true);
setSyncReport(null);
setActionError(null);
try {
const resp = await fetch("/api/anilist/sync", { method: "POST" });
const data = await resp.json();
if (!resp.ok) throw new Error(data.error || "Sync failed");
setSyncReport(data);
} catch (e) {
setActionError(e instanceof Error ? e.message : "Sync failed");
} finally {
setIsSyncing(false);
}
}
async function handlePull() {
setIsPulling(true);
setPullReport(null);
setActionError(null);
try {
const resp = await fetch("/api/anilist/pull", { method: "POST" });
const data = await resp.json();
if (!resp.ok) throw new Error(data.error || "Pull failed");
setPullReport(data);
} catch (e) {
setActionError(e instanceof Error ? e.message : "Pull failed");
} finally {
setIsPulling(false);
}
}
return (
<>
<Card className="mb-6">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Icon name="link" size="md" />
{t("settings.anilistTitle")}
</CardTitle>
<CardDescription>{t("settings.anilistDesc")}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground">{t("settings.anilistConnectDesc")}</p>
{/* Redirect URL info */}
<div className="rounded-md bg-muted/50 border px-3 py-2 text-xs text-muted-foreground space-y-1">
<p className="font-medium text-foreground">{t("settings.anilistRedirectUrlLabel")}</p>
<code className="select-all font-mono">{typeof window !== "undefined" ? `${window.location.origin}/anilist/callback` : "/anilist/callback"}</code>
<p>{t("settings.anilistRedirectUrlHint")}</p>
</div>
<FormRow>
<FormField className="flex-1">
<label className="text-sm font-medium text-muted-foreground mb-1 block">{t("settings.anilistClientId")}</label>
<FormInput
type="text"
autoComplete="off"
value={clientId}
onChange={(e) => setClientId(e.target.value)}
placeholder={t("settings.anilistClientIdPlaceholder")}
/>
</FormField>
</FormRow>
<div className="flex items-center gap-3 flex-wrap">
<Button onClick={handleConnect} disabled={!clientId}>
<Icon name="link" size="sm" className="mr-2" />
{t("settings.anilistConnectButton")}
</Button>
<Button onClick={handleTestConnection} disabled={isTesting || !token} variant="secondary">
{isTesting ? (
<><Icon name="spinner" size="sm" className="animate-spin mr-2" />{t("settings.testing")}</>
) : (
<><Icon name="refresh" size="sm" className="mr-2" />{t("settings.anilistTestConnection")}</>
)}
</Button>
{viewer && (
<span className="text-sm text-success font-medium">
{t("settings.anilistConnected")} <strong>{viewer.username}</strong>
{" · "}
<a href={viewer.site_url} target="_blank" rel="noopener noreferrer" className="underline">AniList</a>
</span>
)}
{token && !viewer && (
<span className="text-sm text-muted-foreground">{t("settings.anilistTokenPresent")}</span>
)}
{testError && <span className="text-sm text-destructive">{testError}</span>}
</div>
<details className="group">
<summary className="text-sm text-muted-foreground cursor-pointer hover:text-foreground select-none">
{t("settings.anilistManualToken")}
</summary>
<div className="mt-3 space-y-3">
<FormRow>
<FormField className="flex-1">
<label className="text-sm font-medium text-muted-foreground mb-1 block">{t("settings.anilistToken")}</label>
<FormInput
type="password"
autoComplete="off"
value={token}
onChange={(e) => setToken(e.target.value)}
placeholder={t("settings.anilistTokenPlaceholder")}
/>
</FormField>
<FormField className="flex-1">
<label className="text-sm font-medium text-muted-foreground mb-1 block">{t("settings.anilistUserId")}</label>
<FormInput
type="text"
autoComplete="off"
value={userId}
onChange={(e) => setUserId(e.target.value)}
placeholder={t("settings.anilistUserIdPlaceholder")}
/>
</FormField>
</FormRow>
<Button onClick={handleSaveToken} disabled={!token}>
{t("common.save")}
</Button>
</div>
</details>
<div className="border-t border-border/50 pt-4 mt-2">
<p className="text-sm font-medium text-foreground mb-1">{t("settings.anilistLocalUserTitle")}</p>
<p className="text-xs text-muted-foreground mb-3">{t("settings.anilistLocalUserDesc")}</p>
<div className="flex items-center gap-3">
<select
value={localUserId}
onChange={(e) => setLocalUserId(e.target.value)}
autoComplete="off"
className="flex-1 text-sm border border-border rounded-lg px-3 py-2.5 bg-background focus:outline-none focus:ring-2 focus:ring-ring h-10"
>
<option value="">{t("settings.anilistLocalUserNone")}</option>
{users.map((u) => (
<option key={u.id} value={u.id}>{u.username}</option>
))}
</select>
<Button onClick={() => handleUpdateSetting("anilist", buildAnilistSettings())} disabled={!localUserId}>
{t("common.save")}
</Button>
</div>
</div>
</CardContent>
</Card>
<Card className="mb-6">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Icon name="refresh" size="md" />
{t("settings.anilistSyncTitle")}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
<div className="space-y-2">
<p className="text-sm text-muted-foreground">{t("settings.anilistSyncDesc")}</p>
<div className="flex items-center gap-2 flex-wrap">
<Button onClick={handlePreview} disabled={isPreviewing} variant="secondary">
{isPreviewing ? (
<><Icon name="spinner" size="sm" className="animate-spin mr-2" />{t("settings.anilistPreviewing")}</>
) : (
<><Icon name="eye" size="sm" className="mr-2" />{t("settings.anilistPreviewButton")}</>
)}
</Button>
<Button onClick={handleSync} disabled={isSyncing}>
{isSyncing ? (
<><Icon name="spinner" size="sm" className="animate-spin mr-2" />{t("settings.anilistSyncing")}</>
) : (
<><Icon name="refresh" size="sm" className="mr-2" />{t("settings.anilistSyncButton")}</>
)}
</Button>
</div>
{syncReport && (
<div className="mt-2 border rounded-lg overflow-hidden">
<div className="px-4 py-2 bg-muted/50 flex items-center gap-3">
<span className="text-sm text-success font-medium">{t("settings.anilistSynced", { count: String(syncReport.synced) })}</span>
{syncReport.skipped > 0 && <span className="text-sm text-muted-foreground">{t("settings.anilistSkipped", { count: String(syncReport.skipped) })}</span>}
{syncReport.errors.length > 0 && <span className="text-sm text-destructive">{t("settings.anilistErrors", { count: String(syncReport.errors.length) })}</span>}
</div>
{syncReport.items.length > 0 && (
<div className="divide-y max-h-60 overflow-y-auto">
{syncReport.items.map((item: AnilistSyncItemDto) => (
<div key={item.series_name} className="flex items-center justify-between px-4 py-2 text-sm">
<a
href={item.anilist_url ?? `https://anilist.co/manga/`}
target="_blank"
rel="noopener noreferrer"
className="truncate font-medium hover:underline min-w-0 mr-3"
>
{item.anilist_title ?? item.series_name}
</a>
<div className="flex items-center gap-2 shrink-0">
<span className={`text-xs px-1.5 py-0.5 rounded-full font-medium ${
item.status === "COMPLETED" ? "bg-green-500/15 text-green-600" :
item.status === "CURRENT" ? "bg-blue-500/15 text-blue-600" :
"bg-muted text-muted-foreground"
}`}>{item.status}</span>
{item.progress_volumes > 0 && (
<span className="text-xs text-muted-foreground">{item.progress_volumes} vol.</span>
)}
</div>
</div>
))}
</div>
)}
{syncReport.errors.map((err: string, i: number) => (
<p key={i} className="text-xs text-destructive px-4 py-1 border-t">{err}</p>
))}
</div>
)}
</div>
<div className="space-y-2">
<p className="text-sm text-muted-foreground">{t("settings.anilistPullDesc")}</p>
<Button onClick={handlePull} disabled={isPulling}>
{isPulling ? (
<><Icon name="spinner" size="sm" className="animate-spin mr-2" />{t("settings.anilistPulling")}</>
) : (
<><Icon name="refresh" size="sm" className="mr-2" />{t("settings.anilistPullButton")}</>
)}
</Button>
{pullReport && (
<div className="mt-2 border rounded-lg overflow-hidden">
<div className="px-4 py-2 bg-muted/50 flex items-center gap-3">
<span className="text-sm text-success font-medium">{t("settings.anilistUpdated", { count: String(pullReport.updated) })}</span>
{pullReport.skipped > 0 && <span className="text-sm text-muted-foreground">{t("settings.anilistSkipped", { count: String(pullReport.skipped) })}</span>}
{pullReport.errors.length > 0 && <span className="text-sm text-destructive">{t("settings.anilistErrors", { count: String(pullReport.errors.length) })}</span>}
</div>
{pullReport.items.length > 0 && (
<div className="divide-y max-h-60 overflow-y-auto">
{pullReport.items.map((item: AnilistPullItemDto) => (
<div key={item.series_name} className="flex items-center justify-between px-4 py-2 text-sm">
<a
href={item.anilist_url ?? `https://anilist.co/manga/`}
target="_blank"
rel="noopener noreferrer"
className="truncate font-medium hover:underline min-w-0 mr-3"
>
{item.anilist_title ?? item.series_name}
</a>
<div className="flex items-center gap-2 shrink-0">
<span className={`text-xs px-1.5 py-0.5 rounded-full font-medium ${
item.anilist_status === "COMPLETED" ? "bg-green-500/15 text-green-600" :
item.anilist_status === "CURRENT" ? "bg-blue-500/15 text-blue-600" :
item.anilist_status === "PLANNING" ? "bg-amber-500/15 text-amber-600" :
"bg-muted text-muted-foreground"
}`}>{item.anilist_status}</span>
<span className="text-xs text-muted-foreground">{item.books_updated} {t("dashboard.books").toLowerCase()}</span>
</div>
</div>
))}
</div>
)}
{pullReport.errors.map((err: string, i: number) => (
<p key={i} className="text-xs text-destructive px-4 py-1 border-t">{err}</p>
))}
</div>
)}
</div>
</div>
{actionError && <p className="text-sm text-destructive">{actionError}</p>}
{previewItems !== null && (
<div className="mt-2 border rounded-lg overflow-hidden">
<div className="px-4 py-2 bg-muted/50 flex items-center justify-between">
<span className="text-sm font-medium">{t("settings.anilistPreviewTitle", { count: String(previewItems.length) })}</span>
<button onClick={() => setPreviewItems(null)} className="text-xs text-muted-foreground hover:text-foreground"></button>
</div>
{previewItems.length === 0 ? (
<p className="text-sm text-muted-foreground px-4 py-3">{t("settings.anilistPreviewEmpty")}</p>
) : (
<div className="divide-y">
{previewItems.map((item) => (
<div key={`${item.anilist_id}-${item.series_name}`} className="flex items-center justify-between px-4 py-2 text-sm">
<div className="flex items-center gap-2 min-w-0">
<a
href={item.anilist_url ?? `https://anilist.co/manga/${item.anilist_id}`}
target="_blank"
rel="noopener noreferrer"
className="truncate font-medium hover:underline"
>
{item.anilist_title ?? item.series_name}
</a>
{item.anilist_title && item.anilist_title !== item.series_name && (
<span className="text-muted-foreground truncate hidden sm:inline"> {item.series_name}</span>
)}
</div>
<div className="flex items-center gap-3 shrink-0 ml-3">
<span className="text-xs text-muted-foreground">{item.books_read}/{item.book_count}</span>
<span className={`text-[10px] px-1.5 py-0.5 rounded-full font-medium ${
item.status === "COMPLETED" ? "bg-success/15 text-success" :
item.status === "CURRENT" ? "bg-blue-500/15 text-blue-600" :
"bg-muted text-muted-foreground"
}`}>
{item.status}
</span>
</div>
</div>
))}
</div>
)}
</div>
)}
</CardContent>
</Card>
</>
);
}

View File

@@ -3,7 +3,8 @@ import SettingsPage from "./SettingsPage";
export const dynamic = "force-dynamic";
export default async function SettingsPageWrapper() {
export default async function SettingsPageWrapper({ searchParams }: { searchParams: Promise<{ tab?: string }> }) {
const { tab } = await searchParams;
const settings = await getSettings().catch(() => ({
image_processing: { format: "webp", quality: 85, filter: "lanczos3", max_width: 2160 },
cache: { enabled: true, directory: "/tmp/stripstream-image-cache", max_size_mb: 10000 },
@@ -25,5 +26,5 @@ export default async function SettingsPageWrapper() {
const users = await fetchUsers().catch(() => []);
return <SettingsPage initialSettings={settings} initialCacheStats={cacheStats} initialThumbnailStats={thumbnailStats} users={users} />;
return <SettingsPage initialSettings={settings} initialCacheStats={cacheStats} initialThumbnailStats={thumbnailStats} users={users} initialTab={tab} />;
}

View File

@@ -0,0 +1,20 @@
import { NextResponse, NextRequest } from "next/server";
import { apiFetch } from "@/lib/api";
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
try {
const { id } = await params;
const body = await request.json();
const data = await apiFetch(`/anilist/libraries/${id}`, {
method: "PATCH",
body: JSON.stringify(body),
});
return NextResponse.json(data);
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to update library AniList setting";
return NextResponse.json({ error: message }, { status: 500 });
}
}

View File

@@ -0,0 +1,12 @@
import { NextResponse } from "next/server";
import { apiFetch } from "@/lib/api";
export async function GET() {
try {
const data = await apiFetch("/anilist/links");
return NextResponse.json(data);
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to fetch AniList links";
return NextResponse.json({ error: message }, { status: 500 });
}
}

View File

@@ -0,0 +1,12 @@
import { NextResponse } from "next/server";
import { apiFetch } from "@/lib/api";
export async function POST() {
try {
const data = await apiFetch("/anilist/pull", { method: "POST", body: "{}" });
return NextResponse.json(data);
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to pull from AniList";
return NextResponse.json({ error: message }, { status: 500 });
}
}

View File

@@ -0,0 +1,16 @@
import { NextResponse, NextRequest } from "next/server";
import { apiFetch } from "@/lib/api";
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const data = await apiFetch("/anilist/search", {
method: "POST",
body: JSON.stringify(body),
});
return NextResponse.json(data);
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to search AniList";
return NextResponse.json({ error: message }, { status: 500 });
}
}

View File

@@ -0,0 +1,46 @@
import { NextResponse, NextRequest } from "next/server";
import { apiFetch } from "@/lib/api";
type Params = Promise<{ libraryId: string; seriesName: string }>;
export async function GET(request: NextRequest, { params }: { params: Params }) {
try {
const { libraryId, seriesName } = await params;
const data = await apiFetch(
`/anilist/series/${libraryId}/${encodeURIComponent(seriesName)}`,
);
return NextResponse.json(data);
} catch (error) {
const message = error instanceof Error ? error.message : "Not found";
return NextResponse.json({ error: message }, { status: 404 });
}
}
export async function POST(request: NextRequest, { params }: { params: Params }) {
try {
const { libraryId, seriesName } = await params;
const body = await request.json();
const data = await apiFetch(
`/anilist/series/${libraryId}/${encodeURIComponent(seriesName)}/link`,
{ method: "POST", body: JSON.stringify(body) },
);
return NextResponse.json(data);
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to link series";
return NextResponse.json({ error: message }, { status: 500 });
}
}
export async function DELETE(request: NextRequest, { params }: { params: Params }) {
try {
const { libraryId, seriesName } = await params;
const data = await apiFetch(
`/anilist/series/${libraryId}/${encodeURIComponent(seriesName)}/unlink`,
{ method: "DELETE" },
);
return NextResponse.json(data);
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to unlink series";
return NextResponse.json({ error: message }, { status: 500 });
}
}

View File

@@ -0,0 +1,12 @@
import { NextResponse } from "next/server";
import { apiFetch } from "@/lib/api";
export async function GET() {
try {
const data = await apiFetch("/anilist/status");
return NextResponse.json(data);
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to get AniList status";
return NextResponse.json({ error: message }, { status: 500 });
}
}

View File

@@ -0,0 +1,12 @@
import { NextResponse } from "next/server";
import { apiFetch } from "@/lib/api";
export async function GET() {
try {
const data = await apiFetch("/anilist/sync/preview");
return NextResponse.json(data);
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to preview sync";
return NextResponse.json({ error: message }, { status: 500 });
}
}

View File

@@ -0,0 +1,12 @@
import { NextResponse } from "next/server";
import { apiFetch } from "@/lib/api";
export async function POST() {
try {
const data = await apiFetch("/anilist/sync", { method: "POST", body: "{}" });
return NextResponse.json(data);
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to sync to AniList";
return NextResponse.json({ error: message }, { status: 500 });
}
}

View File

@@ -0,0 +1,12 @@
import { NextResponse } from "next/server";
import { apiFetch } from "@/lib/api";
export async function GET() {
try {
const data = await apiFetch("/anilist/unlinked");
return NextResponse.json(data);
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to fetch unlinked series";
return NextResponse.json({ error: message }, { status: 500 });
}
}

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>> = {

View File

@@ -14,6 +14,7 @@ export type LibraryDto = {
next_metadata_refresh_at: string | null;
series_count: number;
thumbnail_book_ids: string[];
reading_status_provider: string | null;
};
export type IndexJobDto = {
@@ -140,6 +141,83 @@ export type SeriesDto = {
series_status: string | null;
missing_count: number | null;
metadata_provider: string | null;
anilist_id: number | null;
anilist_url: string | null;
};
export type AnilistStatusDto = {
connected: boolean;
user_id: number;
username: string;
site_url: string;
};
export type AnilistMediaResultDto = {
id: number;
title_romaji: string | null;
title_english: string | null;
title_native: string | null;
site_url: string;
status: string | null;
volumes: number | null;
};
export type AnilistSeriesLinkDto = {
library_id: string;
series_name: string;
anilist_id: number;
anilist_title: string | null;
anilist_url: string | null;
status: string;
linked_at: string;
synced_at: string | null;
};
export type AnilistUnlinkedSeriesDto = {
library_id: string;
library_name: string;
series_name: string;
};
export type AnilistSyncPreviewItemDto = {
series_name: string;
anilist_id: number;
anilist_title: string | null;
anilist_url: string | null;
status: "PLANNING" | "CURRENT" | "COMPLETED";
progress_volumes: number;
books_read: number;
book_count: number;
};
export type AnilistSyncItemDto = {
series_name: string;
anilist_title: string | null;
anilist_url: string | null;
status: string;
progress_volumes: number;
};
export type AnilistSyncReportDto = {
synced: number;
skipped: number;
errors: string[];
items: AnilistSyncItemDto[];
};
export type AnilistPullItemDto = {
series_name: string;
anilist_title: string | null;
anilist_url: string | null;
anilist_status: string;
books_updated: number;
};
export type AnilistPullReportDto = {
updated: number;
skipped: number;
errors: string[];
items: AnilistPullItemDto[];
};
export function config() {
@@ -919,6 +997,12 @@ export async function getMetadataLink(libraryId: string, seriesName: string) {
return apiFetch<ExternalMetadataLinkDto[]>(`/metadata/links?${params.toString()}`);
}
export async function getReadingStatusLink(libraryId: string, seriesName: string) {
return apiFetch<AnilistSeriesLinkDto>(
`/anilist/series/${libraryId}/${encodeURIComponent(seriesName)}`
);
}
export async function getMissingBooks(linkId: string) {
return apiFetch<MissingBooksDto>(`/metadata/missing/${linkId}`);
}

View File

@@ -195,6 +195,23 @@ const en: Record<TranslationKey, string> = {
"libraryActions.metadataRefreshSchedule": "Auto-refresh",
"libraryActions.metadataRefreshDesc": "Periodically re-fetch metadata for existing series",
"libraryActions.saving": "Saving...",
"libraryActions.sectionReadingStatus": "Reading Status",
"libraryActions.readingStatusProvider": "Reading Status Provider",
"libraryActions.readingStatusProviderDesc": "Syncs reading states (read / reading / planned) with an external service",
// Reading status modal
"readingStatus.button": "Reading status",
"readingStatus.linkTo": "Link to {{provider}}",
"readingStatus.search": "Search",
"readingStatus.searching": "Searching…",
"readingStatus.searchPlaceholder": "Series title…",
"readingStatus.noResults": "No results.",
"readingStatus.link": "Link",
"readingStatus.unlink": "Unlink",
"readingStatus.changeLink": "Change",
"readingStatus.status.linked": "linked",
"readingStatus.status.synced": "synced",
"readingStatus.status.error": "error",
// Library sub-page header
"libraryHeader.libraries": "Libraries",
@@ -602,6 +619,59 @@ const en: Record<TranslationKey, string> = {
"settings.telegramHelpChat": "Send a message to your bot, then open <code>https://api.telegram.org/bot&lt;TOKEN&gt;/getUpdates</code> in your browser. The <b>chat id</b> is in <code>message.chat.id</code>.",
"settings.telegramHelpGroup": "For a group: add the bot to the group, send a message, then check the same URL. Group IDs are negative (e.g. <code>-123456789</code>).",
// Settings - AniList
"settings.anilist": "Reading status",
"settings.anilistTitle": "AniList Sync",
"settings.anilistDesc": "Sync your reading progress with AniList. Get a personal access token at anilist.co/settings/developer.",
"settings.anilistToken": "Personal Access Token",
"settings.anilistTokenPlaceholder": "AniList token...",
"settings.anilistUserId": "AniList User ID",
"settings.anilistUserIdPlaceholder": "Numeric (e.g. 123456)",
"settings.anilistConnected": "Connected as",
"settings.anilistNotConnected": "Not connected",
"settings.anilistTestConnection": "Test connection",
"settings.anilistLibraries": "Libraries",
"settings.anilistLibrariesDesc": "Enable AniList sync per library",
"settings.anilistEnabled": "AniList sync enabled",
"settings.anilistLocalUserTitle": "Local user",
"settings.anilistLocalUserDesc": "Select the local user whose reading progress is synced with this AniList account",
"settings.anilistLocalUserNone": "— Select a user —",
"settings.anilistSyncTitle": "Sync",
"settings.anilistSyncDesc": "Push local reading progress to AniList. Rules: none read → PLANNING · at least 1 read → CURRENT (progress = volumes read) · all published volumes read (total_volumes known) → COMPLETED.",
"settings.anilistSyncButton": "Sync to AniList",
"settings.anilistPullButton": "Pull from AniList",
"settings.anilistPullDesc": "Import your AniList reading list and update local reading progress. Rules: COMPLETED/CURRENT/REPEATING → books marked read up to the progress volume · PLANNING/PAUSED/DROPPED → unread.",
"settings.anilistSyncing": "Syncing...",
"settings.anilistPulling": "Pulling...",
"settings.anilistSynced": "{{count}} series synced",
"settings.anilistUpdated": "{{count}} series updated",
"settings.anilistSkipped": "{{count}} skipped",
"settings.anilistErrors": "{{count}} error(s)",
"settings.anilistLinks": "AniList Links",
"settings.anilistLinksDesc": "Series linked to AniList",
"settings.anilistNoLinks": "No series linked to AniList",
"settings.anilistUnlink": "Unlink",
"settings.anilistSyncStatus": "synced",
"settings.anilistLinkedStatus": "linked",
"settings.anilistErrorStatus": "error",
"settings.anilistUnlinkedTitle": "{{count}} unlinked series",
"settings.anilistUnlinkedDesc": "These series belong to AniList-enabled libraries but have no AniList link yet. Search each one to link it.",
"settings.anilistSearchButton": "Search",
"settings.anilistSearchNoResults": "No AniList results.",
"settings.anilistLinkButton": "Link",
"settings.anilistRedirectUrlLabel": "Redirect URL to configure in your AniList app:",
"settings.anilistRedirectUrlHint": "Paste this URL in the « Redirect URL » field of your application at anilist.co/settings/developer.",
"settings.anilistTokenPresent": "Token present — not verified",
"settings.anilistPreviewButton": "Preview",
"settings.anilistPreviewing": "Loading...",
"settings.anilistPreviewTitle": "{{count}} series to sync",
"settings.anilistPreviewEmpty": "No series to sync (link series to AniList first).",
"settings.anilistClientId": "AniList Client ID",
"settings.anilistClientIdPlaceholder": "E.g. 37777",
"settings.anilistConnectButton": "Connect with AniList",
"settings.anilistConnectDesc": "Use OAuth to connect automatically. Find your Client ID in your AniList apps (anilist.co/settings/developer).",
"settings.anilistManualToken": "Manual token (advanced)",
// Settings - Language
"settings.language": "Language",
"settings.languageDesc": "Choose the interface language",

View File

@@ -193,6 +193,23 @@ const fr = {
"libraryActions.metadataRefreshSchedule": "Rafraîchissement auto",
"libraryActions.metadataRefreshDesc": "Re-télécharger périodiquement les métadonnées existantes",
"libraryActions.saving": "Enregistrement...",
"libraryActions.sectionReadingStatus": "État de lecture",
"libraryActions.readingStatusProvider": "Provider d'état de lecture",
"libraryActions.readingStatusProviderDesc": "Synchronise les états de lecture (lu / en cours / planifié) avec un service externe",
// Reading status modal
"readingStatus.button": "État de lecture",
"readingStatus.linkTo": "Lier à {{provider}}",
"readingStatus.search": "Rechercher",
"readingStatus.searching": "Recherche en cours…",
"readingStatus.searchPlaceholder": "Titre de la série…",
"readingStatus.noResults": "Aucun résultat.",
"readingStatus.link": "Lier",
"readingStatus.unlink": "Délier",
"readingStatus.changeLink": "Changer",
"readingStatus.status.linked": "lié",
"readingStatus.status.synced": "synchronisé",
"readingStatus.status.error": "erreur",
// Library sub-page header
"libraryHeader.libraries": "Bibliothèques",
@@ -600,6 +617,59 @@ const fr = {
"settings.telegramHelpChat": "Envoyez un message à votre bot, puis ouvrez <code>https://api.telegram.org/bot&lt;TOKEN&gt;/getUpdates</code> dans votre navigateur. Le <b>chat id</b> apparaît dans <code>message.chat.id</code>.",
"settings.telegramHelpGroup": "Pour un groupe : ajoutez le bot au groupe, envoyez un message, puis consultez la même URL. Les IDs de groupe sont négatifs (ex: <code>-123456789</code>).",
// Settings - AniList
"settings.anilist": "État de lecture",
"settings.anilistTitle": "Synchronisation AniList",
"settings.anilistDesc": "Synchronisez votre progression de lecture avec AniList. Obtenez un token d'accès personnel sur anilist.co/settings/developer.",
"settings.anilistToken": "Token d'accès personnel",
"settings.anilistTokenPlaceholder": "Token AniList...",
"settings.anilistUserId": "ID utilisateur AniList",
"settings.anilistUserIdPlaceholder": "Numérique (ex: 123456)",
"settings.anilistConnected": "Connecté en tant que",
"settings.anilistNotConnected": "Non connecté",
"settings.anilistTestConnection": "Tester la connexion",
"settings.anilistLibraries": "Bibliothèques",
"settings.anilistLibrariesDesc": "Activer la synchronisation AniList pour chaque bibliothèque",
"settings.anilistEnabled": "Sync AniList activée",
"settings.anilistLocalUserTitle": "Utilisateur local",
"settings.anilistLocalUserDesc": "Choisir l'utilisateur local dont la progression est synchronisée avec ce compte AniList",
"settings.anilistLocalUserNone": "— Sélectionner un utilisateur —",
"settings.anilistSyncTitle": "Synchronisation",
"settings.anilistSyncDesc": "Envoyer la progression locale vers AniList. Règles : aucun lu → PLANNING · au moins 1 lu → CURRENT (progression = nbre de tomes lus) · tous les tomes publiés lus (total_volumes connu) → COMPLETED.",
"settings.anilistSyncButton": "Synchroniser vers AniList",
"settings.anilistPullButton": "Importer depuis AniList",
"settings.anilistPullDesc": "Importer votre liste de lecture AniList et mettre à jour la progression locale. Règles : COMPLETED/CURRENT/REPEATING → livres marqués lus jusqu'au volume de progression · PLANNING/PAUSED/DROPPED → non lus.",
"settings.anilistSyncing": "Synchronisation...",
"settings.anilistPulling": "Import...",
"settings.anilistSynced": "{{count}} série(s) synchronisée(s)",
"settings.anilistUpdated": "{{count}} série(s) mise(s) à jour",
"settings.anilistSkipped": "{{count}} ignorée(s)",
"settings.anilistErrors": "{{count}} erreur(s)",
"settings.anilistLinks": "Liens AniList",
"settings.anilistLinksDesc": "Séries associées à AniList",
"settings.anilistNoLinks": "Aucune série liée à AniList",
"settings.anilistUnlink": "Délier",
"settings.anilistSyncStatus": "synced",
"settings.anilistLinkedStatus": "linked",
"settings.anilistErrorStatus": "error",
"settings.anilistUnlinkedTitle": "{{count}} série(s) non liée(s)",
"settings.anilistUnlinkedDesc": "Ces séries appartiennent à des bibliothèques activées mais n'ont pas encore de lien AniList. Recherchez chacune pour la lier.",
"settings.anilistSearchButton": "Rechercher",
"settings.anilistSearchNoResults": "Aucun résultat AniList.",
"settings.anilistLinkButton": "Lier",
"settings.anilistRedirectUrlLabel": "URL de redirection à configurer dans votre app AniList :",
"settings.anilistRedirectUrlHint": "Collez cette URL dans le champ « Redirect URL » de votre application sur anilist.co/settings/developer.",
"settings.anilistTokenPresent": "Token présent — non vérifié",
"settings.anilistPreviewButton": "Prévisualiser",
"settings.anilistPreviewing": "Chargement...",
"settings.anilistPreviewTitle": "{{count}} série(s) à synchroniser",
"settings.anilistPreviewEmpty": "Aucune série à synchroniser (liez des séries à AniList d'abord).",
"settings.anilistClientId": "Client ID AniList",
"settings.anilistClientIdPlaceholder": "Ex: 37777",
"settings.anilistConnectButton": "Connecter avec AniList",
"settings.anilistConnectDesc": "Utilisez OAuth pour vous connecter automatiquement. Le Client ID se trouve dans vos applications AniList (anilist.co/settings/developer).",
"settings.anilistManualToken": "Token manuel (avancé)",
// Settings - Language
"settings.language": "Langue",
"settings.languageDesc": "Choisir la langue de l'interface",