refactor(channels): unify tab layout widths and schema-driven group overrides

- Remove redundant max-w-* constraints from General, Credentials, Groups,
  and Managers tabs so all tabs fill the parent container equally
- Refactor TelegramGroupFields to use ChannelFields with groupOverrideSchema
  instead of manually rendered form fields
- Add tristate, textarea, tool-select, and skill-select field types to
  ChannelFields renderer
- Create SkillNameSelect multi-select component (mirrors ToolNameSelect)
- Wire listManagerGroups into Groups tab for quick-add known groups
- Add i18n keys for knownGroups and selectOrTypeSkills (en/vi/zh)
This commit is contained in:
viettranx committed 2026-03-14 15:09:17 +07:00
1 parent b9b8c8f8aa
commit bc9ebaa2d0
16 files changed
+374 -174

No files matched your search

@@ -0,0 +1,157 @@
import { useMemo, useState, useRef, useEffect, useLayoutEffect } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { X, ChevronDownIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { useSkills } from "@/pages/skills/hooks/use-skills";
interface SkillNameSelectProps {
value: string[];
onChange: (value: string[]) => void;
placeholder?: string;
className?: string;
}
export function SkillNameSelect({
value,
onChange,
placeholder,
className,
}: SkillNameSelectProps) {
const { t } = useTranslation("common");
const { skills } = useSkills();
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const [dropdownStyle, setDropdownStyle] = useState<React.CSSProperties>({});
const allSkills = useMemo(() => {
return skills.map((s) => ({ name: s.name, description: s.description }));
}, [skills]);
const filtered = useMemo(() => {
const q = search.toLowerCase();
return allSkills
.filter((s) => !value.includes(s.name))
.filter((s) => !q || s.name.toLowerCase().includes(q) || s.description.toLowerCase().includes(q));
}, [allSkills, value, search]);
useLayoutEffect(() => {
if (!open || !containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
setDropdownStyle({
position: "fixed",
top: rect.bottom + 4,
left: rect.left,
width: rect.width,
zIndex: 9999,
});
}, [open, search]);
useEffect(() => {
if (!open) return;
const handler = (e: MouseEvent) => {
const target = e.target as Node;
if (
containerRef.current && !containerRef.current.contains(target) &&
(!dropdownRef.current || !dropdownRef.current.contains(target))
) {
setOpen(false);
}
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, [open]);
const addSkill = (name: string) => {
if (!value.includes(name)) {
onChange([...value, name]);
}
setSearch("");
inputRef.current?.focus();
};
const removeSkill = (name: string) => {
onChange(value.filter((v) => v !== name));
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter" || e.key === ",") {
e.preventDefault();
const trimmed = search.trim().replace(/,$/, "");
if (trimmed) addSkill(trimmed);
}
if (e.key === "Backspace" && !search && value.length > 0) {
removeSkill(value[value.length - 1]!);
}
};
return (
<div ref={containerRef} className={cn("relative", className)}>
<div
className={cn(
"border-input dark:bg-input/30 flex min-h-9 flex-wrap items-center gap-1 rounded-md border bg-transparent px-2 py-1 text-sm shadow-xs transition-[color,box-shadow]",
"focus-within:border-ring focus-within:ring-ring/50 focus-within:ring-2",
)}
onClick={() => inputRef.current?.focus()}
>
{value.map((name) => (
<span
key={name}
className="bg-secondary text-secondary-foreground inline-flex items-center gap-0.5 rounded px-1.5 py-0.5 text-xs"
>
{name}
<button
type="button"
className="hover:text-destructive ml-0.5"
onClick={(e) => { e.stopPropagation(); removeSkill(name); }}
>
<X className="h-3 w-3" />
</button>
</span>
))}
<input
ref={inputRef}
value={search}
onChange={(e) => {
setSearch(e.target.value);
if (!open) setOpen(true);
}}
onFocus={() => setOpen(true)}
onKeyDown={handleKeyDown}
placeholder={value.length === 0 ? (placeholder ?? t("selectOrTypeSkills")) : ""}
className="placeholder:text-muted-foreground min-w-[80px] flex-1 bg-transparent py-0.5 text-sm outline-none"
/>
<ChevronDownIcon
className="text-muted-foreground size-4 shrink-0 cursor-pointer opacity-50"
onClick={() => setOpen(!open)}
/>
</div>
{open && filtered.length > 0 && createPortal(
<div
ref={dropdownRef}
style={dropdownStyle}
className="bg-popover text-popover-foreground pointer-events-auto max-h-60 overflow-y-auto rounded-md border p-1 shadow-md"
>
{filtered.map((s) => (
<button
key={s.name}
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={() => addSkill(s.name)}
className="hover:bg-accent hover:text-accent-foreground flex w-full cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none"
>
<span className="shrink-0 font-medium">{s.name}</span>
{s.description && (
<span className="text-muted-foreground truncate text-xs">{s.description}</span>
)}
</button>
))}
</div>,
document.body,
)}
</div>
);
}
+1
View File
@@ -240,6 +240,7 @@
"groupWildcard": "* (wildcard)",
"addGroupPlaceholder": "Chat ID (e.g. -100123456) or *",
"addGroup": "Add Group",
"knownGroups": "Known groups",
"topicOverrides": "Topic Overrides",
"topicLabel": "Topic: {{id}}",
"addTopicPlaceholder": "Topic thread ID",
+1
View File
@@ -102,5 +102,6 @@
"builtinTools": "Built-in",
"customTools": "Custom",
"selectOrTypeTools": "Select or type tool names...",
"selectOrTypeSkills": "Select or type skill names...",
"typeAndPressEnter": "Type and press Enter..."
}
+1
View File
@@ -240,6 +240,7 @@
"groupWildcard": "* (ký tự đại diện)",
"addGroupPlaceholder": "ID cuộc trò chuyện (vd: -100123456) hoặc *",
"addGroup": "Thêm nhóm",
"knownGroups": "Nhóm đã biết",
"topicOverrides": "Ghi đè chủ đề",
"topicLabel": "Chủ đề: {{id}}",
"addTopicPlaceholder": "ID luồng chủ đề",
+1
View File
@@ -73,6 +73,7 @@
"selectAgent": "Chọn agent",
"selectFileToView": "Chọn một tệp để xem nội dung",
"selectOrTypeTools": "Chọn hoặc nhập tên công cụ...",
"selectOrTypeSkills": "Chọn hoặc nhập tên kỹ năng...",
"selectProvider": "Chọn provider",
"sendMessage": "Gửi tin nhắn... (Shift+Enter để xuống dòng)",
"sendMessageTitle": "Gửi tin nhắn",
+1
View File
@@ -240,6 +240,7 @@
"groupWildcard": "* (通配符)",
"addGroupPlaceholder": "聊天 ID(例如 -100123456)或 *",
"addGroup": "添加群组",
"knownGroups": "已知群组",
"topicOverrides": "话题覆盖",
"topicLabel": "话题: {{id}}",
"addTopicPlaceholder": "话题线程 ID",
+1
View File
@@ -73,6 +73,7 @@
"selectAgent": "选择Agent",
"selectFileToView": "选择文件以查看其内容",
"selectOrTypeTools": "选择或输入工具名称...",
"selectOrTypeSkills": "选择或输入技能名称...",
"selectProvider": "选择Provider",
"sendMessage": "发送消息... (Shift+Enter 换行)",
"sendMessageTitle": "发送消息",
@@ -49,7 +49,7 @@ export function ChannelCredentialsTab({ instance, onUpdate }: ChannelCredentials
if (fields.length === 0) {
return (
<div className="max-w-2xl">
<div className="">
<p className="text-sm text-muted-foreground">
{t("detail.credentials.noSchema")}
</p>
@@ -58,7 +58,7 @@ export function ChannelCredentialsTab({ instance, onUpdate }: ChannelCredentials
}
return (
<div className="max-w-2xl space-y-6">
<div className="space-y-6">
<p className="text-sm text-muted-foreground">
{t("detail.credentials.hint")}
</p>
@@ -126,6 +126,7 @@ export function ChannelDetailPage({ instanceId, onBack }: ChannelDetailPageProps
<ChannelGroupsTab
instance={instance}
onUpdate={updateInstance}
listManagerGroups={listManagerGroups}
/>
</TabsContent>
)}
@@ -52,7 +52,7 @@ export function ChannelGeneralTab({ instance, agents, onUpdate }: ChannelGeneral
};
return (
<div className="max-w-2xl space-y-6">
<div className="space-y-6">
{/* Read-only fields */}
<div className="grid gap-1.5">
<Label>{t("detail.general.name")}</Label>
@@ -1,4 +1,4 @@
import { useState } from "react";
import { useState, useEffect, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { Save, Check, AlertCircle } from "lucide-react";
import { Button } from "@/components/ui/button";
@@ -6,6 +6,7 @@ import type { ChannelInstanceData } from "@/types/channel";
import { TelegramGroupOverrides } from "../telegram-group-overrides";
import type { TelegramGroupConfigValues } from "../telegram-group-fields";
import type { TelegramTopicConfigValues } from "../telegram-topic-overrides";
import type { GroupManagerGroupInfo } from "../hooks/use-channel-detail";
interface GroupConfigWithTopics extends TelegramGroupConfigValues {
topics?: Record<string, TelegramTopicConfigValues>;
@@ -14,9 +15,10 @@ interface GroupConfigWithTopics extends TelegramGroupConfigValues {
interface ChannelGroupsTabProps {
instance: ChannelInstanceData;
onUpdate: (updates: Record<string, unknown>) => Promise<void>;
listManagerGroups: () => Promise<GroupManagerGroupInfo[]>;
}
export function ChannelGroupsTab({ instance, onUpdate }: ChannelGroupsTabProps) {
export function ChannelGroupsTab({ instance, onUpdate, listManagerGroups }: ChannelGroupsTabProps) {
const { t } = useTranslation("channels");
const config = (instance.config ?? {}) as Record<string, unknown>;
const [groups, setGroups] = useState<Record<string, GroupConfigWithTopics>>(
@@ -25,6 +27,16 @@ export function ChannelGroupsTab({ instance, onUpdate }: ChannelGroupsTabProps)
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [saved, setSaved] = useState(false);
const [knownGroups, setKnownGroups] = useState<GroupManagerGroupInfo[]>([]);
const loadKnownGroups = useCallback(async () => {
try {
const g = await listManagerGroups();
setKnownGroups(g);
} catch { /* handled by http hook */ }
}, [listManagerGroups]);
useEffect(() => { loadKnownGroups(); }, [loadKnownGroups]);
const handleSave = async () => {
const hasGroups = Object.keys(groups).length > 0;
@@ -49,8 +61,8 @@ export function ChannelGroupsTab({ instance, onUpdate }: ChannelGroupsTabProps)
};
return (
<div className="max-w-3xl space-y-6">
<TelegramGroupOverrides groups={groups} onChange={(g) => setGroups(g)} />
<div className="space-y-6">
<TelegramGroupOverrides groups={groups} onChange={(g) => setGroups(g)} knownGroups={knownGroups} />
{saveError && (
<div className="flex items-center gap-2 rounded-md border border-destructive/50 bg-destructive/10 px-3 py-2 text-sm text-destructive">
@@ -262,7 +262,7 @@ export function ChannelManagersTab({
};
return (
<div className="max-w-3xl space-y-5">
<div className="space-y-5">
<p className="text-sm text-muted-foreground">
{t("detail.managers.description")}
</p>
+114 -1
View File
@@ -10,8 +10,12 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { ToolNameSelect } from "@/components/shared/tool-name-select";
import { SkillNameSelect } from "@/components/shared/skill-name-select";
import type { FieldDef } from "./channel-schemas";
const INHERIT = "__inherit__";
interface ChannelFieldsProps {
fields: FieldDef[];
values: Record<string, unknown>;
@@ -127,6 +131,115 @@ function FieldRenderer({
</div>
);
case "tristate": {
// Tri-state: undefined = inherit, value = override.
// With options: select with Inherit + custom options (string value).
// Without options: select with Inherit/Yes/No (boolean value).
const inheritLabel = t("groupOverrides.fields.inherit", { defaultValue: "Inherit" });
if (field.options) {
// String tri-state (e.g. group_policy)
const allOptions = [{ value: INHERIT, label: inheritLabel }, ...field.options];
const selectValue = (value as string) || INHERIT;
return (
<div className="grid gap-1.5">
<Label>{label}</Label>
<Select
value={selectValue}
onValueChange={(v) => onChange(v === INHERIT ? undefined : v)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{allOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.value === INHERIT ? inheritLabel : t(`fieldOptions.${field.key}.${opt.value}`, { defaultValue: opt.label })}
</SelectItem>
))}
</SelectContent>
</Select>
{help && <p className="text-xs text-muted-foreground">{help}</p>}
</div>
);
}
// Boolean tri-state (e.g. require_mention, enabled)
const yesLabel = t("groupOverrides.fields.yes", { defaultValue: "Yes" });
const noLabel = t("groupOverrides.fields.no", { defaultValue: "No" });
const triOptions = [
{ value: INHERIT, label: inheritLabel },
{ value: "true", label: yesLabel },
{ value: "false", label: noLabel },
];
const boolToStr = (v: unknown): string => {
if (v === undefined || v === null) return INHERIT;
return v ? "true" : "false";
};
const strToBool = (v: string): boolean | undefined => {
if (v === INHERIT) return undefined;
return v === "true";
};
return (
<div className="grid gap-1.5">
<Label>{label}</Label>
<Select value={boolToStr(value)} onValueChange={(v) => onChange(strToBool(v))}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{triOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
))}
</SelectContent>
</Select>
{help && <p className="text-xs text-muted-foreground">{help}</p>}
</div>
);
}
case "textarea":
return (
<div className="grid gap-1.5">
<Label htmlFor={id}>{label}</Label>
<Textarea
id={id}
value={(value as string) ?? ""}
onChange={(e) => onChange(e.target.value || undefined)}
placeholder={field.placeholder}
rows={3}
/>
{help && <p className="text-xs text-muted-foreground">{help}</p>}
</div>
);
case "tool-select":
return (
<div className="grid gap-1.5">
<Label>{label}</Label>
<ToolNameSelect
value={(value as string[]) ?? []}
onChange={(v) => onChange(v.length > 0 ? v : undefined)}
placeholder={field.placeholder}
/>
{help && <p className="text-xs text-muted-foreground">{help}</p>}
</div>
);
case "skill-select":
return (
<div className="grid gap-1.5">
<Label>{label}</Label>
<SkillNameSelect
value={(value as string[]) ?? []}
onChange={(v) => onChange(v.length > 0 ? v : undefined)}
placeholder={field.placeholder}
/>
{help && <p className="text-xs text-muted-foreground">{help}</p>}
</div>
);
case "tags":
return (
<div className="grid gap-1.5">
@@ -138,7 +251,7 @@ function FieldRenderer({
const lines = e.target.value.split("\n").map((l) => l.trim()).filter(Boolean);
onChange(lines.length > 0 ? lines : undefined);
}}
placeholder={t("groupOverrides.fields.allowedUsersPlaceholder")}
placeholder={field.placeholder ?? t("groupOverrides.fields.allowedUsersPlaceholder")}
rows={3}
className="font-mono text-sm"
/>
+16 -1
View File
@@ -4,7 +4,7 @@
export interface FieldDef {
key: string;
label: string;
type: "text" | "password" | "number" | "boolean" | "select" | "tags";
type: "text" | "password" | "number" | "boolean" | "select" | "tags" | "tristate" | "textarea" | "tool-select" | "skill-select";
placeholder?: string;
required?: boolean;
defaultValue?: string | number | boolean | string[];
@@ -146,6 +146,21 @@ export const configSchema: Record<string, FieldDef[]> = {
],
};
// --- Group override schema (Telegram per-group/topic overrides) ---
// Uses tristate fields: undefined = inherit from parent, value = override.
// tristate without options → Inherit/Yes/No (boolean).
// tristate with options → Inherit + custom options (string).
export const groupOverrideSchema: FieldDef[] = [
{ key: "group_policy", label: "Group Policy", type: "tristate", options: groupPolicyOptions },
{ key: "require_mention", label: "Require @mention", type: "tristate" },
{ key: "enabled", label: "Enabled", type: "tristate" },
{ key: "allow_from", label: "Allowed Users", type: "tags", placeholder: "User IDs, one per line", help: "Restrict which users can interact in this group" },
{ key: "skills", label: "Skills Filter", type: "skill-select", help: "Limit available skills for this group" },
{ key: "tools", label: "Tool Allowlist", type: "tool-select", help: "Restrict which tools the agent can use in this group" },
{ key: "system_prompt", label: "System Prompt", type: "textarea", placeholder: "Additional system prompt for this group..." },
];
// --- Post-create wizard configuration ---
// Channels with multi-step create flows (e.g. auth then config).
// Channels not listed here use the default single-step create.
@@ -1,15 +1,5 @@
import { useTranslation } from "react-i18next";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { groupPolicyOptions } from "./channel-schemas";
import { ToolNameSelect } from "@/components/shared/tool-name-select";
import { ChannelFields } from "./channel-fields";
import { groupOverrideSchema } from "./channel-schemas";
export interface TelegramGroupConfigValues {
group_policy?: string;
@@ -21,18 +11,6 @@ export interface TelegramGroupConfigValues {
system_prompt?: string;
}
const INHERIT = "__inherit__";
function triStateValue(val: boolean | undefined): string {
if (val === undefined || val === null) return INHERIT;
return val ? "true" : "false";
}
function parseTriState(val: string): boolean | undefined {
if (val === INHERIT || val === "") return undefined;
return val === "true";
}
interface Props {
config: TelegramGroupConfigValues;
onChange: (config: TelegramGroupConfigValues) => void;
@@ -40,138 +18,12 @@ interface Props {
}
export function TelegramGroupFields({ config, onChange, idPrefix }: Props) {
const { t } = useTranslation("channels");
const inheritLabel = t("groupOverrides.fields.inherit");
const yesLabel = t("groupOverrides.fields.yes");
const noLabel = t("groupOverrides.fields.no");
const triStateOptions = [
{ value: INHERIT, label: inheritLabel },
{ value: "true", label: yesLabel },
{ value: "false", label: noLabel },
];
const groupPolicyWithInherit = [
{ value: INHERIT, label: inheritLabel },
...groupPolicyOptions,
];
const update = (patch: Partial<TelegramGroupConfigValues>) => {
onChange({ ...config, ...patch });
};
return (
<div className="grid gap-3">
<div className="grid gap-1.5">
<Label>{t("groupOverrides.fields.groupPolicy")}</Label>
<Select
value={config.group_policy || INHERIT}
onValueChange={(v) => update({ group_policy: v === INHERIT ? undefined : v })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{groupPolicyWithInherit.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-1.5">
<Label>{t("groupOverrides.fields.requireMention")}</Label>
<Select
value={triStateValue(config.require_mention)}
onValueChange={(v) => update({ require_mention: parseTriState(v) })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{triStateOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-1.5">
<Label>{t("groupOverrides.fields.enabled")}</Label>
<Select
value={triStateValue(config.enabled)}
onValueChange={(v) => update({ enabled: parseTriState(v) })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{triStateOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-1.5">
<Label htmlFor={`${idPrefix}-allow`}>{t("groupOverrides.fields.allowedUsers")}</Label>
<Textarea
id={`${idPrefix}-allow`}
value={config.allow_from?.join("\n") ?? ""}
onChange={(e) => {
const lines = e.target.value.split("\n").map((l) => l.trim()).filter(Boolean);
update({ allow_from: lines.length > 0 ? lines : undefined });
}}
placeholder={t("groupOverrides.fields.allowedUsersPlaceholder")}
rows={2}
className="font-mono text-sm"
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor={`${idPrefix}-skills`}>{t("groupOverrides.fields.skillsFilter")}</Label>
<Textarea
id={`${idPrefix}-skills`}
value={config.skills?.join("\n") ?? ""}
onChange={(e) => {
const lines = e.target.value.split("\n").map((l) => l.trim()).filter(Boolean);
update({ skills: lines.length > 0 ? lines : undefined });
}}
placeholder={t("groupOverrides.fields.skillsPlaceholder")}
rows={2}
className="font-mono text-sm"
/>
</div>
<div className="grid gap-1.5">
<Label>{t("groupOverrides.fields.toolAllow")}</Label>
<ToolNameSelect
value={config.tools ?? []}
onChange={(v) => update({ tools: v.length > 0 ? v : undefined })}
placeholder={t("groupOverrides.fields.toolAllowPlaceholder")}
/>
<p className="text-xs text-muted-foreground">
{t("groupOverrides.fields.toolAllowHint")}
</p>
</div>
<div className="grid gap-1.5">
<Label htmlFor={`${idPrefix}-prompt`}>{t("groupOverrides.fields.systemPrompt")}</Label>
<Textarea
id={`${idPrefix}-prompt`}
value={config.system_prompt ?? ""}
onChange={(e) => update({ system_prompt: e.target.value || undefined })}
placeholder={t("groupOverrides.fields.systemPromptPlaceholder")}
rows={3}
/>
</div>
</div>
<ChannelFields
fields={groupOverrideSchema}
values={config as Record<string, unknown>}
onChange={(key, value) => onChange({ ...config, [key]: value })}
idPrefix={idPrefix}
/>
);
}
@@ -2,9 +2,11 @@ import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Plus, Trash2, ChevronDown, ChevronRight } from "lucide-react";
import { TelegramGroupFields, type TelegramGroupConfigValues } from "./telegram-group-fields";
import { TelegramTopicOverrides, type TelegramTopicConfigValues } from "./telegram-topic-overrides";
import type { GroupManagerGroupInfo } from "./hooks/use-channel-detail";
interface GroupConfigWithTopics extends TelegramGroupConfigValues {
topics?: Record<string, TelegramTopicConfigValues>;
@@ -13,21 +15,23 @@ interface GroupConfigWithTopics extends TelegramGroupConfigValues {
interface Props {
groups: Record<string, GroupConfigWithTopics>;
onChange: (groups: Record<string, GroupConfigWithTopics>) => void;
/** Known groups from the system (e.g. groups with managers assigned) */
knownGroups?: GroupManagerGroupInfo[];
}
export function TelegramGroupOverrides({ groups, onChange }: Props) {
export function TelegramGroupOverrides({ groups, onChange, knownGroups }: Props) {
const { t } = useTranslation("channels");
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
const [newGroupId, setNewGroupId] = useState("");
const groupIds = Object.keys(groups);
const addGroup = () => {
const id = newGroupId.trim();
if (!id || groups[id]) return;
onChange({ ...groups, [id]: {} });
setExpanded((prev) => ({ ...prev, [id]: true }));
setNewGroupId("");
const addGroup = (id?: string) => {
const gid = (id ?? newGroupId).trim();
if (!gid || groups[gid]) return;
onChange({ ...groups, [gid]: {} });
setExpanded((prev) => ({ ...prev, [gid]: true }));
if (!id) setNewGroupId("");
};
const removeGroup = (id: string) => {
@@ -53,6 +57,13 @@ export function TelegramGroupOverrides({ groups, onChange }: Props) {
setExpanded((prev) => ({ ...prev, [id]: !prev[id] }));
};
// Known groups not yet added as overrides
const availableGroups = knownGroups?.filter((g) => {
// Extract the raw group ID from "group:<channel>:<id>" format
const rawId = extractGroupId(g.group_id);
return !groups[rawId] && !groups[g.group_id];
});
return (
<fieldset className="rounded-md border p-3 space-y-3">
<legend className="px-1 text-sm font-medium">{t("groupOverrides.title")}</legend>
@@ -102,6 +113,33 @@ export function TelegramGroupOverrides({ groups, onChange }: Props) {
);
})}
{/* Known groups quick-add */}
{availableGroups && availableGroups.length > 0 && (
<div className="space-y-1.5">
<p className="text-xs font-medium text-muted-foreground">{t("groupOverrides.knownGroups")}</p>
<div className="flex flex-wrap gap-1.5">
{availableGroups.map((g) => {
const rawId = extractGroupId(g.group_id);
return (
<button
key={g.group_id}
type="button"
onClick={() => addGroup(rawId)}
className="inline-flex items-center gap-1 rounded-md border px-2 py-1 text-xs hover:bg-muted/50 transition-colors"
>
<Plus className="h-3 w-3" />
<span className="font-mono">{rawId}</span>
<Badge variant="secondary" className="text-[10px] px-1 py-0">
{g.writer_count}
</Badge>
</button>
);
})}
</div>
</div>
)}
{/* Manual group ID input */}
<div className="flex items-center gap-2">
<Input
value={newGroupId}
@@ -110,7 +148,7 @@ export function TelegramGroupOverrides({ groups, onChange }: Props) {
className="h-8 flex-1 text-sm"
onKeyDown={(e) => e.key === "Enter" && (e.preventDefault(), addGroup())}
/>
<Button type="button" variant="outline" size="sm" className="h-8" onClick={addGroup} disabled={!newGroupId.trim()}>
<Button type="button" variant="outline" size="sm" className="h-8" onClick={() => addGroup()} disabled={!newGroupId.trim()}>
<Plus className="h-3.5 w-3.5 mr-1" />
{t("groupOverrides.addGroup")}
</Button>
@@ -118,3 +156,9 @@ export function TelegramGroupOverrides({ groups, onChange }: Props) {
</fieldset>
);
}
/** Strips "group:<channel>:" prefix, e.g. "group:telegram:-100123" → "-100123" */
function extractGroupId(id: string): string {
const m = id.match(/^group:[^:]+:(.+)$/);
return m?.[1] ?? id;
}