diff --git a/console/ai-proxy-dashboard/src/components/ui/toast.tsx b/console/ai-proxy-dashboard/src/components/ui/toast.tsx new file mode 100644 index 0000000..092b151 --- /dev/null +++ b/console/ai-proxy-dashboard/src/components/ui/toast.tsx @@ -0,0 +1,143 @@ +import { useEffect, useState, useSyncExternalStore } from "react" +import { createPortal } from "react-dom" +import { CheckCircle2, Info, X, XCircle } from "lucide-react" + +import { cn } from "@/lib/utils" + +type ToastVariant = "default" | "success" | "error" + +type ToastItem = { + id: number + message: string + variant: ToastVariant + duration: number +} + +type ToastOptions = { + variant?: ToastVariant + duration?: number +} + +let toasts: ToastItem[] = [] +let nextId = 1 +const listeners = new Set<() => void>() + +function emit() { + for (const listener of listeners) listener() +} + +function subscribe(listener: () => void) { + listeners.add(listener) + return () => { + listeners.delete(listener) + } +} + +function getSnapshot() { + return toasts +} + +function dismiss(id: number) { + toasts = toasts.filter((item) => item.id !== id) + emit() +} + +function push(message: string, options: ToastOptions = {}) { + const id = nextId++ + const item: ToastItem = { + id, + message, + variant: options.variant ?? "default", + duration: options.duration ?? 3000, + } + // Keep at most a few visible at once. + toasts = [...toasts, item].slice(-4) + emit() + return id +} + +type ToastFn = ((message: string, options?: ToastOptions) => number) & { + success: (message: string, options?: ToastOptions) => number + error: (message: string, options?: ToastOptions) => number + dismiss: (id: number) => void +} + +export const toast: ToastFn = Object.assign( + (message: string, options?: ToastOptions) => push(message, options), + { + success: (message: string, options?: ToastOptions) => + push(message, { ...options, variant: "success" }), + error: (message: string, options?: ToastOptions) => + push(message, { ...options, variant: "error" }), + dismiss, + } +) + +const variantStyles: Record = { + default: { + icon: Info, + className: "border-border bg-popover text-popover-foreground", + iconClassName: "text-muted-foreground", + }, + success: { + icon: CheckCircle2, + className: "border-border bg-popover text-popover-foreground", + iconClassName: "text-primary", + }, + error: { + icon: XCircle, + className: "border-destructive/30 bg-popover text-popover-foreground", + iconClassName: "text-destructive", + }, +} + +function ToastCard({ item }: { item: ToastItem }) { + useEffect(() => { + const timer = window.setTimeout(() => dismiss(item.id), item.duration) + return () => window.clearTimeout(timer) + }, [item.id, item.duration]) + + const { icon: Icon, className, iconClassName } = variantStyles[item.variant] + + return ( +
+ + {item.message} + +
+ ) +} + +export function Toaster() { + const items = useSyncExternalStore(subscribe, getSnapshot, getSnapshot) + const [mounted, setMounted] = useState(false) + + useEffect(() => { + setMounted(true) + }, []) + + if (!mounted) return null + + return createPortal( +
+ {items.map((item) => ( + + ))} +
, + document.body + ) +} diff --git a/console/ai-proxy-dashboard/src/features/dashboard/components/detail-view.tsx b/console/ai-proxy-dashboard/src/features/dashboard/components/detail-view.tsx index 075b8ad..b3385cc 100644 --- a/console/ai-proxy-dashboard/src/features/dashboard/components/detail-view.tsx +++ b/console/ai-proxy-dashboard/src/features/dashboard/components/detail-view.tsx @@ -22,6 +22,7 @@ import { TabsTrigger, } from "@/components/ui/tabs" import { Textarea } from "@/components/ui/textarea" +import { toast } from "@/components/ui/toast" import { PayloadPanel } from "@/features/dashboard/components/payload-panel" import type { ConsoleRequestDetail } from "@/features/dashboard/types" import { @@ -354,7 +355,10 @@ export function DetailView({ type="button" className="text-[11.5px] font-semibold text-primary hover:underline" onClick={() => { - void navigator.clipboard?.writeText(JSON.stringify(record, null, 2)) + navigator.clipboard + ?.writeText(JSON.stringify(record, null, 2)) + .then(() => toast.success(t("common.copied"))) + .catch(() => toast.error(t("common.copyFailed"))) }} > {t("detail.copy")} diff --git a/console/ai-proxy-dashboard/src/features/dashboard/components/keys-page.tsx b/console/ai-proxy-dashboard/src/features/dashboard/components/keys-page.tsx index 6cc7e09..c447c64 100644 --- a/console/ai-proxy-dashboard/src/features/dashboard/components/keys-page.tsx +++ b/console/ai-proxy-dashboard/src/features/dashboard/components/keys-page.tsx @@ -23,6 +23,7 @@ import { Field, FieldContent, FieldDescription, FieldLabel } from "@/components/ import { Input } from "@/components/ui/input" import { Combobox } from "@/components/ui/combobox" import { Skeleton } from "@/components/ui/skeleton" +import { toast } from "@/components/ui/toast" import { createKey, deleteKey, fetchKeys, fetchModels, getKey, renameKey, setKeyAllowedModels, setKeyCostQuota } from "@/features/dashboard/api" import type { GatewayModel, ManagedApiKey, ManagedApiKeyDetail } from "@/features/dashboard/types" import { formatCost } from "@/features/dashboard/utils" @@ -51,7 +52,6 @@ export function KeysPage({ const { t } = useTranslation() const [keys, setKeys] = useState(null) const [error, setError] = useState("") - const [feedback, setFeedback] = useState("") const [createOpen, setCreateOpen] = useState(false) const [renameOpen, setRenameOpen] = useState(false) const [modelsOpen, setModelsOpen] = useState(false) @@ -76,8 +76,7 @@ export function KeysPage({ const [modelsModelSelect, setModelsModelSelect] = useState("") const showFeedback = (message: string) => { - setFeedback(message) - window.setTimeout(() => setFeedback(""), 1800) + toast.success(message) } const handleUnauthorized = (message: string) => { @@ -344,13 +343,6 @@ export function KeysPage({ ) : null} - {feedback ? ( - - {t("common.done")} - {feedback} - - ) : null} - {keys === null ? (
{Array.from({ length: 4 }).map((_, index) => ( @@ -421,7 +413,8 @@ export function KeysPage({ onClick={async () => { const detail = await getKey(key.id) const copied = await copyText(detail.key) - showFeedback(copied ? t("keys.keyCopied") : t("keys.copyFailed")) + if (copied) toast.success(t("keys.keyCopied")) + else toast.error(t("keys.copyFailed")) }} > @@ -660,7 +653,8 @@ export function KeysPage({ diff --git a/console/ai-proxy-dashboard/src/features/dashboard/components/providers-page.tsx b/console/ai-proxy-dashboard/src/features/dashboard/components/providers-page.tsx index 72a5055..3442883 100644 --- a/console/ai-proxy-dashboard/src/features/dashboard/components/providers-page.tsx +++ b/console/ai-proxy-dashboard/src/features/dashboard/components/providers-page.tsx @@ -57,6 +57,7 @@ import { } from "@/components/ui/select" import { Separator } from "@/components/ui/separator" import { Skeleton } from "@/components/ui/skeleton" +import { toast } from "@/components/ui/toast" import { Textarea } from "@/components/ui/textarea" import { createProvider, @@ -1748,7 +1749,10 @@ export function ProvidersPage({