// Shared UI primitives extracted from ConfigPage.tsx
import { useState, type ReactNode } from 'react';
import { X, Plus, Trash2 } from 'lucide-react';
import { inputCls } from './constants';
import type { KnownSource } from './types';
export function Field({
label,
children,
hint,
}: {
label: string;
children: ReactNode;
hint?: string;
}) {
return (
{label}
{children}
{hint &&
{hint}
}
);
}
export function Toggle({
checked,
onChange,
}: {
checked: boolean;
onChange: (v: boolean) => void;
}) {
return (
onChange(!checked)}
className={`relative inline-flex h-6 w-11 shrink-0 rounded-full transition-colors duration-200 ${checked ? 'bg-[var(--accent-cyan)]' : 'bg-[var(--bg-hover)]'}`}
>
);
}
export function TagEditor({ tags, onChange }: { tags: string[]; onChange: (t: string[]) => void }) {
const [input, setInput] = useState('');
const add = () => {
const v = input.trim();
if (v && !tags.includes(v)) {
onChange([...tags, v]);
setInput('');
}
};
return (
{tags.map((t, i) => (
{t}
onChange(tags.filter((_, j) => j !== i))}
className="hover:text-white transition-colors"
>
))}
);
}
export function SectionCard({
title,
subtitle,
children,
}: {
title: string;
subtitle?: string;
children: ReactNode;
}) {
return (
{title}
{subtitle && (
{subtitle}
)}
{children}
);
}
/** 模态框标题栏:图标 + 标题 + X 关闭按钮,与项目模态框惯例一致 */
export function ModalHeader({
icon,
title,
onClose,
}: {
icon: ReactNode;
title: string;
onClose: () => void;
}) {
return (
{icon}
{title}
);
}
/** 模态框底部按钮区,与项目模态框惯例一致 */
export function ModalFooter({ children }: { children: ReactNode }) {
return (
{children}
);
}
export function SourceEditor({
sources,
onChange,
knownSources,
examplePaths,
showCustom = true,
}: {
sources: string[];
onChange: (s: string[]) => void;
knownSources: KnownSource[];
examplePaths?: string[];
showCustom?: boolean;
}) {
const [customInput, setCustomInput] = useState('');
const knownKeys = new Set(knownSources.map((k) => k.key));
const customPaths = sources.filter((s) => !knownKeys.has(s));
const toggleKnown = (key: string) => {
if (sources.includes(key)) {
onChange(sources.filter((s) => s !== key));
} else {
onChange([...sources, key]);
}
};
const addCustom = () => {
const v = customInput.trim();
if (v && !sources.includes(v)) {
onChange([...sources, v]);
setCustomInput('');
}
};
const removeCustom = (path: string) => {
onChange(sources.filter((s) => s !== path));
};
return (
{/* Known sources as toggles */}
{knownSources.map((src) => (
{src.label}
{src.description}
toggleKnown(src.key)} />
))}
{/* Custom paths (only shown when showCustom is true) */}
{showCustom && (
自定义路径
{customPaths.length > 0 && (
{customPaths.map((p) => (
{p}
removeCustom(p)}
className="hover:text-white transition-colors"
>
))}
)}
{examplePaths && (
示例: {examplePaths.join('、')}
)}
)}
);
}
export interface CheckboxListOption {
key: string;
label: string;
description?: string;
/** 可选分组标识,配合 groupBy 使用 */
group?: string;
}
interface CheckboxListProps {
options: CheckboxListOption[];
selected: string[];
onChange: (selected: string[]) => void;
/** 未在 options 中出现但已选中的值(legacy 数据),以可移除标签形式展示 */
extraSelected?: string[];
emptyHint?: string;
/** 可选:按返回的分组名分组展示(如 "builtin" / "mcp:xxx") */
groupBy?: (option: CheckboxListOption) => string;
}
/**
* 通用勾选列表组件:用 Toggle 切换每个预设选项;额外的 legacy 已选值以可移除标签展示。
*/
export function CheckboxList({
options,
selected,
onChange,
extraSelected = [],
emptyHint,
groupBy,
}: CheckboxListProps) {
const toggle = (key: string) =>
onChange(selected.includes(key) ? selected.filter((k) => k !== key) : [...selected, key]);
const selectedSet = new Set(selected);
const optionsInList = new Set(options.map((o) => o.key));
// 仅展示未出现在 options 中的额外已选值
const extra = extraSelected.filter((k) => !optionsInList.has(k));
// 分组渲染
const renderOptions = (opts: CheckboxListOption[]) => (
{opts.map((option) => (
{option.label}
{option.description && (
{option.description}
)}
toggle(option.key)} />
))}
);
let body: ReactNode;
if (options.length === 0) {
body = {emptyHint || '无可用选项'}
;
} else if (groupBy) {
const groups = new Map();
for (const opt of options) {
const g = groupBy(opt);
const arr = groups.get(g) ?? [];
arr.push(opt);
groups.set(g, arr);
}
body = (
{Array.from(groups.entries()).map(([g, opts]) => (
{g}
{renderOptions(opts)}
))}
);
} else {
body = renderOptions(options);
}
return (
{body}
{extra.length > 0 && (
其他已选(不在当前选项中)
{extra.map((key) => (
{key}
toggle(key)} className="hover:text-white transition-colors">
))}
)}
);
}
export function MapEntryHeader({
name,
onDelete,
onRename,
}: {
name: string;
onDelete: () => void;
onRename?: (n: string) => void;
}) {
const [editing, setEditing] = useState(false);
const [val, setVal] = useState(name);
return (
{editing ? (
setVal(e.target.value)}
onBlur={() => {
setEditing(false);
onRename?.(val.trim() || name);
}}
onKeyDown={(e) =>
e.key === 'Enter' && (setEditing(false), onRename?.(val.trim() || name))
}
className={inputCls + ' !py-1 !text-xs max-w-[200px]'}
autoFocus
/>
) : (
{
if (onRename) {
setVal(name);
setEditing(true);
}
}}
>
{name}
)}
);
}