- Admin users can choose to install skills to public (built-in) or custom category via UI radio selector in InstallSkillDialog - Backend: SkillStorage.ainstall_skill_from_archive accepts target_category param; non-admin 403 on public install - Backend: AgentStore/SkillStore add count_owned(user_id); UserRepository/LocalAuthProvider add list_users() - Backend: GET /api/v1/auth/users endpoint (admin-only) returns all users with skill_count and agent_count - Frontend: admin-only "用户管理" nav item in sidebar; /page/workspace/admin/users route with user table - i18n: added sidebar.users and adminUsers section in zh-CN/en-US Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
647 lines
22 KiB
TypeScript
647 lines
22 KiB
TypeScript
"use client";
|
|
|
|
import {
|
|
AlertCircle,
|
|
CheckCircle2,
|
|
EyeIcon,
|
|
EyeOffIcon,
|
|
FileArchive,
|
|
Loader2,
|
|
SparklesIcon,
|
|
Trash2Icon,
|
|
TrendingUpIcon,
|
|
Upload,
|
|
} from "lucide-react";
|
|
import { useRouter } from "next/navigation";
|
|
import { useCallback, useMemo, useRef, useState } from "react";
|
|
import { toast } from "sonner";
|
|
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog";
|
|
import {
|
|
Empty,
|
|
EmptyContent,
|
|
EmptyDescription,
|
|
EmptyHeader,
|
|
EmptyMedia,
|
|
EmptyTitle,
|
|
} from "@/components/ui/empty";
|
|
import {
|
|
Item,
|
|
ItemActions,
|
|
ItemTitle,
|
|
ItemContent,
|
|
ItemDescription,
|
|
} from "@/components/ui/item";
|
|
import { Switch } from "@/components/ui/switch";
|
|
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
import { getStoredAuth } from "@/core/auth";
|
|
import { useI18n } from "@/core/i18n/hooks";
|
|
import type { ValidateSkillUploadResponse } from "@/core/skills/api";
|
|
import {
|
|
useDeleteCustomSkill,
|
|
useEnableSkill,
|
|
useInstallSkillUpload,
|
|
useSetSkillPublished,
|
|
useSkills,
|
|
useValidateSkillUpload,
|
|
} from "@/core/skills/hooks";
|
|
import type { Skill } from "@/core/skills/type";
|
|
import { env } from "@/env";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
import { SettingsSection } from "./settings-section";
|
|
|
|
export function SkillSettingsPage({ onClose }: { onClose?: () => void } = {}) {
|
|
const { t } = useI18n();
|
|
const { skills, isLoading, error } = useSkills();
|
|
return (
|
|
<SettingsSection
|
|
title={t.settings.skills.title}
|
|
description={t.settings.skills.description}
|
|
>
|
|
{isLoading ? (
|
|
<div className="text-muted-foreground text-sm">{t.common.loading}</div>
|
|
) : error ? (
|
|
<div>Error: {error.message}</div>
|
|
) : (
|
|
<SkillSettingsList skills={skills} onClose={onClose} />
|
|
)}
|
|
</SettingsSection>
|
|
);
|
|
}
|
|
|
|
function SkillSettingsList({
|
|
skills,
|
|
onClose,
|
|
}: {
|
|
skills: Skill[];
|
|
onClose?: () => void;
|
|
}) {
|
|
const { t } = useI18n();
|
|
const router = useRouter();
|
|
const [filter, setFilter] = useState<string>("public");
|
|
const [installOpen, setInstallOpen] = useState(false);
|
|
const { mutate: enableSkill } = useEnableSkill();
|
|
const filteredSkills = useMemo(
|
|
() => skills.filter((skill) => skill.category === filter),
|
|
[skills, filter],
|
|
);
|
|
|
|
const storedAuth = useMemo(() => getStoredAuth(), []);
|
|
const currentUserId = storedAuth?.user_id ?? null;
|
|
const isAdmin = storedAuth?.system_role === "admin";
|
|
|
|
const handleCreateSkill = () => {
|
|
onClose?.();
|
|
router.push("/?mode=skill");
|
|
};
|
|
return (
|
|
<div className="flex w-full flex-col gap-4">
|
|
<header className="flex justify-between">
|
|
<div className="flex gap-2">
|
|
<Tabs defaultValue="public" onValueChange={setFilter}>
|
|
<TabsList variant="line">
|
|
<TabsTrigger value="public">{t.common.public}</TabsTrigger>
|
|
<TabsTrigger value="custom">{t.common.custom}</TabsTrigger>
|
|
</TabsList>
|
|
</Tabs>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => {
|
|
onClose?.();
|
|
router.push("/page/workspace/skills/evolution");
|
|
}}
|
|
>
|
|
<TrendingUpIcon className="size-4" />
|
|
{t.settings.skills.goToEvolution}
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => setInstallOpen(true)}
|
|
disabled={env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"}
|
|
>
|
|
<Upload className="size-4" />
|
|
{t.settings.skills.installSkill}
|
|
</Button>
|
|
<Button size="sm" onClick={handleCreateSkill}>
|
|
<SparklesIcon className="size-4" />
|
|
{t.settings.skills.createSkill}
|
|
</Button>
|
|
</div>
|
|
</header>
|
|
{filteredSkills.length === 0 && (
|
|
<EmptySkill onCreateSkill={handleCreateSkill} />
|
|
)}
|
|
{filteredSkills.length > 0 &&
|
|
filteredSkills.map((skill) => (
|
|
<SkillRow
|
|
key={skill.name}
|
|
skill={skill}
|
|
currentUserId={currentUserId}
|
|
isAdmin={isAdmin}
|
|
onToggleEnabled={(enabled) =>
|
|
enableSkill({ skillName: skill.name, enabled })
|
|
}
|
|
/>
|
|
))}
|
|
<InstallSkillDialog
|
|
open={installOpen}
|
|
onClose={() => setInstallOpen(false)}
|
|
isAdmin={isAdmin}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SkillRow({
|
|
skill,
|
|
currentUserId,
|
|
isAdmin,
|
|
onToggleEnabled,
|
|
}: {
|
|
skill: Skill;
|
|
currentUserId: string | null;
|
|
isAdmin: boolean;
|
|
onToggleEnabled: (enabled: boolean) => void;
|
|
}) {
|
|
const { t } = useI18n();
|
|
const setPublished = useSetSkillPublished();
|
|
const deleteSkill = useDeleteCustomSkill();
|
|
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
|
|
|
|
const isCustom = skill.category === "custom";
|
|
// Built-in flag falls back to "no DB owner" so legacy responses without
|
|
// the multitenancy fields still classify public skills correctly.
|
|
const isBuiltin = skill.builtin ?? !skill.owner_user_id;
|
|
const isOwned =
|
|
isCustom && !isBuiltin && !!skill.owner_user_id && skill.owner_user_id === currentUserId;
|
|
const isFromOthers = isCustom && !isBuiltin && !isOwned;
|
|
// Admin can edit/delete/unpublish anyone's skill; non-admin only their own.
|
|
const canManage = isCustom && (isOwned || isAdmin);
|
|
const canTogglePublish = canManage && !isBuiltin;
|
|
|
|
async function handleTogglePublished(next: boolean) {
|
|
try {
|
|
await setPublished.mutateAsync({ skillName: skill.name, published: next });
|
|
toast.success(
|
|
next
|
|
? t.settings.skills.publishSuccess
|
|
: t.settings.skills.unpublishSuccess,
|
|
);
|
|
} catch (err) {
|
|
toast.error(err instanceof Error ? err.message : String(err));
|
|
}
|
|
}
|
|
|
|
async function handleDelete() {
|
|
try {
|
|
await deleteSkill.mutateAsync(skill.name);
|
|
toast.success(t.settings.skills.deleteSuccess);
|
|
setConfirmDeleteOpen(false);
|
|
} catch (err) {
|
|
toast.error(err instanceof Error ? err.message : String(err));
|
|
}
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<Item className="w-full" variant="outline">
|
|
<ItemContent>
|
|
<ItemTitle>
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<span>{skill.name}</span>
|
|
{isBuiltin && isCustom && (
|
|
<Badge variant="secondary" className="text-xs">
|
|
{t.settings.skills.badgeBuiltin}
|
|
</Badge>
|
|
)}
|
|
{isOwned && (
|
|
<Badge
|
|
variant="default"
|
|
className="bg-emerald-500/15 text-emerald-700 hover:bg-emerald-500/15 dark:text-emerald-300 text-xs"
|
|
>
|
|
{t.settings.skills.badgeMine}
|
|
</Badge>
|
|
)}
|
|
{isFromOthers && (
|
|
<Badge
|
|
variant="outline"
|
|
className="border-blue-500/30 text-blue-700 dark:text-blue-300 text-xs"
|
|
title={
|
|
skill.owner_user_id
|
|
? t.settings.skills.badgeFromOthersTitle.replace(
|
|
"{user_id}",
|
|
skill.owner_user_id,
|
|
)
|
|
: undefined
|
|
}
|
|
>
|
|
{t.settings.skills.badgeFromOthers}
|
|
</Badge>
|
|
)}
|
|
{isOwned && skill.published && (
|
|
<Badge
|
|
variant="outline"
|
|
className="border-sky-500/30 text-sky-700 dark:text-sky-300 text-xs"
|
|
title={t.settings.skills.badgePublishedTitle}
|
|
>
|
|
{t.settings.skills.badgePublished}
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
</ItemTitle>
|
|
<ItemDescription className="line-clamp-4">
|
|
{skill.description}
|
|
</ItemDescription>
|
|
</ItemContent>
|
|
<ItemActions>
|
|
{canTogglePublish && (
|
|
<Button
|
|
size="icon"
|
|
variant="ghost"
|
|
className="h-8 w-8 shrink-0"
|
|
disabled={setPublished.isPending}
|
|
onClick={() => void handleTogglePublished(!skill.published)}
|
|
title={
|
|
skill.published
|
|
? t.settings.skills.unpublishAction
|
|
: t.settings.skills.publishAction
|
|
}
|
|
>
|
|
{skill.published ? (
|
|
<EyeOffIcon className="size-3.5" />
|
|
) : (
|
|
<EyeIcon className="size-3.5" />
|
|
)}
|
|
</Button>
|
|
)}
|
|
{canManage && (
|
|
<Button
|
|
size="icon"
|
|
variant="ghost"
|
|
className="text-destructive hover:text-destructive h-8 w-8 shrink-0"
|
|
onClick={() => setConfirmDeleteOpen(true)}
|
|
title={t.settings.skills.deleteAction}
|
|
>
|
|
<Trash2Icon className="size-3.5" />
|
|
</Button>
|
|
)}
|
|
<Switch
|
|
checked={skill.enabled}
|
|
disabled={env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"}
|
|
onCheckedChange={(checked) => onToggleEnabled(checked)}
|
|
/>
|
|
</ItemActions>
|
|
</Item>
|
|
<Dialog open={confirmDeleteOpen} onOpenChange={setConfirmDeleteOpen}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>{t.settings.skills.deleteAction}</DialogTitle>
|
|
<DialogDescription>
|
|
{t.settings.skills.deleteConfirm.replace("{name}", skill.name)}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<DialogFooter>
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => setConfirmDeleteOpen(false)}
|
|
disabled={deleteSkill.isPending}
|
|
>
|
|
{t.common.cancel}
|
|
</Button>
|
|
<Button
|
|
variant="destructive"
|
|
onClick={() => void handleDelete()}
|
|
disabled={deleteSkill.isPending}
|
|
>
|
|
{deleteSkill.isPending ? t.common.loading : t.common.delete}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function EmptySkill({ onCreateSkill }: { onCreateSkill: () => void }) {
|
|
const { t } = useI18n();
|
|
return (
|
|
<Empty>
|
|
<EmptyHeader>
|
|
<EmptyMedia variant="icon">
|
|
<SparklesIcon />
|
|
</EmptyMedia>
|
|
<EmptyTitle>{t.settings.skills.emptyTitle}</EmptyTitle>
|
|
<EmptyDescription>
|
|
{t.settings.skills.emptyDescription}
|
|
</EmptyDescription>
|
|
</EmptyHeader>
|
|
<EmptyContent>
|
|
<Button onClick={onCreateSkill}>{t.settings.skills.emptyButton}</Button>
|
|
</EmptyContent>
|
|
</Empty>
|
|
);
|
|
}
|
|
|
|
function InstallSkillDialog({
|
|
open,
|
|
onClose,
|
|
isAdmin,
|
|
}: {
|
|
open: boolean;
|
|
onClose: () => void;
|
|
isAdmin: boolean;
|
|
}) {
|
|
const { t } = useI18n();
|
|
const dialog = t.settings.skills.installDialog;
|
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
|
const [file, setFile] = useState<File | null>(null);
|
|
const [validation, setValidation] = useState<ValidateSkillUploadResponse | null>(null);
|
|
const [validationError, setValidationError] = useState<string | null>(null);
|
|
const [isDragging, setIsDragging] = useState(false);
|
|
const [targetCategory, setTargetCategory] = useState<"custom" | "public">("custom");
|
|
const validateMutation = useValidateSkillUpload();
|
|
const installMutation = useInstallSkillUpload();
|
|
|
|
const reset = useCallback(() => {
|
|
setFile(null);
|
|
setValidation(null);
|
|
setValidationError(null);
|
|
setIsDragging(false);
|
|
setTargetCategory("custom");
|
|
if (inputRef.current) inputRef.current.value = "";
|
|
}, []);
|
|
|
|
const handleClose = useCallback(() => {
|
|
if (validateMutation.isPending || installMutation.isPending) return;
|
|
reset();
|
|
onClose();
|
|
}, [installMutation.isPending, onClose, reset, validateMutation.isPending]);
|
|
|
|
const acceptFile = useCallback(
|
|
async (selected: File) => {
|
|
const lower = selected.name.toLowerCase();
|
|
if (!lower.endsWith(".zip") && !lower.endsWith(".skill")) {
|
|
toast.error(dialog.rejectInvalidExt);
|
|
return;
|
|
}
|
|
setFile(selected);
|
|
setValidation(null);
|
|
setValidationError(null);
|
|
try {
|
|
const result = await validateMutation.mutateAsync(selected);
|
|
setValidation(result);
|
|
} catch (err) {
|
|
setValidationError(err instanceof Error ? err.message : String(err));
|
|
}
|
|
},
|
|
[dialog.rejectInvalidExt, validateMutation],
|
|
);
|
|
|
|
const onDrop = useCallback(
|
|
(e: React.DragEvent<HTMLDivElement>) => {
|
|
e.preventDefault();
|
|
// Stop the global chat-upload listener on `document` from also receiving this drop.
|
|
// React's stopPropagation alone doesn't reach native document listeners — we need to
|
|
// halt the underlying DOM event too.
|
|
e.stopPropagation();
|
|
e.nativeEvent.stopImmediatePropagation();
|
|
setIsDragging(false);
|
|
const dropped = e.dataTransfer.files?.[0];
|
|
if (dropped) void acceptFile(dropped);
|
|
},
|
|
[acceptFile],
|
|
);
|
|
|
|
const onPick = useCallback(
|
|
(e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const picked = e.target.files?.[0];
|
|
if (picked) void acceptFile(picked);
|
|
},
|
|
[acceptFile],
|
|
);
|
|
|
|
const handleInstall = useCallback(
|
|
async (overwrite: boolean) => {
|
|
if (!file) return;
|
|
try {
|
|
const result = await installMutation.mutateAsync({ file, overwrite, targetCategory });
|
|
toast.success(
|
|
(result.overwritten ? dialog.installSuccessReplaced : dialog.installSuccess).replace(
|
|
"{name}",
|
|
result.skill_name,
|
|
),
|
|
);
|
|
reset();
|
|
onClose();
|
|
} catch (err) {
|
|
toast.error(err instanceof Error ? err.message : String(err));
|
|
}
|
|
},
|
|
[dialog.installSuccess, dialog.installSuccessReplaced, file, installMutation, onClose, reset, targetCategory],
|
|
);
|
|
|
|
const isPublicConflict = validation?.conflict && validation.conflict_kind === "public";
|
|
const isCustomConflict = validation?.conflict && validation.conflict_kind === "custom";
|
|
// Admin targeting public directory can overwrite an existing public skill.
|
|
const adminCanOverwritePublic = isAdmin && targetCategory === "public" && !!isPublicConflict;
|
|
const canInstall = !!validation && !validationError && !isCustomConflict && (adminCanOverwritePublic ? false : !isPublicConflict);
|
|
const canOverwrite = !!validation && !validationError && (!!isCustomConflict || adminCanOverwritePublic);
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={(o) => !o && handleClose()}>
|
|
<DialogContent className="max-w-xl">
|
|
<DialogHeader>
|
|
<DialogTitle>{dialog.title}</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="flex flex-col gap-4">
|
|
<div
|
|
onDragOver={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
e.nativeEvent.stopImmediatePropagation();
|
|
setIsDragging(true);
|
|
}}
|
|
onDragLeave={() => setIsDragging(false)}
|
|
onDrop={onDrop}
|
|
className={cn(
|
|
"flex flex-col items-center justify-center gap-2 rounded-md border-2 border-dashed p-6 text-center transition-colors",
|
|
isDragging ? "border-primary bg-accent/40" : "border-muted-foreground/30",
|
|
)}
|
|
>
|
|
<FileArchive className="text-muted-foreground size-8" />
|
|
<div className="text-muted-foreground text-sm">
|
|
{dialog.dropHint}
|
|
<button
|
|
type="button"
|
|
className="text-primary ml-1 underline underline-offset-4"
|
|
onClick={() => inputRef.current?.click()}
|
|
>
|
|
{dialog.chooseFile}
|
|
</button>
|
|
</div>
|
|
{file && (
|
|
<div className="text-muted-foreground mt-1 text-xs">
|
|
{file.name} · {(file.size / 1024).toFixed(1)} KB
|
|
</div>
|
|
)}
|
|
<input
|
|
ref={inputRef}
|
|
type="file"
|
|
accept=".zip,.skill,application/zip"
|
|
hidden
|
|
onChange={onPick}
|
|
/>
|
|
</div>
|
|
|
|
{isAdmin && (
|
|
<div className="flex items-center gap-3 text-sm">
|
|
<span className="text-muted-foreground shrink-0">{dialog.targetCategoryLabel}</span>
|
|
<div className="flex gap-4">
|
|
{(["custom", "public"] as const).map((cat) => (
|
|
<label key={cat} className="flex cursor-pointer items-center gap-1.5">
|
|
<input
|
|
type="radio"
|
|
name="targetCategory"
|
|
value={cat}
|
|
checked={targetCategory === cat}
|
|
onChange={() => setTargetCategory(cat)}
|
|
className="accent-primary"
|
|
/>
|
|
<span>{cat === "custom" ? dialog.categoryCustom : dialog.categoryPublic}</span>
|
|
</label>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{validateMutation.isPending && (
|
|
<div className="text-muted-foreground flex items-center gap-2 text-sm">
|
|
<Loader2 className="size-4 animate-spin" />
|
|
{dialog.validating}
|
|
</div>
|
|
)}
|
|
|
|
{validationError && (
|
|
<div className="text-destructive flex items-start gap-2 rounded-md border border-destructive/40 bg-destructive/5 p-3 text-sm">
|
|
<AlertCircle className="mt-0.5 size-4 shrink-0" />
|
|
<span>{validationError}</span>
|
|
</div>
|
|
)}
|
|
|
|
{validation && !validationError && (
|
|
<div className="flex flex-col gap-2 rounded-md border p-3 text-sm">
|
|
<div className="flex items-center gap-2 text-emerald-600">
|
|
<CheckCircle2 className="size-4" />
|
|
<span className="font-medium">{validation.skill_name}</span>
|
|
</div>
|
|
<Field label={dialog.descriptionLabel}>
|
|
<span className="line-clamp-3 whitespace-pre-wrap">
|
|
{validation.description || "—"}
|
|
</span>
|
|
</Field>
|
|
{validation.license && (
|
|
<Field label={dialog.licenseLabel}>{validation.license}</Field>
|
|
)}
|
|
{validation.allowed_tools.length > 0 && (
|
|
<Field label={dialog.toolsLabel}>
|
|
<div className="flex flex-wrap gap-1">
|
|
{validation.allowed_tools.map((tool) => (
|
|
<Badge key={tool} variant="secondary" className="text-xs">
|
|
{tool}
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
</Field>
|
|
)}
|
|
{validation.warnings.length > 0 && (
|
|
<Field label={dialog.warningsLabel}>
|
|
<ul className="list-disc pl-4 text-xs">
|
|
{validation.warnings.map((w, idx) => (
|
|
<li key={idx}>{w}</li>
|
|
))}
|
|
</ul>
|
|
</Field>
|
|
)}
|
|
{isCustomConflict && (
|
|
<div className="text-amber-600 mt-1 flex items-start gap-2 text-xs">
|
|
<AlertCircle className="mt-0.5 size-3.5 shrink-0" />
|
|
<span>
|
|
{dialog.conflictCustom.replace("{name}", validation.skill_name)}
|
|
</span>
|
|
</div>
|
|
)}
|
|
{isPublicConflict && (
|
|
<div className={cn("mt-1 flex items-start gap-2 text-xs", adminCanOverwritePublic ? "text-amber-600" : "text-destructive")}>
|
|
<AlertCircle className="mt-0.5 size-3.5 shrink-0" />
|
|
<span>
|
|
{(adminCanOverwritePublic ? dialog.conflictPublicAdminHint : dialog.conflictPublic).replace("{name}", validation.skill_name)}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<DialogFooter>
|
|
<Button
|
|
variant="outline"
|
|
onClick={handleClose}
|
|
disabled={installMutation.isPending}
|
|
>
|
|
{dialog.cancel}
|
|
</Button>
|
|
{canOverwrite && (
|
|
<Button
|
|
variant="destructive"
|
|
onClick={() => handleInstall(true)}
|
|
disabled={installMutation.isPending}
|
|
>
|
|
{installMutation.isPending && (
|
|
<Loader2 className="size-4 animate-spin" />
|
|
)}
|
|
{dialog.replace}
|
|
</Button>
|
|
)}
|
|
<Button
|
|
onClick={() => handleInstall(false)}
|
|
disabled={!canInstall || installMutation.isPending}
|
|
>
|
|
{installMutation.isPending && (
|
|
<Loader2 className="size-4 animate-spin" />
|
|
)}
|
|
{installMutation.isPending ? dialog.installing : dialog.install}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
function Field({
|
|
label,
|
|
children,
|
|
}: {
|
|
label: string;
|
|
children: React.ReactNode;
|
|
}) {
|
|
return (
|
|
<div className="grid grid-cols-[80px_1fr] gap-2">
|
|
<span className="text-muted-foreground shrink-0 text-xs">{label}</span>
|
|
<div className="min-w-0">{children}</div>
|
|
</div>
|
|
);
|
|
}
|