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:
@@ -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>
|
||||
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user