Files
stripstream-librarian/apps/backoffice/app/settings/SettingsPage.tsx
Froidefond Julien 81d1586501 feat: add Telegram notification system with granular event toggles
Add notifications crate shared between API and indexer to send Telegram
messages on scan/thumbnail/conversion completion/failure, metadata linking,
batch and refresh events. Configurable via a new Notifications tab in the
backoffice settings with per-event toggle switches grouped by category.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 17:24:43 +01:00

1740 lines
72 KiB
TypeScript

"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<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 [saveMessage, setSaveMessage] = useState<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<KomgaSyncResponse | null>(null);
const [syncError, setSyncError] = useState<string | null>(null);
const [showUnmatched, setShowUnmatched] = useState(false);
const [reports, setReports] = useState<KomgaSyncReportSummary[]>([]);
const [selectedReport, setSelectedReport] = useState<KomgaSyncResponse | null>(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" | "notifications">("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: "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={() => setActiveTab(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>
{saveMessage && (
<Card className="mb-6 border-success/50 bg-success/5">
<CardContent className="pt-6">
<p className="text-success">{saveMessage}</p>
</CardContent>
</Card>
)}
{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 === "integrations" && (<>
{/* Metadata Providers */}
<MetadataProvidersCard handleUpdateSetting={handleUpdateSetting} />
{/* Status Mappings */}
<StatusMappingsCard />
{/* Prowlarr */}
<ProwlarrCard handleUpdateSetting={handleUpdateSetting} />
{/* qBittorrent */}
<QBittorrentCard handleUpdateSetting={handleUpdateSetting} />
{/* Komga Sync */}
<Card className="mb-6">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Icon name="refresh" size="md" />
{t("settings.komgaSync")}
</CardTitle>
<CardDescription>{t("settings.komgaDesc")}</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.komgaUrl")}</label>
<FormInput
type="url"
placeholder="https://komga.example.com"
value={komgaUrl}
onChange={(e) => setKomgaUrl(e.target.value)}
/>
</FormField>
</FormRow>
<FormRow>
<FormField className="flex-1">
<label className="text-sm font-medium text-muted-foreground mb-1 block">{t("settings.username")}</label>
<FormInput
value={komgaUsername}
onChange={(e) => setKomgaUsername(e.target.value)}
/>
</FormField>
<FormField className="flex-1">
<label className="text-sm font-medium text-muted-foreground mb-1 block">{t("settings.password")}</label>
<FormInput
type="password"
value={komgaPassword}
onChange={(e) => setKomgaPassword(e.target.value)}
/>
</FormField>
</FormRow>
<Button
onClick={handleKomgaSync}
disabled={isSyncing || !komgaUrl || !komgaUsername || !komgaPassword}
>
{isSyncing ? (
<>
<Icon name="spinner" size="sm" className="animate-spin -ml-1 mr-2" />
{t("settings.syncing")}
</>
) : (
<>
<Icon name="refresh" size="sm" className="mr-2" />
{t("settings.syncReadBooks")}
</>
)}
</Button>
{syncError && (
<div className="p-3 rounded-lg bg-destructive/10 text-destructive">
{syncError}
</div>
)}
{syncResult && (
<div className="space-y-3">
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 p-4 bg-muted/30 rounded-lg">
<div>
<p className="text-sm text-muted-foreground">{t("settings.komgaRead")}</p>
<p className="text-2xl font-semibold">{syncResult.total_komga_read}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">{t("settings.matched")}</p>
<p className="text-2xl font-semibold">{syncResult.matched}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">{t("settings.alreadyRead")}</p>
<p className="text-2xl font-semibold">{syncResult.already_read}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">{t("settings.newlyMarked")}</p>
<p className="text-2xl font-semibold text-success">{syncResult.newly_marked}</p>
</div>
</div>
{syncResult.matched_books.length > 0 && (
<div>
<button
type="button"
onClick={() => setShowMatchedBooks(!showMatchedBooks)}
className="text-sm text-muted-foreground hover:text-foreground flex items-center gap-1"
>
<Icon name={showMatchedBooks ? "chevronDown" : "chevronRight"} size="sm" />
{t("settings.matchedBooks", { count: syncResult.matched_books.length, plural: syncResult.matched_books.length !== 1 ? "s" : "" })}
</button>
{showMatchedBooks && (
<div className="mt-2 max-h-60 overflow-y-auto p-3 bg-success/5 rounded-lg text-sm space-y-1">
{syncResult.matched_books.map((title, i) => (
<p key={i} className="text-foreground truncate flex items-center gap-1.5" title={title}>
{syncNewlyMarkedSet.has(title) && (
<Icon name="check" size="sm" className="text-success shrink-0" />
)}
{title}
</p>
))}
</div>
)}
</div>
)}
{syncResult.unmatched.length > 0 && (
<div>
<button
type="button"
onClick={() => setShowUnmatched(!showUnmatched)}
className="text-sm text-muted-foreground hover:text-foreground flex items-center gap-1"
>
<Icon name={showUnmatched ? "chevronDown" : "chevronRight"} size="sm" />
{t("settings.unmatchedBooks", { count: syncResult.unmatched.length, plural: syncResult.unmatched.length !== 1 ? "s" : "" })}
</button>
{showUnmatched && (
<div className="mt-2 max-h-60 overflow-y-auto p-3 bg-muted/20 rounded-lg text-sm space-y-1">
{syncResult.unmatched.map((title, i) => (
<p key={i} className="text-muted-foreground truncate" title={title}>{title}</p>
))}
</div>
)}
</div>
)}
</div>
)}
{/* Past reports */}
{reports.length > 0 && (
<div className="border-t border-border pt-4">
<h3 className="text-sm font-medium text-foreground mb-3">{t("settings.syncHistory")}</h3>
<div className="space-y-2">
{reports.map((r) => (
<button
key={r.id}
type="button"
onClick={() => handleViewReport(r.id)}
className={`w-full text-left p-3 rounded-lg border transition-colors ${
selectedReport?.id === r.id
? "border-primary bg-primary/5"
: "border-border/60 bg-muted/20 hover:bg-muted/40"
}`}
>
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-foreground">
{new Date(r.created_at).toLocaleString(locale)}
</span>
<span className="text-xs text-muted-foreground truncate ml-2" title={r.komga_url}>
{r.komga_url}
</span>
</div>
<div className="flex gap-4 mt-1 text-xs text-muted-foreground">
<span>{r.total_komga_read} {t("settings.read")}</span>
<span>{r.matched} {t("settings.matched").toLowerCase()}</span>
<span className="text-success">{r.newly_marked} {t("settings.new")}</span>
{r.unmatched_count > 0 && (
<span className="text-warning">{r.unmatched_count} {t("settings.unmatched")}</span>
)}
</div>
</button>
))}
</div>
{/* Selected report detail */}
{selectedReport && (
<div className="mt-3 space-y-3">
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 p-4 bg-muted/30 rounded-lg">
<div>
<p className="text-sm text-muted-foreground">{t("settings.komgaRead")}</p>
<p className="text-2xl font-semibold">{selectedReport.total_komga_read}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">{t("settings.matched")}</p>
<p className="text-2xl font-semibold">{selectedReport.matched}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">{t("settings.alreadyRead")}</p>
<p className="text-2xl font-semibold">{selectedReport.already_read}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">{t("settings.newlyMarked")}</p>
<p className="text-2xl font-semibold text-success">{selectedReport.newly_marked}</p>
</div>
</div>
{selectedReport.matched_books && selectedReport.matched_books.length > 0 && (
<div>
<button
type="button"
onClick={() => setShowReportMatchedBooks(!showReportMatchedBooks)}
className="text-sm text-muted-foreground hover:text-foreground flex items-center gap-1"
>
<Icon name={showReportMatchedBooks ? "chevronDown" : "chevronRight"} size="sm" />
{t("settings.matchedBooks", { count: selectedReport.matched_books.length, plural: selectedReport.matched_books.length !== 1 ? "s" : "" })}
</button>
{showReportMatchedBooks && (
<div className="mt-2 max-h-60 overflow-y-auto p-3 bg-success/5 rounded-lg text-sm space-y-1">
{selectedReport.matched_books.map((title, i) => (
<p key={i} className="text-foreground truncate flex items-center gap-1.5" title={title}>
{reportNewlyMarkedSet.has(title) && (
<Icon name="check" size="sm" className="text-success shrink-0" />
)}
{title}
</p>
))}
</div>
)}
</div>
)}
{selectedReport.unmatched.length > 0 && (
<div>
<button
type="button"
onClick={() => setShowReportUnmatched(!showReportUnmatched)}
className="text-sm text-muted-foreground hover:text-foreground flex items-center gap-1"
>
<Icon name={showReportUnmatched ? "chevronDown" : "chevronRight"} size="sm" />
{t("settings.unmatchedBooks", { count: selectedReport.unmatched.length, plural: selectedReport.unmatched.length !== 1 ? "s" : "" })}
</button>
{showReportUnmatched && (
<div className="mt-2 max-h-60 overflow-y-auto p-3 bg-muted/20 rounded-lg text-sm space-y-1">
{selectedReport.unmatched.map((title, i) => (
<p key={i} className="text-muted-foreground truncate" title={title}>{title}</p>
))}
</div>
)}
</div>
)}
</div>
)}
</div>
)}
</div>
</CardContent>
</Card>
</>)}
{activeTab === "notifications" && (<>
{/* Telegram Notifications */}
<TelegramCard handleUpdateSetting={handleUpdateSetting} />
</>)}
</>
);
}
// ---------------------------------------------------------------------------
// 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<void> }) {
const { t } = useTranslation();
const [defaultProvider, setDefaultProvider] = useState("google_books");
const [metadataLanguage, setMetadataLanguage] = useState("en");
const [apiKeys, setApiKeys] = useState<Record<string, string>>({});
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<string, string>) {
const value: Record<string, unknown> = {
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 (
<Card className="mb-6">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Icon name="search" size="md" />
{t("settings.metadataProviders")}
</CardTitle>
<CardDescription>{t("settings.metadataProvidersDesc")}</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-6">
{/* Default provider */}
<div>
<label className="text-sm font-medium text-muted-foreground mb-2 block">{t("settings.defaultProvider")}</label>
<div className="flex gap-2 flex-wrap">
{([
{ 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) => (
<button
key={p.value}
type="button"
onClick={() => {
setDefaultProvider(p.value);
save(p.value, metadataLanguage, apiKeys);
}}
className={`inline-flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium border transition-colors ${
defaultProvider === p.value
? "border-primary bg-primary/10 text-primary"
: "border-border bg-card text-muted-foreground hover:text-foreground hover:border-primary/50"
}`}
>
<ProviderIcon provider={p.value} size={18} />
{p.label}
</button>
))}
</div>
<p className="text-xs text-muted-foreground mt-2">{t("settings.defaultProviderHelp")}</p>
</div>
{/* Metadata language */}
<div>
<label className="text-sm font-medium text-muted-foreground mb-2 block">{t("settings.metadataLanguage")}</label>
<div className="flex gap-2">
{METADATA_LANGUAGES.map((l) => (
<button
key={l.value}
type="button"
onClick={() => {
setMetadataLanguage(l.value);
save(defaultProvider, l.value, apiKeys);
}}
className={`px-3 py-2 rounded-lg text-sm font-medium border transition-colors ${
metadataLanguage === l.value
? "border-primary bg-primary/10 text-primary"
: "border-border bg-card text-muted-foreground hover:text-foreground hover:border-primary/50"
}`}
>
{l.label}
</button>
))}
</div>
<p className="text-xs text-muted-foreground mt-2">{t("settings.metadataLanguageHelp")}</p>
</div>
{/* Provider API keys — always visible */}
<div className="border-t border-border/50 pt-4">
<h4 className="text-sm font-medium text-foreground mb-3">{t("settings.apiKeys")}</h4>
<div className="space-y-4">
<FormField>
<label className="text-sm font-medium text-muted-foreground mb-1 flex items-center gap-1.5">
<ProviderIcon provider="google_books" size={16} />
{t("settings.googleBooksKey")}
</label>
<FormInput
type="password"
placeholder={t("settings.googleBooksPlaceholder")}
value={apiKeys.google_books || ""}
onChange={(e) => setApiKeys({ ...apiKeys, google_books: e.target.value })}
onBlur={() => save(defaultProvider, metadataLanguage, apiKeys)}
/>
<p className="text-xs text-muted-foreground mt-1">{t("settings.googleBooksHelp")}</p>
</FormField>
<FormField>
<label className="text-sm font-medium text-muted-foreground mb-1 flex items-center gap-1.5">
<ProviderIcon provider="comicvine" size={16} />
{t("settings.comicvineKey")}
</label>
<FormInput
type="password"
placeholder={t("settings.comicvinePlaceholder")}
value={apiKeys.comicvine || ""}
onChange={(e) => setApiKeys({ ...apiKeys, comicvine: e.target.value })}
onBlur={() => save(defaultProvider, metadataLanguage, apiKeys)}
/>
<p className="text-xs text-muted-foreground mt-1">{t("settings.comicvineHelp")} <span className="font-mono text-foreground/70">comicvine.gamespot.com/api</span>.</p>
</FormField>
<div className="p-3 rounded-lg bg-muted/30 flex items-center gap-3 flex-wrap">
<div className="flex items-center gap-1.5">
<ProviderIcon provider="open_library" size={16} />
<span className="text-xs font-medium text-foreground">Open Library</span>
</div>
<span className="text-xs text-muted-foreground">,</span>
<div className="flex items-center gap-1.5">
<ProviderIcon provider="anilist" size={16} />
<span className="text-xs font-medium text-foreground">AniList</span>
</div>
<span className="text-xs text-muted-foreground">{t("common.and")}</span>
<div className="flex items-center gap-1.5">
<ProviderIcon provider="bedetheque" size={16} />
<span className="text-xs font-medium text-foreground">Bédéthèque</span>
</div>
<span className="text-xs text-muted-foreground">{t("settings.freeProviders")}</span>
</div>
</div>
</div>
</div>
</CardContent>
</Card>
);
}
// ---------------------------------------------------------------------------
// Status Mappings sub-component
// ---------------------------------------------------------------------------
function StatusMappingsCard() {
const { t } = useTranslation();
const [mappings, setMappings] = useState<StatusMappingDto[]>([]);
const [targetStatuses, setTargetStatuses] = useState<string[]>([]);
const [providerStatuses, setProviderStatuses] = useState<string[]>([]);
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<string, StatusMappingDto[]>();
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<string[]>([]);
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<typeof t>[0];
const translated = t(key);
return translated !== key ? translated : status;
}
if (loading) {
return (
<Card className="mb-6">
<CardContent><p className="text-muted-foreground py-4">{t("common.loading")}</p></CardContent>
</Card>
);
}
return (
<Card className="mb-6">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Icon name="settings" size="md" />
{t("settings.statusMappings")}
</CardTitle>
<CardDescription>{t("settings.statusMappingsDesc")}</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{/* Create new target status */}
<div className="flex gap-2 items-center">
<FormInput
placeholder={t("settings.newTargetPlaceholder")}
value={newTargetName}
onChange={(e) => setNewTargetName(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") handleCreateTarget(); }}
className="max-w-[250px]"
/>
<Button
onClick={handleCreateTarget}
disabled={!newTargetName.trim() || allTargets.includes(newTargetName.trim().toLowerCase())}
>
<Icon name="plus" size="sm" />
{t("settings.createTargetStatus")}
</Button>
</div>
{/* Grouped by target status */}
{allTargets.map((target) => {
const items = grouped.get(target) || [];
return (
<div key={target} className="border border-border/50 rounded-lg p-3">
<div className="flex items-center gap-2 mb-2">
<span className="text-sm font-medium text-foreground">
{statusLabel(target)}
</span>
<span className="text-xs text-muted-foreground font-mono">({target})</span>
</div>
<div className="flex flex-wrap gap-2">
{items.map((m) => (
<span
key={m.id}
className="inline-flex items-center gap-1 px-2 py-1 rounded-md bg-muted/50 text-sm font-mono"
>
{m.provider_status}
<button
type="button"
onClick={() => handleUnmap(m.id)}
className="ml-1 text-muted-foreground hover:text-destructive transition-colors"
title={t("common.delete")}
>
<Icon name="x" size="sm" />
</button>
</span>
))}
{items.length === 0 && (
<span className="text-xs text-muted-foreground italic">{t("settings.noMappings")}</span>
)}
</div>
</div>
);
})}
{/* Unmapped provider statuses (null mapped_status + brand new from providers) */}
{(unmappedMappings.length > 0 || newProviderStatuses.length > 0) && (
<div className="border-t border-border/50 pt-4">
<h4 className="text-sm font-medium text-foreground mb-3">{t("settings.unmappedSection")}</h4>
<div className="space-y-2">
{unmappedMappings.map((m) => (
<div key={m.id} className="flex items-center gap-2">
<span className="text-sm font-mono bg-muted/50 px-2 py-1 rounded-md min-w-[120px]">{m.provider_status}</span>
<Icon name="chevronRight" size="sm" />
<FormSelect
className="w-auto"
value=""
onChange={(e) => { if (e.target.value) handleAssign(m.provider_status, e.target.value); }}
>
<option value="">{t("settings.selectTargetStatus")}</option>
{allTargets.map((s) => (
<option key={s} value={s}>{statusLabel(s)}</option>
))}
</FormSelect>
</div>
))}
{newProviderStatuses.map((ps) => (
<div key={ps} className="flex items-center gap-2">
<span className="text-sm font-mono bg-muted/50 px-2 py-1 rounded-md min-w-[120px]">{ps}</span>
<Icon name="chevronRight" size="sm" />
<FormSelect
className="w-auto"
value=""
onChange={(e) => { if (e.target.value) handleAssign(ps, e.target.value); }}
>
<option value="">{t("settings.selectTargetStatus")}</option>
{allTargets.map((s) => (
<option key={s} value={s}>{statusLabel(s)}</option>
))}
</FormSelect>
</div>
))}
</div>
</div>
)}
</div>
</CardContent>
</Card>
);
}
// ---------------------------------------------------------------------------
// Prowlarr sub-component
// ---------------------------------------------------------------------------
function ProwlarrCard({ handleUpdateSetting }: { handleUpdateSetting: (key: string, value: unknown) => Promise<void> }) {
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 (
<Card className="mb-6">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Icon name="search" size="md" />
{t("settings.prowlarr")}
</CardTitle>
<CardDescription>{t("settings.prowlarrDesc")}</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.prowlarrUrl")}</label>
<FormInput
type="url"
placeholder={t("settings.prowlarrUrlPlaceholder")}
value={prowlarrUrl}
onChange={(e) => setProwlarrUrl(e.target.value)}
onBlur={() => saveProwlarr()}
/>
</FormField>
</FormRow>
<FormRow>
<FormField className="flex-1">
<label className="text-sm font-medium text-muted-foreground mb-1 block">{t("settings.prowlarrApiKey")}</label>
<FormInput
type="password"
placeholder={t("settings.prowlarrApiKeyPlaceholder")}
value={prowlarrApiKey}
onChange={(e) => setProwlarrApiKey(e.target.value)}
onBlur={() => saveProwlarr()}
/>
</FormField>
</FormRow>
<FormRow>
<FormField className="flex-1">
<label className="text-sm font-medium text-muted-foreground mb-1 block">{t("settings.prowlarrCategories")}</label>
<FormInput
type="text"
placeholder="7030, 7020"
value={prowlarrCategories}
onChange={(e) => setProwlarrCategories(e.target.value)}
onBlur={() => saveProwlarr()}
/>
<p className="text-xs text-muted-foreground mt-1">{t("settings.prowlarrCategoriesHelp")}</p>
</FormField>
</FormRow>
<div className="flex items-center gap-3">
<Button
onClick={handleTestConnection}
disabled={isTesting || !prowlarrUrl || !prowlarrApiKey}
>
{isTesting ? (
<>
<Icon name="spinner" size="sm" className="animate-spin -ml-1 mr-2" />
{t("settings.testing")}
</>
) : (
<>
<Icon name="refresh" size="sm" className="mr-2" />
{t("settings.testConnection")}
</>
)}
</Button>
{testResult && (
<span className={`text-sm font-medium ${testResult.success ? "text-success" : "text-destructive"}`}>
{testResult.message}
</span>
)}
</div>
</div>
</CardContent>
</Card>
);
}
// ---------------------------------------------------------------------------
// qBittorrent sub-component
// ---------------------------------------------------------------------------
function QBittorrentCard({ handleUpdateSetting }: { handleUpdateSetting: (key: string, value: unknown) => Promise<void> }) {
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 (
<Card className="mb-6">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Icon name="settings" size="md" />
{t("settings.qbittorrent")}
</CardTitle>
<CardDescription>{t("settings.qbittorrentDesc")}</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.qbittorrentUrl")}</label>
<FormInput
type="url"
placeholder={t("settings.qbittorrentUrlPlaceholder")}
value={qbUrl}
onChange={(e) => setQbUrl(e.target.value)}
onBlur={() => saveQbittorrent()}
/>
</FormField>
</FormRow>
<FormRow>
<FormField className="flex-1">
<label className="text-sm font-medium text-muted-foreground mb-1 block">{t("settings.qbittorrentUsername")}</label>
<FormInput
type="text"
value={qbUsername}
onChange={(e) => setQbUsername(e.target.value)}
onBlur={() => saveQbittorrent()}
/>
</FormField>
<FormField className="flex-1">
<label className="text-sm font-medium text-muted-foreground mb-1 block">{t("settings.qbittorrentPassword")}</label>
<FormInput
type="password"
value={qbPassword}
onChange={(e) => setQbPassword(e.target.value)}
onBlur={() => saveQbittorrent()}
/>
</FormField>
</FormRow>
<div className="flex items-center gap-3">
<Button
onClick={handleTestConnection}
disabled={isTesting || !qbUrl || !qbUsername}
>
{isTesting ? (
<>
<Icon name="spinner" size="sm" className="animate-spin -ml-1 mr-2" />
{t("settings.testing")}
</>
) : (
<>
<Icon name="refresh" size="sm" className="mr-2" />
{t("settings.testConnection")}
</>
)}
</Button>
{testResult && (
<span className={`text-sm font-medium ${testResult.success ? "text-success" : "text-destructive"}`}>
{testResult.message}
</span>
)}
</div>
</div>
</CardContent>
</Card>
);
}
// ---------------------------------------------------------------------------
// 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<void> }) {
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 (
<Card className="mb-6">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Icon name="bell" size="md" />
{t("settings.telegram")}
</CardTitle>
<CardDescription>{t("settings.telegramDesc")}</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{/* Setup guide */}
<div>
<button
type="button"
onClick={() => setShowHelp(!showHelp)}
className="text-sm text-primary hover:text-primary/80 flex items-center gap-1 transition-colors"
>
<Icon name={showHelp ? "chevronDown" : "chevronRight"} size="sm" />
{t("settings.telegramHelp")}
</button>
{showHelp && (
<div className="mt-3 p-4 rounded-lg bg-muted/30 space-y-3 text-sm text-foreground">
<div>
<p className="font-medium mb-1">1. Bot Token</p>
<p className="text-muted-foreground" dangerouslySetInnerHTML={{ __html: t("settings.telegramHelpBot") }} />
</div>
<div>
<p className="font-medium mb-1">2. Chat ID</p>
<p className="text-muted-foreground" dangerouslySetInnerHTML={{ __html: t("settings.telegramHelpChat") }} />
</div>
<div>
<p className="font-medium mb-1">3. Group chat</p>
<p className="text-muted-foreground" dangerouslySetInnerHTML={{ __html: t("settings.telegramHelpGroup") }} />
</div>
</div>
)}
</div>
<div className="flex items-center gap-3">
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={enabled}
onChange={(e) => {
setEnabled(e.target.checked);
saveTelegram(undefined, undefined, e.target.checked);
}}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-muted rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary"></div>
</label>
<span className="text-sm font-medium text-foreground">{t("settings.telegramEnabled")}</span>
</div>
<FormRow>
<FormField className="flex-1">
<label className="text-sm font-medium text-muted-foreground mb-1 block">{t("settings.botToken")}</label>
<FormInput
type="password"
placeholder={t("settings.botTokenPlaceholder")}
value={botToken}
onChange={(e) => setBotToken(e.target.value)}
onBlur={() => saveTelegram()}
/>
</FormField>
</FormRow>
<FormRow>
<FormField className="flex-1">
<label className="text-sm font-medium text-muted-foreground mb-1 block">{t("settings.chatId")}</label>
<FormInput
type="text"
placeholder={t("settings.chatIdPlaceholder")}
value={chatId}
onChange={(e) => setChatId(e.target.value)}
onBlur={() => saveTelegram()}
/>
</FormField>
</FormRow>
{/* Event toggles grouped by category */}
<div className="border-t border-border/50 pt-4">
<h4 className="text-sm font-medium text-foreground mb-4">{t("settings.telegramEvents")}</h4>
<div className="grid grid-cols-2 gap-x-6 gap-y-5">
{([
{
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 }) => (
<div key={category}>
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-2 flex items-center gap-1.5">
<Icon name={icon} size="sm" className="text-muted-foreground" />
{category}
</p>
<div className="space-y-1">
{items.map(({ key, label }) => (
<label key={key} className="flex items-center justify-between py-1.5 cursor-pointer group">
<span className="text-sm text-foreground group-hover:text-foreground/80">{label}</span>
<div className="relative">
<input
type="checkbox"
checked={events[key]}
onChange={(e) => {
const updated = { ...events, [key]: e.target.checked };
setEvents(updated);
saveTelegram(undefined, undefined, undefined, updated);
}}
className="sr-only peer"
/>
<div className="w-9 h-5 bg-muted rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-primary" />
</div>
</label>
))}
</div>
</div>
))}
</div>
</div>
<div className="flex items-center gap-3">
<Button
onClick={handleTestConnection}
disabled={isTesting || !botToken || !chatId || !enabled}
>
{isTesting ? (
<>
<Icon name="spinner" size="sm" className="animate-spin -ml-1 mr-2" />
{t("settings.testing")}
</>
) : (
<>
<Icon name="refresh" size="sm" className="mr-2" />
{t("settings.testConnection")}
</>
)}
</Button>
{testResult && (
<span className={`text-sm font-medium ${testResult.success ? "text-success" : "text-destructive"}`}>
{testResult.message}
</span>
)}
</div>
</div>
</CardContent>
</Card>
);
}