mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-04 02:31:27 +00:00
feat(ui): Projects keys section + playground compareUI to shadcn
- Projects/ProjectKeysSection: antd Card/Flex/Input/Spin/Pagination + @ant-design LoadingOutlined \u2192 shadcn Card/Input + lucide Key/ Search + lightweight prev/next pager using ChevronLeft/Right. - Projects/ProjectKeysTable: antd Table + Empty + Tooltip \u2192 shadcn Table + Tooltip + lucide Loader2 for the loading state. Emits a simple empty row when no keys match. - playground/compareUI/MessageInput: antd Input.TextArea + Button + @ant-design ArrowUpOutlined \u2192 shadcn Textarea + Button (icon variant, rounded-full) + lucide ArrowUp. - playground/compareUI/UnifiedSelector: antd Select + Spin \u2192 shadcn Select + lucide Loader2 for the loading placeholder inside the content surface. Gates: TS \u2713 | Build \u2713. Co-authored-by: yuneng-jiang <yuneng-berri@users.noreply.github.com>
This commit is contained in:
parent
b1f9b8bf09
commit
55ca23278a
4 changed files with 198 additions and 128 deletions
|
|
@ -1,7 +1,13 @@
|
|||
import { useKeys } from "@/app/(dashboard)/hooks/keys/useKeys";
|
||||
import { LoadingOutlined } from "@ant-design/icons";
|
||||
import { Card, Flex, Input, Pagination, Spin } from "antd";
|
||||
import { KeyIcon, SearchIcon } from "lucide-react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Key as KeyIcon,
|
||||
Search,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { ProjectKeysTable } from "./ProjectKeysTable";
|
||||
|
||||
|
|
@ -20,48 +26,62 @@ export function ProjectKeysSection({ projectId }: ProjectKeysSectionProps) {
|
|||
selectedKeyAlias: keyAlias || null,
|
||||
});
|
||||
|
||||
// Reset to page 1 when filter changes
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [keyAlias]);
|
||||
|
||||
const keys = data?.keys ?? [];
|
||||
const totalCount = data?.total_count ?? 0;
|
||||
const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE));
|
||||
|
||||
return (
|
||||
<Card
|
||||
title={
|
||||
<Flex align="center" gap={8}>
|
||||
<Card className="h-full">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<KeyIcon size={16} />
|
||||
Keys
|
||||
</Flex>
|
||||
}
|
||||
style={{ height: "100%" }}
|
||||
>
|
||||
<Flex justify="space-between" align="center" style={{ marginBottom: 12 }}>
|
||||
<Input
|
||||
prefix={<SearchIcon size={14} />}
|
||||
placeholder="Filter by key name..."
|
||||
style={{ maxWidth: 220 }}
|
||||
value={keyAlias}
|
||||
onChange={(e) => setKeyAlias(e.target.value)}
|
||||
allowClear
|
||||
size="small"
|
||||
/>
|
||||
<Pagination
|
||||
current={page}
|
||||
total={totalCount}
|
||||
pageSize={PAGE_SIZE}
|
||||
onChange={setPage}
|
||||
size="small"
|
||||
showSizeChanger={false}
|
||||
showTotal={(total) => `${total} keys`}
|
||||
/>
|
||||
</Flex>
|
||||
<ProjectKeysTable
|
||||
keys={keys}
|
||||
loading={isLoading ? { indicator: <Spin indicator={<LoadingOutlined spin />} /> } : false}
|
||||
/>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex justify-between items-center mb-3 gap-2">
|
||||
<div className="relative max-w-[220px]">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground pointer-events-none" />
|
||||
<Input
|
||||
placeholder="Filter by key name..."
|
||||
className="pl-7 h-8"
|
||||
value={keyAlias}
|
||||
onChange={(e) => setKeyAlias(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-sm">
|
||||
<span className="text-muted-foreground">{totalCount} keys</span>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-6 w-6"
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
aria-label="Previous page"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="text-muted-foreground">
|
||||
{page}/{totalPages}
|
||||
</span>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-6 w-6"
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
aria-label="Next page"
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<ProjectKeysTable keys={keys} loading={isLoading} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,58 +1,93 @@
|
|||
import { KeyResponse } from "@/components/key_team_helpers/key_list";
|
||||
import { Empty, Table, Tooltip } from "antd";
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
import type { SpinProps } from "antd";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import DefaultProxyAdminTag from "../common_components/DefaultProxyAdminTag";
|
||||
|
||||
interface ProjectKeysTableProps {
|
||||
keys: KeyResponse[];
|
||||
loading?: boolean | SpinProps;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
const columns: ColumnsType<KeyResponse> = [
|
||||
{
|
||||
title: "Key Name",
|
||||
dataIndex: "key_alias",
|
||||
key: "key_alias",
|
||||
render: (alias: string | null) => alias || "—",
|
||||
},
|
||||
{
|
||||
title: "Owner",
|
||||
key: "owner",
|
||||
render: (_: unknown, record: KeyResponse) => {
|
||||
const email = record.user?.user_email ?? record.user_id ?? null;
|
||||
if (!email) return "—";
|
||||
return (
|
||||
<Tooltip title={email}>
|
||||
<DefaultProxyAdminTag userId={email} />
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Created",
|
||||
dataIndex: "created_at",
|
||||
key: "created_at",
|
||||
render: (date: string) => (date ? new Date(date).toLocaleDateString() : "—"),
|
||||
},
|
||||
{
|
||||
title: "Last Active",
|
||||
dataIndex: "last_active",
|
||||
key: "last_active",
|
||||
render: (date: string | null) => (date ? new Date(date).toLocaleDateString() : "Never"),
|
||||
},
|
||||
];
|
||||
|
||||
export function ProjectKeysTable({ keys, loading }: ProjectKeysTableProps) {
|
||||
return (
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={keys}
|
||||
rowKey="token"
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
size="small"
|
||||
locale={{ emptyText: <Empty description="No keys found" image={Empty.PRESENTED_IMAGE_SIMPLE} /> }}
|
||||
/>
|
||||
<div className="border border-border rounded-md overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Key Name</TableHead>
|
||||
<TableHead>Owner</TableHead>
|
||||
<TableHead>Created</TableHead>
|
||||
<TableHead>Last Active</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="text-center py-6">
|
||||
<Loader2 className="h-4 w-4 animate-spin mx-auto text-muted-foreground" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : keys.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={4}
|
||||
className="text-center py-6 text-muted-foreground"
|
||||
>
|
||||
No keys found
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
keys.map((k) => {
|
||||
const alias = k.key_alias || "—";
|
||||
const email = k.user?.user_email ?? k.user_id ?? null;
|
||||
return (
|
||||
<TableRow key={k.token}>
|
||||
<TableCell>{alias}</TableCell>
|
||||
<TableCell>
|
||||
{email ? (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-flex">
|
||||
<DefaultProxyAdminTag userId={email} />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{email}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{k.created_at
|
||||
? new Date(k.created_at).toLocaleDateString()
|
||||
: "—"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{k.last_active
|
||||
? new Date(k.last_active).toLocaleDateString()
|
||||
: "Never"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
import React from "react";
|
||||
import { Input, Button } from "antd";
|
||||
import { ArrowUpOutlined } from "@ant-design/icons";
|
||||
|
||||
const { TextArea } = Input;
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { ArrowUp } from "lucide-react";
|
||||
|
||||
interface MessageInputProps {
|
||||
value: string;
|
||||
|
|
@ -13,8 +12,16 @@ interface MessageInputProps {
|
|||
uploadComponent?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function MessageInput({ value, onChange, onSend, disabled, hasAttachment, uploadComponent }: MessageInputProps) {
|
||||
const canSend = !disabled && (value.trim().length > 0 || Boolean(hasAttachment));
|
||||
export function MessageInput({
|
||||
value,
|
||||
onChange,
|
||||
onSend,
|
||||
disabled,
|
||||
hasAttachment,
|
||||
uploadComponent,
|
||||
}: MessageInputProps) {
|
||||
const canSend =
|
||||
!disabled && (value.trim().length > 0 || Boolean(hasAttachment));
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
|
|
@ -27,27 +34,27 @@ export function MessageInput({ value, onChange, onSend, disabled, hasAttachment,
|
|||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center flex-1 bg-white border border-gray-300 rounded-xl px-3 py-1 min-h-[44px]">
|
||||
{uploadComponent && <div className="flex-shrink-0 mr-2">{uploadComponent}</div>}
|
||||
<TextArea
|
||||
<div className="flex items-center flex-1 bg-background border border-border rounded-xl px-3 py-1 min-h-[44px]">
|
||||
{uploadComponent && (
|
||||
<div className="flex-shrink-0 mr-2">{uploadComponent}</div>
|
||||
)}
|
||||
<Textarea
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Type your message... (Shift+Enter for new line)"
|
||||
disabled={disabled}
|
||||
className="flex-1"
|
||||
autoSize={{ minRows: 1, maxRows: 4 }}
|
||||
style={{
|
||||
resize: "none",
|
||||
border: "none",
|
||||
boxShadow: "none",
|
||||
background: "transparent",
|
||||
padding: "4px 0",
|
||||
fontSize: "14px",
|
||||
lineHeight: "20px",
|
||||
}}
|
||||
rows={1}
|
||||
className="flex-1 resize-none border-0 shadow-none bg-transparent p-0 py-1 text-sm leading-5 min-h-[20px]"
|
||||
/>
|
||||
<Button onClick={onSend} disabled={!canSend} icon={<ArrowUpOutlined />} shape="circle" />
|
||||
<Button
|
||||
onClick={onSend}
|
||||
disabled={!canSend}
|
||||
size="icon"
|
||||
className="rounded-full h-8 w-8"
|
||||
>
|
||||
<ArrowUp className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
/**
|
||||
* Unified selector component that handles both model and agent selection
|
||||
* based on the current endpoint configuration.
|
||||
*/
|
||||
|
||||
import { Select, Spin } from "antd";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { SelectorOption, EndpointConfig } from "../endpoint_config";
|
||||
|
||||
interface UnifiedSelectorProps {
|
||||
|
|
@ -22,27 +24,33 @@ export function UnifiedSelector({
|
|||
onChange,
|
||||
}: UnifiedSelectorProps) {
|
||||
return (
|
||||
<Select
|
||||
value={value || undefined}
|
||||
placeholder={loading ? `Loading ${config.selectorLabel.toLowerCase()}s...` : config.selectorPlaceholder}
|
||||
onChange={onChange}
|
||||
loading={loading}
|
||||
showSearch
|
||||
filterOption={(input, option) =>
|
||||
(option?.label ?? "").toLowerCase().includes(input.toLowerCase())
|
||||
}
|
||||
options={options}
|
||||
className="w-48 md:w-64 lg:w-72"
|
||||
notFoundContent={
|
||||
loading ? (
|
||||
<Select value={value || undefined} onValueChange={onChange}>
|
||||
<SelectTrigger className="w-48 md:w-64 lg:w-72">
|
||||
<SelectValue
|
||||
placeholder={
|
||||
loading
|
||||
? `Loading ${config.selectorLabel.toLowerCase()}s...`
|
||||
: config.selectorPlaceholder
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-2">
|
||||
<Spin size="small" />
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : options.length === 0 ? (
|
||||
<div className="py-2 px-3 text-sm text-muted-foreground">
|
||||
No {config.selectorLabel.toLowerCase()}s available
|
||||
</div>
|
||||
) : (
|
||||
`No ${config.selectorLabel.toLowerCase()}s available`
|
||||
)
|
||||
}
|
||||
/>
|
||||
options.map((opt) => (
|
||||
<SelectItem key={opt.value as string} value={opt.value as string}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue