diff --git a/admin/src/components/command-palette/CommandPalette.tsx b/admin/src/components/command-palette/CommandPalette.tsx index f4465f5a..144ba626 100644 --- a/admin/src/components/command-palette/CommandPalette.tsx +++ b/admin/src/components/command-palette/CommandPalette.tsx @@ -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(null); const listRef = useRef(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(); - 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(); + + 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(); + 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 ( + {parsed.scope && ( + setQuery(parsed.strippedQuery)} + > + {parsed.scope.label} + + )} - {/* Command groups */} - {Array.from(groupedCommands.entries()).map(([groupKey, items]) => ( -
+ {/* Scope selector (when user types bare @) */} + {parsed.showScopeList && ( +
- {groupKey === 'recent' - ? 'Recent' - : CATEGORY_LABELS[groupKey as CommandCategory] ?? groupKey} - - {items.length} {items.length === 1 ? 'result' : 'results'} - + Filter by scope
- {items.map((item) => { - const idx = flatIndex++; - return ( - } - title={item.title} - badge={item.category === 'action' ? : null} - subtitle={item.group} - token={token} - onSelect={() => handleSelect({ type: 'command', item })} - onHover={() => setSelectedIndex(idx)} - /> - ); - })} -
- ))} - - {/* Entity groups */} - {query && - Array.from(groupedEntities.entries()).map(([entityType, items]) => ( -
+ {SCOPE_DEFINITIONS.map((scope) => (
{ + 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 - - {items.length} {items.length === 1 ? 'result' : 'results'} - + @{scope.prefix}: + + {scope.label}
- {items.map((item) => { - const idx = flatIndex++; - return ( - } - title={item.title} - subtitle={item.subtitle || item.entityType} - token={token} - onSelect={() => handleSelect({ type: 'entity', item })} - onHover={() => setSelectedIndex(idx)} - /> - ); - })} -
- ))} - - {/* Empty state */} - {flatList.length === 0 && !entityLoading && query && ( -
- No results for "{query}" + ))}
)} - {/* 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( + , + ); + flatIndex += items.length; + } + + // Entity groups (only when searching) + if (query && !parsed.showScopeList) { + for (const [entityType, items] of groupedEntities.entries()) { + sections.push( + , + ); + flatIndex += items.length; + } + } + + // Settings last + const settingsItems = groupedCommands.get('settings'); + if (settingsItems) { + sections.push( + , + ); + flatIndex += settingsItems.length; + } + + return sections; + })()} + + {/* Empty state */} + {flatList.length === 0 && !entityLoading && query && !parsed.showScopeList && ( +
+ No results for "{parsed.strippedQuery || query}" +
+ )} + + {/* No query, no recents, no favorites */} {flatList.length === 0 && !query && (
Type to search pages, settings, and data @@ -433,11 +541,13 @@ export default function CommandPalette() { gap: 16, fontSize: 12, color: token.colorTextTertiary, + flexWrap: 'wrap', }} > ↑↓ navigate open esc close + @ scope {isMac ? '⌘' : 'Ctrl'}+K @@ -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; + 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 ( +
+
+ {label} + + {items.length} {items.length === 1 ? 'result' : 'results'} + +
+ {items.map((item) => { + const idx = flatIndexRef.current++; + const isFav = favoritePaths.has(item.path); + return ( + } + title={item.title} + description={item.description} + badge={ + <> + {isFav && } + {item.category === 'action' && ( + + )} + + } + subtitle={item.group} + token={token} + onSelect={() => onSelect({ type: 'command', item })} + onHover={() => onHover(idx)} + /> + ); + })} +
+ ); +} + +/** 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 ( +
+
+ {entityType}s + + {items.length} {items.length === 1 ? 'result' : 'results'} + +
+ {items.map((item) => { + const idx = flatIndexRef.current++; + return ( + } + title={item.title} + subtitle={item.subtitle || item.entityType} + token={token} + onSelect={() => onSelect({ type: 'entity', item })} + onHover={() => onHover(idx)} + /> + ); + })} +
+ ); +} + /** 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({ {icon} - - {title} + + + {title} + + {description && ( + + {description} + + )} {badge} {subtitle && ( diff --git a/admin/src/components/command-palette/registry.ts b/admin/src/components/command-palette/registry.ts index 67c036b4..1b7e92cb 100644 --- a/admin/src/components/command-palette/registry.ts +++ b/admin/src/components/command-palette/registry.ts @@ -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 }, + }, ]; diff --git a/admin/src/components/command-palette/scopeFilter.ts b/admin/src/components/command-palette/scopeFilter.ts new file mode 100644 index 00000000..086a59fc --- /dev/null +++ b/admin/src/components/command-palette/scopeFilter.ts @@ -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 }; +} diff --git a/admin/src/components/command-palette/types.ts b/admin/src/components/command-palette/types.ts index e218962a..915c65a9 100644 --- a/admin/src/components/command-palette/types.ts +++ b/admin/src/components/command-palette/types.ts @@ -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[]; diff --git a/admin/src/components/command-palette/useCommandIndex.ts b/admin/src/components/command-palette/useCommandIndex.ts index 2e67c41b..dba0d28e 100644 --- a/admin/src/components/command-palette/useCommandIndex.ts +++ b/admin/src/components/command-palette/useCommandIndex.ts @@ -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 = { + '/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 }; } diff --git a/admin/src/components/command-palette/useEntitySearch.ts b/admin/src/components/command-palette/useEntitySearch.ts index 65b575cc..a40c6181 100644 --- a/admin/src/components/command-palette/useEntitySearch.ts +++ b/admin/src/components/command-palette/useEntitySearch.ts @@ -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([]); const [loading, setLoading] = useState(false); const abortRef = useRef(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)[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 }; }