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:
parent
1f2ce681a6
commit
46fc92fab8
@ -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 && (
|
||||
|
||||
@ -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 },
|
||||
},
|
||||
];
|
||||
|
||||
69
admin/src/components/command-palette/scopeFilter.ts
Normal file
69
admin/src/components/command-palette/scopeFilter.ts
Normal 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 };
|
||||
}
|
||||
@ -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[];
|
||||
|
||||
@ -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 };
|
||||
}
|
||||
|
||||
@ -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 };
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user