From e5e4993e7b088f7f81da926a87e673836169b98b Mon Sep 17 00:00:00 2001 From: Froidefond Julien Date: Wed, 25 Mar 2026 13:15:43 +0100 Subject: [PATCH] refactor(settings): split SettingsPage into components, restructure tabs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract 7 sub-components into settings/components/ (AnilistTab, KomgaSyncCard, MetadataProvidersCard, StatusMappingsCard, ProwlarrCard, QBittorrentCard, TelegramCard) — SettingsPage.tsx: 2100 → 551 lines - Add "Metadata" tab (MetadataProviders + StatusMappings) - Rename "Integrations" → "Download Tools" (Prowlarr + qBittorrent) - Rename "AniList" → "Reading Status" tab; Komga sync as standalone card - Rename cards: "AniList Config" + "AniList Sync" - Persist active tab in URL searchParams (?tab=...) - Fix hydration mismatch on AniList redirect URL (window.location via useEffect) Co-Authored-By: Claude Sonnet 4.6 --- .../app/(app)/settings/SettingsPage.tsx | 1750 +---------------- .../(app)/settings/components/AnilistTab.tsx | 408 ++++ .../settings/components/KomgaSyncCard.tsx | 281 +++ .../components/MetadataProvidersCard.tsx | 170 ++ .../settings/components/ProwlarrCard.tsx | 134 ++ .../settings/components/QBittorrentCard.tsx | 125 ++ .../components/StatusMappingsCard.tsx | 228 +++ .../settings/components/TelegramCard.tsx | 252 +++ apps/backoffice/lib/i18n/en.ts | 7 +- apps/backoffice/lib/i18n/fr.ts | 7 +- 10 files changed, 1675 insertions(+), 1687 deletions(-) create mode 100644 apps/backoffice/app/(app)/settings/components/AnilistTab.tsx create mode 100644 apps/backoffice/app/(app)/settings/components/KomgaSyncCard.tsx create mode 100644 apps/backoffice/app/(app)/settings/components/MetadataProvidersCard.tsx create mode 100644 apps/backoffice/app/(app)/settings/components/ProwlarrCard.tsx create mode 100644 apps/backoffice/app/(app)/settings/components/QBittorrentCard.tsx create mode 100644 apps/backoffice/app/(app)/settings/components/StatusMappingsCard.tsx create mode 100644 apps/backoffice/app/(app)/settings/components/TelegramCard.tsx diff --git a/apps/backoffice/app/(app)/settings/SettingsPage.tsx b/apps/backoffice/app/(app)/settings/SettingsPage.tsx index fad1d73..d1eb024 100644 --- a/apps/backoffice/app/(app)/settings/SettingsPage.tsx +++ b/apps/backoffice/app/(app)/settings/SettingsPage.tsx @@ -1,11 +1,18 @@ "use client"; -import { useState, useEffect, useCallback, useMemo } from "react"; +import { useState } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; 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, AnilistStatusDto, AnilistSyncReportDto, AnilistPullReportDto, AnilistSyncPreviewItemDto, AnilistSyncItemDto, AnilistPullItemDto } from "@/lib/api"; +import { Settings, CacheStats, ClearCacheResponse, ThumbnailStats, UserDto } from "@/lib/api"; import { useTranslation } from "@/lib/i18n/context"; import type { Locale } from "@/lib/i18n/types"; +import { MetadataProvidersCard } from "./components/MetadataProvidersCard"; +import { StatusMappingsCard } from "./components/StatusMappingsCard"; +import { ProwlarrCard } from "./components/ProwlarrCard"; +import { QBittorrentCard } from "./components/QBittorrentCard"; +import { TelegramCard } from "./components/TelegramCard"; +import { KomgaSyncCard } from "./components/KomgaSyncCard"; +import { AnilistTab } from "./components/AnilistTab"; interface SettingsPageProps { initialSettings: Settings; @@ -17,6 +24,9 @@ interface SettingsPageProps { export default function SettingsPage({ initialSettings, initialCacheStats, initialThumbnailStats, users, initialTab }: SettingsPageProps) { const { t, locale, setLocale } = useTranslation(); + const router = useRouter(); + const searchParams = useSearchParams(); + const [settings, setSettings] = useState({ ...initialSettings, thumbnail: initialSettings.thumbnail || { enabled: true, width: 300, height: 400, quality: 80, format: "webp", directory: "/data/thumbnails" } @@ -27,30 +37,24 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi const [clearResult, setClearResult] = useState(null); const [isSaving, setIsSaving] = useState(false); const [saveMessage, setSaveMessage] = useState(null); - // Komga sync state — URL and username are persisted in settings - const [komgaUrl, setKomgaUrl] = useState(""); - const [komgaUsername, setKomgaUsername] = useState(""); - const [komgaPassword, setKomgaPassword] = useState(""); - const [komgaUserId, setKomgaUserId] = useState(users[0]?.id ?? ""); - const [isSyncing, setIsSyncing] = useState(false); - const [syncResult, setSyncResult] = useState(null); - const [syncError, setSyncError] = useState(null); - const [showUnmatched, setShowUnmatched] = useState(false); - const [reports, setReports] = useState([]); - const [selectedReport, setSelectedReport] = useState(null); - const [showReportUnmatched, setShowReportUnmatched] = useState(false); - const [showMatchedBooks, setShowMatchedBooks] = useState(false); - const [showReportMatchedBooks, setShowReportMatchedBooks] = useState(false); - const syncNewlyMarkedSet = useMemo( - () => new Set(syncResult?.newly_marked_books ?? []), - [syncResult?.newly_marked_books], - ); - const reportNewlyMarkedSet = useMemo( - () => new Set(selectedReport?.newly_marked_books ?? []), - [selectedReport?.newly_marked_books], + const VALID_TABS = ["general", "downloadTools", "metadata", "readingStatus", "notifications"] as const; + type TabId = typeof VALID_TABS[number]; + + function resolveTab(tab: string | null | undefined): TabId { + if (tab && (VALID_TABS as readonly string[]).includes(tab)) return tab as TabId; + return "general"; + } + + const [activeTab, setActiveTab] = useState( + resolveTab(searchParams.get("tab") ?? initialTab) ); + function handleTabChange(tab: TabId) { + setActiveTab(tab); + router.replace(`?tab=${tab}`, { scroll: false }); + } + async function handleUpdateSetting(key: string, value: unknown) { setIsSaving(true); setSaveMessage(null); @@ -66,7 +70,7 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi } else { setSaveMessage(t("settings.savedError")); } - } catch (error) { + } catch { setSaveMessage(t("settings.saveError")); } finally { setIsSaving(false); @@ -86,82 +90,18 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi const stats = await statsResponse.json(); setCacheStats(stats); } - } catch (error) { + } catch { setClearResult({ success: false, message: t("settings.cacheClearError") }); } finally { setIsClearing(false); } } - const fetchReports = useCallback(async () => { - try { - const resp = await fetch("/api/komga/reports"); - if (resp.ok) setReports(await resp.json()); - } catch { /* ignore */ } - }, []); - - useEffect(() => { - fetchReports(); - // Load saved Komga credentials (URL + username only) - fetch("/api/settings/komga").then(r => r.ok ? r.json() : null).then(data => { - if (data) { - if (data.url) setKomgaUrl(data.url); - if (data.username) setKomgaUsername(data.username); - if (data.user_id) setKomgaUserId(data.user_id); - } - }).catch(() => {}); - }, [fetchReports]); - - async function handleViewReport(id: string) { - setSelectedReport(null); - setShowReportUnmatched(false); - setShowReportMatchedBooks(false); - try { - const resp = await fetch(`/api/komga/reports/${id}`); - if (resp.ok) setSelectedReport(await resp.json()); - } catch { /* ignore */ } - } - - async function handleKomgaSync() { - setIsSyncing(true); - setSyncResult(null); - setSyncError(null); - setShowUnmatched(false); - setShowMatchedBooks(false); - try { - const response = await fetch("/api/komga/sync", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ url: komgaUrl, username: komgaUsername, password: komgaPassword, user_id: komgaUserId }), - }); - const data = await response.json(); - if (!response.ok) { - setSyncError(data.error || "Sync failed"); - } else { - setSyncResult(data); - fetchReports(); - // Persist URL and username (not password) - fetch("/api/settings/komga", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ value: { url: komgaUrl, username: komgaUsername, user_id: komgaUserId } }), - }).catch(() => {}); - } - } catch { - setSyncError("Failed to connect to sync endpoint"); - } finally { - setIsSyncing(false); - } - } - - 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: "downloadTools" as const, label: t("settings.downloadTools"), icon: "play" as const }, + { id: "metadata" as const, label: t("settings.metadata"), icon: "tag" as const }, + { id: "readingStatus" as const, label: t("settings.readingStatus"), icon: "eye" as const }, { id: "notifications" as const, label: t("settings.notifications"), icon: "bell" as const }, ]; @@ -179,7 +119,7 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi {tabs.map((tab) => ( - - {syncError && ( -
- {syncError} -
- )} - - {syncResult && ( -
-
-
-

{t("settings.komgaRead")}

-

{syncResult.total_komga_read}

-
-
-

{t("settings.matched")}

-

{syncResult.matched}

-
-
-

{t("settings.alreadyRead")}

-

{syncResult.already_read}

-
-
-

{t("settings.newlyMarked")}

-

{syncResult.newly_marked}

-
-
- - {syncResult.matched_books.length > 0 && ( -
- - {showMatchedBooks && ( -
- {syncResult.matched_books.map((title, i) => ( -

- {syncNewlyMarkedSet.has(title) && ( - - )} - {title} -

- ))} -
- )} -
- )} - - {syncResult.unmatched.length > 0 && ( -
- - {showUnmatched && ( -
- {syncResult.unmatched.map((title, i) => ( -

{title}

- ))} -
- )} -
- )} -
- )} - {/* Past reports */} - {reports.length > 0 && ( -
-

{t("settings.syncHistory")}

-
- {reports.map((r) => ( - - ))} -
- - {/* Selected report detail */} - {selectedReport && ( -
-
-
-

{t("settings.komgaRead")}

-

{selectedReport.total_komga_read}

-
-
-

{t("settings.matched")}

-

{selectedReport.matched}

-
-
-

{t("settings.alreadyRead")}

-

{selectedReport.already_read}

-
-
-

{t("settings.newlyMarked")}

-

{selectedReport.newly_marked}

-
-
- - {selectedReport.matched_books && selectedReport.matched_books.length > 0 && ( -
- - {showReportMatchedBooks && ( -
- {selectedReport.matched_books.map((title, i) => ( -

- {reportNewlyMarkedSet.has(title) && ( - - )} - {title} -

- ))} -
- )} -
- )} - - {selectedReport.unmatched.length > 0 && ( -
- - {showReportUnmatched && ( -
- {selectedReport.unmatched.map((title, i) => ( -

{title}

- ))} -
- )} -
- )} -
- )} -
- )} - - - )} {activeTab === "notifications" && (<> @@ -853,1315 +542,10 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi )} - {activeTab === "anilist" && ( + {activeTab === "readingStatus" && (<> - )} - - ); -} - -// --------------------------------------------------------------------------- -// Metadata Providers sub-component -// --------------------------------------------------------------------------- - -const METADATA_LANGUAGES = [ - { value: "en", label: "English" }, - { value: "fr", label: "Français" }, - { value: "es", label: "Español" }, -] as const; - -function MetadataProvidersCard({ handleUpdateSetting }: { handleUpdateSetting: (key: string, value: unknown) => Promise }) { - const { t } = useTranslation(); - const [defaultProvider, setDefaultProvider] = useState("google_books"); - const [metadataLanguage, setMetadataLanguage] = useState("en"); - const [apiKeys, setApiKeys] = useState>({}); - - useEffect(() => { - fetch("/api/settings/metadata_providers") - .then((r) => (r.ok ? r.json() : null)) - .then((data) => { - if (data) { - if (data.default_provider) setDefaultProvider(data.default_provider); - if (data.metadata_language) setMetadataLanguage(data.metadata_language); - if (data.comicvine?.api_key) setApiKeys((prev) => ({ ...prev, comicvine: data.comicvine.api_key })); - if (data.google_books?.api_key) setApiKeys((prev) => ({ ...prev, google_books: data.google_books.api_key })); - } - }) - .catch(() => {}); - }, []); - - function save(provider: string, lang: string, keys: Record) { - const value: Record = { - default_provider: provider, - metadata_language: lang, - }; - for (const [k, v] of Object.entries(keys)) { - if (v) value[k] = { api_key: v }; - } - handleUpdateSetting("metadata_providers", value); - } - - return ( - - - - - {t("settings.metadataProviders")} - - {t("settings.metadataProvidersDesc")} - - -
- {/* Default provider */} -
- -
- {([ - { value: "google_books", label: "Google Books" }, - { value: "open_library", label: "Open Library" }, - { value: "comicvine", label: "ComicVine" }, - { value: "anilist", label: "AniList" }, - { value: "bedetheque", label: "Bédéthèque" }, - ] as const).map((p) => ( - - ))} -
-

{t("settings.defaultProviderHelp")}

-
- - {/* Metadata language */} -
- -
- {METADATA_LANGUAGES.map((l) => ( - - ))} -
-

{t("settings.metadataLanguageHelp")}

-
- - {/* Provider API keys — always visible */} -
-

{t("settings.apiKeys")}

-
- - - setApiKeys({ ...apiKeys, google_books: e.target.value })} - onBlur={() => save(defaultProvider, metadataLanguage, apiKeys)} - /> -

{t("settings.googleBooksHelp")}

-
- - - - setApiKeys({ ...apiKeys, comicvine: e.target.value })} - onBlur={() => save(defaultProvider, metadataLanguage, apiKeys)} - /> -

{t("settings.comicvineHelp")} comicvine.gamespot.com/api.

-
- -
-
- - Open Library -
- , -
- - AniList -
- {t("common.and")} -
- - Bédéthèque -
- {t("settings.freeProviders")} -
-
-
-
-
-
- ); -} - -// --------------------------------------------------------------------------- -// Status Mappings sub-component -// --------------------------------------------------------------------------- - -function StatusMappingsCard() { - const { t } = useTranslation(); - const [mappings, setMappings] = useState([]); - const [targetStatuses, setTargetStatuses] = useState([]); - const [providerStatuses, setProviderStatuses] = useState([]); - const [newTargetName, setNewTargetName] = useState(""); - const [loading, setLoading] = useState(true); - - const loadData = useCallback(async () => { - try { - const [mRes, sRes, pRes] = await Promise.all([ - fetch("/api/settings/status-mappings").then((r) => r.ok ? r.json() : []), - fetch("/api/series/statuses").then((r) => r.ok ? r.json() : []), - fetch("/api/series/provider-statuses").then((r) => r.ok ? r.json() : []), - ]); - setMappings(mRes); - setTargetStatuses(sRes); - setProviderStatuses(pRes); - } catch { - // ignore - } finally { - setLoading(false); - } - }, []); - - useEffect(() => { loadData(); }, [loadData]); - - // Group mappings by target status (only those with a non-null mapped_status) - const grouped = useMemo(() => { - const map = new Map(); - for (const m of mappings) { - if (m.mapped_status) { - const list = map.get(m.mapped_status) || []; - list.push(m); - map.set(m.mapped_status, list); - } - } - return map; - }, [mappings]); - - // Unmapped = mappings with null mapped_status + provider statuses not in status_mappings at all - const knownProviderStatuses = useMemo( - () => new Set(mappings.map((m) => m.provider_status)), - [mappings], - ); - const unmappedMappings = useMemo( - () => mappings.filter((m) => !m.mapped_status), - [mappings], - ); - const newProviderStatuses = useMemo( - () => providerStatuses.filter((ps) => !knownProviderStatuses.has(ps)), - [providerStatuses, knownProviderStatuses], - ); - - // All possible targets = existing statuses from DB + custom ones added locally - const [customTargets, setCustomTargets] = useState([]); - const allTargets = useMemo(() => { - const set = new Set([...targetStatuses, ...customTargets]); - return [...set].sort(); - }, [targetStatuses, customTargets]); - - async function handleAssign(providerStatus: string, targetStatus: string) { - if (!providerStatus || !targetStatus) return; - try { - const res = await fetch("/api/settings/status-mappings", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ provider_status: providerStatus, mapped_status: targetStatus }), - }); - if (res.ok) { - const created: StatusMappingDto = await res.json(); - setMappings((prev) => [...prev.filter((m) => m.provider_status !== created.provider_status), created]); - } - } catch { - // ignore - } - } - - async function handleUnmap(id: string) { - try { - const res = await fetch(`/api/settings/status-mappings/${id}`, { method: "DELETE" }); - if (res.ok) { - const updated: StatusMappingDto = await res.json(); - setMappings((prev) => prev.map((m) => (m.id === id ? updated : m))); - } - } catch { - // ignore - } - } - - function handleCreateTarget() { - const name = newTargetName.trim().toLowerCase(); - if (!name || allTargets.includes(name)) return; - setCustomTargets((prev) => [...prev, name]); - setNewTargetName(""); - } - - function statusLabel(status: string) { - const key = `seriesStatus.${status}` as Parameters[0]; - const translated = t(key); - return translated !== key ? translated : status; - } - - if (loading) { - return ( - -

{t("common.loading")}

-
- ); - } - - return ( - - - - - {t("settings.statusMappings")} - - {t("settings.statusMappingsDesc")} - - -
- {/* Create new target status */} -
- setNewTargetName(e.target.value)} - onKeyDown={(e) => { if (e.key === "Enter") handleCreateTarget(); }} - className="max-w-[250px]" - /> - -
- - {/* Grouped by target status */} - {allTargets.map((target) => { - const items = grouped.get(target) || []; - return ( -
-
- - {statusLabel(target)} - - ({target}) -
-
- {items.map((m) => ( - - {m.provider_status} - - - ))} - {items.length === 0 && ( - {t("settings.noMappings")} - )} -
-
- ); - })} - - {/* Unmapped provider statuses (null mapped_status + brand new from providers) */} - {(unmappedMappings.length > 0 || newProviderStatuses.length > 0) && ( -
-

{t("settings.unmappedSection")}

-
- {unmappedMappings.map((m) => ( -
- {m.provider_status} - - { if (e.target.value) handleAssign(m.provider_status, e.target.value); }} - > - - {allTargets.map((s) => ( - - ))} - -
- ))} - {newProviderStatuses.map((ps) => ( -
- {ps} - - { if (e.target.value) handleAssign(ps, e.target.value); }} - > - - {allTargets.map((s) => ( - - ))} - -
- ))} -
-
- )} -
-
-
- ); -} - -// --------------------------------------------------------------------------- -// Prowlarr sub-component -// --------------------------------------------------------------------------- - -function ProwlarrCard({ handleUpdateSetting }: { handleUpdateSetting: (key: string, value: unknown) => Promise }) { - const { t } = useTranslation(); - const [prowlarrUrl, setProwlarrUrl] = useState(""); - const [prowlarrApiKey, setProwlarrApiKey] = useState(""); - const [prowlarrCategories, setProwlarrCategories] = useState("7030, 7020"); - const [isTesting, setIsTesting] = useState(false); - const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null); - - useEffect(() => { - fetch("/api/settings/prowlarr") - .then((r) => (r.ok ? r.json() : null)) - .then((data) => { - if (data) { - if (data.url) setProwlarrUrl(data.url); - if (data.api_key) setProwlarrApiKey(data.api_key); - if (data.categories) setProwlarrCategories(data.categories.join(", ")); - } - }) - .catch(() => {}); - }, []); - - function saveProwlarr(url?: string, apiKey?: string, cats?: string) { - const categories = (cats ?? prowlarrCategories) - .split(",") - .map((s) => parseInt(s.trim())) - .filter((n) => !isNaN(n)); - handleUpdateSetting("prowlarr", { - url: url ?? prowlarrUrl, - api_key: apiKey ?? prowlarrApiKey, - categories, - }); - } - - async function handleTestConnection() { - setIsTesting(true); - setTestResult(null); - try { - const resp = await fetch("/api/prowlarr/test"); - const data = await resp.json(); - if (data.error) { - setTestResult({ success: false, message: data.error }); - } else { - setTestResult(data); - } - } catch { - setTestResult({ success: false, message: "Failed to connect" }); - } finally { - setIsTesting(false); - } - } - - return ( - - - - - {t("settings.prowlarr")} - - {t("settings.prowlarrDesc")} - - -
- - - - setProwlarrUrl(e.target.value)} - onBlur={() => saveProwlarr()} - /> - - - - - - setProwlarrApiKey(e.target.value)} - onBlur={() => saveProwlarr()} - /> - - - - - - setProwlarrCategories(e.target.value)} - onBlur={() => saveProwlarr()} - /> -

{t("settings.prowlarrCategoriesHelp")}

-
-
- -
- - {testResult && ( - - {testResult.message} - - )} -
-
-
-
- ); -} - -// --------------------------------------------------------------------------- -// qBittorrent sub-component -// --------------------------------------------------------------------------- - -function QBittorrentCard({ handleUpdateSetting }: { handleUpdateSetting: (key: string, value: unknown) => Promise }) { - const { t } = useTranslation(); - const [qbUrl, setQbUrl] = useState(""); - const [qbUsername, setQbUsername] = useState(""); - const [qbPassword, setQbPassword] = useState(""); - const [isTesting, setIsTesting] = useState(false); - const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null); - - useEffect(() => { - fetch("/api/settings/qbittorrent") - .then((r) => (r.ok ? r.json() : null)) - .then((data) => { - if (data) { - if (data.url) setQbUrl(data.url); - if (data.username) setQbUsername(data.username); - if (data.password) setQbPassword(data.password); - } - }) - .catch(() => {}); - }, []); - - function saveQbittorrent() { - handleUpdateSetting("qbittorrent", { - url: qbUrl, - username: qbUsername, - password: qbPassword, - }); - } - - async function handleTestConnection() { - setIsTesting(true); - setTestResult(null); - try { - const resp = await fetch("/api/qbittorrent/test"); - const data = await resp.json(); - if (data.error) { - setTestResult({ success: false, message: data.error }); - } else { - setTestResult(data); - } - } catch { - setTestResult({ success: false, message: "Failed to connect" }); - } finally { - setIsTesting(false); - } - } - - return ( - - - - - {t("settings.qbittorrent")} - - {t("settings.qbittorrentDesc")} - - -
- - - - setQbUrl(e.target.value)} - onBlur={() => saveQbittorrent()} - /> - - - - - - setQbUsername(e.target.value)} - onBlur={() => saveQbittorrent()} - /> - - - - setQbPassword(e.target.value)} - onBlur={() => saveQbittorrent()} - /> - - - -
- - {testResult && ( - - {testResult.message} - - )} -
-
-
-
- ); -} - -// --------------------------------------------------------------------------- -// Telegram Notifications sub-component -// --------------------------------------------------------------------------- - -const DEFAULT_EVENTS = { - scan_completed: true, - scan_failed: true, - scan_cancelled: true, - thumbnail_completed: true, - thumbnail_failed: true, - conversion_completed: true, - conversion_failed: true, - metadata_approved: true, - metadata_batch_completed: true, - metadata_batch_failed: true, - metadata_refresh_completed: true, - metadata_refresh_failed: true, -}; - -function TelegramCard({ handleUpdateSetting }: { handleUpdateSetting: (key: string, value: unknown) => Promise }) { - const { t } = useTranslation(); - const [botToken, setBotToken] = useState(""); - const [chatId, setChatId] = useState(""); - const [enabled, setEnabled] = useState(false); - const [events, setEvents] = useState(DEFAULT_EVENTS); - const [isTesting, setIsTesting] = useState(false); - const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null); - const [showHelp, setShowHelp] = useState(false); - - useEffect(() => { - fetch("/api/settings/telegram") - .then((r) => (r.ok ? r.json() : null)) - .then((data) => { - if (data) { - if (data.bot_token) setBotToken(data.bot_token); - if (data.chat_id) setChatId(data.chat_id); - if (data.enabled !== undefined) setEnabled(data.enabled); - if (data.events) setEvents({ ...DEFAULT_EVENTS, ...data.events }); - } - }) - .catch(() => {}); - }, []); - - function saveTelegram(token?: string, chat?: string, en?: boolean, ev?: typeof events) { - handleUpdateSetting("telegram", { - bot_token: token ?? botToken, - chat_id: chat ?? chatId, - enabled: en ?? enabled, - events: ev ?? events, - }); - } - - async function handleTestConnection() { - setIsTesting(true); - setTestResult(null); - try { - const resp = await fetch("/api/telegram/test"); - const data = await resp.json(); - if (data.error) { - setTestResult({ success: false, message: data.error }); - } else { - setTestResult(data); - } - } catch { - setTestResult({ success: false, message: "Failed to connect" }); - } finally { - setIsTesting(false); - } - } - - return ( - - - - - {t("settings.telegram")} - - {t("settings.telegramDesc")} - - -
- {/* Setup guide */} -
- - {showHelp && ( -
-
-

1. Bot Token

-

-

-
-

2. Chat ID

-

-

-
-

3. Group chat

-

-

-
- )} -
- -
- - {t("settings.telegramEnabled")} -
- - - - - setBotToken(e.target.value)} - onBlur={() => saveTelegram()} - /> - - - - - - setChatId(e.target.value)} - onBlur={() => saveTelegram()} - /> - - - - {/* Event toggles grouped by category */} -
-

{t("settings.telegramEvents")}

-
- {([ - { - category: t("settings.eventCategoryScan"), - icon: "search" as const, - items: [ - { key: "scan_completed" as const, label: t("settings.eventCompleted") }, - { key: "scan_failed" as const, label: t("settings.eventFailed") }, - { key: "scan_cancelled" as const, label: t("settings.eventCancelled") }, - ], - }, - { - category: t("settings.eventCategoryThumbnail"), - icon: "image" as const, - items: [ - { key: "thumbnail_completed" as const, label: t("settings.eventCompleted") }, - { key: "thumbnail_failed" as const, label: t("settings.eventFailed") }, - ], - }, - { - category: t("settings.eventCategoryConversion"), - icon: "refresh" as const, - items: [ - { key: "conversion_completed" as const, label: t("settings.eventCompleted") }, - { key: "conversion_failed" as const, label: t("settings.eventFailed") }, - ], - }, - { - category: t("settings.eventCategoryMetadata"), - icon: "tag" as const, - items: [ - { key: "metadata_approved" as const, label: t("settings.eventLinked") }, - { key: "metadata_batch_completed" as const, label: t("settings.eventBatchCompleted") }, - { key: "metadata_batch_failed" as const, label: t("settings.eventBatchFailed") }, - { key: "metadata_refresh_completed" as const, label: t("settings.eventRefreshCompleted") }, - { key: "metadata_refresh_failed" as const, label: t("settings.eventRefreshFailed") }, - ], - }, - ]).map(({ category, icon, items }) => ( -
-

- - {category} -

-
- {items.map(({ key, label }) => ( -
- ))} -
-
- -
- - {testResult && ( - - {testResult.message} - - )} -
-
- - - ); -} - -// --------------------------------------------------------------------------- -// AniList sub-component -// --------------------------------------------------------------------------- - -function AnilistTab({ - handleUpdateSetting, - users, -}: { - handleUpdateSetting: (key: string, value: unknown) => Promise; - 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(null); - const [testError, setTestError] = useState(null); - - const [isSyncing, setIsSyncing] = useState(false); - const [syncReport, setSyncReport] = useState(null); - const [isPulling, setIsPulling] = useState(false); - const [pullReport, setPullReport] = useState(null); - const [actionError, setActionError] = useState(null); - const [isPreviewing, setIsPreviewing] = useState(false); - const [previewItems, setPreviewItems] = useState(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 ( - <> - - - - - {t("settings.anilistTitle")} - - {t("settings.anilistDesc")} - - -

{t("settings.anilistConnectDesc")}

- {/* Redirect URL info */} -
-

{t("settings.anilistRedirectUrlLabel")}

- {typeof window !== "undefined" ? `${window.location.origin}/anilist/callback` : "/anilist/callback"} -

{t("settings.anilistRedirectUrlHint")}

-
- - - - setClientId(e.target.value)} - placeholder={t("settings.anilistClientIdPlaceholder")} - /> - - -
- - - {viewer && ( - - {t("settings.anilistConnected")} {viewer.username} - {" · "} - AniList - - )} - {token && !viewer && ( - {t("settings.anilistTokenPresent")} - )} - {testError && {testError}} -
-
- - {t("settings.anilistManualToken")} - -
- - - - setToken(e.target.value)} - placeholder={t("settings.anilistTokenPlaceholder")} - /> - - - - setUserId(e.target.value)} - placeholder={t("settings.anilistUserIdPlaceholder")} - /> - - - -
-
-
-

{t("settings.anilistLocalUserTitle")}

-

{t("settings.anilistLocalUserDesc")}

-
- - -
-
-
-
- - - - - - {t("settings.anilistSyncTitle")} - - - -
-
-

{t("settings.anilistSyncDesc")}

-
- - -
- {syncReport && ( -
-
- {t("settings.anilistSynced", { count: String(syncReport.synced) })} - {syncReport.skipped > 0 && {t("settings.anilistSkipped", { count: String(syncReport.skipped) })}} - {syncReport.errors.length > 0 && {t("settings.anilistErrors", { count: String(syncReport.errors.length) })}} -
- {syncReport.items.length > 0 && ( -
- {syncReport.items.map((item: AnilistSyncItemDto) => ( -
- - {item.anilist_title ?? item.series_name} - -
- {item.status} - {item.progress_volumes > 0 && ( - {item.progress_volumes} vol. - )} -
-
- ))} -
- )} - {syncReport.errors.map((err: string, i: number) => ( -

{err}

- ))} -
- )} -
-
-

{t("settings.anilistPullDesc")}

- - {pullReport && ( -
-
- {t("settings.anilistUpdated", { count: String(pullReport.updated) })} - {pullReport.skipped > 0 && {t("settings.anilistSkipped", { count: String(pullReport.skipped) })}} - {pullReport.errors.length > 0 && {t("settings.anilistErrors", { count: String(pullReport.errors.length) })}} -
- {pullReport.items.length > 0 && ( -
- {pullReport.items.map((item: AnilistPullItemDto) => ( -
- - {item.anilist_title ?? item.series_name} - -
- {item.anilist_status} - {item.books_updated} {t("dashboard.books").toLowerCase()} -
-
- ))} -
- )} - {pullReport.errors.map((err: string, i: number) => ( -

{err}

- ))} -
- )} -
-
- {actionError &&

{actionError}

} - {previewItems !== null && ( -
-
- {t("settings.anilistPreviewTitle", { count: String(previewItems.length) })} - -
- {previewItems.length === 0 ? ( -

{t("settings.anilistPreviewEmpty")}

- ) : ( -
- {previewItems.map((item) => ( -
-
- - {item.anilist_title ?? item.series_name} - - {item.anilist_title && item.anilist_title !== item.series_name && ( - — {item.series_name} - )} -
-
- {item.books_read}/{item.book_count} - - {item.status} - -
-
- ))} -
- )} -
- )} -
-
- + + )} ); } diff --git a/apps/backoffice/app/(app)/settings/components/AnilistTab.tsx b/apps/backoffice/app/(app)/settings/components/AnilistTab.tsx new file mode 100644 index 0000000..f9a1133 --- /dev/null +++ b/apps/backoffice/app/(app)/settings/components/AnilistTab.tsx @@ -0,0 +1,408 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Card, CardHeader, CardTitle, CardDescription, CardContent, Button, FormField, FormInput, Icon } from "@/app/components/ui"; +import { UserDto, AnilistStatusDto, AnilistSyncReportDto, AnilistPullReportDto, AnilistSyncPreviewItemDto, AnilistSyncItemDto, AnilistPullItemDto } from "@/lib/api"; +import { useTranslation } from "@/lib/i18n/context"; + +export function AnilistTab({ + handleUpdateSetting, + users, +}: { + handleUpdateSetting: (key: string, value: unknown) => Promise; + users: UserDto[]; +}) { + const { t } = useTranslation(); + + const [origin, setOrigin] = useState(""); + useEffect(() => { setOrigin(window.location.origin); }, []); + + 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(null); + const [testError, setTestError] = useState(null); + + const [isSyncing, setIsSyncing] = useState(false); + const [syncReport, setSyncReport] = useState(null); + const [isPulling, setIsPulling] = useState(false); + const [pullReport, setPullReport] = useState(null); + const [actionError, setActionError] = useState(null); + const [isPreviewing, setIsPreviewing] = useState(false); + const [previewItems, setPreviewItems] = useState(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 ( + <> + + + + + {t("settings.anilistTitle")} + + {t("settings.anilistDesc")} + + +

{t("settings.anilistConnectDesc")}

+ {/* Redirect URL info */} +
+

{t("settings.anilistRedirectUrlLabel")}

+ {origin ? `${origin}/anilist/callback` : "/anilist/callback"} +

{t("settings.anilistRedirectUrlHint")}

+
+
+ + + setClientId(e.target.value)} + placeholder={t("settings.anilistClientIdPlaceholder")} + /> + +
+
+ + + {viewer && ( + + {t("settings.anilistConnected")} {viewer.username} + {" · "} + AniList + + )} + {token && !viewer && ( + {t("settings.anilistTokenPresent")} + )} + {testError && {testError}} +
+
+ + {t("settings.anilistManualToken")} + +
+
+ + + setToken(e.target.value)} + placeholder={t("settings.anilistTokenPlaceholder")} + /> + + + + setUserId(e.target.value)} + placeholder={t("settings.anilistUserIdPlaceholder")} + /> + +
+ +
+
+
+

{t("settings.anilistLocalUserTitle")}

+

{t("settings.anilistLocalUserDesc")}

+
+ + +
+
+
+
+ + + + + + {t("settings.anilistSyncTitle")} + + + +
+
+

{t("settings.anilistSyncDesc")}

+
+ + +
+ {syncReport && ( +
+
+ {t("settings.anilistSynced", { count: String(syncReport.synced) })} + {syncReport.skipped > 0 && {t("settings.anilistSkipped", { count: String(syncReport.skipped) })}} + {syncReport.errors.length > 0 && {t("settings.anilistErrors", { count: String(syncReport.errors.length) })}} +
+ {syncReport.items.length > 0 && ( +
+ {syncReport.items.map((item: AnilistSyncItemDto) => ( +
+ + {item.anilist_title ?? item.series_name} + +
+ {item.status} + {item.progress_volumes > 0 && ( + {item.progress_volumes} vol. + )} +
+
+ ))} +
+ )} + {syncReport.errors.map((err: string, i: number) => ( +

{err}

+ ))} +
+ )} +
+
+

{t("settings.anilistPullDesc")}

+ + {pullReport && ( +
+
+ {t("settings.anilistUpdated", { count: String(pullReport.updated) })} + {pullReport.skipped > 0 && {t("settings.anilistSkipped", { count: String(pullReport.skipped) })}} + {pullReport.errors.length > 0 && {t("settings.anilistErrors", { count: String(pullReport.errors.length) })}} +
+ {pullReport.items.length > 0 && ( +
+ {pullReport.items.map((item: AnilistPullItemDto) => ( +
+ + {item.anilist_title ?? item.series_name} + +
+ {item.anilist_status} + {item.books_updated} {t("dashboard.books").toLowerCase()} +
+
+ ))} +
+ )} + {pullReport.errors.map((err: string, i: number) => ( +

{err}

+ ))} +
+ )} +
+
+ {actionError &&

{actionError}

} + {previewItems !== null && ( +
+
+ {t("settings.anilistPreviewTitle", { count: String(previewItems.length) })} + +
+ {previewItems.length === 0 ? ( +

{t("settings.anilistPreviewEmpty")}

+ ) : ( +
+ {previewItems.map((item) => ( +
+
+ + {item.anilist_title ?? item.series_name} + + {item.anilist_title && item.anilist_title !== item.series_name && ( + — {item.series_name} + )} +
+
+ {item.books_read}/{item.book_count} + + {item.status} + +
+
+ ))} +
+ )} +
+ )} +
+
+ + ); +} diff --git a/apps/backoffice/app/(app)/settings/components/KomgaSyncCard.tsx b/apps/backoffice/app/(app)/settings/components/KomgaSyncCard.tsx new file mode 100644 index 0000000..d8af8d4 --- /dev/null +++ b/apps/backoffice/app/(app)/settings/components/KomgaSyncCard.tsx @@ -0,0 +1,281 @@ +"use client"; + +import { useState, useEffect, useCallback, useMemo } from "react"; +import { Card, CardHeader, CardTitle, CardDescription, CardContent, Button, FormField, FormInput, FormSelect, Icon } from "@/app/components/ui"; +import { KomgaSyncResponse, KomgaSyncReportSummary, UserDto } from "@/lib/api"; +import { useTranslation } from "@/lib/i18n/context"; + +export function KomgaSyncCard({ users }: { users: UserDto[] }) { + const { t, locale } = useTranslation(); + const [komgaUrl, setKomgaUrl] = useState(""); + const [komgaUsername, setKomgaUsername] = useState(""); + const [komgaPassword, setKomgaPassword] = useState(""); + const [komgaUserId, setKomgaUserId] = useState(users[0]?.id ?? ""); + const [isSyncing, setIsSyncing] = useState(false); + const [syncResult, setSyncResult] = useState(null); + const [syncError, setSyncError] = useState(null); + const [showUnmatched, setShowUnmatched] = useState(false); + const [showMatchedBooks, setShowMatchedBooks] = useState(false); + const [reports, setReports] = useState([]); + const [selectedReport, setSelectedReport] = useState(null); + const [showReportUnmatched, setShowReportUnmatched] = useState(false); + const [showReportMatchedBooks, setShowReportMatchedBooks] = useState(false); + + const syncNewlyMarkedSet = useMemo( + () => new Set(syncResult?.newly_marked_books ?? []), + [syncResult?.newly_marked_books], + ); + const reportNewlyMarkedSet = useMemo( + () => new Set(selectedReport?.newly_marked_books ?? []), + [selectedReport?.newly_marked_books], + ); + + const fetchReports = useCallback(async () => { + try { + const resp = await fetch("/api/komga/reports"); + if (resp.ok) setReports(await resp.json()); + } catch { /* ignore */ } + }, []); + + useEffect(() => { + fetchReports(); + fetch("/api/settings/komga").then(r => r.ok ? r.json() : null).then(data => { + if (data) { + if (data.url) setKomgaUrl(data.url); + if (data.username) setKomgaUsername(data.username); + if (data.user_id) setKomgaUserId(data.user_id); + } + }).catch(() => {}); + }, [fetchReports]); + + async function handleViewReport(id: string) { + setSelectedReport(null); + setShowReportUnmatched(false); + setShowReportMatchedBooks(false); + try { + const resp = await fetch(`/api/komga/reports/${id}`); + if (resp.ok) setSelectedReport(await resp.json()); + } catch { /* ignore */ } + } + + async function handleKomgaSync() { + setIsSyncing(true); + setSyncResult(null); + setSyncError(null); + setShowUnmatched(false); + setShowMatchedBooks(false); + try { + const response = await fetch("/api/komga/sync", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ url: komgaUrl, username: komgaUsername, password: komgaPassword, user_id: komgaUserId }), + }); + const data = await response.json(); + if (!response.ok) { + setSyncError(data.error || "Sync failed"); + } else { + setSyncResult(data); + fetchReports(); + fetch("/api/settings/komga", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ value: { url: komgaUrl, username: komgaUsername, user_id: komgaUserId } }), + }).catch(() => {}); + } + } catch { + setSyncError("Failed to connect to sync endpoint"); + } finally { + setIsSyncing(false); + } + } + + return ( + + + + + {t("settings.komgaSync")} + + {t("settings.komgaDesc")} + + +
+
+ + + setKomgaUrl(e.target.value)} /> + +
+
+ + + setKomgaUsername(e.target.value)} /> + + + + setKomgaPassword(e.target.value)} /> + +
+ {users.length > 0 && ( +
+ + + setKomgaUserId(e.target.value)}> + {users.map((u) => ( + + ))} + + +
+ )} + + {syncError &&
{syncError}
} + {syncResult && ( +
+
+
+

{t("settings.komgaRead")}

+

{syncResult.total_komga_read}

+
+
+

{t("settings.matched")}

+

{syncResult.matched}

+
+
+

{t("settings.alreadyRead")}

+

{syncResult.already_read}

+
+
+

{t("settings.newlyMarked")}

+

{syncResult.newly_marked}

+
+
+ {syncResult.matched_books.length > 0 && ( +
+ + {showMatchedBooks && ( +
+ {syncResult.matched_books.map((title, i) => ( +

+ {syncNewlyMarkedSet.has(title) && } + {title} +

+ ))} +
+ )} +
+ )} + {syncResult.unmatched.length > 0 && ( +
+ + {showUnmatched && ( +
+ {syncResult.unmatched.map((title, i) => ( +

{title}

+ ))} +
+ )} +
+ )} +
+ )} + {reports.length > 0 && ( +
+

{t("settings.syncHistory")}

+
+ {reports.map((r) => ( + + ))} +
+ {selectedReport && ( +
+
+
+

{t("settings.komgaRead")}

+

{selectedReport.total_komga_read}

+
+
+

{t("settings.matched")}

+

{selectedReport.matched}

+
+
+

{t("settings.alreadyRead")}

+

{selectedReport.already_read}

+
+
+

{t("settings.newlyMarked")}

+

{selectedReport.newly_marked}

+
+
+ {selectedReport.matched_books && selectedReport.matched_books.length > 0 && ( +
+ + {showReportMatchedBooks && ( +
+ {selectedReport.matched_books.map((title, i) => ( +

+ {reportNewlyMarkedSet.has(title) && } + {title} +

+ ))} +
+ )} +
+ )} + {selectedReport.unmatched.length > 0 && ( +
+ + {showReportUnmatched && ( +
+ {selectedReport.unmatched.map((title, i) => ( +

{title}

+ ))} +
+ )} +
+ )} +
+ )} +
+ )} +
+
+
+ ); +} diff --git a/apps/backoffice/app/(app)/settings/components/MetadataProvidersCard.tsx b/apps/backoffice/app/(app)/settings/components/MetadataProvidersCard.tsx new file mode 100644 index 0000000..46dcbd4 --- /dev/null +++ b/apps/backoffice/app/(app)/settings/components/MetadataProvidersCard.tsx @@ -0,0 +1,170 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Card, CardHeader, CardTitle, CardDescription, CardContent, FormField, FormInput, FormSelect, Icon } from "@/app/components/ui"; +import { ProviderIcon } from "@/app/components/ProviderIcon"; +import { useTranslation } from "@/lib/i18n/context"; + +export const METADATA_LANGUAGES = [ + { value: "en", label: "English" }, + { value: "fr", label: "Français" }, + { value: "es", label: "Español" }, +] as const; + +export function MetadataProvidersCard({ handleUpdateSetting }: { handleUpdateSetting: (key: string, value: unknown) => Promise }) { + const { t } = useTranslation(); + const [defaultProvider, setDefaultProvider] = useState("google_books"); + const [metadataLanguage, setMetadataLanguage] = useState("en"); + const [apiKeys, setApiKeys] = useState>({}); + + useEffect(() => { + fetch("/api/settings/metadata_providers") + .then((r) => (r.ok ? r.json() : null)) + .then((data) => { + if (data) { + if (data.default_provider) setDefaultProvider(data.default_provider); + if (data.metadata_language) setMetadataLanguage(data.metadata_language); + if (data.comicvine?.api_key) setApiKeys((prev) => ({ ...prev, comicvine: data.comicvine.api_key })); + if (data.google_books?.api_key) setApiKeys((prev) => ({ ...prev, google_books: data.google_books.api_key })); + } + }) + .catch(() => {}); + }, []); + + function save(provider: string, lang: string, keys: Record) { + const value: Record = { + default_provider: provider, + metadata_language: lang, + }; + for (const [k, v] of Object.entries(keys)) { + if (v) value[k] = { api_key: v }; + } + handleUpdateSetting("metadata_providers", value); + } + + return ( + + + + + {t("settings.metadataProviders")} + + {t("settings.metadataProvidersDesc")} + + +
+ {/* Default provider */} +
+ +
+ {([ + { value: "google_books", label: "Google Books" }, + { value: "open_library", label: "Open Library" }, + { value: "comicvine", label: "ComicVine" }, + { value: "anilist", label: "AniList" }, + { value: "bedetheque", label: "Bédéthèque" }, + ] as const).map((p) => ( + + ))} +
+

{t("settings.defaultProviderHelp")}

+
+ + {/* Metadata language */} +
+ +
+ {METADATA_LANGUAGES.map((l) => ( + + ))} +
+

{t("settings.metadataLanguageHelp")}

+
+ + {/* Provider API keys — always visible */} +
+

{t("settings.apiKeys")}

+
+ + + setApiKeys({ ...apiKeys, google_books: e.target.value })} + onBlur={() => save(defaultProvider, metadataLanguage, apiKeys)} + /> +

{t("settings.googleBooksHelp")}

+
+ + + + setApiKeys({ ...apiKeys, comicvine: e.target.value })} + onBlur={() => save(defaultProvider, metadataLanguage, apiKeys)} + /> +

{t("settings.comicvineHelp")} comicvine.gamespot.com/api.

+
+ +
+
+ + Open Library +
+ , +
+ + AniList +
+ {t("common.and")} +
+ + Bédéthèque +
+ {t("settings.freeProviders")} +
+
+
+
+
+
+ ); +} diff --git a/apps/backoffice/app/(app)/settings/components/ProwlarrCard.tsx b/apps/backoffice/app/(app)/settings/components/ProwlarrCard.tsx new file mode 100644 index 0000000..03d940a --- /dev/null +++ b/apps/backoffice/app/(app)/settings/components/ProwlarrCard.tsx @@ -0,0 +1,134 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Card, CardHeader, CardTitle, CardDescription, CardContent, Button, FormField, FormInput, Icon } from "@/app/components/ui"; +import { useTranslation } from "@/lib/i18n/context"; + +export function ProwlarrCard({ handleUpdateSetting }: { handleUpdateSetting: (key: string, value: unknown) => Promise }) { + const { t } = useTranslation(); + const [prowlarrUrl, setProwlarrUrl] = useState(""); + const [prowlarrApiKey, setProwlarrApiKey] = useState(""); + const [prowlarrCategories, setProwlarrCategories] = useState("7030, 7020"); + const [isTesting, setIsTesting] = useState(false); + const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null); + + useEffect(() => { + fetch("/api/settings/prowlarr") + .then((r) => (r.ok ? r.json() : null)) + .then((data) => { + if (data) { + if (data.url) setProwlarrUrl(data.url); + if (data.api_key) setProwlarrApiKey(data.api_key); + if (data.categories) setProwlarrCategories(data.categories.join(", ")); + } + }) + .catch(() => {}); + }, []); + + function saveProwlarr(url?: string, apiKey?: string, cats?: string) { + const categories = (cats ?? prowlarrCategories) + .split(",") + .map((s) => parseInt(s.trim())) + .filter((n) => !isNaN(n)); + handleUpdateSetting("prowlarr", { + url: url ?? prowlarrUrl, + api_key: apiKey ?? prowlarrApiKey, + categories, + }); + } + + async function handleTestConnection() { + setIsTesting(true); + setTestResult(null); + try { + const resp = await fetch("/api/prowlarr/test"); + const data = await resp.json(); + if (data.error) { + setTestResult({ success: false, message: data.error }); + } else { + setTestResult(data); + } + } catch { + setTestResult({ success: false, message: "Failed to connect" }); + } finally { + setIsTesting(false); + } + } + + return ( + + + + + {t("settings.prowlarr")} + + {t("settings.prowlarrDesc")} + + +
+
+ + + setProwlarrUrl(e.target.value)} + onBlur={() => saveProwlarr()} + /> + +
+
+ + + setProwlarrApiKey(e.target.value)} + onBlur={() => saveProwlarr()} + /> + +
+
+ + + setProwlarrCategories(e.target.value)} + onBlur={() => saveProwlarr()} + /> +

{t("settings.prowlarrCategoriesHelp")}

+
+
+ +
+ + {testResult && ( + + {testResult.message} + + )} +
+
+
+
+ ); +} diff --git a/apps/backoffice/app/(app)/settings/components/QBittorrentCard.tsx b/apps/backoffice/app/(app)/settings/components/QBittorrentCard.tsx new file mode 100644 index 0000000..051da43 --- /dev/null +++ b/apps/backoffice/app/(app)/settings/components/QBittorrentCard.tsx @@ -0,0 +1,125 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Card, CardHeader, CardTitle, CardDescription, CardContent, Button, FormField, FormInput, Icon } from "@/app/components/ui"; +import { useTranslation } from "@/lib/i18n/context"; + +export function QBittorrentCard({ handleUpdateSetting }: { handleUpdateSetting: (key: string, value: unknown) => Promise }) { + const { t } = useTranslation(); + const [qbUrl, setQbUrl] = useState(""); + const [qbUsername, setQbUsername] = useState(""); + const [qbPassword, setQbPassword] = useState(""); + const [isTesting, setIsTesting] = useState(false); + const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null); + + useEffect(() => { + fetch("/api/settings/qbittorrent") + .then((r) => (r.ok ? r.json() : null)) + .then((data) => { + if (data) { + if (data.url) setQbUrl(data.url); + if (data.username) setQbUsername(data.username); + if (data.password) setQbPassword(data.password); + } + }) + .catch(() => {}); + }, []); + + function saveQbittorrent() { + handleUpdateSetting("qbittorrent", { + url: qbUrl, + username: qbUsername, + password: qbPassword, + }); + } + + async function handleTestConnection() { + setIsTesting(true); + setTestResult(null); + try { + const resp = await fetch("/api/qbittorrent/test"); + const data = await resp.json(); + if (data.error) { + setTestResult({ success: false, message: data.error }); + } else { + setTestResult(data); + } + } catch { + setTestResult({ success: false, message: "Failed to connect" }); + } finally { + setIsTesting(false); + } + } + + return ( + + + + + {t("settings.qbittorrent")} + + {t("settings.qbittorrentDesc")} + + +
+
+ + + setQbUrl(e.target.value)} + onBlur={() => saveQbittorrent()} + /> + +
+
+ + + setQbUsername(e.target.value)} + onBlur={() => saveQbittorrent()} + /> + + + + setQbPassword(e.target.value)} + onBlur={() => saveQbittorrent()} + /> + +
+ +
+ + {testResult && ( + + {testResult.message} + + )} +
+
+
+
+ ); +} diff --git a/apps/backoffice/app/(app)/settings/components/StatusMappingsCard.tsx b/apps/backoffice/app/(app)/settings/components/StatusMappingsCard.tsx new file mode 100644 index 0000000..c5151cc --- /dev/null +++ b/apps/backoffice/app/(app)/settings/components/StatusMappingsCard.tsx @@ -0,0 +1,228 @@ +"use client"; + +import { useState, useEffect, useCallback, useMemo } from "react"; +import { Card, CardHeader, CardTitle, CardDescription, CardContent, Button, FormField, FormInput, FormSelect, Icon } from "@/app/components/ui"; +import { StatusMappingDto } from "@/lib/api"; +import { useTranslation } from "@/lib/i18n/context"; + +export function StatusMappingsCard() { + const { t } = useTranslation(); + const [mappings, setMappings] = useState([]); + const [targetStatuses, setTargetStatuses] = useState([]); + const [providerStatuses, setProviderStatuses] = useState([]); + const [newTargetName, setNewTargetName] = useState(""); + const [loading, setLoading] = useState(true); + + const loadData = useCallback(async () => { + try { + const [mRes, sRes, pRes] = await Promise.all([ + fetch("/api/settings/status-mappings").then((r) => r.ok ? r.json() : []), + fetch("/api/series/statuses").then((r) => r.ok ? r.json() : []), + fetch("/api/series/provider-statuses").then((r) => r.ok ? r.json() : []), + ]); + setMappings(mRes); + setTargetStatuses(sRes); + setProviderStatuses(pRes); + } catch { + // ignore + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { loadData(); }, [loadData]); + + // Group mappings by target status (only those with a non-null mapped_status) + const grouped = useMemo(() => { + const map = new Map(); + for (const m of mappings) { + if (m.mapped_status) { + const list = map.get(m.mapped_status) || []; + list.push(m); + map.set(m.mapped_status, list); + } + } + return map; + }, [mappings]); + + // Unmapped = mappings with null mapped_status + provider statuses not in status_mappings at all + const knownProviderStatuses = useMemo( + () => new Set(mappings.map((m) => m.provider_status)), + [mappings], + ); + const unmappedMappings = useMemo( + () => mappings.filter((m) => !m.mapped_status), + [mappings], + ); + const newProviderStatuses = useMemo( + () => providerStatuses.filter((ps) => !knownProviderStatuses.has(ps)), + [providerStatuses, knownProviderStatuses], + ); + + // All possible targets = existing statuses from DB + custom ones added locally + const [customTargets, setCustomTargets] = useState([]); + const allTargets = useMemo(() => { + const set = new Set([...targetStatuses, ...customTargets]); + return [...set].sort(); + }, [targetStatuses, customTargets]); + + async function handleAssign(providerStatus: string, targetStatus: string) { + if (!providerStatus || !targetStatus) return; + try { + const res = await fetch("/api/settings/status-mappings", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ provider_status: providerStatus, mapped_status: targetStatus }), + }); + if (res.ok) { + const created: StatusMappingDto = await res.json(); + setMappings((prev) => [...prev.filter((m) => m.provider_status !== created.provider_status), created]); + } + } catch { + // ignore + } + } + + async function handleUnmap(id: string) { + try { + const res = await fetch(`/api/settings/status-mappings/${id}`, { method: "DELETE" }); + if (res.ok) { + const updated: StatusMappingDto = await res.json(); + setMappings((prev) => prev.map((m) => (m.id === id ? updated : m))); + } + } catch { + // ignore + } + } + + function handleCreateTarget() { + const name = newTargetName.trim().toLowerCase(); + if (!name || allTargets.includes(name)) return; + setCustomTargets((prev) => [...prev, name]); + setNewTargetName(""); + } + + function statusLabel(status: string) { + const key = `seriesStatus.${status}` as Parameters[0]; + const translated = t(key); + return translated !== key ? translated : status; + } + + if (loading) { + return ( + +

{t("common.loading")}

+
+ ); + } + + return ( + + + + + {t("settings.statusMappings")} + + {t("settings.statusMappingsDesc")} + + +
+ {/* Create new target status */} +
+ setNewTargetName(e.target.value)} + onKeyDown={(e) => { if (e.key === "Enter") handleCreateTarget(); }} + className="max-w-[250px]" + /> + +
+ + {/* Grouped by target status */} + {allTargets.map((target) => { + const items = grouped.get(target) || []; + return ( +
+
+ + {statusLabel(target)} + + ({target}) +
+
+ {items.map((m) => ( + + {m.provider_status} + + + ))} + {items.length === 0 && ( + {t("settings.noMappings")} + )} +
+
+ ); + })} + + {/* Unmapped provider statuses (null mapped_status + brand new from providers) */} + {(unmappedMappings.length > 0 || newProviderStatuses.length > 0) && ( +
+

{t("settings.unmappedSection")}

+
+ {unmappedMappings.map((m) => ( +
+ {m.provider_status} + + { if (e.target.value) handleAssign(m.provider_status, e.target.value); }} + > + + {allTargets.map((s) => ( + + ))} + +
+ ))} + {newProviderStatuses.map((ps) => ( +
+ {ps} + + { if (e.target.value) handleAssign(ps, e.target.value); }} + > + + {allTargets.map((s) => ( + + ))} + +
+ ))} +
+
+ )} +
+
+
+ ); +} diff --git a/apps/backoffice/app/(app)/settings/components/TelegramCard.tsx b/apps/backoffice/app/(app)/settings/components/TelegramCard.tsx new file mode 100644 index 0000000..a01215b --- /dev/null +++ b/apps/backoffice/app/(app)/settings/components/TelegramCard.tsx @@ -0,0 +1,252 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Card, CardHeader, CardTitle, CardDescription, CardContent, Button, FormField, FormInput, Icon } from "@/app/components/ui"; +import { useTranslation } from "@/lib/i18n/context"; + +export const DEFAULT_EVENTS = { + scan_completed: true, + scan_failed: true, + scan_cancelled: true, + thumbnail_completed: true, + thumbnail_failed: true, + conversion_completed: true, + conversion_failed: true, + metadata_approved: true, + metadata_batch_completed: true, + metadata_batch_failed: true, + metadata_refresh_completed: true, + metadata_refresh_failed: true, +}; + +export function TelegramCard({ handleUpdateSetting }: { handleUpdateSetting: (key: string, value: unknown) => Promise }) { + const { t } = useTranslation(); + const [botToken, setBotToken] = useState(""); + const [chatId, setChatId] = useState(""); + const [enabled, setEnabled] = useState(false); + const [events, setEvents] = useState(DEFAULT_EVENTS); + const [isTesting, setIsTesting] = useState(false); + const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null); + const [showHelp, setShowHelp] = useState(false); + + useEffect(() => { + fetch("/api/settings/telegram") + .then((r) => (r.ok ? r.json() : null)) + .then((data) => { + if (data) { + if (data.bot_token) setBotToken(data.bot_token); + if (data.chat_id) setChatId(data.chat_id); + if (data.enabled !== undefined) setEnabled(data.enabled); + if (data.events) setEvents({ ...DEFAULT_EVENTS, ...data.events }); + } + }) + .catch(() => {}); + }, []); + + function saveTelegram(token?: string, chat?: string, en?: boolean, ev?: typeof events) { + handleUpdateSetting("telegram", { + bot_token: token ?? botToken, + chat_id: chat ?? chatId, + enabled: en ?? enabled, + events: ev ?? events, + }); + } + + async function handleTestConnection() { + setIsTesting(true); + setTestResult(null); + try { + const resp = await fetch("/api/telegram/test"); + const data = await resp.json(); + if (data.error) { + setTestResult({ success: false, message: data.error }); + } else { + setTestResult(data); + } + } catch { + setTestResult({ success: false, message: "Failed to connect" }); + } finally { + setIsTesting(false); + } + } + + return ( + + + + + {t("settings.telegram")} + + {t("settings.telegramDesc")} + + +
+ {/* Setup guide */} +
+ + {showHelp && ( +
+
+

1. Bot Token

+

+

+
+

2. Chat ID

+

+

+
+

3. Group chat

+

+

+
+ )} +
+ +
+ + {t("settings.telegramEnabled")} +
+ +
+ + + setBotToken(e.target.value)} + onBlur={() => saveTelegram()} + /> + +
+
+ + + setChatId(e.target.value)} + onBlur={() => saveTelegram()} + /> + +
+ + {/* Event toggles grouped by category */} +
+

{t("settings.telegramEvents")}

+
+ {([ + { + category: t("settings.eventCategoryScan"), + icon: "search" as const, + items: [ + { key: "scan_completed" as const, label: t("settings.eventCompleted") }, + { key: "scan_failed" as const, label: t("settings.eventFailed") }, + { key: "scan_cancelled" as const, label: t("settings.eventCancelled") }, + ], + }, + { + category: t("settings.eventCategoryThumbnail"), + icon: "image" as const, + items: [ + { key: "thumbnail_completed" as const, label: t("settings.eventCompleted") }, + { key: "thumbnail_failed" as const, label: t("settings.eventFailed") }, + ], + }, + { + category: t("settings.eventCategoryConversion"), + icon: "refresh" as const, + items: [ + { key: "conversion_completed" as const, label: t("settings.eventCompleted") }, + { key: "conversion_failed" as const, label: t("settings.eventFailed") }, + ], + }, + { + category: t("settings.eventCategoryMetadata"), + icon: "tag" as const, + items: [ + { key: "metadata_approved" as const, label: t("settings.eventLinked") }, + { key: "metadata_batch_completed" as const, label: t("settings.eventBatchCompleted") }, + { key: "metadata_batch_failed" as const, label: t("settings.eventBatchFailed") }, + { key: "metadata_refresh_completed" as const, label: t("settings.eventRefreshCompleted") }, + { key: "metadata_refresh_failed" as const, label: t("settings.eventRefreshFailed") }, + ], + }, + ]).map(({ category, icon, items }) => ( +
+

+ + {category} +

+
+ {items.map(({ key, label }) => ( +
+ ))} +
+
+ +
+ + {testResult && ( + + {testResult.message} + + )} +
+
+ + + ); +} diff --git a/apps/backoffice/lib/i18n/en.ts b/apps/backoffice/lib/i18n/en.ts index 86cb1f5..d28182a 100644 --- a/apps/backoffice/lib/i18n/en.ts +++ b/apps/backoffice/lib/i18n/en.ts @@ -474,6 +474,9 @@ const en: Record = { "settings.title": "Settings", "settings.general": "General", "settings.integrations": "Integrations", + "settings.downloadTools": "Download Tools", + "settings.metadata": "Metadata", + "settings.readingStatus": "Reading Status", "settings.savedSuccess": "Settings saved successfully", "settings.savedError": "Failed to save settings", "settings.saveError": "Error saving settings", @@ -651,7 +654,7 @@ const en: Record = { // Settings - AniList "settings.anilist": "Reading status", - "settings.anilistTitle": "AniList Sync", + "settings.anilistTitle": "AniList Config", "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...", @@ -666,7 +669,7 @@ const en: Record = { "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.anilistSyncTitle": "AniList 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", diff --git a/apps/backoffice/lib/i18n/fr.ts b/apps/backoffice/lib/i18n/fr.ts index 863dbb7..9e07087 100644 --- a/apps/backoffice/lib/i18n/fr.ts +++ b/apps/backoffice/lib/i18n/fr.ts @@ -472,6 +472,9 @@ const fr = { "settings.title": "Paramètres", "settings.general": "Général", "settings.integrations": "Intégrations", + "settings.downloadTools": "Outils de téléchargement", + "settings.metadata": "Métadonnées", + "settings.readingStatus": "Statut de lecture", "settings.savedSuccess": "Paramètres enregistrés avec succès", "settings.savedError": "Échec de l'enregistrement des paramètres", "settings.saveError": "Erreur lors de l'enregistrement des paramètres", @@ -649,7 +652,7 @@ const fr = { // Settings - AniList "settings.anilist": "État de lecture", - "settings.anilistTitle": "Synchronisation AniList", + "settings.anilistTitle": "AniList Config", "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...", @@ -664,7 +667,7 @@ const fr = { "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.anilistSyncTitle": "AniList Sync", "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",