"use client"; import { useState, useEffect, useCallback, useMemo } from "react"; import { Card, CardHeader, CardTitle, CardDescription, CardContent, Button, FormField, FormInput, FormSelect, FormRow, Icon } from "../components/ui"; import { ProviderIcon } from "../components/ProviderIcon"; import { Settings, CacheStats, ClearCacheResponse, ThumbnailStats, KomgaSyncResponse, KomgaSyncReportSummary, StatusMappingDto } from "../../lib/api"; import { useTranslation } from "../../lib/i18n/context"; import type { Locale } from "../../lib/i18n/types"; interface SettingsPageProps { initialSettings: Settings; initialCacheStats: CacheStats; initialThumbnailStats: ThumbnailStats; } export default function SettingsPage({ initialSettings, initialCacheStats, initialThumbnailStats }: SettingsPageProps) { const { t, locale, setLocale } = useTranslation(); const [settings, setSettings] = useState({ ...initialSettings, thumbnail: initialSettings.thumbnail || { enabled: true, width: 300, height: 400, quality: 80, format: "webp", directory: "/data/thumbnails" } }); const [cacheStats, setCacheStats] = useState(initialCacheStats); const [thumbnailStats, setThumbnailStats] = useState(initialThumbnailStats); const [isClearing, setIsClearing] = useState(false); 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 [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], ); async function handleUpdateSetting(key: string, value: unknown) { setIsSaving(true); setSaveMessage(null); try { const response = await fetch(`/api/settings/${key}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ value }) }); if (response.ok) { setSaveMessage(t("settings.savedSuccess")); setTimeout(() => setSaveMessage(null), 3000); } else { setSaveMessage(t("settings.savedError")); } } catch (error) { setSaveMessage(t("settings.saveError")); } finally { setIsSaving(false); } } async function handleClearCache() { setIsClearing(true); setClearResult(null); try { const response = await fetch("/api/settings/cache/clear", { method: "POST" }); const result = await response.json(); setClearResult(result); // Refresh cache stats const statsResponse = await fetch("/api/settings/cache/stats"); if (statsResponse.ok) { const stats = await statsResponse.json(); setCacheStats(stats); } } catch (error) { 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); } }).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 }), }); 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 } }), }).catch(() => {}); } } catch { setSyncError("Failed to connect to sync endpoint"); } finally { setIsSyncing(false); } } const [activeTab, setActiveTab] = useState<"general" | "integrations">("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 }, ]; return ( <>

{t("settings.title")}

{/* Tab Navigation */}
{tabs.map((tab) => ( ))}
{saveMessage && (

{saveMessage}

)} {activeTab === "general" && (<> {/* Language Selector */} {t("settings.language")} {t("settings.languageDesc")} setLocale(e.target.value as Locale)} > {/* Image Processing Settings */} {t("settings.imageProcessing")}
{ const newSettings = { ...settings, image_processing: { ...settings.image_processing, format: e.target.value } }; setSettings(newSettings); handleUpdateSetting("image_processing", newSettings.image_processing); }} > { const quality = parseInt(e.target.value) || 85; const newSettings = { ...settings, image_processing: { ...settings.image_processing, quality } }; setSettings(newSettings); }} onBlur={() => handleUpdateSetting("image_processing", settings.image_processing)} /> { const newSettings = { ...settings, image_processing: { ...settings.image_processing, filter: e.target.value } }; setSettings(newSettings); handleUpdateSetting("image_processing", newSettings.image_processing); }} > { const max_width = parseInt(e.target.value) || 2160; const newSettings = { ...settings, image_processing: { ...settings.image_processing, max_width } }; setSettings(newSettings); }} onBlur={() => handleUpdateSetting("image_processing", settings.image_processing)} />
{/* Cache Settings */} {t("settings.cache")} {t("settings.cacheDesc")}

{t("settings.cacheSize")}

{cacheStats.total_size_mb.toFixed(2)} MB

{t("settings.files")}

{cacheStats.file_count}

{t("settings.directory")}

{cacheStats.directory}

{clearResult && (
{clearResult.message}
)} { const newSettings = { ...settings, cache: { ...settings.cache, directory: e.target.value } }; setSettings(newSettings); }} onBlur={() => handleUpdateSetting("cache", settings.cache)} /> { const max_size_mb = parseInt(e.target.value) || 10000; const newSettings = { ...settings, cache: { ...settings.cache, max_size_mb } }; setSettings(newSettings); }} onBlur={() => handleUpdateSetting("cache", settings.cache)} />
{/* Limits Settings */} {t("settings.performanceLimits")} {t("settings.performanceDesc")}
{ const concurrent_renders = parseInt(e.target.value) || 4; const newSettings = { ...settings, limits: { ...settings.limits, concurrent_renders } }; setSettings(newSettings); }} onBlur={() => handleUpdateSetting("limits", settings.limits)} />

{t("settings.concurrentRendersHelp")}

{ const timeout_seconds = parseInt(e.target.value) || 12; const newSettings = { ...settings, limits: { ...settings.limits, timeout_seconds } }; setSettings(newSettings); }} onBlur={() => handleUpdateSetting("limits", settings.limits)} /> { const rate_limit_per_second = parseInt(e.target.value) || 120; const newSettings = { ...settings, limits: { ...settings.limits, rate_limit_per_second } }; setSettings(newSettings); }} onBlur={() => handleUpdateSetting("limits", settings.limits)} />

{t("settings.limitsNote")}

{/* Thumbnail Settings */} {t("settings.thumbnails")} {t("settings.thumbnailsDesc")}
{ const newSettings = { ...settings, thumbnail: { ...settings.thumbnail, enabled: e.target.value === "true" } }; setSettings(newSettings); handleUpdateSetting("thumbnail", newSettings.thumbnail); }} > { const newSettings = { ...settings, thumbnail: { ...settings.thumbnail, format: e.target.value } }; setSettings(newSettings); handleUpdateSetting("thumbnail", newSettings.thumbnail); }} >

{settings.thumbnail.format === "original" ? t("settings.formatOriginalDesc") : t("settings.formatReencodeDesc")}

{ const width = parseInt(e.target.value) || 300; const newSettings = { ...settings, thumbnail: { ...settings.thumbnail, width } }; setSettings(newSettings); }} onBlur={() => handleUpdateSetting("thumbnail", settings.thumbnail)} /> { const height = parseInt(e.target.value) || 400; const newSettings = { ...settings, thumbnail: { ...settings.thumbnail, height } }; setSettings(newSettings); }} onBlur={() => handleUpdateSetting("thumbnail", settings.thumbnail)} /> { const quality = parseInt(e.target.value) || 80; const newSettings = { ...settings, thumbnail: { ...settings.thumbnail, quality } }; setSettings(newSettings); }} onBlur={() => handleUpdateSetting("thumbnail", settings.thumbnail)} /> { const newSettings = { ...settings, thumbnail: { ...settings.thumbnail, directory: e.target.value } }; setSettings(newSettings); }} onBlur={() => handleUpdateSetting("thumbnail", settings.thumbnail)} />

{t("settings.totalSize")}

{thumbnailStats.total_size_mb.toFixed(2)} MB

{t("settings.files")}

{thumbnailStats.file_count}

{t("settings.directory")}

{thumbnailStats.directory}

{t("settings.thumbnailsNote")}

)} {activeTab === "integrations" && (<> {/* Metadata Providers */} {/* Status Mappings */} {/* Prowlarr */} {/* qBittorrent */} {/* Komga Sync */} {t("settings.komgaSync")} {t("settings.komgaDesc")}
setKomgaUrl(e.target.value)} /> setKomgaUsername(e.target.value)} /> setKomgaPassword(e.target.value)} /> {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}

))}
)}
)}
)}
)}
)} ); } // --------------------------------------------------------------------------- // 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} )}
); }