From e7295a371d25f03caf7434234927649e5612bfab Mon Sep 17 00:00:00 2001 From: Froidefond Julien Date: Fri, 27 Mar 2026 09:11:12 +0100 Subject: [PATCH] feat: SSR pour toutes les cards de la page Settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../app/(app)/settings/SettingsPage.tsx | 26 +++++--- .../(app)/settings/components/AnilistTab.tsx | 25 ++------ .../settings/components/KomgaSyncCard.tsx | 15 ++--- .../components/MetadataProvidersCard.tsx | 35 +++++------ .../settings/components/ProwlarrCard.tsx | 25 +++----- .../settings/components/QBittorrentCard.tsx | 29 ++------- .../components/StatusMappingsCard.tsx | 38 ++--------- .../settings/components/TelegramCard.tsx | 28 +++------ apps/backoffice/app/(app)/settings/page.tsx | 63 ++++++++++++------- 9 files changed, 111 insertions(+), 173 deletions(-) diff --git a/apps/backoffice/app/(app)/settings/SettingsPage.tsx b/apps/backoffice/app/(app)/settings/SettingsPage.tsx index 9403555..fab7819 100644 --- a/apps/backoffice/app/(app)/settings/SettingsPage.tsx +++ b/apps/backoffice/app/(app)/settings/SettingsPage.tsx @@ -20,9 +20,19 @@ interface SettingsPageProps { initialThumbnailStats: ThumbnailStats; users: UserDto[]; initialTab?: string; + initialProwlarr: Record | null; + initialQbittorrent: Record | null; + initialTorrentImport: Record | null; + initialTelegram: Record | null; + initialAnilist: Record | null; + initialKomga: Record | null; + initialMetadataProviders: Record | null; + initialStatusMappings: Record[]; + 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 */} - + {/* Status Mappings */} - + )} {activeTab === "downloadTools" && (<> {/* Prowlarr */} - + {/* qBittorrent */} - + )} {activeTab === "notifications" && (<> {/* Telegram Notifications */} - + )} {activeTab === "readingStatus" && (<> - - + + )} diff --git a/apps/backoffice/app/(app)/settings/components/AnilistTab.tsx b/apps/backoffice/app/(app)/settings/components/AnilistTab.tsx index dc89f0e..56c1e5e 100644 --- a/apps/backoffice/app/(app)/settings/components/AnilistTab.tsx +++ b/apps/backoffice/app/(app)/settings/components/AnilistTab.tsx @@ -8,19 +8,21 @@ import { useTranslation } from "@/lib/i18n/context"; export function AnilistTab({ handleUpdateSetting, users, + initialData, }: { handleUpdateSetting: (key: string, value: unknown) => Promise; users: UserDto[]; + initialData: Record | 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(null); const [testError, setTestError] = useState(null); @@ -33,21 +35,6 @@ export function AnilistTab({ const [isPreviewing, setIsPreviewing] = useState(false); const [previewItems, setPreviewItems] = useState(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, diff --git a/apps/backoffice/app/(app)/settings/components/KomgaSyncCard.tsx b/apps/backoffice/app/(app)/settings/components/KomgaSyncCard.tsx index d8af8d4..cd8ab11 100644 --- a/apps/backoffice/app/(app)/settings/components/KomgaSyncCard.tsx +++ b/apps/backoffice/app/(app)/settings/components/KomgaSyncCard.tsx @@ -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 | 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(null); const [syncError, setSyncError] = useState(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) { diff --git a/apps/backoffice/app/(app)/settings/components/MetadataProvidersCard.tsx b/apps/backoffice/app/(app)/settings/components/MetadataProvidersCard.tsx index 46dcbd4..0420f8f 100644 --- a/apps/backoffice/app/(app)/settings/components/MetadataProvidersCard.tsx +++ b/apps/backoffice/app/(app)/settings/components/MetadataProvidersCard.tsx @@ -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 }) { - const { t } = useTranslation(); - const [defaultProvider, setDefaultProvider] = useState("google_books"); - const [metadataLanguage, setMetadataLanguage] = useState("en"); - const [apiKeys, setApiKeys] = useState>({}); +function extractInitialApiKeys(data: Record | null): Record { + const keys: Record = {}; + if (data) { + const comicvine = data.comicvine as Record | undefined; + const googleBooks = data.google_books as Record | 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; initialData: Record | 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>(extractInitialApiKeys(initialData)); function save(provider: string, lang: string, keys: Record) { const value: Record = { diff --git a/apps/backoffice/app/(app)/settings/components/ProwlarrCard.tsx b/apps/backoffice/app/(app)/settings/components/ProwlarrCard.tsx index 03d940a..079dcf1 100644 --- a/apps/backoffice/app/(app)/settings/components/ProwlarrCard.tsx +++ b/apps/backoffice/app/(app)/settings/components/ProwlarrCard.tsx @@ -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 }) { +export function ProwlarrCard({ handleUpdateSetting, initialData }: { handleUpdateSetting: (key: string, value: unknown) => Promise; initialData: Record | 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(",") diff --git a/apps/backoffice/app/(app)/settings/components/QBittorrentCard.tsx b/apps/backoffice/app/(app)/settings/components/QBittorrentCard.tsx index 3cc50bb..4c5db8c 100644 --- a/apps/backoffice/app/(app)/settings/components/QBittorrentCard.tsx +++ b/apps/backoffice/app/(app)/settings/components/QBittorrentCard.tsx @@ -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 }) { +export function QBittorrentCard({ handleUpdateSetting, initialQbittorrent, initialTorrentImport }: { handleUpdateSetting: (key: string, value: unknown) => Promise; initialQbittorrent: Record | null; initialTorrentImport: Record | 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", { diff --git a/apps/backoffice/app/(app)/settings/components/StatusMappingsCard.tsx b/apps/backoffice/app/(app)/settings/components/StatusMappingsCard.tsx index c5151cc..45a79da 100644 --- a/apps/backoffice/app/(app)/settings/components/StatusMappingsCard.tsx +++ b/apps/backoffice/app/(app)/settings/components/StatusMappingsCard.tsx @@ -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[]; initialSeriesStatuses: string[]; initialProviderStatuses: string[] }) { const { t } = useTranslation(); - const [mappings, setMappings] = useState([]); - const [targetStatuses, setTargetStatuses] = useState([]); - const [providerStatuses, setProviderStatuses] = useState([]); + const [mappings, setMappings] = useState(initialStatusMappings as unknown as StatusMappingDto[]); + const [targetStatuses, setTargetStatuses] = useState(initialSeriesStatuses); + const [providerStatuses] = useState(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 ( - -

{t("common.loading")}

-
- ); - } - return ( diff --git a/apps/backoffice/app/(app)/settings/components/TelegramCard.tsx b/apps/backoffice/app/(app)/settings/components/TelegramCard.tsx index 7d73280..b9fabd7 100644 --- a/apps/backoffice/app/(app)/settings/components/TelegramCard.tsx +++ b/apps/backoffice/app/(app)/settings/components/TelegramCard.tsx @@ -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 }) { +export function TelegramCard({ handleUpdateSetting, initialData }: { handleUpdateSetting: (key: string, value: unknown) => Promise; initialData: Record | 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) } : 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, diff --git a/apps/backoffice/app/(app)/settings/page.tsx b/apps/backoffice/app/(app)/settings/page.tsx index e0c2792..f28fc45 100644 --- a/apps/backoffice/app/(app)/settings/page.tsx +++ b/apps/backoffice/app/(app)/settings/page.tsx @@ -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>("/settings/prowlarr").catch(() => null), + apiFetch>("/settings/qbittorrent").catch(() => null), + apiFetch>("/settings/torrent_import").catch(() => null), + apiFetch>("/settings/telegram").catch(() => null), + apiFetch>("/settings/anilist").catch(() => null), + apiFetch>("/settings/komga").catch(() => null), + apiFetch>("/settings/metadata_providers").catch(() => null), + apiFetch("/settings/status-mappings").catch(() => []), + apiFetch("/series/statuses").catch(() => []), + apiFetch("/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 ; + return ( + []} + initialSeriesStatuses={seriesStatuses as string[]} + initialProviderStatuses={providerStatuses as string[]} + /> + ); }