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) <noreply@anthropic.com>
This commit is contained in:
@@ -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<ClearCacheResponse | null>(null);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [saveMessage, setSaveMessage] = useState<string | null>(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
|
||||
))}
|
||||
</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">
|
||||
@@ -546,6 +544,8 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
|
||||
<AnilistTab handleUpdateSetting={handleUpdateSetting} users={users} />
|
||||
<KomgaSyncCard users={users} />
|
||||
</>)}
|
||||
|
||||
<Toaster />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -228,7 +228,14 @@ export function AnilistTab({
|
||||
<div className="flex items-center gap-3">
|
||||
<select
|
||||
value={localUserId}
|
||||
onChange={(e) => setLocalUserId(e.target.value)}
|
||||
onChange={(e) => {
|
||||
const newLocalUserId = e.target.value;
|
||||
setLocalUserId(newLocalUserId);
|
||||
handleUpdateSetting("anilist", {
|
||||
...buildAnilistSettings(),
|
||||
local_user_id: newLocalUserId || undefined,
|
||||
});
|
||||
}}
|
||||
autoComplete="off"
|
||||
className="flex-1 text-sm border border-border rounded-lg px-3 py-2.5 bg-background focus:outline-none focus:ring-2 focus:ring-ring h-10"
|
||||
>
|
||||
@@ -237,9 +244,6 @@ export function AnilistTab({
|
||||
<option key={u.id} value={u.id}>{u.username}</option>
|
||||
))}
|
||||
</select>
|
||||
<Button onClick={() => handleUpdateSetting("anilist", buildAnilistSettings())} disabled={!localUserId}>
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
99
apps/backoffice/app/components/ui/Toast.tsx
Normal file
99
apps/backoffice/app/components/ui/Toast.tsx
Normal file
@@ -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(
|
||||
<div className="fixed bottom-4 right-4 z-50 flex flex-col gap-2">
|
||||
{items.map((t) => (
|
||||
<ToastCard key={t.id} item={t} onDismiss={() => removeToast(t.id)} />
|
||||
))}
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
|
||||
const variantStyles: Record<ToastVariant, string> = {
|
||||
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<ToastVariant, "check" | "warning" | "eye"> = {
|
||||
success: "check",
|
||||
error: "warning",
|
||||
info: "eye",
|
||||
};
|
||||
|
||||
function ToastCard({ item, onDismiss }: { item: ToastItem; onDismiss: () => void }) {
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center gap-3 px-4 py-3 rounded-lg border shadow-lg backdrop-blur-sm animate-slide-in-right min-w-[280px] max-w-[420px] ${variantStyles[item.variant]}`}
|
||||
>
|
||||
<Icon name={variantIcons[item.variant]} size="sm" />
|
||||
<span className="text-sm font-medium flex-1">{item.message}</span>
|
||||
<button
|
||||
onClick={onDismiss}
|
||||
className="opacity-60 hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<Icon name="x" size="sm" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user