diff --git a/src/components/common/CommandPalette.tsx b/src/components/common/CommandPalette.tsx new file mode 100644 index 0000000..878de89 --- /dev/null +++ b/src/components/common/CommandPalette.tsx @@ -0,0 +1,90 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +import { useCallback, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog'; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from '@/components/ui/command'; +import { friendlyName, getActionInfo, getObjectKind, useGlobalSearch } from '@/hooks/useGlobalSearch'; +import type { SearchIndexEntry } from '@/stores/schemaStore'; + +interface CommandPaletteProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function CommandPalette({ open, onOpenChange }: CommandPaletteProps) { + const { t } = useTranslation(); + const closePalette = useCallback(() => onOpenChange(false), [onOpenChange]); + const { query, setQuery, results, groups, selectEntry, reset, schema } = useGlobalSearch(closePalette); + + const GROUP_LABELS: Record = { + link: t('globalSearch.pages', 'Pages'), + form: t('globalSearch.formSections', 'Form Sections'), + field: t('globalSearch.fields', 'Fields'), + }; + + useEffect(() => { + if (!open) reset(); + }, [open, reset]); + + return ( + + + {t('globalSearch.title', 'Search')} + + + + + {query.trim() + ? t('globalSearch.noResults', 'No results found.') + : t('globalSearch.typeToSearch', 'Type to search the admin panel.')} + + {Array.from(groups.entries()).map(([type, entries]) => ( + + {entries.map((entry) => { + const flatIdx = results.indexOf(entry); + const objectKind = schema ? getObjectKind(schema, entry.viewName) : null; + const { label: actionLabel, Icon: ActionIcon } = getActionInfo(entry.type, objectKind, t); + + return ( + selectEntry(entry)} + > + +
+ {friendlyName(entry.text)} + {entry.breadcrumb} +
+ {actionLabel} +
+ ); + })} +
+ ))} +
+
+
+
+ ); +} diff --git a/src/components/common/GlobalSearch.tsx b/src/components/common/GlobalSearch.tsx deleted file mode 100644 index 435a380..0000000 --- a/src/components/common/GlobalSearch.tsx +++ /dev/null @@ -1,245 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL - */ - -import { useState, useCallback, useMemo, useRef, useEffect } from 'react'; -import { useNavigate } from 'react-router-dom'; -import { useTranslation } from 'react-i18next'; -import { Search, List, Settings, Plus } from 'lucide-react'; -import { useSchemaStore, type SearchIndexEntry } from '@/stores/schemaStore'; -import { useAccountStore } from '@/stores/accountStore'; -import { resolveObject } from '@/lib/schemaResolver'; -import type { Schema } from '@/types/schema'; - -const MAX_RESULTS = 15; - -const TYPE_ORDER: Record = { - link: 0, - form: 1, - field: 2, -}; - -function getObjectKind(schema: Schema, viewName: string): 'singleton' | 'object' | null { - const resolved = resolveObject(schema, viewName); - if (!resolved) return null; - return resolved.objectType.type === 'singleton' ? 'singleton' : 'object'; -} - -function getActionInfo( - entryType: SearchIndexEntry['type'], - objectKind: 'singleton' | 'object' | null, - t: (key: string, fallback: string) => string, -): { label: string; Icon: typeof List } { - if (entryType === 'link') { - return objectKind === 'singleton' - ? { label: t('globalSearch.settings', 'Settings'), Icon: Settings } - : { label: t('globalSearch.list', 'List'), Icon: List }; - } - return objectKind === 'singleton' - ? { label: t('globalSearch.settings', 'Settings'), Icon: Settings } - : { label: t('globalSearch.create', 'Create'), Icon: Plus }; -} - -function getNavigationPath( - entryType: SearchIndexEntry['type'], - objectKind: 'singleton' | 'object' | null, - section: string, - viewName: string, -): string { - const encodedView = viewName; - if (entryType === 'link') { - return objectKind === 'singleton' ? `/${section}/${encodedView}/singleton` : `/${section}/${encodedView}`; - } - return objectKind === 'singleton' ? `/${section}/${encodedView}/singleton` : `/${section}/${encodedView}/new`; -} - -function friendlyName(viewName: string): string { - const stripped = viewName.replace(/^x:/, ''); - const parts = stripped.split('/'); - return parts[parts.length - 1]; -} - -interface GlobalSearchProps { - onAfterSelect?: () => void; - autoFocus?: boolean; -} - -export function GlobalSearch({ onAfterSelect, autoFocus }: GlobalSearchProps = {}) { - const { t } = useTranslation(); - const navigate = useNavigate(); - const GROUP_LABELS: Record = { - link: t('globalSearch.pages', 'Pages'), - form: t('globalSearch.formSections', 'Form Sections'), - field: t('globalSearch.fields', 'Fields'), - }; - const [query, setQuery] = useState(''); - const [debouncedQuery, setDebouncedQuery] = useState(''); - const [dropdownOpen, setDropdownOpen] = useState(false); - const [activeIndex, setActiveIndex] = useState(-1); - const timerRef = useRef | null>(null); - const containerRef = useRef(null); - const inputRef = useRef(null); - - const schema = useSchemaStore((s) => s.schema); - const searchIndex = useSchemaStore((s) => s.searchIndex); - const hasObjectPermission = useAccountStore((s) => s.hasObjectPermission); - - const handleQueryChange = useCallback((value: string) => { - setQuery(value); - setActiveIndex(-1); - if (timerRef.current) clearTimeout(timerRef.current); - timerRef.current = setTimeout(() => setDebouncedQuery(value), 300); - }, []); - - useEffect(() => { - return () => { - if (timerRef.current) clearTimeout(timerRef.current); - }; - }, []); - - useEffect(() => { - function handleClickOutside(e: MouseEvent) { - if (containerRef.current && !containerRef.current.contains(e.target as Node)) { - setDropdownOpen(false); - } - } - document.addEventListener('mousedown', handleClickOutside); - return () => document.removeEventListener('mousedown', handleClickOutside); - }, []); - - const results = useMemo(() => { - if (!debouncedQuery.trim() || !schema) return []; - - const tokens = debouncedQuery - .toLowerCase() - .split(/\s+/) - .filter((s) => s.length > 0); - if (tokens.length === 0) return []; - - const filtered = searchIndex.filter((entry) => { - const haystack = (entry.text + ' ' + (entry.keywords?.join(' ') ?? '')).toLowerCase(); - for (const token of tokens) { - if (!haystack.includes(token)) return false; - } - const resolved = resolveObject(schema, entry.viewName); - if (!resolved) return false; - return hasObjectPermission(resolved.permissionPrefix, 'Get'); - }); - - filtered.sort((a, b) => TYPE_ORDER[a.type] - TYPE_ORDER[b.type]); - return filtered.slice(0, MAX_RESULTS); - }, [debouncedQuery, searchIndex, schema, hasObjectPermission]); - - const groups = useMemo(() => { - const map = new Map(); - for (const entry of results) { - const arr = map.get(entry.type); - if (arr) arr.push(entry); - else map.set(entry.type, [entry]); - } - return map; - }, [results]); - - const handleSelect = useCallback( - (entry: SearchIndexEntry) => { - if (!schema) return; - const objectKind = getObjectKind(schema, entry.viewName); - const path = getNavigationPath(entry.type, objectKind, entry.section, entry.viewName); - setDropdownOpen(false); - setQuery(''); - setDebouncedQuery(''); - navigate(path); - onAfterSelect?.(); - }, - [schema, navigate, onAfterSelect], - ); - - const handleKeyDown = useCallback( - (e: React.KeyboardEvent) => { - if (!dropdownOpen || results.length === 0) return; - if (e.key === 'ArrowDown') { - e.preventDefault(); - setActiveIndex((i) => (i + 1) % results.length); - } else if (e.key === 'ArrowUp') { - e.preventDefault(); - setActiveIndex((i) => (i - 1 + results.length) % results.length); - } else if (e.key === 'Enter' && activeIndex >= 0) { - e.preventDefault(); - handleSelect(results[activeIndex]); - } else if (e.key === 'Escape') { - setDropdownOpen(false); - } - }, - [dropdownOpen, results, activeIndex, handleSelect], - ); - - const showDropdown = dropdownOpen && debouncedQuery.trim().length > 0; - - return ( -
- - { - handleQueryChange(e.target.value); - setDropdownOpen(true); - }} - onFocus={() => { - if (query.trim()) setDropdownOpen(true); - }} - onKeyDown={handleKeyDown} - className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 pl-9 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" - /> - - {showDropdown && ( -
- {results.length === 0 ? ( -
- {t('globalSearch.noResults', 'No results found.')} -
- ) : ( -
- {Array.from(groups.entries()).map(([type, entries]) => ( -
-
{GROUP_LABELS[type]}
- {entries.map((entry) => { - const flatIdx = results.indexOf(entry); - const objectKind = schema ? getObjectKind(schema, entry.viewName) : null; - const { label: actionLabel, Icon: ActionIcon } = getActionInfo(entry.type, objectKind, t); - - return ( - - ); - })} -
- ))} -
- )} -
- )} -
- ); -} diff --git a/src/components/layout/TopBar.tsx b/src/components/layout/TopBar.tsx index a031f14..67e1405 100644 --- a/src/components/layout/TopBar.tsx +++ b/src/components/layout/TopBar.tsx @@ -9,8 +9,7 @@ import { useTranslation } from 'react-i18next'; import * as LucideIcons from 'lucide-react'; const { Sun, Moon, User, LogOut, Check, Menu, Sparkles, Search } = LucideIcons; import { Button } from '@/components/ui/button'; -import { GlobalSearch } from '@/components/common/GlobalSearch'; -import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog'; +import { CommandPalette } from '@/components/common/CommandPalette'; import { DropdownMenu, DropdownMenuContent, @@ -27,7 +26,7 @@ import { findFirstAccessibleLinkInLayout, findFirstVisibleLinkInLayout, visibleL import { useUIStore } from '@/stores/uiStore'; import { useAuthStore } from '@/stores/authStore'; import { buildEndSessionUrl, getPostLogoutRedirectUri } from '@/services/auth/oauth'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { useAccountStore } from '@/stores/accountStore'; import { useSchemaStore } from '@/stores/schemaStore'; @@ -55,7 +54,20 @@ export function TopBar() { const hasPermission = useAccountStore((s) => s.hasPermission); const schema = useSchemaStore((s) => s.schema); const [upsellOpen, setUpsellOpen] = useState(false); - const [mobileSearchOpen, setMobileSearchOpen] = useState(false); + const [paletteOpen, setPaletteOpen] = useState(false); + + useEffect(() => { + function handleGlobalKeyDown(e: KeyboardEvent) { + if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') { + e.preventDefault(); + setPaletteOpen((open) => !open); + } + } + document.addEventListener('keydown', handleGlobalKeyDown); + return () => document.removeEventListener('keydown', handleGlobalKeyDown); + }, []); + + const isMac = /Mac|iPhone|iPad|iPod/.test(navigator.userAgent); const navigableLayouts = schema ? visibleLayouts(schema, edition, (prefix) => hasObjectPermission(prefix, 'Get'), hasPermission) @@ -81,7 +93,19 @@ export function TopBar() {
- +
@@ -91,18 +115,13 @@ export function TopBar() { variant="ghost" size="icon" className="md:hidden" - onClick={() => setMobileSearchOpen(true)} + onClick={() => setPaletteOpen(true)} aria-label={t('search', 'Search')} > - - - {t('search', 'Search')} - setMobileSearchOpen(false)} /> - - +