Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
143 changes: 143 additions & 0 deletions console/ai-proxy-dashboard/src/components/ui/toast.tsx
Original file line number Diff line number Diff line change
@@ -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<ToastVariant, { icon: typeof Info; className: string; iconClassName: string }> = {
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 (
<div
role="status"
className={cn(
"pointer-events-auto flex w-[320px] max-w-[calc(100vw-2rem)] items-start gap-2.5 rounded-lg border px-3 py-2.5 text-xs shadow-lg",
"animate-in fade-in slide-in-from-bottom-2 duration-200",
className
)}
>
<Icon className={cn("mt-px size-4 shrink-0", iconClassName)} />
<span className="flex-1 leading-relaxed">{item.message}</span>
<button
type="button"
aria-label="Dismiss"
onClick={() => dismiss(item.id)}
className="-mr-1 -mt-0.5 rounded-md p-0.5 text-muted-foreground transition-colors hover:text-foreground"
>
<X className="size-3.5" />
</button>
</div>
)
}

export function Toaster() {
const items = useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
const [mounted, setMounted] = useState(false)

useEffect(() => {
setMounted(true)
}, [])

if (!mounted) return null

return createPortal(
<div className="pointer-events-none fixed bottom-4 right-4 z-[100] flex flex-col gap-2">
{items.map((item) => (
<ToastCard key={item.id} item={item} />
))}
</div>,
document.body
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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")}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -51,7 +52,6 @@ export function KeysPage({
const { t } = useTranslation()
const [keys, setKeys] = useState<ManagedApiKey[] | null>(null)
const [error, setError] = useState("")
const [feedback, setFeedback] = useState("")
const [createOpen, setCreateOpen] = useState(false)
const [renameOpen, setRenameOpen] = useState(false)
const [modelsOpen, setModelsOpen] = useState(false)
Expand All @@ -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) => {
Expand Down Expand Up @@ -344,13 +343,6 @@ export function KeysPage({
</Alert>
) : null}

{feedback ? (
<Alert>
<AlertTitle>{t("common.done")}</AlertTitle>
<AlertDescription>{feedback}</AlertDescription>
</Alert>
) : null}

{keys === null ? (
<div className="space-y-3">
{Array.from({ length: 4 }).map((_, index) => (
Expand Down Expand Up @@ -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"))
}}
>
<Copy className="h-3.5 w-3.5" />
Expand Down Expand Up @@ -660,7 +653,8 @@ export function KeysPage({
<Button type="button" variant="outline" onClick={async () => {
if (!visibleKey?.key) return
const copied = await copyText(visibleKey.key)
showFeedback(copied ? t("keys.keyCopied") : t("keys.copyFailed"))
if (copied) toast.success(t("keys.keyCopied"))
else toast.error(t("keys.copyFailed"))
}}>
<Copy data-icon="inline-start" />{t("keys.copyKey")}
</Button>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -1748,7 +1749,10 @@ export function ProvidersPage({
<Button
type="button"
onClick={() => {
navigator.clipboard.writeText(configJson).catch(() => {})
navigator.clipboard
.writeText(configJson)
.then(() => toast.success(t("common.copied")))
.catch(() => toast.error(t("common.copyFailed")))
}}
>
<Copy data-icon="inline-start" />
Expand Down
2 changes: 2 additions & 0 deletions console/ai-proxy-dashboard/src/i18n/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ export default {
deleting: "Deleting...",
loading: "Loading...",
copy: "Copy",
copied: "Copied",
copyFailed: "Copy failed, please copy manually",
help: "Help",
viewLogs: "View Logs",
clearFilters: "Clear Filters",
Expand Down
2 changes: 2 additions & 0 deletions console/ai-proxy-dashboard/src/i18n/locales/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ export default {
deleting: "删除中...",
loading: "加载中...",
copy: "复制",
copied: "已复制",
copyFailed: "复制失败,请手动复制",
help: "帮助",
viewLogs: "查看日志",
clearFilters: "清空筛选",
Expand Down
2 changes: 2 additions & 0 deletions console/ai-proxy-dashboard/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@ import { createRoot } from "react-dom/client"
import "@/i18n"
import "./index.css"
import App from "./App.tsx"
import { Toaster } from "@/components/ui/toast"

createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
<Toaster />
</StrictMode>
)
Loading