Add a standalone toast notification system (no Provider needed) and use it for settings save feedback. Skip save when fields are empty. Remove save button on Anilist local user select in favor of auto-save on change. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
552 lines
23 KiB
TypeScript
552 lines
23 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "react";
|
|
import { useRouter, useSearchParams } from "next/navigation";
|
|
import { Card, CardHeader, CardTitle, CardDescription, CardContent, Button, FormField, FormInput, FormSelect, FormRow, Icon, toast, Toaster } from "@/app/components/ui";
|
|
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;
|
|
initialCacheStats: CacheStats;
|
|
initialThumbnailStats: ThumbnailStats;
|
|
users: UserDto[];
|
|
initialTab?: string;
|
|
}
|
|
|
|
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<Settings>({
|
|
...initialSettings,
|
|
thumbnail: initialSettings.thumbnail || { enabled: true, width: 300, height: 400, quality: 80, format: "webp", directory: "/data/thumbnails" }
|
|
});
|
|
const [cacheStats, setCacheStats] = useState<CacheStats>(initialCacheStats);
|
|
const [thumbnailStats, setThumbnailStats] = useState<ThumbnailStats>(initialThumbnailStats);
|
|
const [isClearing, setIsClearing] = useState(false);
|
|
const [clearResult, setClearResult] = useState<ClearCacheResponse | null>(null);
|
|
const [isSaving, setIsSaving] = useState(false);
|
|
|
|
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<TabId>(
|
|
resolveTab(searchParams.get("tab") ?? initialTab)
|
|
);
|
|
|
|
function handleTabChange(tab: TabId) {
|
|
setActiveTab(tab);
|
|
router.replace(`?tab=${tab}`, { scroll: false });
|
|
}
|
|
|
|
function hasEmptyValue(v: unknown): boolean {
|
|
if (v === null || v === "") return true;
|
|
if (typeof v === "object" && v !== null) {
|
|
return Object.values(v).some((val) => val !== undefined && hasEmptyValue(val));
|
|
}
|
|
return false;
|
|
}
|
|
|
|
async function handleUpdateSetting(key: string, value: unknown) {
|
|
if (hasEmptyValue(value)) return;
|
|
setIsSaving(true);
|
|
try {
|
|
const response = await fetch(`/api/settings/${key}`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ value })
|
|
});
|
|
if (response.ok) {
|
|
toast(t("settings.savedSuccess"), "success");
|
|
} else {
|
|
toast(t("settings.savedError"), "error");
|
|
}
|
|
} catch {
|
|
toast(t("settings.saveError"), "error");
|
|
} 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 {
|
|
setClearResult({ success: false, message: t("settings.cacheClearError") });
|
|
} finally {
|
|
setIsClearing(false);
|
|
}
|
|
}
|
|
|
|
const tabs = [
|
|
{ id: "general" as const, label: t("settings.general"), icon: "settings" 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 },
|
|
];
|
|
|
|
return (
|
|
<>
|
|
<div className="mb-6">
|
|
<h1 className="text-3xl font-bold text-foreground flex items-center gap-3">
|
|
<Icon name="settings" size="xl" />
|
|
{t("settings.title")}
|
|
</h1>
|
|
</div>
|
|
|
|
{/* Tab Navigation */}
|
|
<div className="flex gap-1 mb-6 border-b border-border">
|
|
{tabs.map((tab) => (
|
|
<button
|
|
key={tab.id}
|
|
onClick={() => handleTabChange(tab.id)}
|
|
className={`flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 transition-colors -mb-px ${
|
|
activeTab === tab.id
|
|
? "border-primary text-primary"
|
|
: "border-transparent text-muted-foreground hover:text-foreground hover:border-border"
|
|
}`}
|
|
>
|
|
<Icon name={tab.icon} size="sm" />
|
|
{tab.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{activeTab === "general" && (<>
|
|
{/* Language Selector */}
|
|
<Card className="mb-6">
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<Icon name="settings" size="md" />
|
|
{t("settings.language")}
|
|
</CardTitle>
|
|
<CardDescription>{t("settings.languageDesc")}</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<FormSelect
|
|
value={locale}
|
|
onChange={(e) => setLocale(e.target.value as Locale)}
|
|
>
|
|
<option value="fr">Français</option>
|
|
<option value="en">English</option>
|
|
</FormSelect>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Image Processing Settings */}
|
|
<Card className="mb-6">
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<Icon name="image" size="md" />
|
|
{t("settings.imageProcessing")}
|
|
</CardTitle>
|
|
<CardDescription><span dangerouslySetInnerHTML={{ __html: t("settings.imageProcessingDesc") }} /></CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="space-y-4">
|
|
<FormRow>
|
|
<FormField className="flex-1">
|
|
<label className="text-sm font-medium text-muted-foreground mb-1 block">{t("settings.defaultFormat")}</label>
|
|
<FormSelect
|
|
value={settings.image_processing.format}
|
|
onChange={(e) => {
|
|
const newSettings = { ...settings, image_processing: { ...settings.image_processing, format: e.target.value } };
|
|
setSettings(newSettings);
|
|
handleUpdateSetting("image_processing", newSettings.image_processing);
|
|
}}
|
|
>
|
|
<option value="webp">WebP</option>
|
|
<option value="jpeg">JPEG</option>
|
|
<option value="png">PNG</option>
|
|
</FormSelect>
|
|
</FormField>
|
|
<FormField className="flex-1">
|
|
<label className="text-sm font-medium text-muted-foreground mb-1 block">{t("settings.defaultQuality")}</label>
|
|
<FormInput
|
|
type="number"
|
|
min={1}
|
|
max={100}
|
|
value={settings.image_processing.quality}
|
|
onChange={(e) => {
|
|
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)}
|
|
/>
|
|
</FormField>
|
|
</FormRow>
|
|
<FormRow>
|
|
<FormField className="flex-1">
|
|
<label className="text-sm font-medium text-muted-foreground mb-1 block">{t("settings.defaultFilter")}</label>
|
|
<FormSelect
|
|
value={settings.image_processing.filter}
|
|
onChange={(e) => {
|
|
const newSettings = { ...settings, image_processing: { ...settings.image_processing, filter: e.target.value } };
|
|
setSettings(newSettings);
|
|
handleUpdateSetting("image_processing", newSettings.image_processing);
|
|
}}
|
|
>
|
|
<option value="lanczos3">{t("settings.filterLanczos")}</option>
|
|
<option value="triangle">{t("settings.filterTriangle")}</option>
|
|
<option value="nearest">{t("settings.filterNearest")}</option>
|
|
</FormSelect>
|
|
</FormField>
|
|
<FormField className="flex-1">
|
|
<label className="text-sm font-medium text-muted-foreground mb-1 block">{t("settings.maxWidth")}</label>
|
|
<FormInput
|
|
type="number"
|
|
min={100}
|
|
max={2160}
|
|
value={settings.image_processing.max_width}
|
|
onChange={(e) => {
|
|
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)}
|
|
/>
|
|
</FormField>
|
|
</FormRow>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Cache Settings */}
|
|
<Card className="mb-6">
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<Icon name="cache" size="md" />
|
|
{t("settings.cache")}
|
|
</CardTitle>
|
|
<CardDescription>{t("settings.cacheDesc")}</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="space-y-4">
|
|
<div className="grid grid-cols-3 gap-4 p-4 bg-muted/30 rounded-lg">
|
|
<div>
|
|
<p className="text-sm text-muted-foreground">{t("settings.cacheSize")}</p>
|
|
<p className="text-2xl font-semibold">{cacheStats.total_size_mb.toFixed(2)} MB</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-sm text-muted-foreground">{t("settings.files")}</p>
|
|
<p className="text-2xl font-semibold">{cacheStats.file_count}</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-sm text-muted-foreground">{t("settings.directory")}</p>
|
|
<p className="text-sm font-mono truncate" title={cacheStats.directory}>{cacheStats.directory}</p>
|
|
</div>
|
|
</div>
|
|
|
|
{clearResult && (
|
|
<div className={`p-3 rounded-lg ${clearResult.success ? 'bg-success/10 text-success' : 'bg-destructive/10 text-destructive'}`}>
|
|
{clearResult.message}
|
|
</div>
|
|
)}
|
|
|
|
<FormRow>
|
|
<FormField className="flex-1">
|
|
<label className="text-sm font-medium text-muted-foreground mb-1 block">{t("settings.cacheDirectory")}</label>
|
|
<FormInput
|
|
value={settings.cache.directory}
|
|
onChange={(e) => {
|
|
const newSettings = { ...settings, cache: { ...settings.cache, directory: e.target.value } };
|
|
setSettings(newSettings);
|
|
}}
|
|
onBlur={() => handleUpdateSetting("cache", settings.cache)}
|
|
/>
|
|
</FormField>
|
|
<FormField className="w-32">
|
|
<label className="text-sm font-medium text-muted-foreground mb-1 block">{t("settings.maxSizeMb")}</label>
|
|
<FormInput
|
|
type="number"
|
|
value={settings.cache.max_size_mb}
|
|
onChange={(e) => {
|
|
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)}
|
|
/>
|
|
</FormField>
|
|
</FormRow>
|
|
|
|
<Button
|
|
onClick={handleClearCache}
|
|
disabled={isClearing}
|
|
variant="destructive"
|
|
>
|
|
{isClearing ? (
|
|
<>
|
|
<Icon name="spinner" size="sm" className="animate-spin -ml-1 mr-2" />
|
|
{t("settings.clearing")}
|
|
</>
|
|
) : (
|
|
<>
|
|
<Icon name="trash" size="sm" className="mr-2" />
|
|
{t("settings.clearCache")}
|
|
</>
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Limits Settings */}
|
|
<Card className="mb-6">
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<Icon name="performance" size="md" />
|
|
{t("settings.performanceLimits")}
|
|
</CardTitle>
|
|
<CardDescription>{t("settings.performanceDesc")}</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="space-y-4">
|
|
<FormRow>
|
|
<FormField className="flex-1">
|
|
<label className="text-sm font-medium text-muted-foreground mb-1 block">{t("settings.concurrentRenders")}</label>
|
|
<FormInput
|
|
type="number"
|
|
min={1}
|
|
max={20}
|
|
value={settings.limits.concurrent_renders}
|
|
onChange={(e) => {
|
|
const concurrent_renders = parseInt(e.target.value) || 4;
|
|
const newSettings = { ...settings, limits: { ...settings.limits, concurrent_renders } };
|
|
setSettings(newSettings);
|
|
}}
|
|
onBlur={() => handleUpdateSetting("limits", settings.limits)}
|
|
/>
|
|
<p className="text-xs text-muted-foreground mt-1">
|
|
{t("settings.concurrentRendersHelp")}
|
|
</p>
|
|
</FormField>
|
|
<FormField className="flex-1">
|
|
<label className="text-sm font-medium text-muted-foreground mb-1 block">{t("settings.timeoutSeconds")}</label>
|
|
<FormInput
|
|
type="number"
|
|
min={5}
|
|
max={60}
|
|
value={settings.limits.timeout_seconds}
|
|
onChange={(e) => {
|
|
const timeout_seconds = parseInt(e.target.value) || 12;
|
|
const newSettings = { ...settings, limits: { ...settings.limits, timeout_seconds } };
|
|
setSettings(newSettings);
|
|
}}
|
|
onBlur={() => handleUpdateSetting("limits", settings.limits)}
|
|
/>
|
|
</FormField>
|
|
<FormField className="flex-1">
|
|
<label className="text-sm font-medium text-muted-foreground mb-1 block">{t("settings.rateLimit")}</label>
|
|
<FormInput
|
|
type="number"
|
|
min={10}
|
|
max={1000}
|
|
value={settings.limits.rate_limit_per_second}
|
|
onChange={(e) => {
|
|
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)}
|
|
/>
|
|
</FormField>
|
|
</FormRow>
|
|
<p className="text-sm text-muted-foreground">
|
|
{t("settings.limitsNote")}
|
|
</p>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Thumbnail Settings */}
|
|
<Card className="mb-6">
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<Icon name="image" size="md" />
|
|
{t("settings.thumbnails")}
|
|
</CardTitle>
|
|
<CardDescription>{t("settings.thumbnailsDesc")}</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="space-y-4">
|
|
<FormRow>
|
|
<FormField className="flex-1">
|
|
<label className="text-sm font-medium text-muted-foreground mb-1 block">{t("settings.enableThumbnails")}</label>
|
|
<FormSelect
|
|
value={settings.thumbnail.enabled ? "true" : "false"}
|
|
onChange={(e) => {
|
|
const newSettings = { ...settings, thumbnail: { ...settings.thumbnail, enabled: e.target.value === "true" } };
|
|
setSettings(newSettings);
|
|
handleUpdateSetting("thumbnail", newSettings.thumbnail);
|
|
}}
|
|
>
|
|
<option value="true">{t("common.enabled")}</option>
|
|
<option value="false">{t("common.disabled")}</option>
|
|
</FormSelect>
|
|
</FormField>
|
|
<FormField className="flex-1">
|
|
<label className="text-sm font-medium text-muted-foreground mb-1 block">{t("settings.outputFormat")}</label>
|
|
<FormSelect
|
|
value={settings.thumbnail.format}
|
|
onChange={(e) => {
|
|
const newSettings = { ...settings, thumbnail: { ...settings.thumbnail, format: e.target.value } };
|
|
setSettings(newSettings);
|
|
handleUpdateSetting("thumbnail", newSettings.thumbnail);
|
|
}}
|
|
>
|
|
<option value="original">{t("settings.formatOriginal")}</option>
|
|
<option value="webp">WebP</option>
|
|
<option value="jpeg">JPEG</option>
|
|
<option value="png">PNG</option>
|
|
</FormSelect>
|
|
<p className="text-xs text-muted-foreground mt-1">
|
|
{settings.thumbnail.format === "original"
|
|
? t("settings.formatOriginalDesc")
|
|
: t("settings.formatReencodeDesc")}
|
|
</p>
|
|
</FormField>
|
|
</FormRow>
|
|
<FormRow>
|
|
<FormField className="flex-1">
|
|
<label className="text-sm font-medium text-muted-foreground mb-1 block">{t("settings.width")}</label>
|
|
<FormInput
|
|
type="number"
|
|
min={50}
|
|
max={600}
|
|
value={settings.thumbnail.width}
|
|
onChange={(e) => {
|
|
const width = parseInt(e.target.value) || 300;
|
|
const newSettings = { ...settings, thumbnail: { ...settings.thumbnail, width } };
|
|
setSettings(newSettings);
|
|
}}
|
|
onBlur={() => handleUpdateSetting("thumbnail", settings.thumbnail)}
|
|
/>
|
|
</FormField>
|
|
<FormField className="flex-1">
|
|
<label className="text-sm font-medium text-muted-foreground mb-1 block">{t("settings.height")}</label>
|
|
<FormInput
|
|
type="number"
|
|
min={50}
|
|
max={800}
|
|
value={settings.thumbnail.height}
|
|
onChange={(e) => {
|
|
const height = parseInt(e.target.value) || 400;
|
|
const newSettings = { ...settings, thumbnail: { ...settings.thumbnail, height } };
|
|
setSettings(newSettings);
|
|
}}
|
|
onBlur={() => handleUpdateSetting("thumbnail", settings.thumbnail)}
|
|
/>
|
|
</FormField>
|
|
<FormField className="flex-1">
|
|
<label className="text-sm font-medium text-muted-foreground mb-1 block">{t("settings.quality")}</label>
|
|
<FormInput
|
|
type="number"
|
|
min={1}
|
|
max={100}
|
|
value={settings.thumbnail.quality}
|
|
onChange={(e) => {
|
|
const quality = parseInt(e.target.value) || 80;
|
|
const newSettings = { ...settings, thumbnail: { ...settings.thumbnail, quality } };
|
|
setSettings(newSettings);
|
|
}}
|
|
onBlur={() => handleUpdateSetting("thumbnail", settings.thumbnail)}
|
|
/>
|
|
</FormField>
|
|
</FormRow>
|
|
<FormRow>
|
|
<FormField className="flex-1">
|
|
<label className="text-sm font-medium text-muted-foreground mb-1 block">{t("settings.thumbnailDirectory")}</label>
|
|
<FormInput
|
|
value={settings.thumbnail.directory}
|
|
onChange={(e) => {
|
|
const newSettings = { ...settings, thumbnail: { ...settings.thumbnail, directory: e.target.value } };
|
|
setSettings(newSettings);
|
|
}}
|
|
onBlur={() => handleUpdateSetting("thumbnail", settings.thumbnail)}
|
|
/>
|
|
</FormField>
|
|
</FormRow>
|
|
|
|
<div className="grid grid-cols-3 gap-4 p-4 bg-muted/30 rounded-lg">
|
|
<div>
|
|
<p className="text-sm text-muted-foreground">{t("settings.totalSize")}</p>
|
|
<p className="text-2xl font-semibold">{thumbnailStats.total_size_mb.toFixed(2)} MB</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-sm text-muted-foreground">{t("settings.files")}</p>
|
|
<p className="text-2xl font-semibold">{thumbnailStats.file_count}</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-sm text-muted-foreground">{t("settings.directory")}</p>
|
|
<p className="text-sm font-mono truncate" title={thumbnailStats.directory}>{thumbnailStats.directory}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<p className="text-sm text-muted-foreground">
|
|
{t("settings.thumbnailsNote")}
|
|
</p>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
</>)}
|
|
|
|
{activeTab === "metadata" && (<>
|
|
{/* Metadata Providers */}
|
|
<MetadataProvidersCard handleUpdateSetting={handleUpdateSetting} />
|
|
|
|
{/* Status Mappings */}
|
|
<StatusMappingsCard />
|
|
</>)}
|
|
|
|
{activeTab === "downloadTools" && (<>
|
|
{/* Prowlarr */}
|
|
<ProwlarrCard handleUpdateSetting={handleUpdateSetting} />
|
|
|
|
{/* qBittorrent */}
|
|
<QBittorrentCard handleUpdateSetting={handleUpdateSetting} />
|
|
</>)}
|
|
|
|
{activeTab === "notifications" && (<>
|
|
{/* Telegram Notifications */}
|
|
<TelegramCard handleUpdateSetting={handleUpdateSetting} />
|
|
</>)}
|
|
|
|
{activeTab === "readingStatus" && (<>
|
|
<AnilistTab handleUpdateSetting={handleUpdateSetting} users={users} />
|
|
<KomgaSyncCard users={users} />
|
|
</>)}
|
|
|
|
<Toaster />
|
|
</>
|
|
);
|
|
}
|