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 { useState, useEffect, useRef, useMemo, useCallback } from 'react';
import { useNavigate, useLocation } from 'react-router-dom'; 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 { import {
SearchOutlined, SearchOutlined,
DashboardOutlined, DashboardOutlined,
@ -42,11 +42,14 @@ import {
FileMarkdownOutlined, FileMarkdownOutlined,
ContactsOutlined, ContactsOutlined,
ScissorOutlined, ScissorOutlined,
StarFilled,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { useCommandPaletteStore } from '@/stores/command-palette.store'; import { useCommandPaletteStore } from '@/stores/command-palette.store';
import { useAuthStore } from '@/stores/auth.store'; import { useAuthStore } from '@/stores/auth.store';
import { useFavoritesStore } from '@/stores/favorites.store';
import { useCommandIndex } from './useCommandIndex'; import { useCommandIndex } from './useCommandIndex';
import { useEntitySearch } from './useEntitySearch'; import { useEntitySearch } from './useEntitySearch';
import { parseQuery, SCOPE_DEFINITIONS } from './scopeFilter';
import type { CommandItem, EntityResult, CommandCategory } from './types'; import type { CommandItem, EntityResult, CommandCategory } from './types';
const { Text } = Typography; const { Text } = Typography;
@ -108,6 +111,7 @@ type FlatItem =
export default function CommandPalette() { export default function CommandPalette() {
const { isOpen, close, recentItems, addRecent } = useCommandPaletteStore(); const { isOpen, close, recentItems, addRecent } = useCommandPaletteStore();
const { isAuthenticated } = useAuthStore(); const { isAuthenticated } = useAuthStore();
const { favorites } = useFavoritesStore();
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
const { token } = theme.useToken(); const { token } = theme.useToken();
@ -119,8 +123,14 @@ export default function CommandPalette() {
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLDivElement>(null); const listRef = useRef<HTMLDivElement>(null);
// Parse query for scope prefix
const parsed = useMemo(() => parseQuery(query), [query]);
const { search, allItems } = useCommandIndex(); 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 // Only render inside admin panel
const isAdminRoute = location.pathname.startsWith('/app'); const isAdminRoute = location.pathname.startsWith('/app');
@ -156,37 +166,60 @@ export default function CommandPalette() {
// Build command results from search // Build command results from search
const commandResults = useMemo(() => { const commandResults = useMemo(() => {
if (!query) return []; if (!parsed.strippedQuery && !parsed.scope) return [];
return search(query); // If scope is active but query is empty, show all items in that scope's groups
}, [query, search]); 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(() => { const recentCommandItems = useMemo(() => {
if (query) return []; if (query) return [];
const favIds = new Set(favoriteCommandItems.map((i) => i.id));
return recentItems return recentItems
.map((id) => allItems.find((item) => item.id === id)) .map((id) => allItems.find((item) => item.id === id))
.filter((item): item is CommandItem => !!item); .filter((item): item is CommandItem => !!item && !favIds.has(item.id));
}, [query, recentItems, allItems]); }, [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 flatList = useMemo((): FlatItem[] => {
const items: FlatItem[] = []; const items: FlatItem[] = [];
if (!query) { if (!query) {
// Show recents for (const item of favoriteCommandItems) {
items.push({ type: 'command', item });
}
for (const item of recentCommandItems) { for (const item of recentCommandItems) {
items.push({ type: 'command', item }); items.push({ type: 'command', item });
} }
} else if (parsed.showScopeList) {
// No items when showing scope selector
} else { } else {
// Show search results // Pages (navigation) first, then actions
for (const item of commandResults) { const pages = commandResults.filter((i) => i.category === 'navigation');
items.push({ type: 'command', item }); const actions = commandResults.filter((i) => i.category === 'action');
} const settings = commandResults.filter((i) => i.category === 'settings');
for (const item of entityResults) { for (const item of pages) items.push({ type: 'command', item });
items.push({ type: 'entity', 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; return items;
}, [query, recentCommandItems, commandResults, entityResults]); }, [query, favoriteCommandItems, recentCommandItems, parsed.showScopeList, commandResults, entityResults]);
// Clamp selected index // Clamp selected index
useEffect(() => { useEffect(() => {
@ -233,18 +266,39 @@ export default function CommandPalette() {
[flatList, selectedIndex, handleSelect], [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 // 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 groupedCommands = useMemo(() => {
const items = query ? commandResults : recentCommandItems; const unordered = new Map<string, CommandItem[]>();
const groups = new Map<string, CommandItem[]>();
for (const item of items) { if (!query) {
const key = query ? item.category : 'recent'; // No-query mode: favorites then recents
if (!groups.has(key)) groups.set(key, []); if (favoriteCommandItems.length > 0) {
groups.get(key)!.push(item); 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 // Group entities by type
const groupedEntities = useMemo(() => { const groupedEntities = useMemo(() => {
@ -263,6 +317,11 @@ export default function CommandPalette() {
const isMac = navigator.platform?.toLowerCase().includes('mac'); const isMac = navigator.platform?.toLowerCase().includes('mac');
// Dynamic placeholder
const placeholder = parsed.scope
? `Search ${parsed.scope.label.toLowerCase()}...`
: 'Search pages, settings, data...';
return ( return (
<Modal <Modal
open={isOpen} open={isOpen}
@ -295,6 +354,16 @@ export default function CommandPalette() {
}} }}
> >
<SearchOutlined style={{ color: token.colorTextSecondary, fontSize: 16 }} /> <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 <input
ref={inputRef} ref={inputRef}
value={query} value={query}
@ -302,7 +371,7 @@ export default function CommandPalette() {
setQuery(e.target.value); setQuery(e.target.value);
setSelectedIndex(0); setSelectedIndex(0);
}} }}
placeholder="Search pages, settings, data..." placeholder={placeholder}
style={{ style={{
flex: 1, flex: 1,
border: 'none', border: 'none',
@ -325,9 +394,9 @@ export default function CommandPalette() {
padding: '4px 0', padding: '4px 0',
}} }}
> >
{/* Command groups */} {/* Scope selector (when user types bare @) */}
{Array.from(groupedCommands.entries()).map(([groupKey, items]) => ( {parsed.showScopeList && (
<div key={groupKey}> <div>
<div <div
style={{ style={{
padding: '8px 16px 4px', padding: '8px 16px 4px',
@ -338,85 +407,124 @@ export default function CommandPalette() {
color: token.colorTextSecondary, color: token.colorTextSecondary,
}} }}
> >
{groupKey === 'recent' Filter by scope
? '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>
</div> </div>
{items.map((item) => { {SCOPE_DEFINITIONS.map((scope) => (
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}>
<div <div
key={scope.prefix}
onClick={() => {
setQuery(`@${scope.prefix}:`);
setSelectedIndex(0);
inputRef.current?.focus();
}}
style={{ style={{
padding: '8px 16px 4px', padding: '8px 16px',
fontSize: 11, display: 'flex',
fontWeight: 600, alignItems: 'center',
textTransform: 'uppercase', gap: 10,
letterSpacing: '0.05em', cursor: 'pointer',
color: token.colorTextSecondary, 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 <span
<Text style={{
type="secondary" fontFamily: 'monospace',
style={{ float: 'right', fontSize: 11, fontWeight: 400, textTransform: 'none', letterSpacing: 0 }} fontSize: 13,
color: token.colorPrimary,
width: 90,
flexShrink: 0,
}}
> >
{items.length} {items.length === 1 ? 'result' : 'results'} @{scope.prefix}:
</Text> </span>
<span style={{ color: token.colorText, fontSize: 14 }}>{scope.label}</span>
</div> </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> </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 && ( {flatList.length === 0 && !query && (
<div style={{ padding: '24px 16px', textAlign: 'center' }}> <div style={{ padding: '24px 16px', textAlign: 'center' }}>
<Text type="secondary">Type to search pages, settings, and data</Text> <Text type="secondary">Type to search pages, settings, and data</Text>
@ -433,11 +541,13 @@ export default function CommandPalette() {
gap: 16, gap: 16,
fontSize: 12, fontSize: 12,
color: token.colorTextTertiary, color: token.colorTextTertiary,
flexWrap: 'wrap',
}} }}
> >
<span><kbd style={kbdStyle(token)}></kbd> navigate</span> <span><kbd style={kbdStyle(token)}></kbd> navigate</span>
<span><kbd style={kbdStyle(token)}></kbd> open</span> <span><kbd style={kbdStyle(token)}></kbd> open</span>
<span><kbd style={kbdStyle(token)}>esc</kbd> close</span> <span><kbd style={kbdStyle(token)}>esc</kbd> close</span>
<span><kbd style={kbdStyle(token)}>@</kbd> scope</span>
<span style={{ marginLeft: 'auto' }}> <span style={{ marginLeft: 'auto' }}>
<kbd style={kbdStyle(token)}>{isMac ? '⌘' : 'Ctrl'}</kbd>+<kbd style={kbdStyle(token)}>K</kbd> <kbd style={kbdStyle(token)}>{isMac ? '⌘' : 'Ctrl'}</kbd>+<kbd style={kbdStyle(token)}>K</kbd>
</span> </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 */ /** Single result row */
function ResultRow({ function ResultRow({
index, index,
selected, selected,
icon, icon,
title, title,
description,
subtitle, subtitle,
badge, badge,
token, token,
@ -475,6 +711,7 @@ function ResultRow({
selected: boolean; selected: boolean;
icon: React.ReactNode; icon: React.ReactNode;
title: string; title: string;
description?: string;
subtitle?: string; subtitle?: string;
badge?: React.ReactNode; badge?: React.ReactNode;
token: GlobalToken; token: GlobalToken;
@ -501,8 +738,34 @@ function ResultRow({
<span style={{ fontSize: 16, color: token.colorTextSecondary, flexShrink: 0, width: 20, textAlign: 'center' }}> <span style={{ fontSize: 16, color: token.colorTextSecondary, flexShrink: 0, width: 20, textAlign: 'center' }}>
{icon} {icon}
</span> </span>
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', color: token.colorText, fontSize: 14 }}> <span style={{ flex: 1, overflow: 'hidden', minWidth: 0 }}>
{title} <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> </span>
{badge} {badge}
{subtitle && ( {subtitle && (

View File

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

View File

@ -1,4 +1,5 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useLocation } from 'react-router-dom';
import MiniSearch from 'minisearch'; import MiniSearch from 'minisearch';
import { commandRegistry } from './registry'; import { commandRegistry } from './registry';
import type { CommandItem } from './types'; import type { CommandItem } from './types';
@ -11,6 +12,33 @@ interface IndexedItem extends CommandItem {
keywordsJoined: string; 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, * Builds a MiniSearch index from the command registry,
* filtered by the current user's roles and enabled feature flags. * filtered by the current user's roles and enabled feature flags.
@ -18,6 +46,7 @@ interface IndexedItem extends CommandItem {
export function useCommandIndex() { export function useCommandIndex() {
const { settings } = useSettingsStore(); const { settings } = useSettingsStore();
const { user } = useAuthStore(); const { user } = useAuthStore();
const location = useLocation();
const { search, allItems } = useMemo(() => { const { search, allItems } = useMemo(() => {
const userRoles = user ? getUserRoles(user) : []; const userRoles = user ? getUserRoles(user) : [];
@ -67,16 +96,55 @@ export function useCommandIndex() {
// Create lookup map for fast retrieval // Create lookup map for fast retrieval
const itemMap = new Map(filtered.map((item) => [item.id, item])); 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 []; if (!query || query.length < 1) return [];
const results = idx.search(query); const results = idx.search(query);
return results
.map((r) => itemMap.get(r.id as string)) // Map to items with scores
.filter((item): item is CommandItem => !!item); 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 }; return { search: searchFn, allItems: filtered };
}, [settings, user]); }, [settings, user, location.pathname]);
return { search, allItems }; return { search, allItems };
} }

View File

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