Equicord/src/equicordplugins/commandPalette/settingsPanel.tsx
thororen1234 ef453ce06e
Fix a few classnames
literally guessing on some
2025-12-04 10:16:12 -05:00

531 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
* 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-base-low)", 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-alt)", 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-base-low)", 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>
);
}