"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 { Settings, CacheStats, ClearCacheResponse, ThumbnailStats, KomgaSyncResponse, KomgaSyncReportSummary } from "../../lib/api"; interface SettingsPageProps { initialSettings: Settings; initialCacheStats: CacheStats; initialThumbnailStats: ThumbnailStats; } export default function SettingsPage({ initialSettings, initialCacheStats, initialThumbnailStats }: SettingsPageProps) { 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); const [isResyncing, setIsResyncing] = useState(false); const [resyncResult, setResyncResult] = useState<{ success: boolean; message: string } | null>(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("Settings saved successfully"); setTimeout(() => setSaveMessage(null), 3000); } else { setSaveMessage("Failed to save settings"); } } catch (error) { setSaveMessage("Error saving settings"); } 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: "Failed to clear cache" }); } finally { setIsClearing(false); } } async function handleSearchResync() { setIsResyncing(true); setResyncResult(null); try { const response = await fetch("/api/settings/search/resync", { method: "POST" }); const result = await response.json(); setResyncResult(result); } catch { setResyncResult({ success: false, message: "Failed to trigger search resync" }); } finally { setIsResyncing(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: "General", icon: "settings" as const }, { id: "integrations" as const, label: "Integrations", icon: "refresh" as const }, ]; return ( <>

Settings

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

{saveMessage}

)} {activeTab === "general" && (<> {/* Image Processing Settings */} Image Processing These settings only apply when a client explicitly requests format conversion via the API (e.g. ?format=webp&width=800). Pages served without parameters are delivered as-is from the archive, with no processing.
{ 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 */} Cache Manage the image cache and storage

Cache Size

{cacheStats.total_size_mb.toFixed(2)} MB

Files

{cacheStats.file_count}

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)} />
{/* Search Index */} Search Index Force a full resync of the Meilisearch index. This will re-index all books on the next indexer cycle.
{resyncResult && (
{resyncResult.message}
)}
{/* Limits Settings */} Performance Limits Configure API performance, rate limiting, and thumbnail generation concurrency
{ const concurrent_renders = parseInt(e.target.value) || 4; const newSettings = { ...settings, limits: { ...settings.limits, concurrent_renders } }; setSettings(newSettings); }} onBlur={() => handleUpdateSetting("limits", settings.limits)} />

Maximum number of page renders and thumbnail generations running in parallel

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

Note: Changes to limits require a server restart to take effect. The "Concurrent Renders" setting controls both page rendering and thumbnail generation parallelism.

{/* Thumbnail Settings */} Thumbnails Configure thumbnail generation during indexing
{ 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" ? "Resizes to target dimensions, keeps source format (JPEG→JPEG). Much faster generation." : "Resizes and re-encodes to selected format."}

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

Total Size

{thumbnailStats.total_size_mb.toFixed(2)} MB

Files

{thumbnailStats.file_count}

Directory

{thumbnailStats.directory}

Note: Thumbnail settings are used during indexing. Existing thumbnails will not be regenerated automatically. The concurrency for thumbnail generation is controlled by the "Concurrent Renders" setting in Performance Limits above.

)} {activeTab === "integrations" && (<> {/* Komga Sync */} Komga Sync Import read status from a Komga server. Books are matched by title (case-insensitive). Credentials are not stored.
setKomgaUrl(e.target.value)} /> setKomgaUsername(e.target.value)} /> setKomgaPassword(e.target.value)} /> {syncError && (
{syncError}
)} {syncResult && (

Komga read

{syncResult.total_komga_read}

Matched

{syncResult.matched}

Already read

{syncResult.already_read}

Newly marked

{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 && (

Sync History

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

Komga read

{selectedReport.total_komga_read}

Matched

{selectedReport.matched}

Already read

{selectedReport.already_read}

Newly marked

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

))}
)}
)}
)}
)}
)} ); }