532 lines
26 KiB
TypeScript
532 lines
26 KiB
TypeScript
|
|
/*
|
|||
|
|
* Vencord, a Discord client mod
|
|||
|
|
* Copyright (c) 2025 Vendicated and contributors
|
|||
|
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
|||
|
|
*/
|
|||
|
|
|
|||
|
|
import { Switch } from "@components/Switch";
|
|||
|
|
import { Button, Forms, React, Select, TextInput } from "@webpack/common";
|
|||
|
|
|
|||
|
|
import { openCommandPicker } from "./CommandPicker";
|
|||
|
|
import {
|
|||
|
|
type CustomCommandDefinition,
|
|||
|
|
getCategoryPath,
|
|||
|
|
getCommandById,
|
|||
|
|
getCustomCommandsSnapshot,
|
|||
|
|
getSettingsCommandMetaById,
|
|||
|
|
getSettingsCommandMetaByRoute,
|
|||
|
|
listAllTags,
|
|||
|
|
listCategories,
|
|||
|
|
normalizeTag,
|
|||
|
|
saveCustomCommands,
|
|||
|
|
subscribeCustomCommands,
|
|||
|
|
subscribeRegistry
|
|||
|
|
} from "./registry";
|
|||
|
|
|
|||
|
|
const ACTION_OPTIONS = [
|
|||
|
|
{ label: "Run Command", value: "command" },
|
|||
|
|
{ label: "Open Settings Page", value: "settings" },
|
|||
|
|
{ label: "Open URL", value: "url" },
|
|||
|
|
{ label: "Run Macro", value: "macro" }
|
|||
|
|
] as const;
|
|||
|
|
|
|||
|
|
type ActionType = typeof ACTION_OPTIONS[number]["value"];
|
|||
|
|
|
|||
|
|
const ACTION_HELP_TEXT: Record<ActionType, string> = {
|
|||
|
|
command: "Alias another palette command by ID. Use the picker if you’re unsure of the identifier.",
|
|||
|
|
settings: "Jump directly to a Discord settings page. Choose a page from the picker.",
|
|||
|
|
url: "Open a link. Use https:// links for best compatibility.",
|
|||
|
|
macro: "Run a sequence of commands in order. Add steps via the picker."
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
type CategoryOption = { label: string; value: string; description?: string; };
|
|||
|
|
|
|||
|
|
function buildCategoryOptions(): CategoryOption[] {
|
|||
|
|
const categories = listCategories();
|
|||
|
|
const options: CategoryOption[] = categories.map(category => {
|
|||
|
|
const path = getCategoryPath(category.id);
|
|||
|
|
const label = path.length ? path.map(item => item.label).join(" › ") : category.label;
|
|||
|
|
return {
|
|||
|
|
label: `${label} (${category.id})`,
|
|||
|
|
value: category.id,
|
|||
|
|
description: category.description
|
|||
|
|
} satisfies CategoryOption;
|
|||
|
|
});
|
|||
|
|
return [
|
|||
|
|
{
|
|||
|
|
label: "Auto (use default placement)",
|
|||
|
|
value: ""
|
|||
|
|
},
|
|||
|
|
...options
|
|||
|
|
];
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function createDefaultCommand(): CustomCommandDefinition {
|
|||
|
|
const baseId = `custom-${Math.random().toString(36).slice(2, 8)}`;
|
|||
|
|
return {
|
|||
|
|
id: baseId,
|
|||
|
|
label: "New Command",
|
|||
|
|
keywords: [],
|
|||
|
|
tags: [],
|
|||
|
|
categoryId: undefined,
|
|||
|
|
description: "",
|
|||
|
|
danger: false,
|
|||
|
|
action: {
|
|||
|
|
type: "command",
|
|||
|
|
commandId: ""
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function normalizeTagList(values: string[]): string[] {
|
|||
|
|
const seen = new Set<string>();
|
|||
|
|
const result: string[] = [];
|
|||
|
|
for (const raw of values) {
|
|||
|
|
const trimmed = raw.trim();
|
|||
|
|
if (!trimmed) continue;
|
|||
|
|
const slug = normalizeTag(trimmed);
|
|||
|
|
if (!slug || seen.has(slug)) continue;
|
|||
|
|
seen.add(slug);
|
|||
|
|
result.push(trimmed);
|
|||
|
|
}
|
|||
|
|
return result;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function duplicateWithUpdates(command: CustomCommandDefinition, updates: Partial<CustomCommandDefinition>): CustomCommandDefinition {
|
|||
|
|
const nextTags = updates.tags !== undefined ? normalizeTagList(updates.tags) : command.tags;
|
|||
|
|
return {
|
|||
|
|
...command,
|
|||
|
|
...updates,
|
|||
|
|
keywords: updates.keywords ?? command.keywords,
|
|||
|
|
tags: nextTags,
|
|||
|
|
action: updates.action ?? command.action
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
type TemplateConfig = {
|
|||
|
|
label: string;
|
|||
|
|
description: string;
|
|||
|
|
apply(command: CustomCommandDefinition): CustomCommandDefinition;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const TEMPLATE_CONFIGS: TemplateConfig[] = [
|
|||
|
|
{
|
|||
|
|
label: "Alias",
|
|||
|
|
description: "Mirror an existing command",
|
|||
|
|
apply: command => duplicateWithUpdates(command, {
|
|||
|
|
action: { type: "command", commandId: "" }
|
|||
|
|
})
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
label: "Settings",
|
|||
|
|
description: "Open Discord settings",
|
|||
|
|
apply: command => duplicateWithUpdates(command, {
|
|||
|
|
action: { type: "settings", route: "My Account" }
|
|||
|
|
})
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
label: "Link",
|
|||
|
|
description: "Open an external URL",
|
|||
|
|
apply: command => duplicateWithUpdates(command, {
|
|||
|
|
action: { type: "url", url: "https://", openExternal: true }
|
|||
|
|
})
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
label: "Macro",
|
|||
|
|
description: "Run multiple commands",
|
|||
|
|
apply: command => duplicateWithUpdates(command, {
|
|||
|
|
action: { type: "macro", steps: [] }
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
function parseListInput(value: string): string[] {
|
|||
|
|
const parts = value
|
|||
|
|
.split(",")
|
|||
|
|
.map(item => item.trim());
|
|||
|
|
return normalizeTagList(parts);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function renderActionFields(command: CustomCommandDefinition, onChange: (action: CustomCommandDefinition["action"]) => void) {
|
|||
|
|
const { action } = command;
|
|||
|
|
|
|||
|
|
switch (action.type) {
|
|||
|
|
case "command":
|
|||
|
|
const trimmedId = action.commandId.trim();
|
|||
|
|
let commandError: string | undefined;
|
|||
|
|
if (!trimmedId) {
|
|||
|
|
commandError = "Enter a command ID or choose one below.";
|
|||
|
|
} else if (!getCommandById(trimmedId)) {
|
|||
|
|
commandError = "No command matches this ID.";
|
|||
|
|
}
|
|||
|
|
return (
|
|||
|
|
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
|||
|
|
<TextInput
|
|||
|
|
label="Target Command ID"
|
|||
|
|
value={action.commandId}
|
|||
|
|
placeholder="Existing command id"
|
|||
|
|
error={commandError}
|
|||
|
|
onChange={value => onChange({ type: "command", commandId: value })}
|
|||
|
|
/>
|
|||
|
|
<Button
|
|||
|
|
size={Button.Sizes.SMALL}
|
|||
|
|
onClick={() => openCommandPicker({
|
|||
|
|
initialQuery: action.commandId,
|
|||
|
|
onSelect: command => onChange({ type: "command", commandId: command.id })
|
|||
|
|
})}
|
|||
|
|
>
|
|||
|
|
Choose Command…
|
|||
|
|
</Button>
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
case "settings": {
|
|||
|
|
const selectedSettings = action.route ? getSettingsCommandMetaByRoute(action.route) : undefined;
|
|||
|
|
const currentSummary = selectedSettings
|
|||
|
|
? `${selectedSettings.label} (${selectedSettings.route})`
|
|||
|
|
: action.route || "No page selected";
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
|||
|
|
<Forms.FormText style={{ color: "var(--text-muted, #a5a6ab)" }}>
|
|||
|
|
Current page: {currentSummary}
|
|||
|
|
</Forms.FormText>
|
|||
|
|
<Button
|
|||
|
|
size={Button.Sizes.SMALL}
|
|||
|
|
onClick={() => openCommandPicker({
|
|||
|
|
initialSelectedIds: selectedSettings ? [selectedSettings.id] : undefined,
|
|||
|
|
filter: entry => entry.categoryId === "discord-settings",
|
|||
|
|
onSelect: command => {
|
|||
|
|
const meta = getSettingsCommandMetaById(command.id);
|
|||
|
|
onChange({ type: "settings", route: meta?.route ?? command.label });
|
|||
|
|
}
|
|||
|
|
})}
|
|||
|
|
>
|
|||
|
|
Choose Page…
|
|||
|
|
</Button>
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
case "url": {
|
|||
|
|
let urlError: string | undefined;
|
|||
|
|
const trimmedUrl = action.url.trim();
|
|||
|
|
if (trimmedUrl && !trimmedUrl.startsWith("http://") && !trimmedUrl.startsWith("https://")) {
|
|||
|
|
urlError = "Use http:// or https:// links.";
|
|||
|
|
} else if (trimmedUrl) {
|
|||
|
|
try {
|
|||
|
|
new URL(trimmedUrl);
|
|||
|
|
} catch {
|
|||
|
|
urlError = "Enter a valid URL.";
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return (
|
|||
|
|
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
|||
|
|
<TextInput
|
|||
|
|
label="URL"
|
|||
|
|
value={action.url}
|
|||
|
|
placeholder="https://example.com"
|
|||
|
|
error={urlError}
|
|||
|
|
onChange={value => onChange({ type: "url", url: value, openExternal: action.openExternal })}
|
|||
|
|
/>
|
|||
|
|
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
|||
|
|
<Switch
|
|||
|
|
checked={Boolean(action.openExternal)}
|
|||
|
|
onChange={value => onChange({ type: "url", url: action.url, openExternal: value })}
|
|||
|
|
/>
|
|||
|
|
<Forms.FormText>Open externally</Forms.FormText>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
case "macro":
|
|||
|
|
const invalidSteps = action.steps.filter(step => !getCommandById(step));
|
|||
|
|
return (
|
|||
|
|
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
|||
|
|
<TextInput
|
|||
|
|
label="Command Sequence"
|
|||
|
|
value={action.steps.join(", ")}
|
|||
|
|
placeholder="command-a, command-b"
|
|||
|
|
onChange={value => onChange({ type: "macro", steps: parseListInput(value) })}
|
|||
|
|
/>
|
|||
|
|
<Button
|
|||
|
|
size={Button.Sizes.SMALL}
|
|||
|
|
onClick={() => openCommandPicker({
|
|||
|
|
allowMultiple: true,
|
|||
|
|
initialSelectedIds: action.steps,
|
|||
|
|
onComplete: (commands, ids) => {
|
|||
|
|
const nextIds = ids ?? commands.map(command => command.id);
|
|||
|
|
onChange({ type: "macro", steps: nextIds });
|
|||
|
|
}
|
|||
|
|
})}
|
|||
|
|
>
|
|||
|
|
Add Step…
|
|||
|
|
</Button>
|
|||
|
|
{action.steps.length > 0 && (
|
|||
|
|
<div style={{ display: "flex", flexWrap: "wrap", gap: 6 }}>
|
|||
|
|
{action.steps.map((step, index) => (
|
|||
|
|
<Button
|
|||
|
|
key={`${step}-${index}`}
|
|||
|
|
size={Button.Sizes.MIN}
|
|||
|
|
onClick={() => {
|
|||
|
|
const next = [...action.steps];
|
|||
|
|
next.splice(index, 1);
|
|||
|
|
onChange({ type: "macro", steps: next });
|
|||
|
|
}}
|
|||
|
|
look={Button.Looks.LINK}
|
|||
|
|
>
|
|||
|
|
{step} ✕
|
|||
|
|
</Button>
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
{invalidSteps.length > 0 && (
|
|||
|
|
<Forms.FormText style={{ color: "var(--status-danger-text, #f04747)" }}>
|
|||
|
|
Unknown command IDs: {invalidSteps.join(", ")}
|
|||
|
|
</Forms.FormText>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
default:
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export function CommandPaletteSettingsPanel() {
|
|||
|
|
const [commands, setCommands] = React.useState<CustomCommandDefinition[]>(() => getCustomCommandsSnapshot());
|
|||
|
|
const [tagMeta, setTagMeta] = React.useState(() => listAllTags());
|
|||
|
|
const [categoryOptions, setCategoryOptions] = React.useState(() => buildCategoryOptions());
|
|||
|
|
const [advancedOpen, setAdvancedOpen] = React.useState<Record<string, boolean>>({});
|
|||
|
|
const [collapsed, setCollapsed] = React.useState<Record<string, boolean>>({});
|
|||
|
|
|
|||
|
|
React.useEffect(() => {
|
|||
|
|
return subscribeCustomCommands(next => setCommands(next));
|
|||
|
|
}, []);
|
|||
|
|
|
|||
|
|
React.useEffect(() => {
|
|||
|
|
setTagMeta(listAllTags());
|
|||
|
|
setCategoryOptions(buildCategoryOptions());
|
|||
|
|
return subscribeRegistry(() => {
|
|||
|
|
setTagMeta(listAllTags());
|
|||
|
|
setCategoryOptions(buildCategoryOptions());
|
|||
|
|
});
|
|||
|
|
}, []);
|
|||
|
|
|
|||
|
|
const updateCommands = React.useCallback((next: CustomCommandDefinition[]) => {
|
|||
|
|
setCommands(next);
|
|||
|
|
void saveCustomCommands(next);
|
|||
|
|
}, []);
|
|||
|
|
|
|||
|
|
const handleAdd = () => {
|
|||
|
|
updateCommands([...commands, createDefaultCommand()]);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const handleRemove = (id: string) => {
|
|||
|
|
updateCommands(commands.filter(command => command.id !== id));
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const handleUpdate = (id: string, updater: (command: CustomCommandDefinition) => CustomCommandDefinition) => {
|
|||
|
|
updateCommands(commands.map(command => (command.id === id ? updater(command) : command)));
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
React.useEffect(() => {
|
|||
|
|
setAdvancedOpen(prev => {
|
|||
|
|
const next = { ...prev };
|
|||
|
|
for (const key of Object.keys(next)) {
|
|||
|
|
if (!commands.some(command => command.id === key)) {
|
|||
|
|
delete next[key];
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return next;
|
|||
|
|
});
|
|||
|
|
setCollapsed(prev => {
|
|||
|
|
const next = { ...prev };
|
|||
|
|
for (const key of Object.keys(next)) {
|
|||
|
|
if (!commands.some(command => command.id === key)) {
|
|||
|
|
delete next[key];
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return next;
|
|||
|
|
});
|
|||
|
|
}, [commands]);
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<section>
|
|||
|
|
<Forms.FormTitle tag="h4" style={{ color: "var(--text-normal, #dcddde)" }}>Custom Commands</Forms.FormTitle>
|
|||
|
|
<Forms.FormText className="vc-cp-settings-help">
|
|||
|
|
{/* Hank, don't abbreviate cyberpunk hank */}
|
|||
|
|
1) Name the command · 2) Add optional description/keywords/tags/category · 3) Choose an action and fill its details (IDs should match existing palette commands for aliases and macros).
|
|||
|
|
</Forms.FormText>
|
|||
|
|
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
|||
|
|
{commands.map(command => {
|
|||
|
|
const manualAdvanced = advancedOpen[command.id];
|
|||
|
|
const hasAdvancedContent = Boolean(command.description?.trim()?.length || (command.keywords?.length ?? 0) || (command.tags?.length ?? 0) || command.categoryId || command.danger);
|
|||
|
|
const showAdvanced = manualAdvanced ?? hasAdvancedContent;
|
|||
|
|
const categoryId = command.categoryId ?? "";
|
|||
|
|
const categoryPath = categoryId ? getCategoryPath(categoryId) : [];
|
|||
|
|
const categoryLabel = categoryId
|
|||
|
|
? (categoryPath.length ? categoryPath.map(item => item.label).join(" › ") : categoryId)
|
|||
|
|
: "Auto (default)";
|
|||
|
|
const isCollapsed = collapsed[command.id] ?? false;
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div key={command.id} style={{ padding: 14, borderRadius: 8, background: "var(--background-primary)", border: "1px solid var(--background-tertiary)", display: "flex", flexDirection: "column", gap: 12 }}>
|
|||
|
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
|||
|
|
<Forms.FormTitle tag="h5" style={{ color: "var(--text-normal, #dcddde)" }}>{command.label || "Untitled Command"}</Forms.FormTitle>
|
|||
|
|
<Button
|
|||
|
|
size={Button.Sizes.MIN}
|
|||
|
|
look={Button.Looks.LINK}
|
|||
|
|
onClick={() => setCollapsed(prev => ({ ...prev, [command.id]: !isCollapsed }))}
|
|||
|
|
>
|
|||
|
|
{isCollapsed ? "Expand" : "Collapse"}
|
|||
|
|
</Button>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{!isCollapsed && (
|
|||
|
|
<>
|
|||
|
|
<TextInput
|
|||
|
|
label="Label"
|
|||
|
|
value={command.label}
|
|||
|
|
placeholder="Display name"
|
|||
|
|
onChange={value => handleUpdate(command.id, prev => duplicateWithUpdates(prev, { label: value }))}
|
|||
|
|
/>
|
|||
|
|
|
|||
|
|
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
|||
|
|
<div style={{ display: "flex", flexWrap: "wrap", gap: 8, alignItems: "center" }}>
|
|||
|
|
<Select
|
|||
|
|
options={ACTION_OPTIONS}
|
|||
|
|
isSelected={value => value === command.action.type}
|
|||
|
|
select={value => {
|
|||
|
|
const nextType = value as ActionType;
|
|||
|
|
switch (nextType) {
|
|||
|
|
case "command":
|
|||
|
|
handleUpdate(command.id, prev => TEMPLATE_CONFIGS[0].apply(prev));
|
|||
|
|
break;
|
|||
|
|
case "settings":
|
|||
|
|
handleUpdate(command.id, prev => TEMPLATE_CONFIGS[1].apply(prev));
|
|||
|
|
break;
|
|||
|
|
case "url":
|
|||
|
|
handleUpdate(command.id, prev => TEMPLATE_CONFIGS[2].apply(prev));
|
|||
|
|
break;
|
|||
|
|
case "macro":
|
|||
|
|
handleUpdate(command.id, prev => TEMPLATE_CONFIGS[3].apply(prev));
|
|||
|
|
break;
|
|||
|
|
default:
|
|||
|
|
break;
|
|||
|
|
}
|
|||
|
|
}}
|
|||
|
|
serialize={option => option.value}
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
<Forms.FormText style={{ color: "var(--text-muted, #a5a6ab)", fontSize: 12 }}>
|
|||
|
|
{ACTION_HELP_TEXT[command.action.type]}
|
|||
|
|
</Forms.FormText>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{renderActionFields(command, action => handleUpdate(command.id, prev => duplicateWithUpdates(prev, { action })))}
|
|||
|
|
|
|||
|
|
<Button
|
|||
|
|
size={Button.Sizes.SMALL}
|
|||
|
|
look={Button.Looks.LINK}
|
|||
|
|
onClick={() => setAdvancedOpen(prev => ({ ...prev, [command.id]: !(showAdvanced ?? false) }))}
|
|||
|
|
>
|
|||
|
|
{showAdvanced ? "Hide advanced options" : "Show advanced options"}
|
|||
|
|
</Button>
|
|||
|
|
|
|||
|
|
{showAdvanced && (
|
|||
|
|
<div style={{ display: "flex", flexDirection: "column", gap: 12, background: "var(--background-secondary)", borderRadius: 8, padding: 12 }}>
|
|||
|
|
<TextInput
|
|||
|
|
label="Description"
|
|||
|
|
value={command.description ?? ""}
|
|||
|
|
placeholder="Optional subtitle"
|
|||
|
|
onChange={value => handleUpdate(command.id, prev => duplicateWithUpdates(prev, { description: value }))}
|
|||
|
|
/>
|
|||
|
|
<TextInput
|
|||
|
|
label="Keywords"
|
|||
|
|
value={(command.keywords ?? []).join(", ")}
|
|||
|
|
placeholder="Comma-separated keywords"
|
|||
|
|
onChange={value => handleUpdate(command.id, prev => duplicateWithUpdates(prev, { keywords: parseListInput(value) }))}
|
|||
|
|
/>
|
|||
|
|
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
|||
|
|
<TextInput
|
|||
|
|
label="Tags"
|
|||
|
|
value={(command.tags ?? []).join(", ")}
|
|||
|
|
placeholder="Comma-separated tags"
|
|||
|
|
onChange={value => handleUpdate(command.id, prev => duplicateWithUpdates(prev, { tags: parseListInput(value) }))}
|
|||
|
|
/>
|
|||
|
|
{tagMeta.length > 0 && (
|
|||
|
|
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
|||
|
|
<Forms.FormText style={{ color: "var(--text-muted, #a5a6ab)", fontSize: 12 }}>
|
|||
|
|
Suggestions
|
|||
|
|
</Forms.FormText>
|
|||
|
|
<div style={{ display: "flex", flexWrap: "wrap", gap: 6 }}>
|
|||
|
|
{tagMeta
|
|||
|
|
.filter(meta => !(command.tags ?? []).some(tag => normalizeTag(tag) === meta.id))
|
|||
|
|
.slice(0, 12)
|
|||
|
|
.map(meta => (
|
|||
|
|
<Button
|
|||
|
|
key={meta.id}
|
|||
|
|
size={Button.Sizes.MIN}
|
|||
|
|
look={Button.Looks.LINK}
|
|||
|
|
onClick={() => handleUpdate(command.id, prev => duplicateWithUpdates(prev, {
|
|||
|
|
tags: normalizeTagList([...(prev.tags ?? []), meta.label])
|
|||
|
|
}))}
|
|||
|
|
>
|
|||
|
|
{meta.label}
|
|||
|
|
</Button>
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
|||
|
|
<Select
|
|||
|
|
options={categoryOptions}
|
|||
|
|
isSelected={value => value === categoryId}
|
|||
|
|
select={value => handleUpdate(command.id, prev => duplicateWithUpdates(prev, { categoryId: value || undefined }))}
|
|||
|
|
serialize={option => option.value}
|
|||
|
|
/>
|
|||
|
|
<Forms.FormText style={{ color: "var(--text-muted, #a5a6ab)", fontSize: 12 }}>
|
|||
|
|
Choose where this command appears in the palette.
|
|||
|
|
</Forms.FormText>
|
|||
|
|
{categoryId && (
|
|||
|
|
<div style={{ display: "flex", flexWrap: "wrap", gap: 6 }}>
|
|||
|
|
<span style={{ display: "inline-flex", alignItems: "center", gap: 6, borderRadius: 999, padding: "2px 10px", fontSize: 12, background: "var(--background-tertiary)", color: "var(--text-normal, #dcddde)" }}>
|
|||
|
|
{categoryLabel}
|
|||
|
|
<span style={{ color: "var(--text-muted, #a5a6ab)" }}>· {categoryId}</span>
|
|||
|
|
</span>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
|||
|
|
<Switch
|
|||
|
|
checked={Boolean(command.danger)}
|
|||
|
|
onChange={value => handleUpdate(command.id, prev => duplicateWithUpdates(prev, { danger: value }))}
|
|||
|
|
/>
|
|||
|
|
<Forms.FormText>Show as dangerous</Forms.FormText>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
<Button
|
|||
|
|
color={Button.Colors.RED}
|
|||
|
|
size={Button.Sizes.SMALL}
|
|||
|
|
onClick={() => handleRemove(command.id)}
|
|||
|
|
>
|
|||
|
|
Remove Command
|
|||
|
|
</Button>
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
})}
|
|||
|
|
</div>
|
|||
|
|
<Button
|
|||
|
|
color={Button.Colors.BRAND}
|
|||
|
|
style={{ marginTop: 16 }}
|
|||
|
|
onClick={handleAdd}
|
|||
|
|
>
|
|||
|
|
Add Command
|
|||
|
|
</Button>
|
|||
|
|
</section>
|
|||
|
|
);
|
|||
|
|
}
|