Add Ctrl+K/Cmd+K command palette search
Replace the TopBar dropdown search with a cmdk-based command palette opened via a visible trigger button (with platform-aware shortcut badge) or the Ctrl+K/Cmd+K global shortcut. Shared search logic moves to a useGlobalSearch hook; the palette reuses the existing ui/command components.
This commit is contained in:
@@ -0,0 +1,90 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
|
*
|
||||||
|
* 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<SearchIndexEntry['type'], string> = {
|
||||||
|
link: t('globalSearch.pages', 'Pages'),
|
||||||
|
form: t('globalSearch.formSections', 'Form Sections'),
|
||||||
|
field: t('globalSearch.fields', 'Fields'),
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) reset();
|
||||||
|
}, [open, reset]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="top-[15%] translate-y-0 overflow-hidden p-0">
|
||||||
|
<DialogTitle className="sr-only">{t('globalSearch.title', 'Search')}</DialogTitle>
|
||||||
|
<Command
|
||||||
|
shouldFilter={false}
|
||||||
|
loop
|
||||||
|
className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5"
|
||||||
|
>
|
||||||
|
<CommandInput
|
||||||
|
placeholder={t('globalSearch.placeholder', 'Search pages, fields, settings...')}
|
||||||
|
value={query}
|
||||||
|
onValueChange={setQuery}
|
||||||
|
/>
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty>
|
||||||
|
{query.trim()
|
||||||
|
? t('globalSearch.noResults', 'No results found.')
|
||||||
|
: t('globalSearch.typeToSearch', 'Type to search the admin panel.')}
|
||||||
|
</CommandEmpty>
|
||||||
|
{Array.from(groups.entries()).map(([type, entries]) => (
|
||||||
|
<CommandGroup key={type} heading={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 (
|
||||||
|
<CommandItem
|
||||||
|
key={`${type}-${entry.viewName}-${flatIdx}`}
|
||||||
|
value={`${type}-${entry.viewName}-${flatIdx}`}
|
||||||
|
onSelect={() => selectEntry(entry)}
|
||||||
|
>
|
||||||
|
<ActionIcon className="mr-2 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||||
|
<div className="flex flex-1 flex-col overflow-hidden">
|
||||||
|
<span className="truncate font-medium">{friendlyName(entry.text)}</span>
|
||||||
|
<span className="truncate text-xs text-muted-foreground">{entry.breadcrumb}</span>
|
||||||
|
</div>
|
||||||
|
<span className="ml-auto shrink-0 pl-2 text-xs text-muted-foreground">{actionLabel}</span>
|
||||||
|
</CommandItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</CommandGroup>
|
||||||
|
))}
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,245 +0,0 @@
|
|||||||
/*
|
|
||||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
|
||||||
*
|
|
||||||
* 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<SearchIndexEntry['type'], number> = {
|
|
||||||
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<SearchIndexEntry['type'], string> = {
|
|
||||||
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<ReturnType<typeof setTimeout> | null>(null);
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
|
||||||
const inputRef = useRef<HTMLInputElement>(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<SearchIndexEntry['type'], SearchIndexEntry[]>();
|
|
||||||
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 (
|
|
||||||
<div className="relative w-full max-w-md" ref={containerRef}>
|
|
||||||
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
|
||||||
<input
|
|
||||||
ref={inputRef}
|
|
||||||
type="text"
|
|
||||||
value={query}
|
|
||||||
autoFocus={autoFocus}
|
|
||||||
onChange={(e) => {
|
|
||||||
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 && (
|
|
||||||
<div className="absolute top-full left-0 z-50 mt-1 w-full rounded-md border bg-popover shadow-lg">
|
|
||||||
{results.length === 0 ? (
|
|
||||||
<div className="px-3 py-4 text-center text-sm text-muted-foreground">
|
|
||||||
{t('globalSearch.noResults', 'No results found.')}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="max-h-80 overflow-y-auto py-1">
|
|
||||||
{Array.from(groups.entries()).map(([type, entries]) => (
|
|
||||||
<div key={type}>
|
|
||||||
<div className="px-3 py-1.5 text-xs font-medium text-muted-foreground">{GROUP_LABELS[type]}</div>
|
|
||||||
{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 (
|
|
||||||
<button
|
|
||||||
key={`${type}-${entry.viewName}-${flatIdx}`}
|
|
||||||
type="button"
|
|
||||||
className={`flex w-full items-center gap-2 px-3 py-2 text-left text-sm hover:bg-accent ${
|
|
||||||
flatIdx === activeIndex ? 'bg-accent' : ''
|
|
||||||
}`}
|
|
||||||
onMouseDown={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
handleSelect(entry);
|
|
||||||
}}
|
|
||||||
onMouseEnter={() => setActiveIndex(flatIdx)}
|
|
||||||
>
|
|
||||||
<ActionIcon className="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
||||||
<div className="flex flex-1 flex-col overflow-hidden">
|
|
||||||
<span className="truncate font-medium">{friendlyName(entry.text)}</span>
|
|
||||||
<span className="truncate text-xs text-muted-foreground">{entry.breadcrumb}</span>
|
|
||||||
</div>
|
|
||||||
<span className="shrink-0 text-xs text-muted-foreground">{actionLabel}</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -9,8 +9,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import * as LucideIcons from 'lucide-react';
|
import * as LucideIcons from 'lucide-react';
|
||||||
const { Sun, Moon, User, LogOut, Check, Menu, Sparkles, Search } = LucideIcons;
|
const { Sun, Moon, User, LogOut, Check, Menu, Sparkles, Search } = LucideIcons;
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { GlobalSearch } from '@/components/common/GlobalSearch';
|
import { CommandPalette } from '@/components/common/CommandPalette';
|
||||||
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
|
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@@ -27,7 +26,7 @@ import { findFirstAccessibleLinkInLayout, findFirstVisibleLinkInLayout, visibleL
|
|||||||
import { useUIStore } from '@/stores/uiStore';
|
import { useUIStore } from '@/stores/uiStore';
|
||||||
import { useAuthStore } from '@/stores/authStore';
|
import { useAuthStore } from '@/stores/authStore';
|
||||||
import { buildEndSessionUrl, getPostLogoutRedirectUri } from '@/services/auth/oauth';
|
import { buildEndSessionUrl, getPostLogoutRedirectUri } from '@/services/auth/oauth';
|
||||||
import { useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useAccountStore } from '@/stores/accountStore';
|
import { useAccountStore } from '@/stores/accountStore';
|
||||||
import { useSchemaStore } from '@/stores/schemaStore';
|
import { useSchemaStore } from '@/stores/schemaStore';
|
||||||
|
|
||||||
@@ -55,7 +54,20 @@ export function TopBar() {
|
|||||||
const hasPermission = useAccountStore((s) => s.hasPermission);
|
const hasPermission = useAccountStore((s) => s.hasPermission);
|
||||||
const schema = useSchemaStore((s) => s.schema);
|
const schema = useSchemaStore((s) => s.schema);
|
||||||
const [upsellOpen, setUpsellOpen] = useState(false);
|
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
|
const navigableLayouts = schema
|
||||||
? visibleLayouts(schema, edition, (prefix) => hasObjectPermission(prefix, 'Get'), hasPermission)
|
? visibleLayouts(schema, edition, (prefix) => hasObjectPermission(prefix, 'Get'), hasPermission)
|
||||||
@@ -81,7 +93,19 @@ export function TopBar() {
|
|||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
|
|
||||||
<div className="hidden min-w-0 flex-1 items-center justify-center px-4 md:flex">
|
<div className="hidden min-w-0 flex-1 items-center justify-center px-4 md:flex">
|
||||||
<GlobalSearch />
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPaletteOpen(true)}
|
||||||
|
className="flex h-9 w-full max-w-md items-center gap-2 rounded-md border border-input bg-transparent px-3 text-sm text-muted-foreground shadow-sm transition-colors hover:bg-accent"
|
||||||
|
>
|
||||||
|
<Search className="h-4 w-4" />
|
||||||
|
<span className="flex-1 text-left">
|
||||||
|
{t('globalSearch.placeholder', 'Search pages, fields, settings...')}
|
||||||
|
</span>
|
||||||
|
<kbd className="pointer-events-none flex h-5 select-none items-center rounded border bg-muted px-1.5 font-mono text-[10px] font-medium">
|
||||||
|
{isMac ? '⌘K' : 'Ctrl K'}
|
||||||
|
</kbd>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="ml-auto flex items-center gap-2 md:ml-0">
|
<div className="ml-auto flex items-center gap-2 md:ml-0">
|
||||||
@@ -91,18 +115,13 @@ export function TopBar() {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="md:hidden"
|
className="md:hidden"
|
||||||
onClick={() => setMobileSearchOpen(true)}
|
onClick={() => setPaletteOpen(true)}
|
||||||
aria-label={t('search', 'Search')}
|
aria-label={t('search', 'Search')}
|
||||||
>
|
>
|
||||||
<Search className="h-4 w-4" />
|
<Search className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Dialog open={mobileSearchOpen} onOpenChange={setMobileSearchOpen}>
|
<CommandPalette open={paletteOpen} onOpenChange={setPaletteOpen} />
|
||||||
<DialogContent className="top-4 translate-y-0 max-w-[calc(100vw-2rem)] p-4">
|
|
||||||
<DialogTitle className="sr-only">{t('search', 'Search')}</DialogTitle>
|
|
||||||
<GlobalSearch autoFocus onAfterSelect={() => setMobileSearchOpen(false)} />
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
<Button variant="ghost" size="icon" onClick={toggleTheme} aria-label={t('toggleTheme', 'Toggle theme')}>
|
<Button variant="ghost" size="icon" onClick={toggleTheme} aria-label={t('toggleTheme', 'Toggle theme')}>
|
||||||
{theme === 'light' ? <Moon className="h-4 w-4" /> : <Sun className="h-4 w-4" />}
|
{theme === 'light' ? <Moon className="h-4 w-4" /> : <Sun className="h-4 w-4" />}
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { List, Plus, Settings } 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 DEBOUNCE_MS = 300;
|
||||||
|
|
||||||
|
const TYPE_ORDER: Record<SearchIndexEntry['type'], number> = {
|
||||||
|
link: 0,
|
||||||
|
form: 1,
|
||||||
|
field: 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ObjectKind = 'singleton' | 'object' | null;
|
||||||
|
|
||||||
|
export function getObjectKind(schema: Schema, viewName: string): ObjectKind {
|
||||||
|
const resolved = resolveObject(schema, viewName);
|
||||||
|
if (!resolved) return null;
|
||||||
|
return resolved.objectType.type === 'singleton' ? 'singleton' : 'object';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getActionInfo(
|
||||||
|
entryType: SearchIndexEntry['type'],
|
||||||
|
objectKind: ObjectKind,
|
||||||
|
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: ObjectKind,
|
||||||
|
section: string,
|
||||||
|
viewName: string,
|
||||||
|
): string {
|
||||||
|
if (entryType === 'link') {
|
||||||
|
return objectKind === 'singleton' ? `/${section}/${viewName}/singleton` : `/${section}/${viewName}`;
|
||||||
|
}
|
||||||
|
return objectKind === 'singleton' ? `/${section}/${viewName}/singleton` : `/${section}/${viewName}/new`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function friendlyName(viewName: string): string {
|
||||||
|
const stripped = viewName.replace(/^x:/, '');
|
||||||
|
const parts = stripped.split('/');
|
||||||
|
return parts[parts.length - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useGlobalSearch(onAfterSelect?: () => void) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const [debouncedQuery, setDebouncedQuery] = useState('');
|
||||||
|
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(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);
|
||||||
|
if (timerRef.current) clearTimeout(timerRef.current);
|
||||||
|
timerRef.current = setTimeout(() => setDebouncedQuery(value), DEBOUNCE_MS);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (timerRef.current) clearTimeout(timerRef.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
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<SearchIndexEntry['type'], SearchIndexEntry[]>();
|
||||||
|
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 selectEntry = useCallback(
|
||||||
|
(entry: SearchIndexEntry) => {
|
||||||
|
if (!schema) return;
|
||||||
|
const objectKind = getObjectKind(schema, entry.viewName);
|
||||||
|
navigate(getNavigationPath(entry.type, objectKind, entry.section, entry.viewName));
|
||||||
|
onAfterSelect?.();
|
||||||
|
},
|
||||||
|
[schema, navigate, onAfterSelect],
|
||||||
|
);
|
||||||
|
|
||||||
|
const reset = useCallback(() => {
|
||||||
|
setQuery('');
|
||||||
|
setDebouncedQuery('');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { query, setQuery: handleQueryChange, results, groups, selectEntry, reset, schema };
|
||||||
|
}
|
||||||
+4
-1
@@ -222,7 +222,10 @@
|
|||||||
"list": "List",
|
"list": "List",
|
||||||
"noResults": "No results found.",
|
"noResults": "No results found.",
|
||||||
"pages": "Pages",
|
"pages": "Pages",
|
||||||
"settings": "Settings"
|
"placeholder": "Search pages, fields, settings...",
|
||||||
|
"settings": "Settings",
|
||||||
|
"title": "Search",
|
||||||
|
"typeToSearch": "Type to search the admin panel."
|
||||||
},
|
},
|
||||||
"jmapErrors": {
|
"jmapErrors": {
|
||||||
"addressBookHasContents": "This address book has contacts. Remove them first.",
|
"addressBookHasContents": "This address book has contacts. Remove them first.",
|
||||||
|
|||||||
Reference in New Issue
Block a user