feat(ui): i18n for projects pages and modals

This commit is contained in:
吹雪 2026-06-11 03:41:30 +09:00
parent 8a3335494d
commit 7298777feb
9 changed files with 402 additions and 159 deletions

View file

@ -19,6 +19,7 @@ import { LoadingOutlined } from "@ant-design/icons";
import { BarChart } from "@tremor/react";
import { ArrowLeftIcon, DollarSignIcon, EditIcon, KeyIcon, UsersIcon } from "lucide-react";
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import DefaultProxyAdminTag from "../common_components/DefaultProxyAdminTag";
import { EditProjectModal } from "./ProjectModals/EditProjectModal";
@ -41,6 +42,7 @@ interface ProjectDetailProps {
}
export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) {
const { t } = useTranslation();
const { data: project, isLoading } = useProjectDetails(projectId);
const { data: teamData } = useTeam(project?.team_id ?? undefined);
// teamInfoCall returns { team_id, team_info: {...}, keys, team_memberships }
@ -86,7 +88,7 @@ export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) {
}}
>
<Button icon={<ArrowLeftIcon size={16} />} onClick={onBack} type="text" style={{ marginBottom: 16 }} />
<Empty description="Project not found" />
<Empty description={t("projects.projectDetailsPage.notFound")} />
</Content>
);
}
@ -109,7 +111,9 @@ export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) {
<Title level={2} style={{ margin: 0 }}>
{project.project_alias ?? project.project_id}
</Title>
<Tag color={project.blocked ? "red" : "green"}>{project.blocked ? "Blocked" : "Active"}</Tag>
<Tag color={project.blocked ? "red" : "green"}>
{project.blocked ? t("projects.projectDetailsPage.statusBlocked") : t("common.active")}
</Tag>
</Flex>
<Text type="secondary">
ID: <Text copyable>{project.project_id}</Text>
@ -117,29 +121,29 @@ export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) {
</div>
</div>
<Button type="primary" icon={<EditIcon size={16} />} onClick={() => setIsEditModalVisible(true)}>
Edit Project
{t("projects.projectDetailsPage.editProject")}
</Button>
</div>
{/* Project Details */}
<Row style={{ marginBottom: 24 }}>
<Card>
<Descriptions title="Project Details" column={1}>
<Descriptions.Item label="Description">{project.description || "\u2014"}</Descriptions.Item>
<Descriptions.Item label="Created">
<Descriptions title={t("projects.projectDetailsPage.detailsTitle")} column={1}>
<Descriptions.Item label={t("common.description")}>{project.description || "\u2014"}</Descriptions.Item>
<Descriptions.Item label={t("common.createdAt")}>
{new Date(project.created_at).toLocaleString()}
{project.created_by && (
<Text>
&nbsp;{"by"}&nbsp;
&nbsp;{t("projects.projectDetailsPage.by")}&nbsp;
<DefaultProxyAdminTag userId={project.created_by} />
</Text>
)}
</Descriptions.Item>
<Descriptions.Item label="Last Updated">
<Descriptions.Item label={t("common.updatedAt")}>
{new Date(project.updated_at).toLocaleString()}
{project.updated_by && (
<Text>
&nbsp;{"by"}&nbsp;
&nbsp;{t("projects.projectDetailsPage.by")}&nbsp;
<DefaultProxyAdminTag userId={project.updated_by} />
</Text>
)}
@ -155,7 +159,7 @@ export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) {
title={
<Flex align="center" gap={8}>
<DollarSignIcon size={16} />
Budget
{t("projects.projectDetailsPage.budgetTitle")}
</Flex>
}
style={{ height: "100%" }}
@ -166,13 +170,19 @@ export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) {
${spend.toFixed(2)}
</Text>
<br />
<Text type="secondary">{hasLimit ? `of $${maxBudget.toFixed(2)} budget` : "No budget limit"}</Text>
<Text type="secondary">
{hasLimit
? t("projects.projectDetailsPage.budgetOf", { budget: maxBudget.toFixed(2) })
: t("projects.projectDetailsPage.noBudgetLimit")}
</Text>
</div>
{hasLimit && (
<div>
<Progress percent={Math.round(spendPercent * 10) / 10} strokeColor={spendColor} showInfo={false} />
<Text type="secondary" style={{ fontSize: 12 }}>
{(Math.round(spendPercent * 10) / 10).toFixed(1)}% utilized
{t("projects.projectDetailsPage.utilized", {
percent: (Math.round(spendPercent * 10) / 10).toFixed(1),
})}
</Text>
</div>
)}
@ -180,7 +190,7 @@ export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) {
</Card>
</Col>
<Col xs={24} lg={16}>
<Card title="Spend by Model" style={{ height: "100%" }}>
<Card title={t("projects.projectDetailsPage.spendByModel")} style={{ height: "100%" }}>
{modelSpendData.length > 0 ? (
<BarChart
data={modelSpendData}
@ -194,7 +204,7 @@ export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) {
style={{ height: Math.max(modelSpendData.length * 40, 120) }}
/>
) : (
<Empty description="No model spend recorded yet" image={Empty.PRESENTED_IMAGE_SIMPLE} />
<Empty description={t("projects.projectDetailsPage.noModelSpend")} image={Empty.PRESENTED_IMAGE_SIMPLE} />
)}
</Card>
</Col>
@ -207,12 +217,15 @@ export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) {
title={
<Flex align="center" gap={8}>
<KeyIcon size={16} />
Keys
{t("projects.projectDetailsPage.keysTitle")}
</Flex>
}
style={{ height: "100%" }}
>
<Empty description="No keys to display" image={Empty.PRESENTED_IMAGE_SIMPLE} />
<Empty
description={t("projects.projectDetailsPage.noKeysToDisplay")}
image={Empty.PRESENTED_IMAGE_SIMPLE}
/>
</Card>
</Col>
<Col xs={24} lg={12}>
@ -220,7 +233,7 @@ export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) {
title={
<Flex align="center" gap={8}>
<UsersIcon size={16} />
Team
{t("projects.projectDetailsPage.teamTitle")}
</Flex>
}
style={{ height: "100%" }}
@ -252,7 +265,7 @@ export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) {
{/* Models */}
<div>
<Text type="secondary" style={{ fontSize: 12, display: "block", marginBottom: 4 }}>
Models
{t("projects.projectDetailsPage.modelsLabel")}
</Text>
{(teamInfo.models?.length ?? 0) > 0 ? (
<Flex wrap="wrap" gap={4} style={{ maxHeight: 60, overflow: "hidden" }}>
@ -263,7 +276,7 @@ export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) {
))}
</Flex>
) : (
<Text type="secondary">All models</Text>
<Text type="secondary">{t("projects.projectDetailsPage.allModels")}</Text>
)}
</div>
@ -271,7 +284,7 @@ export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) {
<div>
<Flex justify="space-between" align="center" style={{ marginBottom: 2 }}>
<Text type="secondary" style={{ fontSize: 12 }}>
Spend
{t("projects.projectDetailsPage.spendLabel")}
</Text>
<Text style={{ fontSize: 12 }}>
${teamSpend.toFixed(2)}
@ -283,7 +296,7 @@ export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) {
) : (
<Text type="secondary" style={{ fontSize: 12 }}>
{" "}
(Unlimited)
({t("projects.projectDetailsPage.unlimited")})
</Text>
)}
</Text>
@ -301,7 +314,7 @@ export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) {
{/* Members */}
<Flex justify="space-between">
<Text type="secondary" style={{ fontSize: 12 }}>
Members
{t("projects.projectDetailsPage.membersLabel")}
</Text>
<Text style={{ fontSize: 12 }}>{teamInfo.members_with_roles?.length ?? 0}</Text>
</Flex>
@ -313,7 +326,10 @@ export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) {
<Spin indicator={<LoadingOutlined spin />} size="small" />
</Flex>
) : (
<Empty description="No team assigned" image={Empty.PRESENTED_IMAGE_SIMPLE} />
<Empty
description={t("projects.projectDetailsPage.noTeamAssigned")}
image={Empty.PRESENTED_IMAGE_SIMPLE}
/>
)}
</Card>
</Col>

View file

@ -3,6 +3,7 @@ import { LoadingOutlined } from "@ant-design/icons";
import { Card, Flex, Input, Pagination, Spin } from "antd";
import { KeyIcon, SearchIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { ProjectKeysTable } from "./ProjectKeysTable";
interface ProjectKeysSectionProps {
@ -12,6 +13,7 @@ interface ProjectKeysSectionProps {
const PAGE_SIZE = 5;
export function ProjectKeysSection({ projectId }: ProjectKeysSectionProps) {
const { t } = useTranslation();
const [page, setPage] = useState(1);
const [keyAlias, setKeyAlias] = useState<string>("");
@ -33,7 +35,7 @@ export function ProjectKeysSection({ projectId }: ProjectKeysSectionProps) {
title={
<Flex align="center" gap={8}>
<KeyIcon size={16} />
Keys
{t("projects.projectKeysSection.title")}
</Flex>
}
style={{ height: "100%" }}
@ -41,7 +43,7 @@ export function ProjectKeysSection({ projectId }: ProjectKeysSectionProps) {
<Flex justify="space-between" align="center" style={{ marginBottom: 12 }}>
<Input
prefix={<SearchIcon size={14} />}
placeholder="Filter by key name..."
placeholder={t("projects.projectKeysSection.filterPlaceholder")}
style={{ maxWidth: 220 }}
value={keyAlias}
onChange={(e) => setKeyAlias(e.target.value)}
@ -55,7 +57,7 @@ export function ProjectKeysSection({ projectId }: ProjectKeysSectionProps) {
onChange={setPage}
size="small"
showSizeChanger={false}
showTotal={(total) => `${total} keys`}
showTotal={(total) => t("projects.projectKeysSection.totalKeys", { count: total })}
/>
</Flex>
<ProjectKeysTable

View file

@ -3,21 +3,24 @@ import { Empty, Table, Tooltip } from "antd";
import type { ColumnsType } from "antd/es/table";
import type { SpinProps } from "antd";
import DefaultProxyAdminTag from "../common_components/DefaultProxyAdminTag";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { useMemo } from "react";
interface ProjectKeysTableProps {
keys: KeyResponse[];
loading?: boolean | SpinProps;
}
const columns: ColumnsType<KeyResponse> = [
const getProjectKeysTableColumns = (t: TFunction): ColumnsType<KeyResponse> => [
{
title: "Key Name",
title: t("projects.projectKeysTable.colKeyName"),
dataIndex: "key_alias",
key: "key_alias",
render: (alias: string | null) => alias || "—",
},
{
title: "Owner",
title: t("projects.projectKeysTable.colOwner"),
key: "owner",
render: (_: unknown, record: KeyResponse) => {
const email = record.user?.user_email ?? record.user_id ?? null;
@ -30,20 +33,23 @@ const columns: ColumnsType<KeyResponse> = [
},
},
{
title: "Created",
title: t("common.createdAt"),
dataIndex: "created_at",
key: "created_at",
render: (date: string) => (date ? new Date(date).toLocaleDateString() : "—"),
},
{
title: "Last Active",
title: t("projects.projectKeysTable.colLastActive"),
dataIndex: "last_active",
key: "last_active",
render: (date: string | null) => (date ? new Date(date).toLocaleDateString() : "Never"),
render: (date: string | null) => (date ? new Date(date).toLocaleDateString() : t("common.never")),
},
];
export function ProjectKeysTable({ keys, loading }: ProjectKeysTableProps) {
const { t } = useTranslation();
const columns = useMemo(() => getProjectKeysTableColumns(t), [t]);
return (
<Table
columns={columns}
@ -52,7 +58,11 @@ export function ProjectKeysTable({ keys, loading }: ProjectKeysTableProps) {
loading={loading}
pagination={false}
size="small"
locale={{ emptyText: <Empty description="No keys found" image={Empty.PRESENTED_IMAGE_SIMPLE} /> }}
locale={{
emptyText: (
<Empty description={t("projects.projectKeysTable.noKeysFound")} image={Empty.PRESENTED_IMAGE_SIMPLE} />
),
}}
/>
);
}

View file

@ -2,6 +2,7 @@ import { Modal, Form, Button, Typography } from "antd";
import { FolderAddOutlined } from "@ant-design/icons";
import MessageManager from "@/components/molecules/message_manager";
import { useCreateProject, ProjectCreateParams } from "@/app/(dashboard)/hooks/projects/useCreateProject";
import { useTranslation } from "react-i18next";
import { ProjectBaseForm, ProjectFormValues } from "./ProjectBaseForm";
import { buildProjectApiParams } from "./projectFormUtils";
@ -11,6 +12,7 @@ interface CreateProjectModalProps {
}
export function CreateProjectModal({ isOpen, onClose }: CreateProjectModalProps) {
const { t } = useTranslation();
const [form] = Form.useForm<ProjectFormValues>();
const createMutation = useCreateProject();
@ -24,12 +26,12 @@ export function CreateProjectModal({ isOpen, onClose }: CreateProjectModalProps)
createMutation.mutate(params, {
onSuccess: () => {
MessageManager.success("Project created successfully");
MessageManager.success(t("projects.createProjectModal.createSuccess"));
form.resetFields();
onClose();
},
onError: (error) => {
MessageManager.error(error.message || "Failed to create project");
MessageManager.error(error.message || t("projects.createProjectModal.createFailed"));
},
});
} catch (error) {
@ -46,7 +48,7 @@ export function CreateProjectModal({ isOpen, onClose }: CreateProjectModalProps)
<Modal
title={
<Typography.Text strong style={{ fontSize: 18 }}>
Create New Project
{t("projects.createProjectModal.title")}
</Typography.Text>
}
open={isOpen}
@ -55,7 +57,7 @@ export function CreateProjectModal({ isOpen, onClose }: CreateProjectModalProps)
destroyOnHidden
footer={[
<Button key="cancel" onClick={handleCancel}>
Cancel
{t("common.cancel")}
</Button>,
<Button
key="submit"
@ -64,7 +66,7 @@ export function CreateProjectModal({ isOpen, onClose }: CreateProjectModalProps)
loading={createMutation.isPending}
onClick={handleSubmit}
>
Create Project
{t("projects.createProjectModal.createBtn")}
</Button>,
]}
>

View file

@ -4,6 +4,7 @@ import { SaveOutlined } from "@ant-design/icons";
import MessageManager from "@/components/molecules/message_manager";
import { ProjectResponse } from "@/app/(dashboard)/hooks/projects/useProjects";
import { useUpdateProject, ProjectUpdateParams } from "@/app/(dashboard)/hooks/projects/useUpdateProject";
import { useTranslation } from "react-i18next";
import { ProjectBaseForm, ProjectFormValues } from "./ProjectBaseForm";
import { buildProjectApiParams } from "./projectFormUtils";
@ -15,6 +16,7 @@ interface EditProjectModalProps {
}
export function EditProjectModal({ isOpen, project, onClose, onSuccess }: EditProjectModalProps) {
const { t } = useTranslation();
const [form] = Form.useForm<ProjectFormValues>();
const updateMutation = useUpdateProject();
@ -72,12 +74,12 @@ export function EditProjectModal({ isOpen, project, onClose, onSuccess }: EditPr
{ projectId: project.project_id, params },
{
onSuccess: () => {
MessageManager.success("Project updated successfully");
MessageManager.success(t("projects.editProjectModal.updateSuccess"));
onSuccess?.();
onClose();
},
onError: (error) => {
MessageManager.error(error.message || "Failed to update project");
MessageManager.error(error.message || t("projects.editProjectModal.updateFailed"));
},
},
);
@ -90,7 +92,7 @@ export function EditProjectModal({ isOpen, project, onClose, onSuccess }: EditPr
<Modal
title={
<Typography.Text strong style={{ fontSize: 18 }}>
Edit Project
{t("projects.editProjectModal.title")}
</Typography.Text>
}
open={isOpen}
@ -99,7 +101,7 @@ export function EditProjectModal({ isOpen, project, onClose, onSuccess }: EditPr
destroyOnHidden
footer={[
<Button key="cancel" onClick={onClose}>
Cancel
{t("common.cancel")}
</Button>,
<Button
key="submit"
@ -108,7 +110,7 @@ export function EditProjectModal({ isOpen, project, onClose, onSuccess }: EditPr
loading={updateMutation.isPending}
onClick={handleSubmit}
>
Save Changes
{t("projects.editProjectModal.saveBtn")}
</Button>,
]}
>

View file

@ -18,6 +18,7 @@ import {
import type { FormInstance } from "antd";
import { PlusOutlined, MinusCircleOutlined } from "@ant-design/icons";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { useTranslation } from "react-i18next";
import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
import { Team } from "../../key_team_helpers/key_list";
import { fetchTeamModels } from "../../organisms/create_key_button";
@ -41,6 +42,7 @@ interface ProjectBaseFormProps {
}
export function ProjectBaseForm({ form }: ProjectBaseFormProps) {
const { t } = useTranslation();
const { accessToken, userId, userRole } = useAuthorized();
const { data: teams } = useTeams();
@ -109,7 +111,7 @@ export function ProjectBaseForm({ form }: ProjectBaseFormProps) {
letterSpacing: "0.05em",
}}
>
Basic Information
{t("projects.projectBaseForm.basicInfoTitle")}
</Typography.Text>
<Divider style={{ marginTop: 8, marginBottom: 16 }} />
@ -117,17 +119,21 @@ export function ProjectBaseForm({ form }: ProjectBaseFormProps) {
<Col span={12}>
<Form.Item
name="project_alias"
label="Project Name"
rules={[{ required: true, message: "Please enter a project name" }]}
label={t("projects.projectBaseForm.projectNameLabel")}
rules={[{ required: true, message: t("projects.projectBaseForm.projectNameRequired") }]}
>
<Input placeholder="e.g. Customer Support Bot" />
<Input placeholder={t("projects.projectBaseForm.projectNamePlaceholder")} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="team_id" label="Team" rules={[{ required: true, message: "Please select a team" }]}>
<Form.Item
name="team_id"
label={t("projects.projectBaseForm.teamLabel")}
rules={[{ required: true, message: t("projects.projectBaseForm.teamRequired") }]}
>
<Select
showSearch
placeholder="Search or select a team"
placeholder={t("projects.projectBaseForm.teamPlaceholder")}
onChange={handleTeamChange}
allowClear
optionLabelProp="label"
@ -153,8 +159,8 @@ export function ProjectBaseForm({ form }: ProjectBaseFormProps) {
<Row>
<Col span={24}>
<Form.Item name="description" label="Description">
<Input.TextArea placeholder="Describe the purpose of this project" rows={3} />
<Form.Item name="description" label={t("common.description")}>
<Input.TextArea placeholder={t("projects.projectBaseForm.descriptionPlaceholder")} rows={3} />
</Form.Item>
</Col>
</Row>
@ -163,12 +169,16 @@ export function ProjectBaseForm({ form }: ProjectBaseFormProps) {
<Col span={24}>
<Form.Item
name="models"
label="Allowed Models (scoped to selected team's models)"
help={!selectedTeam ? "Select a team first to see available models" : undefined}
label={t("projects.projectBaseForm.allowedModelsLabel")}
help={!selectedTeam ? t("projects.projectBaseForm.selectTeamFirst") : undefined}
>
<Select
mode="multiple"
placeholder={selectedTeam ? "Select models" : "Select a team first"}
placeholder={
selectedTeam
? t("projects.projectBaseForm.selectModels")
: t("projects.projectBaseForm.selectTeamFirstPlaceholder")
}
disabled={!selectedTeam}
allowClear
maxTagCount="responsive"
@ -179,7 +189,7 @@ export function ProjectBaseForm({ form }: ProjectBaseFormProps) {
}}
>
<Select.Option key="all-team-models" value="all-team-models">
All Team Models
{t("projects.projectBaseForm.allTeamModels")}
</Select.Option>
{modelsToPick.map((model) => (
<Select.Option key={model} value={model}>
@ -193,7 +203,7 @@ export function ProjectBaseForm({ form }: ProjectBaseFormProps) {
<Row gutter={24}>
<Col span={12}>
<Form.Item name="max_budget" label="Max Budget (USD)">
<Form.Item name="max_budget" label={t("projects.projectBaseForm.maxBudgetLabel")}>
<InputNumber prefix="$" style={{ width: "100%" }} placeholder="0.00" min={0} precision={2} />
</Form.Item>
</Col>
@ -214,13 +224,13 @@ export function ProjectBaseForm({ form }: ProjectBaseFormProps) {
key: "1",
label: (
<Typography.Text strong style={{ color: "#374151" }}>
Advanced Settings
{t("projects.projectBaseForm.advancedSettingsTitle")}
</Typography.Text>
),
children: (
<>
<Flex align="center" gap={12}>
<Typography.Text strong>Block Project</Typography.Text>
<Typography.Text strong>{t("projects.projectBaseForm.blockProjectLabel")}</Typography.Text>
<Form.Item name="isBlocked" valuePropName="checked" noStyle>
<Switch />
</Form.Item>
@ -232,7 +242,7 @@ export function ProjectBaseForm({ form }: ProjectBaseFormProps) {
banner
type="warning"
showIcon
message="All API requests using keys under this project will be rejected."
message={t("projects.projectBaseForm.blockProjectWarning")}
style={{ marginTop: 12 }}
/>
) : null
@ -241,11 +251,15 @@ export function ProjectBaseForm({ form }: ProjectBaseFormProps) {
<Divider />
<Form.Item label="Guardrails" name="guardrails" help="Select existing guardrails or enter new ones">
<Form.Item
label={t("projects.projectBaseForm.guardrailsLabel")}
name="guardrails"
help={t("projects.projectBaseForm.guardrailsHelp")}
>
<Select
mode="tags"
style={{ width: "100%" }}
placeholder="Select or enter guardrails"
placeholder={t("projects.projectBaseForm.guardrailsPlaceholder")}
options={guardrailsList.map((name) => ({
value: name,
label: name,
@ -256,7 +270,7 @@ export function ProjectBaseForm({ form }: ProjectBaseFormProps) {
<Divider />
<Typography.Text strong style={{ display: "block", marginBottom: 12 }}>
Model-Specific Limits
{t("projects.projectBaseForm.modelLimitsTitle")}
</Typography.Text>
<Form.List name="modelLimits">
{(fields, { add, remove }) => (
@ -267,34 +281,34 @@ export function ProjectBaseForm({ form }: ProjectBaseFormProps) {
{...restField}
name={[name, "model"]}
rules={[
{ required: true, message: "Missing model" },
{ required: true, message: t("projects.projectBaseForm.missingModel") },
{
validator: (_, value) => {
if (!value) return Promise.resolve();
const all = form.getFieldValue("modelLimits") ?? [];
const dupes = all.filter((entry: { model?: string }) => entry?.model === value);
if (dupes.length > 1) {
return Promise.reject(new Error("Duplicate model"));
return Promise.reject(new Error(t("projects.projectBaseForm.duplicateModel")));
}
return Promise.resolve();
},
},
]}
>
<Input placeholder="Model name (e.g. gpt-4)" />
<Input placeholder={t("projects.projectBaseForm.modelNamePlaceholder")} />
</Form.Item>
<Form.Item {...restField} name={[name, "tpm"]}>
<InputNumber placeholder="TPM Limit" min={0} />
<InputNumber placeholder={t("projects.projectBaseForm.tpmPlaceholder")} min={0} />
</Form.Item>
<Form.Item {...restField} name={[name, "rpm"]}>
<InputNumber placeholder="RPM Limit" min={0} />
<InputNumber placeholder={t("projects.projectBaseForm.rpmPlaceholder")} min={0} />
</Form.Item>
<MinusCircleOutlined onClick={() => remove(name)} style={{ color: "#ef4444" }} />
</Space>
))}
<Form.Item>
<Button type="dashed" onClick={() => add()} block icon={<PlusOutlined />}>
Add Model Limit
{t("projects.projectBaseForm.addModelLimit")}
</Button>
</Form.Item>
</>
@ -304,7 +318,7 @@ export function ProjectBaseForm({ form }: ProjectBaseFormProps) {
<Divider />
<Typography.Text strong style={{ display: "block", marginBottom: 12 }}>
Metadata
{t("projects.projectBaseForm.metadataTitle")}
</Typography.Text>
<Form.List name="metadata">
{(fields, { add, remove }) => (
@ -315,35 +329,35 @@ export function ProjectBaseForm({ form }: ProjectBaseFormProps) {
{...restField}
name={[name, "key"]}
rules={[
{ required: true, message: "Missing key" },
{ required: true, message: t("projects.projectBaseForm.missingKey") },
{
validator: (_, value) => {
if (!value) return Promise.resolve();
const all = form.getFieldValue("metadata") ?? [];
const dupes = all.filter((entry: { key?: string }) => entry?.key === value);
if (dupes.length > 1) {
return Promise.reject(new Error("Duplicate key"));
return Promise.reject(new Error(t("projects.projectBaseForm.duplicateKey")));
}
return Promise.resolve();
},
},
]}
>
<Input placeholder="Key" />
<Input placeholder={t("projects.projectBaseForm.keyPlaceholder")} />
</Form.Item>
<Form.Item
{...restField}
name={[name, "value"]}
rules={[{ required: true, message: "Missing value" }]}
rules={[{ required: true, message: t("projects.projectBaseForm.missingValue") }]}
>
<Input placeholder="Value" />
<Input placeholder={t("projects.projectBaseForm.valuePlaceholder")} />
</Form.Item>
<MinusCircleOutlined onClick={() => remove(name)} style={{ color: "#ef4444" }} />
</Space>
))}
<Form.Item>
<Button type="dashed" onClick={() => add()} block icon={<PlusOutlined />}>
Add Key-Value Pair
{t("projects.projectBaseForm.addKeyValue")}
</Button>
</Form.Item>
</>

View file

@ -19,6 +19,7 @@ import {
import type { ColumnsType } from "antd/es/table";
import { LayersIcon, SearchIcon } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { CreateProjectModal } from "./ProjectModals/CreateProjectModal";
import { ProjectDetail } from "./ProjectDetailsPage";
@ -26,6 +27,7 @@ const { Title, Text } = Typography;
const { Content } = Layout;
export function ProjectsPage() {
const { t } = useTranslation();
const { token } = theme.useToken();
const { data: projects, isLoading } = useProjects();
const { data: teams, isLoading: isTeamsLoading } = useTeams();
@ -66,87 +68,94 @@ export function ProjectsPage() {
}, [projects, searchText, teamAliasMap]);
// ---------- Ant Design columns ----------
const columns: ColumnsType<ProjectResponse> = [
{
title: "ID",
dataIndex: "project_id",
key: "project_id",
width: 170,
render: (id: string) => (
<Tooltip title={id}>
<Text
ellipsis
className="text-blue-500 bg-blue-50 hover:bg-blue-100 text-xs cursor-pointer"
style={{ fontSize: 14, padding: "1px 8px" }}
onClick={() => setSelectedProjectId(id)}
>
{id}
</Text>
</Tooltip>
),
},
{
title: "Name",
dataIndex: "project_alias",
key: "project_alias",
sorter: (a, b) => (a.project_alias ?? "").localeCompare(b.project_alias ?? ""),
render: (alias: string | null) => alias ?? "—",
},
{
title: "Team",
key: "team",
sorter: (a, b) => {
const aAlias = teamAliasMap.get(a.team_id ?? "") ?? "";
const bAlias = teamAliasMap.get(b.team_id ?? "") ?? "";
return aAlias.localeCompare(bAlias);
},
render: (_: unknown, record: ProjectResponse) => {
if (!record.team_id) return "—";
const alias = teamAliasMap.get(record.team_id);
if (alias) return alias;
if (isTeamsLoading) return <Spin indicator={<LoadingOutlined spin />} size="small" />;
return record.team_id;
},
},
{
title: "Models",
key: "models",
render: (_: unknown, record: ProjectResponse) => {
const models = record.models ?? [];
return (
<Tooltip title={models.length > 0 ? models.join(", ") : "No models"}>
<Tag color="blue" style={{ fontSize: 14, padding: "2px 8px", margin: 0 }}>
<Flex align="center" gap={6}>
<LayersIcon size={14} />
{models.length}
</Flex>
</Tag>
const columns: ColumnsType<ProjectResponse> = useMemo(
() => [
{
title: "ID",
dataIndex: "project_id",
key: "project_id",
width: 170,
render: (id: string) => (
<Tooltip title={id}>
<Text
ellipsis
className="text-blue-500 bg-blue-50 hover:bg-blue-100 text-xs cursor-pointer"
style={{ fontSize: 14, padding: "1px 8px" }}
onClick={() => setSelectedProjectId(id)}
>
{id}
</Text>
</Tooltip>
);
),
},
},
{
title: "Status",
dataIndex: "blocked",
key: "status",
render: (blocked: boolean) => <Tag color={blocked ? "red" : "green"}>{blocked ? "Blocked" : "Active"}</Tag>,
},
{
title: "Created",
dataIndex: "created_at",
key: "created_at",
sorter: (a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime(),
responsive: ["lg"],
render: (date: string) => new Date(date).toLocaleDateString(),
},
{
title: "Updated",
dataIndex: "updated_at",
key: "updated_at",
responsive: ["xl"],
render: (date: string) => new Date(date).toLocaleDateString(),
},
];
{
title: t("common.name"),
dataIndex: "project_alias",
key: "project_alias",
sorter: (a, b) => (a.project_alias ?? "").localeCompare(b.project_alias ?? ""),
render: (alias: string | null) => alias ?? "—",
},
{
title: t("projects.projectsPage.colTeam"),
key: "team",
sorter: (a, b) => {
const aAlias = teamAliasMap.get(a.team_id ?? "") ?? "";
const bAlias = teamAliasMap.get(b.team_id ?? "") ?? "";
return aAlias.localeCompare(bAlias);
},
render: (_: unknown, record: ProjectResponse) => {
if (!record.team_id) return "—";
const alias = teamAliasMap.get(record.team_id);
if (alias) return alias;
if (isTeamsLoading) return <Spin indicator={<LoadingOutlined spin />} size="small" />;
return record.team_id;
},
},
{
title: t("projects.projectsPage.colModels"),
key: "models",
render: (_: unknown, record: ProjectResponse) => {
const models = record.models ?? [];
return (
<Tooltip title={models.length > 0 ? models.join(", ") : t("projects.projectsPage.noModels")}>
<Tag color="blue" style={{ fontSize: 14, padding: "2px 8px", margin: 0 }}>
<Flex align="center" gap={6}>
<LayersIcon size={14} />
{models.length}
</Flex>
</Tag>
</Tooltip>
);
},
},
{
title: t("common.status"),
dataIndex: "blocked",
key: "status",
render: (blocked: boolean) => (
<Tag color={blocked ? "red" : "green"}>
{blocked ? t("projects.projectsPage.statusBlocked") : t("common.active")}
</Tag>
),
},
{
title: t("common.createdAt"),
dataIndex: "created_at",
key: "created_at",
sorter: (a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime(),
responsive: ["lg"],
render: (date: string) => new Date(date).toLocaleDateString(),
},
{
title: t("common.updatedAt"),
dataIndex: "updated_at",
key: "updated_at",
responsive: ["xl"],
render: (date: string) => new Date(date).toLocaleDateString(),
},
],
[t, teamAliasMap, isTeamsLoading, setSelectedProjectId],
);
if (selectedProjectId) {
return <ProjectDetail projectId={selectedProjectId} onBack={() => setSelectedProjectId(null)} />;
@ -157,12 +166,12 @@ export function ProjectsPage() {
<Flex justify="space-between" align="center" style={{ marginBottom: 16 }}>
<Space direction="vertical" size={0}>
<Title level={2} style={{ margin: 0 }}>
Projects
{t("projects.projectsPage.title")}
</Title>
<Text type="secondary">Manage projects within your teams</Text>
<Text type="secondary">{t("projects.projectsPage.subtitle")}</Text>
</Space>
<Button type="primary" icon={<PlusOutlined />} onClick={() => setIsCreateModalVisible(true)}>
Create Project
{t("projects.projectsPage.createBtn")}
</Button>
</Flex>
@ -170,7 +179,7 @@ export function ProjectsPage() {
<Flex justify="space-between" align="center" style={{ padding: "12px 16px" }}>
<Input
prefix={<SearchIcon size={16} />}
placeholder="Search projects by name, ID, description, or team..."
placeholder={t("projects.projectsPage.searchPlaceholder")}
style={{ maxWidth: 400 }}
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
@ -182,7 +191,7 @@ export function ProjectsPage() {
pageSize={pageSize}
onChange={(page) => setCurrentPage(page)}
size="small"
showTotal={(total) => `${total} projects`}
showTotal={(total) => t("projects.projectsPage.totalProjects", { count: total })}
showSizeChanger={false}
/>
</Flex>

View file

@ -6690,5 +6690,99 @@
"successCount_other": "Successfully made {{count}} model groups public!",
"failedToMakePublic": "Failed to make model groups public. Please try again."
}
},
"projects": {
"projectDetailsPage": {
"notFound": "Project not found",
"statusBlocked": "Blocked",
"editProject": "Edit Project",
"detailsTitle": "Project Details",
"by": "by",
"budgetTitle": "Budget",
"budgetOf": "of ${{budget}} budget",
"noBudgetLimit": "No budget limit",
"utilized": "{{percent}}% utilized",
"spendByModel": "Spend by Model",
"noModelSpend": "No model spend recorded yet",
"keysTitle": "Keys",
"noKeysToDisplay": "No keys to display",
"teamTitle": "Team",
"modelsLabel": "Models",
"allModels": "All models",
"spendLabel": "Spend",
"unlimited": "Unlimited",
"membersLabel": "Members",
"noTeamAssigned": "No team assigned"
},
"projectKeysSection": {
"title": "Keys",
"filterPlaceholder": "Filter by key name...",
"totalKeys": "{{count}} keys"
},
"projectKeysTable": {
"colKeyName": "Key Name",
"colOwner": "Owner",
"colLastActive": "Last Active",
"noKeysFound": "No keys found"
},
"createProjectModal": {
"title": "Create New Project",
"createBtn": "Create Project",
"createSuccess": "Project created successfully",
"createFailed": "Failed to create project"
},
"editProjectModal": {
"title": "Edit Project",
"saveBtn": "Save Changes",
"updateSuccess": "Project updated successfully",
"updateFailed": "Failed to update project"
},
"projectBaseForm": {
"basicInfoTitle": "Basic Information",
"projectNameLabel": "Project Name",
"projectNameRequired": "Please enter a project name",
"projectNamePlaceholder": "e.g. Customer Support Bot",
"teamLabel": "Team",
"teamRequired": "Please select a team",
"teamPlaceholder": "Search or select a team",
"descriptionPlaceholder": "Describe the purpose of this project",
"allowedModelsLabel": "Allowed Models (scoped to selected team's models)",
"selectTeamFirst": "Select a team first to see available models",
"selectModels": "Select models",
"selectTeamFirstPlaceholder": "Select a team first",
"allTeamModels": "All Team Models",
"maxBudgetLabel": "Max Budget (USD)",
"advancedSettingsTitle": "Advanced Settings",
"blockProjectLabel": "Block Project",
"blockProjectWarning": "All API requests using keys under this project will be rejected.",
"guardrailsLabel": "Guardrails",
"guardrailsHelp": "Select existing guardrails or enter new ones",
"guardrailsPlaceholder": "Select or enter guardrails",
"modelLimitsTitle": "Model-Specific Limits",
"missingModel": "Missing model",
"duplicateModel": "Duplicate model",
"modelNamePlaceholder": "Model name (e.g. gpt-4)",
"tpmPlaceholder": "TPM Limit",
"rpmPlaceholder": "RPM Limit",
"addModelLimit": "Add Model Limit",
"metadataTitle": "Metadata",
"missingKey": "Missing key",
"duplicateKey": "Duplicate key",
"keyPlaceholder": "Key",
"missingValue": "Missing value",
"valuePlaceholder": "Value",
"addKeyValue": "Add Key-Value Pair"
},
"projectsPage": {
"title": "Projects",
"subtitle": "Manage projects within your teams",
"createBtn": "Create Project",
"searchPlaceholder": "Search projects by name, ID, description, or team...",
"totalProjects": "{{count}} projects",
"colTeam": "Team",
"colModels": "Models",
"noModels": "No models",
"statusBlocked": "Blocked"
}
}
}

View file

@ -6690,5 +6690,99 @@
"successCount_other": "成功将 {{count}} 个模型组设为公开!",
"failedToMakePublic": "将模型组设为公开失败,请重试。"
}
},
"projects": {
"projectDetailsPage": {
"notFound": "项目未找到",
"statusBlocked": "已封锁",
"editProject": "编辑项目",
"detailsTitle": "项目详情",
"by": "由",
"budgetTitle": "预算",
"budgetOf": "共 ${{budget}} 预算",
"noBudgetLimit": "无预算限制",
"utilized": "已使用 {{percent}}%",
"spendByModel": "按模型花费",
"noModelSpend": "暂无模型花费记录",
"keysTitle": "密钥",
"noKeysToDisplay": "暂无密钥可显示",
"teamTitle": "团队",
"modelsLabel": "模型",
"allModels": "所有模型",
"spendLabel": "花费",
"unlimited": "无限制",
"membersLabel": "成员",
"noTeamAssigned": "未分配团队"
},
"projectKeysSection": {
"title": "密钥",
"filterPlaceholder": "按密钥名称筛选...",
"totalKeys": "共 {{count}} 个密钥"
},
"projectKeysTable": {
"colKeyName": "密钥名称",
"colOwner": "所有者",
"colLastActive": "最后活跃",
"noKeysFound": "未找到密钥"
},
"createProjectModal": {
"title": "新建项目",
"createBtn": "创建项目",
"createSuccess": "项目创建成功",
"createFailed": "创建项目失败"
},
"editProjectModal": {
"title": "编辑项目",
"saveBtn": "保存更改",
"updateSuccess": "项目更新成功",
"updateFailed": "更新项目失败"
},
"projectBaseForm": {
"basicInfoTitle": "基本信息",
"projectNameLabel": "项目名称",
"projectNameRequired": "请输入项目名称",
"projectNamePlaceholder": "例如:客服机器人",
"teamLabel": "团队",
"teamRequired": "请选择团队",
"teamPlaceholder": "搜索或选择团队",
"descriptionPlaceholder": "描述该项目的用途",
"allowedModelsLabel": "允许的模型(限定于所选团队的模型)",
"selectTeamFirst": "请先选择团队以查看可用模型",
"selectModels": "选择模型",
"selectTeamFirstPlaceholder": "请先选择团队",
"allTeamModels": "团队所有模型",
"maxBudgetLabel": "最大预算(美元)",
"advancedSettingsTitle": "高级设置",
"blockProjectLabel": "封锁项目",
"blockProjectWarning": "该项目下所有密钥的 API 请求将被拒绝。",
"guardrailsLabel": "护栏",
"guardrailsHelp": "选择已有护栏或输入新护栏",
"guardrailsPlaceholder": "选择或输入护栏",
"modelLimitsTitle": "模型专属限制",
"missingModel": "缺少模型名称",
"duplicateModel": "模型重复",
"modelNamePlaceholder": "模型名称(例如 gpt-4)",
"tpmPlaceholder": "TPM 限制",
"rpmPlaceholder": "RPM 限制",
"addModelLimit": "添加模型限制",
"metadataTitle": "元数据",
"missingKey": "缺少键名",
"duplicateKey": "键名重复",
"keyPlaceholder": "键",
"missingValue": "缺少值",
"valuePlaceholder": "值",
"addKeyValue": "添加键值对"
},
"projectsPage": {
"title": "项目",
"subtitle": "管理团队内的项目",
"createBtn": "创建项目",
"searchPlaceholder": "按名称、ID、描述或团队搜索项目...",
"totalProjects": "共 {{count}} 个项目",
"colTeam": "团队",
"colModels": "模型",
"noModels": "无模型",
"statusBlocked": "已封锁"
}
}
}