Add command palette improvements: descriptions, favorites, scope filters, contextual boost, and information hierarchy

- Add description field to CommandItem and descriptions to all 59 registry entries
- Add SMS Templates navigation and quick action entries
- Integrate favorites store: show starred items above recents when no query, star badges in search results
- Add @prefix: scope filtering (10 scopes) with parseQuery(), scope chip UI, and clickable scope list
- Add contextual command boosting based on current route (+50 path match, +25 group match)
- Reorder result hierarchy: Pages → Actions → Entities → Settings
- Extract CommandGroupSection and EntityGroupSection render helpers

Bunker Admin
This commit is contained in:
bunker-admin 2026-02-28 09:04:24 -07:00
parent 1f2ce681a6
commit 46fc92fab8
6 changed files with 617 additions and 110 deletions

View File

@ -1,6 +1,6 @@
import { useState, useEffect, useRef, useMemo, useCallback } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { Modal, Typography, Grid, Spin, theme, type GlobalToken } from 'antd';
import { Modal, Typography, Grid, Spin, Tag, theme, type GlobalToken } from 'antd';
import {
SearchOutlined,
DashboardOutlined,
@ -42,11 +42,14 @@ import {
FileMarkdownOutlined,
ContactsOutlined,
ScissorOutlined,
StarFilled,
} from '@ant-design/icons';
import { useCommandPaletteStore } from '@/stores/command-palette.store';
import { useAuthStore } from '@/stores/auth.store';
import { useFavoritesStore } from '@/stores/favorites.store';
import { useCommandIndex } from './useCommandIndex';
import { useEntitySearch } from './useEntitySearch';
import { parseQuery, SCOPE_DEFINITIONS } from './scopeFilter';
import type { CommandItem, EntityResult, CommandCategory } from './types';
const { Text } = Typography;
@ -108,6 +111,7 @@ type FlatItem =
export default function CommandPalette() {
const { isOpen, close, recentItems, addRecent } = useCommandPaletteStore();
const { isAuthenticated } = useAuthStore();
const { favorites } = useFavoritesStore();
const location = useLocation();
const navigate = useNavigate();
const { token } = theme.useToken();
@ -119,8 +123,14 @@ export default function CommandPalette() {
const inputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLDivElement>(null);
// Parse query for scope prefix
const parsed = useMemo(() => parseQuery(query), [query]);
const { search, allItems } = useCommandIndex();
const { results: entityResults, loading: entityLoading } = useEntitySearch(query);
const { results: entityResults, loading: entityLoading } = useEntitySearch(
parsed.strippedQuery,
parsed.scope?.entityTypes,
);
// Only render inside admin panel
const isAdminRoute = location.pathname.startsWith('/app');
@ -156,37 +166,60 @@ export default function CommandPalette() {
// Build command results from search
const commandResults = useMemo(() => {
if (!query) return [];
return search(query);
}, [query, search]);
if (!parsed.strippedQuery && !parsed.scope) return [];
// If scope is active but query is empty, show all items in that scope's groups
if (parsed.scope && !parsed.strippedQuery) {
return allItems.filter((item) => parsed.scope!.groups.includes(item.group));
}
return search(parsed.strippedQuery, parsed.scope?.groups);
}, [parsed, search, allItems]);
// Build recent items list (when no query)
// Build favorite items (when no query)
const favoriteCommandItems = useMemo(() => {
if (query) return [];
const favoriteSet = new Set(favorites);
return allItems.filter((item) => favoriteSet.has(item.path));
}, [query, favorites, allItems]);
// Set of favorited paths for badge rendering
const favoritePaths = useMemo(() => new Set(favorites), [favorites]);
// Build recent items list (when no query), excluding favorites
const recentCommandItems = useMemo(() => {
if (query) return [];
const favIds = new Set(favoriteCommandItems.map((i) => i.id));
return recentItems
.map((id) => allItems.find((item) => item.id === id))
.filter((item): item is CommandItem => !!item);
}, [query, recentItems, allItems]);
.filter((item): item is CommandItem => !!item && !favIds.has(item.id));
}, [query, recentItems, allItems, favoriteCommandItems]);
// Flatten all results into a single list for keyboard navigation
// Flatten all results into a single list for keyboard navigation.
// Order: Favorites → Recent → Pages → Actions → Entities → Settings
const flatList = useMemo((): FlatItem[] => {
const items: FlatItem[] = [];
if (!query) {
// Show recents
for (const item of favoriteCommandItems) {
items.push({ type: 'command', item });
}
for (const item of recentCommandItems) {
items.push({ type: 'command', item });
}
} else if (parsed.showScopeList) {
// No items when showing scope selector
} else {
// Show search results
for (const item of commandResults) {
items.push({ type: 'command', item });
}
for (const item of entityResults) {
items.push({ type: 'entity', item });
}
// Pages (navigation) first, then actions
const pages = commandResults.filter((i) => i.category === 'navigation');
const actions = commandResults.filter((i) => i.category === 'action');
const settings = commandResults.filter((i) => i.category === 'settings');
for (const item of pages) items.push({ type: 'command', item });
for (const item of actions) items.push({ type: 'command', item });
// Entities in the middle
for (const item of entityResults) items.push({ type: 'entity', item });
// Settings last
for (const item of settings) items.push({ type: 'command', item });
}
return items;
}, [query, recentCommandItems, commandResults, entityResults]);
}, [query, favoriteCommandItems, recentCommandItems, parsed.showScopeList, commandResults, entityResults]);
// Clamp selected index
useEffect(() => {
@ -233,18 +266,39 @@ export default function CommandPalette() {
[flatList, selectedIndex, handleSelect],
);
// Group commands by category for display
// Group commands by category for display, in priority order:
// Pages (navigation) → Actions → Settings (least frequent)
// NOTE: These hooks must stay above the early return to satisfy Rules of Hooks
const CATEGORY_ORDER: (CommandCategory | 'favorites' | 'recent')[] = [
'favorites', 'recent', 'navigation', 'action', 'settings',
];
const groupedCommands = useMemo(() => {
const items = query ? commandResults : recentCommandItems;
const groups = new Map<string, CommandItem[]>();
for (const item of items) {
const key = query ? item.category : 'recent';
if (!groups.has(key)) groups.set(key, []);
groups.get(key)!.push(item);
const unordered = new Map<string, CommandItem[]>();
if (!query) {
// No-query mode: favorites then recents
if (favoriteCommandItems.length > 0) {
unordered.set('favorites', favoriteCommandItems);
}
if (recentCommandItems.length > 0) {
unordered.set('recent', recentCommandItems);
}
} else if (!parsed.showScopeList) {
for (const item of commandResults) {
const key = item.category;
if (!unordered.has(key)) unordered.set(key, []);
unordered.get(key)!.push(item);
}
}
return groups;
}, [query, commandResults, recentCommandItems]);
// Re-insert in priority order
const ordered = new Map<string, CommandItem[]>();
for (const key of CATEGORY_ORDER) {
if (unordered.has(key)) ordered.set(key, unordered.get(key)!);
}
return ordered;
}, [query, parsed.showScopeList, commandResults, favoriteCommandItems, recentCommandItems]);
// Group entities by type
const groupedEntities = useMemo(() => {
@ -263,6 +317,11 @@ export default function CommandPalette() {
const isMac = navigator.platform?.toLowerCase().includes('mac');
// Dynamic placeholder
const placeholder = parsed.scope
? `Search ${parsed.scope.label.toLowerCase()}...`
: 'Search pages, settings, data...';
return (
<Modal
open={isOpen}
@ -295,6 +354,16 @@ export default function CommandPalette() {
}}
>
<SearchOutlined style={{ color: token.colorTextSecondary, fontSize: 16 }} />
{parsed.scope && (
<Tag
color={token.colorPrimary}
style={{ margin: 0, fontSize: 11, lineHeight: '18px', padding: '0 6px' }}
closable
onClose={() => setQuery(parsed.strippedQuery)}
>
{parsed.scope.label}
</Tag>
)}
<input
ref={inputRef}
value={query}
@ -302,7 +371,7 @@ export default function CommandPalette() {
setQuery(e.target.value);
setSelectedIndex(0);
}}
placeholder="Search pages, settings, data..."
placeholder={placeholder}
style={{
flex: 1,
border: 'none',
@ -325,9 +394,9 @@ export default function CommandPalette() {
padding: '4px 0',
}}
>
{/* Command groups */}
{Array.from(groupedCommands.entries()).map(([groupKey, items]) => (
<div key={groupKey}>
{/* Scope selector (when user types bare @) */}
{parsed.showScopeList && (
<div>
<div
style={{
padding: '8px 16px 4px',
@ -338,85 +407,124 @@ export default function CommandPalette() {
color: token.colorTextSecondary,
}}
>
{groupKey === 'recent'
? 'Recent'
: CATEGORY_LABELS[groupKey as CommandCategory] ?? groupKey}
<Text
type="secondary"
style={{ float: 'right', fontSize: 11, fontWeight: 400, textTransform: 'none', letterSpacing: 0 }}
>
{items.length} {items.length === 1 ? 'result' : 'results'}
</Text>
Filter by scope
</div>
{items.map((item) => {
const idx = flatIndex++;
return (
<ResultRow
key={item.id}
index={idx}
selected={idx === selectedIndex}
icon={ICON_MAP[item.icon ?? ''] ?? <SearchOutlined />}
title={item.title}
badge={item.category === 'action' ? <ThunderboltOutlined style={{ fontSize: 10, color: token.colorWarning }} /> : null}
subtitle={item.group}
token={token}
onSelect={() => handleSelect({ type: 'command', item })}
onHover={() => setSelectedIndex(idx)}
/>
);
})}
</div>
))}
{/* Entity groups */}
{query &&
Array.from(groupedEntities.entries()).map(([entityType, items]) => (
<div key={entityType}>
{SCOPE_DEFINITIONS.map((scope) => (
<div
key={scope.prefix}
onClick={() => {
setQuery(`@${scope.prefix}:`);
setSelectedIndex(0);
inputRef.current?.focus();
}}
style={{
padding: '8px 16px 4px',
fontSize: 11,
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: '0.05em',
color: token.colorTextSecondary,
padding: '8px 16px',
display: 'flex',
alignItems: 'center',
gap: 10,
cursor: 'pointer',
borderRadius: 4,
margin: '0 4px',
transition: 'background 0.1s',
}}
onMouseEnter={(e) => {
(e.currentTarget as HTMLDivElement).style.background = token.colorPrimaryBg;
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLDivElement).style.background = 'transparent';
}}
>
{entityType}s
<Text
type="secondary"
style={{ float: 'right', fontSize: 11, fontWeight: 400, textTransform: 'none', letterSpacing: 0 }}
<span
style={{
fontFamily: 'monospace',
fontSize: 13,
color: token.colorPrimary,
width: 90,
flexShrink: 0,
}}
>
{items.length} {items.length === 1 ? 'result' : 'results'}
</Text>
@{scope.prefix}:
</span>
<span style={{ color: token.colorText, fontSize: 14 }}>{scope.label}</span>
</div>
{items.map((item) => {
const idx = flatIndex++;
return (
<ResultRow
key={item.id}
index={idx}
selected={idx === selectedIndex}
icon={ICON_MAP[item.icon ?? ''] ?? <SearchOutlined />}
title={item.title}
subtitle={item.subtitle || item.entityType}
token={token}
onSelect={() => handleSelect({ type: 'entity', item })}
onHover={() => setSelectedIndex(idx)}
/>
);
})}
</div>
))}
{/* Empty state */}
{flatList.length === 0 && !entityLoading && query && (
<div style={{ padding: '24px 16px', textAlign: 'center' }}>
<Text type="secondary">No results for "{query}"</Text>
))}
</div>
)}
{/* No query, no recents */}
{/* Render in priority order: Pages Actions Entities Settings
For no-query mode: Favorites Recent (no entities) */}
{(() => {
const sections: React.ReactNode[] = [];
const commandEntries = Array.from(groupedCommands.entries());
// Render command groups that come before entities (everything except settings)
for (const [groupKey, items] of commandEntries) {
if (groupKey === 'settings') continue; // settings rendered after entities
sections.push(
<CommandGroupSection
key={groupKey}
groupKey={groupKey}
items={items}
flatIndexRef={{ current: flatIndex }}
selectedIndex={selectedIndex}
favoritePaths={favoritePaths}
token={token}
onSelect={handleSelect}
onHover={setSelectedIndex}
/>,
);
flatIndex += items.length;
}
// Entity groups (only when searching)
if (query && !parsed.showScopeList) {
for (const [entityType, items] of groupedEntities.entries()) {
sections.push(
<EntityGroupSection
key={entityType}
entityType={entityType}
items={items}
flatIndexRef={{ current: flatIndex }}
selectedIndex={selectedIndex}
token={token}
onSelect={handleSelect}
onHover={setSelectedIndex}
/>,
);
flatIndex += items.length;
}
}
// Settings last
const settingsItems = groupedCommands.get('settings');
if (settingsItems) {
sections.push(
<CommandGroupSection
key="settings"
groupKey="settings"
items={settingsItems}
flatIndexRef={{ current: flatIndex }}
selectedIndex={selectedIndex}
favoritePaths={favoritePaths}
token={token}
onSelect={handleSelect}
onHover={setSelectedIndex}
/>,
);
flatIndex += settingsItems.length;
}
return sections;
})()}
{/* Empty state */}
{flatList.length === 0 && !entityLoading && query && !parsed.showScopeList && (
<div style={{ padding: '24px 16px', textAlign: 'center' }}>
<Text type="secondary">No results for "{parsed.strippedQuery || query}"</Text>
</div>
)}
{/* No query, no recents, no favorites */}
{flatList.length === 0 && !query && (
<div style={{ padding: '24px 16px', textAlign: 'center' }}>
<Text type="secondary">Type to search pages, settings, and data</Text>
@ -433,11 +541,13 @@ export default function CommandPalette() {
gap: 16,
fontSize: 12,
color: token.colorTextTertiary,
flexWrap: 'wrap',
}}
>
<span><kbd style={kbdStyle(token)}></kbd> navigate</span>
<span><kbd style={kbdStyle(token)}></kbd> open</span>
<span><kbd style={kbdStyle(token)}>esc</kbd> close</span>
<span><kbd style={kbdStyle(token)}>@</kbd> scope</span>
<span style={{ marginLeft: 'auto' }}>
<kbd style={kbdStyle(token)}>{isMac ? '⌘' : 'Ctrl'}</kbd>+<kbd style={kbdStyle(token)}>K</kbd>
</span>
@ -459,12 +569,138 @@ function kbdStyle(token: GlobalToken): React.CSSProperties {
};
}
const GROUP_HEADER_STYLE: React.CSSProperties = {
padding: '8px 16px 4px',
fontSize: 11,
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: '0.05em',
};
/** Render a group of command items with a header */
function CommandGroupSection({
groupKey,
items,
flatIndexRef,
selectedIndex,
favoritePaths,
token,
onSelect,
onHover,
}: {
groupKey: string;
items: CommandItem[];
flatIndexRef: { current: number };
selectedIndex: number;
favoritePaths: Set<string>;
token: GlobalToken;
onSelect: (item: FlatItem) => void;
onHover: (idx: number) => void;
}) {
const label =
groupKey === 'recent'
? 'Recent'
: groupKey === 'favorites'
? 'Favorites'
: CATEGORY_LABELS[groupKey as CommandCategory] ?? groupKey;
return (
<div>
<div style={{ ...GROUP_HEADER_STYLE, color: token.colorTextSecondary }}>
{label}
<Text
type="secondary"
style={{ float: 'right', fontSize: 11, fontWeight: 400, textTransform: 'none', letterSpacing: 0 }}
>
{items.length} {items.length === 1 ? 'result' : 'results'}
</Text>
</div>
{items.map((item) => {
const idx = flatIndexRef.current++;
const isFav = favoritePaths.has(item.path);
return (
<ResultRow
key={item.id}
index={idx}
selected={idx === selectedIndex}
icon={ICON_MAP[item.icon ?? ''] ?? <SearchOutlined />}
title={item.title}
description={item.description}
badge={
<>
{isFav && <StarFilled style={{ fontSize: 10, color: '#faad14' }} />}
{item.category === 'action' && (
<ThunderboltOutlined style={{ fontSize: 10, color: token.colorWarning }} />
)}
</>
}
subtitle={item.group}
token={token}
onSelect={() => onSelect({ type: 'command', item })}
onHover={() => onHover(idx)}
/>
);
})}
</div>
);
}
/** Render a group of entity results with a header */
function EntityGroupSection({
entityType,
items,
flatIndexRef,
selectedIndex,
token,
onSelect,
onHover,
}: {
entityType: string;
items: EntityResult[];
flatIndexRef: { current: number };
selectedIndex: number;
token: GlobalToken;
onSelect: (item: FlatItem) => void;
onHover: (idx: number) => void;
}) {
return (
<div>
<div style={{ ...GROUP_HEADER_STYLE, color: token.colorTextSecondary }}>
{entityType}s
<Text
type="secondary"
style={{ float: 'right', fontSize: 11, fontWeight: 400, textTransform: 'none', letterSpacing: 0 }}
>
{items.length} {items.length === 1 ? 'result' : 'results'}
</Text>
</div>
{items.map((item) => {
const idx = flatIndexRef.current++;
return (
<ResultRow
key={item.id}
index={idx}
selected={idx === selectedIndex}
icon={ICON_MAP[item.icon ?? ''] ?? <SearchOutlined />}
title={item.title}
subtitle={item.subtitle || item.entityType}
token={token}
onSelect={() => onSelect({ type: 'entity', item })}
onHover={() => onHover(idx)}
/>
);
})}
</div>
);
}
/** Single result row */
function ResultRow({
index,
selected,
icon,
title,
description,
subtitle,
badge,
token,
@ -475,6 +711,7 @@ function ResultRow({
selected: boolean;
icon: React.ReactNode;
title: string;
description?: string;
subtitle?: string;
badge?: React.ReactNode;
token: GlobalToken;
@ -501,8 +738,34 @@ function ResultRow({
<span style={{ fontSize: 16, color: token.colorTextSecondary, flexShrink: 0, width: 20, textAlign: 'center' }}>
{icon}
</span>
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', color: token.colorText, fontSize: 14 }}>
{title}
<span style={{ flex: 1, overflow: 'hidden', minWidth: 0 }}>
<span
style={{
display: 'block',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
color: token.colorText,
fontSize: 14,
}}
>
{title}
</span>
{description && (
<span
style={{
display: 'block',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
color: token.colorTextTertiary,
fontSize: 12,
lineHeight: '16px',
}}
>
{description}
</span>
)}
</span>
{badge}
{subtitle && (

View File

@ -12,6 +12,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'nav-dashboard',
title: 'Dashboard',
description: 'Platform overview with key metrics and activity',
group: 'General',
path: '/app',
icon: 'DashboardOutlined',
@ -21,6 +22,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'nav-users',
title: 'Users',
description: 'Manage user accounts, roles, and permissions',
group: 'People & Access',
path: '/app/users',
icon: 'TeamOutlined',
@ -30,6 +32,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'nav-people',
title: 'People',
description: 'CRM contacts directory and engagement tracking',
group: 'People & Access',
path: '/app/people',
icon: 'ContactsOutlined',
@ -41,6 +44,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'nav-settings',
title: 'Settings',
description: 'Global platform configuration and preferences',
group: 'General',
path: '/app/settings',
icon: 'SettingOutlined',
@ -53,6 +57,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'nav-social-dashboard',
title: 'Social Dashboard',
description: 'Community engagement overview and social stats',
group: 'Social',
path: '/app/social',
icon: 'TeamOutlined',
@ -64,6 +69,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'nav-social-graph',
title: 'Social Graph',
description: 'Visualize connections between community members',
group: 'Social',
path: '/app/social/graph',
icon: 'BranchesOutlined',
@ -75,6 +81,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'nav-social-moderation',
title: 'Social Moderation',
description: 'Review flagged content and user reports',
group: 'Social',
path: '/app/social/moderation',
icon: 'MessageOutlined',
@ -88,6 +95,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'nav-campaigns',
title: 'Campaigns',
description: 'Create and manage advocacy email campaigns',
group: 'Advocacy',
path: '/app/campaigns',
icon: 'SendOutlined',
@ -98,6 +106,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'nav-campaign-review',
title: 'Campaign Review',
description: 'Moderate and approve pending campaign submissions',
group: 'Advocacy',
path: '/app/campaign-moderation',
icon: 'FileTextOutlined',
@ -108,6 +117,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'nav-representatives',
title: 'Representatives',
description: 'Browse and manage elected official data cache',
group: 'Advocacy',
path: '/app/representatives',
icon: 'IdcardOutlined',
@ -118,6 +128,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'nav-outgoing-emails',
title: 'Outgoing Emails',
description: 'Monitor the advocacy email sending queue',
group: 'Advocacy',
path: '/app/email-queue',
icon: 'MailOutlined',
@ -128,6 +139,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'nav-responses',
title: 'Responses',
description: 'Moderate public responses and feedback',
group: 'Advocacy',
path: '/app/responses',
icon: 'MessageOutlined',
@ -138,6 +150,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'nav-effectiveness',
title: 'Effectiveness',
description: 'Campaign performance analytics and metrics',
group: 'Advocacy',
path: '/app/influence/effectiveness',
icon: 'LineChartOutlined',
@ -150,6 +163,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'nav-newsletter',
title: 'Newsletter',
description: 'Manage mailing lists and broadcast emails',
group: 'Broadcast',
path: '/app/listmonk',
icon: 'MailOutlined',
@ -161,6 +175,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'nav-email-templates',
title: 'Email Templates',
description: 'Design reusable email templates',
group: 'Broadcast',
path: '/app/email-templates',
icon: 'FileTextOutlined',
@ -171,6 +186,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'nav-sms-setup',
title: 'SMS Setup',
description: 'Configure the Termux SMS bridge device',
group: 'Broadcast',
path: '/app/sms/setup',
icon: 'SettingOutlined',
@ -182,6 +198,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'nav-sms-dashboard',
title: 'SMS Dashboard',
description: 'Text messaging overview and delivery stats',
group: 'Broadcast',
path: '/app/sms',
icon: 'PhoneOutlined',
@ -192,6 +209,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'nav-sms-contacts',
title: 'SMS Contacts',
description: 'Manage contact lists and phone numbers',
group: 'Broadcast',
path: '/app/sms/contacts',
icon: 'TeamOutlined',
@ -202,6 +220,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'nav-sms-campaigns',
title: 'SMS Campaigns',
description: 'Create and send bulk text message campaigns',
group: 'Broadcast',
path: '/app/sms/campaigns',
icon: 'SendOutlined',
@ -212,6 +231,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'nav-sms-conversations',
title: 'SMS Threads',
description: 'View and reply to text message conversations',
group: 'Broadcast',
path: '/app/sms/conversations',
icon: 'MessageOutlined',
@ -219,11 +239,23 @@ export const commandRegistry: CommandItem[] = [
category: 'navigation',
featureFlag: 'enableSms',
},
{
id: 'nav-sms-templates',
title: 'SMS Templates',
description: 'Manage reusable text message templates',
group: 'Broadcast',
path: '/app/sms/templates',
icon: 'FileTextOutlined',
keywords: ['sms template', 'message template', 'canned response'],
category: 'navigation',
featureFlag: 'enableSms',
},
// ── Navigation: Web ───────────────────────────────────
{
id: 'nav-landing-pages',
title: 'Landing Pages',
description: 'Build and manage website landing pages',
group: 'Web',
path: '/app/pages',
icon: 'FileTextOutlined',
@ -234,6 +266,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'nav-navigation',
title: 'Navigation',
description: 'Configure public site header and menu links',
group: 'Web',
path: '/app/navigation',
icon: 'GlobalOutlined',
@ -243,6 +276,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'nav-documentation',
title: 'Documentation',
description: 'Manage MkDocs knowledge base articles',
group: 'Web',
path: '/app/docs',
icon: 'BookOutlined',
@ -252,6 +286,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'nav-docs-analytics',
title: 'Docs Analytics',
description: 'View documentation page views and metrics',
group: 'Web',
path: '/app/docs/analytics',
icon: 'BarChartOutlined',
@ -261,6 +296,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'nav-docs-comments',
title: 'Docs Comments',
description: 'Moderate documentation feedback and discussion',
group: 'Web',
path: '/app/docs/comments',
icon: 'MessageOutlined',
@ -270,6 +306,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'nav-docs-settings',
title: 'Docs Settings',
description: 'Configure MkDocs site and theme options',
group: 'Web',
path: '/app/docs/settings',
icon: 'SettingOutlined',
@ -279,6 +316,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'nav-code-editor',
title: 'Code Editor',
description: 'Open the web-based Code Server IDE',
group: 'Web',
path: '/app/code',
icon: 'CodeOutlined',
@ -291,6 +329,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'nav-locations',
title: 'Locations',
description: 'Manage addresses, geocoding, and CSV imports',
group: 'Map',
path: '/app/map',
icon: 'EnvironmentOutlined',
@ -301,6 +340,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'nav-data-quality',
title: 'Data Quality',
description: 'Geocoding quality metrics and data health',
group: 'Map',
path: '/app/map/data-quality',
icon: 'BarChartOutlined',
@ -311,6 +351,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'nav-shifts',
title: 'Shifts',
description: 'Schedule volunteer shifts and manage signups',
group: 'Map',
path: '/app/map/shifts',
icon: 'ScheduleOutlined',
@ -321,6 +362,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'nav-areas',
title: 'Areas',
description: 'Draw and manage canvassing territory boundaries',
group: 'Map',
path: '/app/map/cuts',
icon: 'ScissorOutlined',
@ -331,6 +373,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'nav-canvassing',
title: 'Canvassing',
description: 'View volunteer sessions, visits, and routes',
group: 'Map',
path: '/app/map/canvass',
icon: 'TeamOutlined',
@ -341,6 +384,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'nav-map-settings',
title: 'Map Settings',
description: 'Configure map center, zoom, and geocoding provider',
group: 'Map',
path: '/app/map/settings',
icon: 'SettingOutlined',
@ -353,6 +397,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'nav-media-library',
title: 'Library',
description: 'Upload and manage video files',
group: 'Media',
path: '/app/media/library',
icon: 'FolderOutlined',
@ -363,6 +408,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'nav-media-analytics',
title: 'Media Analytics',
description: 'Video views, watch time, and engagement stats',
group: 'Media',
path: '/app/media/analytics',
icon: 'BarChartOutlined',
@ -373,6 +419,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'nav-media-curated',
title: 'Curated',
description: 'Manage featured playlists and collections',
group: 'Media',
path: '/app/media/curated',
icon: 'OrderedListOutlined',
@ -383,6 +430,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'nav-media-moderation',
title: 'Media Moderation',
description: 'Review and moderate video comments',
group: 'Media',
path: '/app/media/moderation',
icon: 'MessageOutlined',
@ -393,6 +441,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'nav-gallery-ads',
title: 'Gallery Ads',
description: 'Manage banner advertisements in the gallery',
group: 'Payments',
path: '/app/payments/ads',
icon: 'PictureOutlined',
@ -404,6 +453,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'nav-ad-analytics',
title: 'Ad Analytics',
description: 'Track ad impressions, clicks, and performance',
group: 'Payments',
path: '/app/payments/ads/analytics',
icon: 'LineChartOutlined',
@ -415,6 +465,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'nav-media-jobs',
title: 'Processing Jobs',
description: 'Monitor video encoding and processing tasks',
group: 'Media',
path: '/app/media/jobs',
icon: 'HistoryOutlined',
@ -427,6 +478,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'nav-payments-dashboard',
title: 'Payments Dashboard',
description: 'Revenue overview and transaction summary',
group: 'Payments',
path: '/app/payments',
icon: 'DashboardOutlined',
@ -438,6 +490,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'nav-plans',
title: 'Plans',
description: 'Create subscription plans and pricing tiers',
group: 'Payments',
path: '/app/payments/plans',
icon: 'TagOutlined',
@ -449,6 +502,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'nav-subscribers',
title: 'Subscribers',
description: 'View and manage paying subscribers',
group: 'Payments',
path: '/app/payments/subscribers',
icon: 'CrownOutlined',
@ -460,6 +514,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'nav-products',
title: 'Products',
description: 'Manage store products and merchandise',
group: 'Payments',
path: '/app/payments/products',
icon: 'ShoppingOutlined',
@ -471,6 +526,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'nav-donation-pages',
title: 'Donation Pages',
description: 'Create fundraising and donation pages',
group: 'Payments',
path: '/app/payments/donation-pages',
icon: 'HeartOutlined',
@ -482,6 +538,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'nav-donation-orders',
title: 'Donation Orders',
description: 'Track received donations and transaction history',
group: 'Payments',
path: '/app/payments/donations',
icon: 'DollarOutlined',
@ -493,6 +550,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'nav-payment-settings',
title: 'Payment Settings',
description: 'Configure payment gateway and Stripe integration',
group: 'Payments',
path: '/app/payments/settings',
icon: 'SettingOutlined',
@ -506,6 +564,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'nav-tunnel',
title: 'Tunnel',
description: 'Manage Pangolin reverse tunnel for public access',
group: 'Services',
path: '/app/tunnel',
icon: 'CloudServerOutlined',
@ -516,6 +575,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'nav-monitoring',
title: 'Monitoring',
description: 'Prometheus metrics, Grafana dashboards, and alerts',
group: 'Services',
path: '/app/observability',
icon: 'LineChartOutlined',
@ -526,6 +586,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'nav-database',
title: 'Database',
description: 'Browse database tables with NocoDB',
group: 'Services',
path: '/app/services/nocodb',
icon: 'DatabaseOutlined',
@ -536,6 +597,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'nav-vault',
title: 'Vault',
description: 'Vaultwarden password manager for the team',
group: 'Services',
path: '/app/services/vaultwarden',
icon: 'LockOutlined',
@ -546,6 +608,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'nav-mailhog',
title: 'MailHog',
description: 'Capture and inspect test emails in development',
group: 'Services',
path: '/app/services/mailhog',
icon: 'MailOutlined',
@ -556,6 +619,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'nav-workflows',
title: 'Workflows',
description: 'n8n workflow automation and integrations',
group: 'Services',
path: '/app/services/n8n',
icon: 'ApiOutlined',
@ -566,6 +630,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'nav-git',
title: 'Git',
description: 'Gitea self-hosted Git repositories',
group: 'Services',
path: '/app/services/gitea',
icon: 'BranchesOutlined',
@ -576,6 +641,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'nav-whiteboard',
title: 'Whiteboard',
description: 'Excalidraw collaborative drawing board',
group: 'Services',
path: '/app/services/excalidraw',
icon: 'EditOutlined',
@ -586,6 +652,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'nav-team-chat',
title: 'Team Chat',
description: 'Rocket.Chat team messaging and channels',
group: 'Services',
path: '/app/services/rocketchat',
icon: 'MessageOutlined',
@ -596,6 +663,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'nav-events',
title: 'Events',
description: 'Gancio event calendar and scheduling',
group: 'Services',
path: '/app/services/gancio',
icon: 'CalendarOutlined',
@ -606,6 +674,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'nav-jitsi',
title: 'Video Meetings',
description: 'Jitsi Meet video conferencing setup',
group: 'Services',
path: '/app/services/jitsi',
icon: 'PlaySquareOutlined',
@ -617,6 +686,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'nav-qr-codes',
title: 'QR Codes',
description: 'Generate QR code images for links',
group: 'Services',
path: '/app/services/miniqr',
icon: 'QrcodeOutlined',
@ -629,6 +699,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'settings-general',
title: 'General Settings',
description: 'Organization name, branding, and basic config',
group: 'Settings',
path: '/app/settings',
icon: 'SettingOutlined',
@ -640,6 +711,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'settings-features',
title: 'Feature Flags',
description: 'Toggle platform modules on or off',
group: 'Settings',
path: '/app/settings',
icon: 'SettingOutlined',
@ -651,6 +723,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'settings-theme',
title: 'Theme Settings',
description: 'Customize colors, appearance, and branding',
group: 'Settings',
path: '/app/settings',
icon: 'SettingOutlined',
@ -662,6 +735,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'settings-email',
title: 'Email Settings',
description: 'SMTP server and email sender configuration',
group: 'Settings',
path: '/app/settings',
icon: 'MailOutlined',
@ -673,6 +747,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'settings-provisioning',
title: 'User Provisioning',
description: 'Auto-create accounts in Gitea, Vaultwarden, and more',
group: 'Settings',
path: '/app/settings',
icon: 'TeamOutlined',
@ -684,6 +759,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'settings-navigation',
title: 'Navigation Settings',
description: 'Configure public site header links and menus',
group: 'Settings',
path: '/app/navigation',
icon: 'GlobalOutlined',
@ -693,6 +769,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'settings-map',
title: 'Map Settings',
description: 'Set map center, zoom, and geocoding provider',
group: 'Settings',
path: '/app/map/settings',
icon: 'EnvironmentOutlined',
@ -703,6 +780,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'settings-payments',
title: 'Payment Settings',
description: 'Stripe gateway and payment configuration',
group: 'Settings',
path: '/app/payments/settings',
icon: 'DollarOutlined',
@ -716,6 +794,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'action-create-campaign',
title: 'Create Campaign',
description: 'Start a new advocacy email campaign',
group: 'Actions',
path: '/app/campaigns',
icon: 'SendOutlined',
@ -727,6 +806,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'action-add-location',
title: 'Add Location',
description: 'Add a new address to the map database',
group: 'Actions',
path: '/app/map',
icon: 'EnvironmentOutlined',
@ -738,6 +818,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'action-new-shift',
title: 'New Shift',
description: 'Schedule a new volunteer shift',
group: 'Actions',
path: '/app/map/shifts',
icon: 'ScheduleOutlined',
@ -749,6 +830,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'action-new-landing-page',
title: 'New Landing Page',
description: 'Create a new landing page with the editor',
group: 'Actions',
path: '/app/pages',
icon: 'FileTextOutlined',
@ -760,6 +842,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'action-new-email-template',
title: 'New Email Template',
description: 'Create a reusable email template',
group: 'Actions',
path: '/app/email-templates',
icon: 'FileTextOutlined',
@ -771,6 +854,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'action-new-product',
title: 'New Product',
description: 'Add a new product to the store',
group: 'Actions',
path: '/app/payments/products',
icon: 'ShoppingOutlined',
@ -783,6 +867,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'action-add-contact',
title: 'Add Contact',
description: 'Create a new CRM contact record',
group: 'Actions',
path: '/app/people',
icon: 'ContactsOutlined',
@ -795,6 +880,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'action-new-plan',
title: 'New Plan',
description: 'Create a new subscription pricing plan',
group: 'Actions',
path: '/app/payments/plans',
icon: 'TagOutlined',
@ -807,6 +893,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'action-new-donation-page',
title: 'New Donation Page',
description: 'Create a new fundraising page',
group: 'Actions',
path: '/app/payments/donation-pages',
icon: 'HeartOutlined',
@ -819,6 +906,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'action-new-sms-campaign',
title: 'New SMS Campaign',
description: 'Start a new bulk text message campaign',
group: 'Actions',
path: '/app/sms/campaigns',
icon: 'PhoneOutlined',
@ -827,4 +915,16 @@ export const commandRegistry: CommandItem[] = [
featureFlag: 'enableSms',
navigationState: { openCreate: true },
},
{
id: 'action-new-sms-template',
title: 'New SMS Template',
description: 'Create a reusable text message template',
group: 'Actions',
path: '/app/sms/templates',
icon: 'FileTextOutlined',
keywords: ['create sms template', 'new message template', 'canned response'],
category: 'action',
featureFlag: 'enableSms',
navigationState: { openCreate: true },
},
];

View File

@ -0,0 +1,69 @@
/**
* Search scope definitions for the command palette.
* Users type `@prefix:query` to restrict results to a specific domain.
*/
export interface ScopeDefinition {
/** The prefix users type, e.g. "user" for `@user:` */
prefix: string;
/** Human-readable label shown in the scope list */
label: string;
/** Command registry groups to include */
groups: string[];
/** Entity types to include (from useEntitySearch configs) */
entityTypes?: string[];
}
export const SCOPE_DEFINITIONS: ScopeDefinition[] = [
{ prefix: 'user', label: 'People & Access', groups: ['People & Access'], entityTypes: ['User', 'Person'] },
{ prefix: 'campaign', label: 'Campaigns', groups: ['Advocacy'], entityTypes: ['Campaign'] },
{ prefix: 'sms', label: 'Broadcast (SMS)', groups: ['Broadcast'] },
{ prefix: 'settings', label: 'Settings', groups: ['Settings'] },
{ prefix: 'media', label: 'Media', groups: ['Media'] },
{ prefix: 'map', label: 'Map & Locations', groups: ['Map'], entityTypes: ['Location', 'Shift'] },
{ prefix: 'web', label: 'Web & Docs', groups: ['Web'], entityTypes: ['Landing Page', 'Email Template', 'Doc File'] },
{ prefix: 'service', label: 'Services', groups: ['Services'] },
{ prefix: 'payment', label: 'Payments', groups: ['Payments'], entityTypes: ['Product', 'Donation Page', 'Plan'] },
{ prefix: 'social', label: 'Social', groups: ['Social'] },
];
const SCOPE_MAP = new Map(SCOPE_DEFINITIONS.map((s) => [s.prefix, s]));
export interface ParsedQuery {
/** The active scope, or null if no valid scope prefix */
scope: ScopeDefinition | null;
/** The query string with scope prefix stripped */
strippedQuery: string;
/** Whether the user typed a bare `@` (show scope list) */
showScopeList: boolean;
}
/**
* Parse a raw query string for scope prefixes.
*
* - `@user:john` scope=user, strippedQuery="john"
* - `@user:` scope=user, strippedQuery=""
* - `@` showScopeList=true
* - `@foo:bar` invalid prefix, treated as normal search
* - `hello` no scope, strippedQuery="hello"
*/
export function parseQuery(raw: string): ParsedQuery {
const trimmed = raw.trim();
// Bare `@` — show scope selector
if (trimmed === '@') {
return { scope: null, strippedQuery: '', showScopeList: true };
}
// Match `@prefix:rest`
const match = trimmed.match(/^@(\w+):(.*)$/);
if (match) {
const scope = SCOPE_MAP.get(match[1]!.toLowerCase()) ?? null;
if (scope) {
return { scope, strippedQuery: match[2]!.trimStart(), showScopeList: false };
}
// Invalid prefix — fall through to normal search
}
return { scope: null, strippedQuery: trimmed, showScopeList: false };
}

View File

@ -5,6 +5,7 @@ export type CommandCategory = 'navigation' | 'settings' | 'action';
export interface CommandItem {
id: string;
title: string;
description?: string;
group: string;
path: string;
keywords: string[];

View File

@ -1,4 +1,5 @@
import { useMemo } from 'react';
import { useLocation } from 'react-router-dom';
import MiniSearch from 'minisearch';
import { commandRegistry } from './registry';
import type { CommandItem } from './types';
@ -11,6 +12,33 @@ interface IndexedItem extends CommandItem {
keywordsJoined: string;
}
/** Map current path prefixes to the command groups they relate to */
const CONTEXT_PREFIXES: Record<string, string[]> = {
'/app/campaigns': ['Advocacy'],
'/app/campaign-moderation': ['Advocacy'],
'/app/representatives': ['Advocacy'],
'/app/email-queue': ['Advocacy'],
'/app/responses': ['Advocacy'],
'/app/influence': ['Advocacy'],
'/app/sms': ['Broadcast'],
'/app/listmonk': ['Broadcast'],
'/app/email-templates': ['Broadcast'],
'/app/map': ['Map'],
'/app/media': ['Media'],
'/app/pages': ['Web'],
'/app/docs': ['Web'],
'/app/code': ['Web'],
'/app/navigation': ['Web'],
'/app/payments': ['Payments'],
'/app/social': ['Social'],
'/app/services': ['Services'],
'/app/tunnel': ['Services'],
'/app/observability': ['Services'],
'/app/settings': ['Settings'],
'/app/users': ['People & Access'],
'/app/people': ['People & Access'],
};
/**
* Builds a MiniSearch index from the command registry,
* filtered by the current user's roles and enabled feature flags.
@ -18,6 +46,7 @@ interface IndexedItem extends CommandItem {
export function useCommandIndex() {
const { settings } = useSettingsStore();
const { user } = useAuthStore();
const location = useLocation();
const { search, allItems } = useMemo(() => {
const userRoles = user ? getUserRoles(user) : [];
@ -67,16 +96,55 @@ export function useCommandIndex() {
// Create lookup map for fast retrieval
const itemMap = new Map(filtered.map((item) => [item.id, item]));
const searchFn = (query: string): CommandItem[] => {
// Resolve contextual groups from current path
const currentPath = location.pathname;
const contextGroups: string[] = [];
if (currentPath !== '/app') {
for (const [prefix, groups] of Object.entries(CONTEXT_PREFIXES)) {
if (currentPath.startsWith(prefix)) {
contextGroups.push(...groups);
break;
}
}
}
const searchFn = (query: string, scopeGroups?: string[]): CommandItem[] => {
if (!query || query.length < 1) return [];
const results = idx.search(query);
return results
.map((r) => itemMap.get(r.id as string))
.filter((item): item is CommandItem => !!item);
// Map to items with scores
const scored = results
.map((r) => {
const item = itemMap.get(r.id as string);
if (!item) return null;
// Apply scope filter: skip items not in scope groups
if (scopeGroups && scopeGroups.length > 0) {
if (!scopeGroups.includes(item.group)) return null;
}
// Apply contextual boost
let boostedScore = r.score;
if (contextGroups.length > 0) {
if (currentPath.startsWith(item.path) && item.path !== '/app') {
boostedScore += 50;
} else if (contextGroups.includes(item.group)) {
boostedScore += 25;
}
}
return { item, score: boostedScore };
})
.filter((entry): entry is { item: CommandItem; score: number } => !!entry);
// Re-sort by boosted score (descending)
scored.sort((a, b) => b.score - a.score);
return scored.map((s) => s.item);
};
return { search: searchFn, allItems: filtered };
}, [settings, user]);
}, [settings, user, location.pathname]);
return { search, allItems };
}

View File

@ -130,8 +130,10 @@ const entityConfigs: EntitySearchConfig[] = [
/**
* Debounced API entity search. Fires after 300ms when query is 2+ chars.
* Uses AbortController to cancel in-flight requests on query change.
*
* @param scopeEntityTypes when provided, only search these entity types
*/
export function useEntitySearch(query: string) {
export function useEntitySearch(query: string, scopeEntityTypes?: string[]) {
const [results, setResults] = useState<EntityResult[]>([]);
const [loading, setLoading] = useState(false);
const abortRef = useRef<AbortController | null>(null);
@ -154,8 +156,12 @@ export function useEntitySearch(query: string) {
const timer = setTimeout(async () => {
try {
// Filter configs by feature flags + roles
// Filter configs by feature flags + roles + scope
const activeConfigs = entityConfigs.filter((cfg) => {
// Scope filter: only include entity types in the active scope
if (scopeEntityTypes && scopeEntityTypes.length > 0) {
if (!scopeEntityTypes.includes(cfg.entityType)) return false;
}
if (cfg.featureFlag && settings) {
const val = (settings as unknown as Record<string, unknown>)[cfg.featureFlag];
const defaultsOff = ['enablePayments', 'enableSms', 'enablePeople', 'enableSocial', 'enableChat', 'enableMeet'];
@ -211,7 +217,7 @@ export function useEntitySearch(query: string) {
clearTimeout(timer);
controller.abort();
};
}, [query, settings, user]);
}, [query, settings, user, scopeEntityTypes]);
return { results, loading };
}