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;