Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
172 changes: 172 additions & 0 deletions apps/web/src/components/CommandPalette.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
// Cmd-K (or `/`-triggered) command palette. Shows the catalog filtered to
// commands the current member can run, grouped by tier. Selecting a row
// inserts its usage template into the message composer; the inline
// parser in the composer handles actual execution on send.

import { useEffect, useMemo, useRef, useState } from "react";
import { ALL_COMMANDS, fetchCatalog, type Tier } from "../lib/commands";

interface Props {
open: boolean;
serverId: string | undefined;
onClose(): void;
/** Called with the slash-prefixed string to insert into the composer. */
onPick(insertion: string): void;
}

interface Row {
name: string;
tier: Tier;
description: string;
usage: string;
allowed: boolean;
}

const TIER_ORDER: Tier[] = ["user", "mod", "admin", "owner"];
const TIER_LABEL: Record<Tier, string> = {
user: "Member",
mod: "Moderator",
admin: "Admin",
owner: "Owner",
};

export function CommandPalette({ open, serverId, onClose, onPick }: Props) {
const [filter, setFilter] = useState("");
const [rows, setRows] = useState<Row[]>([]);
const [cursor, setCursor] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);

useEffect(() => {
if (!open) return;
setFilter("");
setCursor(0);
inputRef.current?.focus();
}, [open]);

useEffect(() => {
if (!open || !serverId) return;
let cancelled = false;
(async () => {
try {
const cat = await fetchCatalog(serverId);
if (cancelled) return;
// Merge server-side rows (with `allowed`) with client-only rows
// so cosmetic and read commands show up too. Client-only ones
// are always allowed.
const serverNames = new Set(cat.commands.map(c => c.name));
const clientOnly: Row[] = ALL_COMMANDS
.filter(c => !serverNames.has(c.name))
.map(c => ({
name: c.name,
tier: c.tier,
description: c.description,
usage: c.usage,
allowed: true,
}));
const all = [...cat.commands, ...clientOnly];
all.sort((a, b) => {
const t = TIER_ORDER.indexOf(a.tier) - TIER_ORDER.indexOf(b.tier);
if (t !== 0) return t;
return a.name.localeCompare(b.name);
});
setRows(all);
} catch {
// If the catalog endpoint fails (offline, denied, etc), fall back
// to the static client list with everything marked allowed so the
// palette is still useful.
const fallback: Row[] = ALL_COMMANDS.map(c => ({
name: c.name,
tier: c.tier,
description: c.description,
usage: c.usage,
allowed: true,
}));
setRows(fallback);
}
})();
return () => {
cancelled = true;
};
}, [open, serverId]);

const filtered = useMemo(() => {
const q = filter.trim().toLowerCase();
if (!q) return rows;
return rows.filter(r =>
r.name.toLowerCase().includes(q) ||
r.description.toLowerCase().includes(q),
);
}, [rows, filter]);

useEffect(() => {
if (cursor >= filtered.length) setCursor(Math.max(0, filtered.length - 1));
}, [filtered.length, cursor]);

if (!open) return null;

function pick(r: Row) {
if (!r.allowed) return;
onPick(r.usage);
onClose();
}

return (
<div
className="cmdp-backdrop"
onClick={onClose}
role="dialog"
aria-modal="true"
aria-label="Command palette"
>
<div className="cmdp" onClick={e => e.stopPropagation()}>
<input
ref={inputRef}
className="cmdp-input"
placeholder="Search commands"
value={filter}
onChange={e => setFilter(e.target.value)}
onKeyDown={e => {
if (e.key === "Escape") {
onClose();
} else if (e.key === "ArrowDown") {
e.preventDefault();
setCursor(c => Math.min(filtered.length - 1, c + 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setCursor(c => Math.max(0, c - 1));
} else if (e.key === "Enter") {
const r = filtered[cursor];
if (r) pick(r);
}
}}
/>
<ul className="cmdp-list">
{filtered.length === 0 && (
<li className="cmdp-empty muted">No matching commands.</li>
)}
{filtered.map((r, i) => (
<li
key={r.name}
className={`cmdp-row${i === cursor ? " is-active" : ""}${r.allowed ? "" : " is-disabled"}`}
onMouseEnter={() => setCursor(i)}
onClick={() => pick(r)}
aria-disabled={!r.allowed}
>
<div className="cmdp-row-head">
<span className="cmdp-name">/{r.name}</span>
<span className={`cmdp-tier cmdp-tier-${r.tier}`}>{TIER_LABEL[r.tier]}</span>
</div>
<div className="cmdp-usage muted">{r.usage}</div>
<div className="cmdp-desc small muted">{r.description}</div>
</li>
))}
</ul>
<div className="cmdp-foot small muted">
<span>↑↓ to move</span>
<span>↵ to insert</span>
<span>esc to close</span>
</div>
</div>
</div>
);
}
Loading
Loading