feat: SSR pour toutes les cards de la page Settings

Toutes les configurations (Prowlarr, qBittorrent, Telegram, Anilist,
Komga, metadata providers, status mappings) sont maintenant récupérées
côté serveur dans page.tsx et passées en props aux cards.

Supprime ~10 fetchs client useEffect au chargement, élimine les
layout shifts et réduit le temps de rendu initial.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-27 09:11:12 +01:00
parent 432bb519ab
commit e7295a371d
9 changed files with 111 additions and 173 deletions

View File

@@ -20,9 +20,19 @@ interface SettingsPageProps {
initialThumbnailStats: ThumbnailStats;
users: UserDto[];
initialTab?: string;
initialProwlarr: Record<string, unknown> | null;
initialQbittorrent: Record<string, unknown> | null;
initialTorrentImport: Record<string, unknown> | null;
initialTelegram: Record<string, unknown> | null;
initialAnilist: Record<string, unknown> | null;
initialKomga: Record<string, unknown> | null;
initialMetadataProviders: Record<string, unknown> | null;
initialStatusMappings: Record<string, unknown>[];
initialSeriesStatuses: string[];
initialProviderStatuses: string[];
}
export default function SettingsPage({ initialSettings, initialCacheStats, initialThumbnailStats, users, initialTab }: SettingsPageProps) {
export default function SettingsPage({ initialSettings, initialCacheStats, initialThumbnailStats, users, initialTab, initialProwlarr, initialQbittorrent, initialTorrentImport, initialTelegram, initialAnilist, initialKomga, initialMetadataProviders, initialStatusMappings, initialSeriesStatuses, initialProviderStatuses }: SettingsPageProps) {
const { t, locale, setLocale } = useTranslation();
const router = useRouter();
const searchParams = useSearchParams();
@@ -521,28 +531,28 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
{activeTab === "metadata" && (<>
{/* Metadata Providers */}
<MetadataProvidersCard handleUpdateSetting={handleUpdateSetting} />
<MetadataProvidersCard handleUpdateSetting={handleUpdateSetting} initialData={initialMetadataProviders} />
{/* Status Mappings */}
<StatusMappingsCard />
<StatusMappingsCard initialStatusMappings={initialStatusMappings} initialSeriesStatuses={initialSeriesStatuses} initialProviderStatuses={initialProviderStatuses} />
</>)}
{activeTab === "downloadTools" && (<>
{/* Prowlarr */}
<ProwlarrCard handleUpdateSetting={handleUpdateSetting} />
<ProwlarrCard handleUpdateSetting={handleUpdateSetting} initialData={initialProwlarr} />
{/* qBittorrent */}
<QBittorrentCard handleUpdateSetting={handleUpdateSetting} />
<QBittorrentCard handleUpdateSetting={handleUpdateSetting} initialQbittorrent={initialQbittorrent} initialTorrentImport={initialTorrentImport} />
</>)}
{activeTab === "notifications" && (<>
{/* Telegram Notifications */}
<TelegramCard handleUpdateSetting={handleUpdateSetting} />
<TelegramCard handleUpdateSetting={handleUpdateSetting} initialData={initialTelegram} />
</>)}
{activeTab === "readingStatus" && (<>
<AnilistTab handleUpdateSetting={handleUpdateSetting} users={users} />
<KomgaSyncCard users={users} />
<AnilistTab handleUpdateSetting={handleUpdateSetting} users={users} initialData={initialAnilist} />
<KomgaSyncCard users={users} initialData={initialKomga} />
</>)}
<Toaster />

View File

@@ -8,19 +8,21 @@ import { useTranslation } from "@/lib/i18n/context";
export function AnilistTab({
handleUpdateSetting,
users,
initialData,
}: {
handleUpdateSetting: (key: string, value: unknown) => Promise<void>;
users: UserDto[];
initialData: Record<string, unknown> | null;
}) {
const { t } = useTranslation();
const [origin, setOrigin] = useState("");
useEffect(() => { setOrigin(window.location.origin); }, []);
const [clientId, setClientId] = useState("");
const [token, setToken] = useState("");
const [userId, setUserId] = useState("");
const [localUserId, setLocalUserId] = useState("");
const [clientId, setClientId] = useState(initialData?.client_id ? String(initialData.client_id) : "");
const [token, setToken] = useState(initialData?.access_token ? String(initialData.access_token) : "");
const [userId, setUserId] = useState(initialData?.user_id ? String(initialData.user_id) : "");
const [localUserId, setLocalUserId] = useState(initialData?.local_user_id ? String(initialData.local_user_id) : "");
const [isTesting, setIsTesting] = useState(false);
const [viewer, setViewer] = useState<AnilistStatusDto | null>(null);
const [testError, setTestError] = useState<string | null>(null);
@@ -33,21 +35,6 @@ export function AnilistTab({
const [isPreviewing, setIsPreviewing] = useState(false);
const [previewItems, setPreviewItems] = useState<AnilistSyncPreviewItemDto[] | null>(null);
useEffect(() => {
fetch("/api/settings/anilist")
.then((r) => r.ok ? r.json() : null)
.then((data) => {
if (data) {
if (data.client_id) setClientId(String(data.client_id));
if (data.access_token) setToken(data.access_token);
if (data.user_id) setUserId(String(data.user_id));
if (data.local_user_id) setLocalUserId(String(data.local_user_id));
}
})
.catch(() => {});
}, []);
function buildAnilistSettings() {
return {
client_id: clientId || undefined,

View File

@@ -5,12 +5,12 @@ import { Card, CardHeader, CardTitle, CardDescription, CardContent, Button, Form
import { KomgaSyncResponse, KomgaSyncReportSummary, UserDto } from "@/lib/api";
import { useTranslation } from "@/lib/i18n/context";
export function KomgaSyncCard({ users }: { users: UserDto[] }) {
export function KomgaSyncCard({ users, initialData }: { users: UserDto[]; initialData: Record<string, unknown> | null }) {
const { t, locale } = useTranslation();
const [komgaUrl, setKomgaUrl] = useState("");
const [komgaUsername, setKomgaUsername] = useState("");
const [komgaUrl, setKomgaUrl] = useState(initialData?.url ? String(initialData.url) : "");
const [komgaUsername, setKomgaUsername] = useState(initialData?.username ? String(initialData.username) : "");
const [komgaPassword, setKomgaPassword] = useState("");
const [komgaUserId, setKomgaUserId] = useState(users[0]?.id ?? "");
const [komgaUserId, setKomgaUserId] = useState(initialData?.user_id ? String(initialData.user_id) : (users[0]?.id ?? ""));
const [isSyncing, setIsSyncing] = useState(false);
const [syncResult, setSyncResult] = useState<KomgaSyncResponse | null>(null);
const [syncError, setSyncError] = useState<string | null>(null);
@@ -39,13 +39,6 @@ export function KomgaSyncCard({ users }: { users: UserDto[] }) {
useEffect(() => {
fetchReports();
fetch("/api/settings/komga").then(r => r.ok ? r.json() : null).then(data => {
if (data) {
if (data.url) setKomgaUrl(data.url);
if (data.username) setKomgaUsername(data.username);
if (data.user_id) setKomgaUserId(data.user_id);
}
}).catch(() => {});
}, [fetchReports]);
async function handleViewReport(id: string) {

View File

@@ -1,6 +1,6 @@
"use client";
import { useState, useEffect } from "react";
import { useState } from "react";
import { Card, CardHeader, CardTitle, CardDescription, CardContent, FormField, FormInput, FormSelect, Icon } from "@/app/components/ui";
import { ProviderIcon } from "@/app/components/ProviderIcon";
import { useTranslation } from "@/lib/i18n/context";
@@ -11,25 +11,22 @@ export const METADATA_LANGUAGES = [
{ value: "es", label: "Español" },
] as const;
export 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>>({});
function extractInitialApiKeys(data: Record<string, unknown> | null): Record<string, string> {
const keys: Record<string, string> = {};
if (data) {
const comicvine = data.comicvine as Record<string, unknown> | undefined;
const googleBooks = data.google_books as Record<string, unknown> | undefined;
if (comicvine?.api_key) keys.comicvine = String(comicvine.api_key);
if (googleBooks?.api_key) keys.google_books = String(googleBooks.api_key);
}
return keys;
}
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(() => {});
}, []);
export function MetadataProvidersCard({ handleUpdateSetting, initialData }: { handleUpdateSetting: (key: string, value: unknown) => Promise<void>; initialData: Record<string, unknown> | null }) {
const { t } = useTranslation();
const [defaultProvider, setDefaultProvider] = useState(initialData?.default_provider ? String(initialData.default_provider) : "google_books");
const [metadataLanguage, setMetadataLanguage] = useState(initialData?.metadata_language ? String(initialData.metadata_language) : "en");
const [apiKeys, setApiKeys] = useState<Record<string, string>>(extractInitialApiKeys(initialData));
function save(provider: string, lang: string, keys: Record<string, string>) {
const value: Record<string, unknown> = {

View File

@@ -1,30 +1,19 @@
"use client";
import { useState, useEffect } from "react";
import { useState } from "react";
import { Card, CardHeader, CardTitle, CardDescription, CardContent, Button, FormField, FormInput, Icon } from "@/app/components/ui";
import { useTranslation } from "@/lib/i18n/context";
export function ProwlarrCard({ handleUpdateSetting }: { handleUpdateSetting: (key: string, value: unknown) => Promise<void> }) {
export function ProwlarrCard({ handleUpdateSetting, initialData }: { handleUpdateSetting: (key: string, value: unknown) => Promise<void>; initialData: Record<string, unknown> | null }) {
const { t } = useTranslation();
const [prowlarrUrl, setProwlarrUrl] = useState("");
const [prowlarrApiKey, setProwlarrApiKey] = useState("");
const [prowlarrCategories, setProwlarrCategories] = useState("7030, 7020");
const [prowlarrUrl, setProwlarrUrl] = useState(initialData?.url ? String(initialData.url) : "");
const [prowlarrApiKey, setProwlarrApiKey] = useState(initialData?.api_key ? String(initialData.api_key) : "");
const [prowlarrCategories, setProwlarrCategories] = useState(
Array.isArray(initialData?.categories) ? (initialData.categories as number[]).join(", ") : "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(",")

View File

@@ -1,34 +1,17 @@
"use client";
import { useState, useEffect } from "react";
import { useState } from "react";
import { Card, CardHeader, CardTitle, CardDescription, CardContent, Button, FormField, FormInput, FormSelect, Icon } from "@/app/components/ui";
import { useTranslation } from "@/lib/i18n/context";
export function QBittorrentCard({ handleUpdateSetting }: { handleUpdateSetting: (key: string, value: unknown) => Promise<void> }) {
export function QBittorrentCard({ handleUpdateSetting, initialQbittorrent, initialTorrentImport }: { handleUpdateSetting: (key: string, value: unknown) => Promise<void>; initialQbittorrent: Record<string, unknown> | null; initialTorrentImport: Record<string, unknown> | null }) {
const { t } = useTranslation();
const [qbUrl, setQbUrl] = useState("");
const [qbUsername, setQbUsername] = useState("");
const [qbPassword, setQbPassword] = useState("");
const [qbUrl, setQbUrl] = useState(initialQbittorrent?.url ? String(initialQbittorrent.url) : "");
const [qbUsername, setQbUsername] = useState(initialQbittorrent?.username ? String(initialQbittorrent.username) : "");
const [qbPassword, setQbPassword] = useState(initialQbittorrent?.password ? String(initialQbittorrent.password) : "");
const [isTesting, setIsTesting] = useState(false);
const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null);
const [importEnabled, setImportEnabled] = useState(false);
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(() => {});
fetch("/api/settings/torrent_import")
.then((r) => (r.ok ? r.json() : null))
.then((data) => { if (data?.enabled !== undefined) setImportEnabled(data.enabled); })
.catch(() => {});
}, []);
const [importEnabled, setImportEnabled] = useState(initialTorrentImport?.enabled === true);
function saveQbittorrent() {
handleUpdateSetting("qbittorrent", {

View File

@@ -1,36 +1,16 @@
"use client";
import { useState, useEffect, useCallback, useMemo } from "react";
import { useState, useMemo } from "react";
import { Card, CardHeader, CardTitle, CardDescription, CardContent, Button, FormField, FormInput, FormSelect, Icon } from "@/app/components/ui";
import { StatusMappingDto } from "@/lib/api";
import { useTranslation } from "@/lib/i18n/context";
export function StatusMappingsCard() {
export function StatusMappingsCard({ initialStatusMappings, initialSeriesStatuses, initialProviderStatuses }: { initialStatusMappings: Record<string, unknown>[]; initialSeriesStatuses: string[]; initialProviderStatuses: string[] }) {
const { t } = useTranslation();
const [mappings, setMappings] = useState<StatusMappingDto[]>([]);
const [targetStatuses, setTargetStatuses] = useState<string[]>([]);
const [providerStatuses, setProviderStatuses] = useState<string[]>([]);
const [mappings, setMappings] = useState<StatusMappingDto[]>(initialStatusMappings as unknown as StatusMappingDto[]);
const [targetStatuses, setTargetStatuses] = useState<string[]>(initialSeriesStatuses);
const [providerStatuses] = useState<string[]>(initialProviderStatuses);
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(() => {
@@ -108,14 +88,6 @@ export function StatusMappingsCard() {
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>

View File

@@ -1,6 +1,6 @@
"use client";
import { useState, useEffect } from "react";
import { useState } from "react";
import { Card, CardHeader, CardTitle, CardDescription, CardContent, Button, FormField, FormInput, Icon } from "@/app/components/ui";
import { useTranslation } from "@/lib/i18n/context";
@@ -25,30 +25,18 @@ export const DEFAULT_EVENTS = {
download_detection_failed: true,
};
export function TelegramCard({ handleUpdateSetting }: { handleUpdateSetting: (key: string, value: unknown) => Promise<void> }) {
export function TelegramCard({ handleUpdateSetting, initialData }: { handleUpdateSetting: (key: string, value: unknown) => Promise<void>; initialData: Record<string, unknown> | null }) {
const { t } = useTranslation();
const [botToken, setBotToken] = useState("");
const [chatId, setChatId] = useState("");
const [enabled, setEnabled] = useState(false);
const [events, setEvents] = useState(DEFAULT_EVENTS);
const [botToken, setBotToken] = useState(initialData?.bot_token ? String(initialData.bot_token) : "");
const [chatId, setChatId] = useState(initialData?.chat_id ? String(initialData.chat_id) : "");
const [enabled, setEnabled] = useState(initialData?.enabled === true);
const [events, setEvents] = useState(
initialData?.events ? { ...DEFAULT_EVENTS, ...(initialData.events as Record<string, boolean>) } : 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,

View File

@@ -1,30 +1,49 @@
import { getSettings, getCacheStats, getThumbnailStats, fetchUsers } from "@/lib/api";
import { getSettings, getCacheStats, getThumbnailStats, fetchUsers, apiFetch } from "@/lib/api";
import SettingsPage from "./SettingsPage";
export const dynamic = "force-dynamic";
export default async function SettingsPageWrapper({ searchParams }: { searchParams: Promise<{ tab?: string }> }) {
const { tab } = await searchParams;
const settings = await getSettings().catch(() => ({
image_processing: { format: "webp", quality: 85, filter: "lanczos3", max_width: 2160 },
cache: { enabled: true, directory: "/tmp/stripstream-image-cache", max_size_mb: 10000 },
limits: { concurrent_renders: 4, timeout_seconds: 12, rate_limit_per_second: 120 },
thumbnail: { enabled: true, width: 300, height: 400, quality: 80, format: "webp", directory: "/data/thumbnails" }
}));
const [settings, cacheStats, thumbnailStats, users, prowlarr, qbittorrent, torrentImport, telegram, anilist, komga, metadataProviders, statusMappings, seriesStatuses, providerStatuses] = await Promise.all([
getSettings().catch(() => ({
image_processing: { format: "webp", quality: 85, filter: "lanczos3", max_width: 2160 },
cache: { enabled: true, directory: "/tmp/stripstream-image-cache", max_size_mb: 10000 },
limits: { concurrent_renders: 4, timeout_seconds: 12, rate_limit_per_second: 120 },
thumbnail: { enabled: true, width: 300, height: 400, quality: 80, format: "webp", directory: "/data/thumbnails" }
})),
getCacheStats().catch(() => ({ total_size_mb: 0, file_count: 0, directory: "/tmp/stripstream-image-cache" })),
getThumbnailStats().catch(() => ({ total_size_mb: 0, file_count: 0, directory: "/data/thumbnails" })),
fetchUsers().catch(() => []),
apiFetch<Record<string, unknown>>("/settings/prowlarr").catch(() => null),
apiFetch<Record<string, unknown>>("/settings/qbittorrent").catch(() => null),
apiFetch<Record<string, unknown>>("/settings/torrent_import").catch(() => null),
apiFetch<Record<string, unknown>>("/settings/telegram").catch(() => null),
apiFetch<Record<string, unknown>>("/settings/anilist").catch(() => null),
apiFetch<Record<string, unknown>>("/settings/komga").catch(() => null),
apiFetch<Record<string, unknown>>("/settings/metadata_providers").catch(() => null),
apiFetch<unknown[]>("/settings/status-mappings").catch(() => []),
apiFetch<unknown[]>("/series/statuses").catch(() => []),
apiFetch<unknown[]>("/series/provider-statuses").catch(() => []),
]);
const cacheStats = await getCacheStats().catch(() => ({
total_size_mb: 0,
file_count: 0,
directory: "/tmp/stripstream-image-cache"
}));
const thumbnailStats = await getThumbnailStats().catch(() => ({
total_size_mb: 0,
file_count: 0,
directory: "/data/thumbnails"
}));
const users = await fetchUsers().catch(() => []);
return <SettingsPage initialSettings={settings} initialCacheStats={cacheStats} initialThumbnailStats={thumbnailStats} users={users} initialTab={tab} />;
return (
<SettingsPage
initialSettings={settings}
initialCacheStats={cacheStats}
initialThumbnailStats={thumbnailStats}
users={users}
initialTab={tab}
initialProwlarr={prowlarr}
initialQbittorrent={qbittorrent}
initialTorrentImport={torrentImport}
initialTelegram={telegram}
initialAnilist={anilist}
initialKomga={komga}
initialMetadataProviders={metadataProviders}
initialStatusMappings={statusMappings as Record<string, unknown>[]}
initialSeriesStatuses={seriesStatuses as string[]}
initialProviderStatuses={providerStatuses as string[]}
/>
);
}