From d1261ac9ab33b9cfa3fd56c16a4023cba442774a Mon Sep 17 00:00:00 2001 From: Froidefond Julien Date: Thu, 26 Mar 2026 06:13:25 +0100 Subject: [PATCH] feat: replace inline save status with toast notifications in settings Add a standalone toast notification system (no Provider needed) and use it for settings save feedback. Skip save when fields are empty. Remove save button on Anilist local user select in favor of auto-save on change. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../app/(app)/settings/SettingsPage.tsx | 30 +++--- .../(app)/settings/components/AnilistTab.tsx | 12 ++- apps/backoffice/app/components/ui/Toast.tsx | 99 +++++++++++++++++++ apps/backoffice/app/components/ui/index.ts | 1 + apps/backoffice/app/globals.css | 9 ++ 5 files changed, 132 insertions(+), 19 deletions(-) create mode 100644 apps/backoffice/app/components/ui/Toast.tsx diff --git a/apps/backoffice/app/(app)/settings/SettingsPage.tsx b/apps/backoffice/app/(app)/settings/SettingsPage.tsx index d1eb024..2fc8b0c 100644 --- a/apps/backoffice/app/(app)/settings/SettingsPage.tsx +++ b/apps/backoffice/app/(app)/settings/SettingsPage.tsx @@ -2,7 +2,7 @@ import { useState } from "react"; import { useRouter, useSearchParams } from "next/navigation"; -import { Card, CardHeader, CardTitle, CardDescription, CardContent, Button, FormField, FormInput, FormSelect, FormRow, Icon } from "@/app/components/ui"; +import { Card, CardHeader, CardTitle, CardDescription, CardContent, Button, FormField, FormInput, FormSelect, FormRow, Icon, toast, Toaster } from "@/app/components/ui"; import { Settings, CacheStats, ClearCacheResponse, ThumbnailStats, UserDto } from "@/lib/api"; import { useTranslation } from "@/lib/i18n/context"; import type { Locale } from "@/lib/i18n/types"; @@ -36,7 +36,6 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi const [isClearing, setIsClearing] = useState(false); const [clearResult, setClearResult] = useState(null); const [isSaving, setIsSaving] = useState(false); - const [saveMessage, setSaveMessage] = useState(null); const VALID_TABS = ["general", "downloadTools", "metadata", "readingStatus", "notifications"] as const; type TabId = typeof VALID_TABS[number]; @@ -55,9 +54,17 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi router.replace(`?tab=${tab}`, { scroll: false }); } + function hasEmptyValue(v: unknown): boolean { + if (v === null || v === "") return true; + if (typeof v === "object" && v !== null) { + return Object.values(v).some((val) => val !== undefined && hasEmptyValue(val)); + } + return false; + } + async function handleUpdateSetting(key: string, value: unknown) { + if (hasEmptyValue(value)) return; setIsSaving(true); - setSaveMessage(null); try { const response = await fetch(`/api/settings/${key}`, { method: "POST", @@ -65,13 +72,12 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi body: JSON.stringify({ value }) }); if (response.ok) { - setSaveMessage(t("settings.savedSuccess")); - setTimeout(() => setSaveMessage(null), 3000); + toast(t("settings.savedSuccess"), "success"); } else { - setSaveMessage(t("settings.savedError")); + toast(t("settings.savedError"), "error"); } } catch { - setSaveMessage(t("settings.saveError")); + toast(t("settings.saveError"), "error"); } finally { setIsSaving(false); } @@ -132,14 +138,6 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi ))} - {saveMessage && ( - - -

{saveMessage}

-
-
- )} - {activeTab === "general" && (<> {/* Language Selector */} @@ -546,6 +544,8 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi )} + + ); } diff --git a/apps/backoffice/app/(app)/settings/components/AnilistTab.tsx b/apps/backoffice/app/(app)/settings/components/AnilistTab.tsx index f9a1133..dc89f0e 100644 --- a/apps/backoffice/app/(app)/settings/components/AnilistTab.tsx +++ b/apps/backoffice/app/(app)/settings/components/AnilistTab.tsx @@ -228,7 +228,14 @@ export function AnilistTab({
-
diff --git a/apps/backoffice/app/components/ui/Toast.tsx b/apps/backoffice/app/components/ui/Toast.tsx new file mode 100644 index 0000000..ab1602c --- /dev/null +++ b/apps/backoffice/app/components/ui/Toast.tsx @@ -0,0 +1,99 @@ +"use client"; + +import { useSyncExternalStore, useCallback } from "react"; +import { createPortal } from "react-dom"; +import { Icon } from "./Icon"; + +type ToastVariant = "success" | "error" | "info"; + +interface ToastItem { + id: number; + message: string; + variant: ToastVariant; +} + +// Module-level store — no Provider needed +let toasts: ToastItem[] = []; +let nextId = 0; +const listeners = new Set<() => void>(); + +function notify() { + listeners.forEach((l) => l()); +} + +function addToast(message: string, variant: ToastVariant = "success") { + const id = nextId++; + toasts = [...toasts, { id, message, variant }]; + notify(); + setTimeout(() => { + removeToast(id); + }, 3000); +} + +function removeToast(id: number) { + toasts = toasts.filter((t) => t.id !== id); + notify(); +} + +function subscribe(listener: () => void) { + listeners.add(listener); + return () => listeners.delete(listener); +} + +function getSnapshot() { + return toasts; +} + +function getServerSnapshot(): ToastItem[] { + return []; +} + +export function toast(message: string, variant?: ToastVariant) { + addToast(message, variant); +} + +export function Toaster() { + const items = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); + + if (items.length === 0) return null; + + if (typeof document === "undefined") return null; + + return createPortal( +
+ {items.map((t) => ( + removeToast(t.id)} /> + ))} +
, + document.body + ); +} + +const variantStyles: Record = { + success: "border-success/50 bg-success/10 text-success", + error: "border-destructive/50 bg-destructive/10 text-destructive", + info: "border-primary/50 bg-primary/10 text-primary", +}; + +const variantIcons: Record = { + success: "check", + error: "warning", + info: "eye", +}; + +function ToastCard({ item, onDismiss }: { item: ToastItem; onDismiss: () => void }) { + return ( +
+ + {item.message} + +
+ ); +} diff --git a/apps/backoffice/app/components/ui/index.ts b/apps/backoffice/app/components/ui/index.ts index 00d7d5e..d8f9ce3 100644 --- a/apps/backoffice/app/components/ui/index.ts +++ b/apps/backoffice/app/components/ui/index.ts @@ -20,3 +20,4 @@ export { export { PageIcon, NavIcon, Icon } from "./Icon"; export { CursorPagination, OffsetPagination } from "./Pagination"; export { Tooltip } from "./Tooltip"; +export { toast, Toaster } from "./Toast"; diff --git a/apps/backoffice/app/globals.css b/apps/backoffice/app/globals.css index 5dfd3bc..c4ea7e6 100644 --- a/apps/backoffice/app/globals.css +++ b/apps/backoffice/app/globals.css @@ -226,6 +226,15 @@ body::after { animation: fade-in 0.3s ease-in; } +@keyframes slide-in-right { + from { opacity: 0; transform: translateX(100%); } + to { opacity: 1; transform: translateX(0); } +} + +.animate-slide-in-right { + animation: slide-in-right 0.3s ease-out; +} + /* Line clamp utilities */ .line-clamp-1 { display: -webkit-box;