175 lines
6.8 KiB
TypeScript
175 lines
6.8 KiB
TypeScript
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { Search } from 'lucide-react';
|
|
import { useAuthStore } from '@/store/authStore';
|
|
import { NAV_GROUPS, filterNavGroupsByRole, canAccessRoute } from '@/config/adminAccess';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogTitle,
|
|
} from '@/components/ui/dialog';
|
|
import { Input } from '@/components/ui/input';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
type PaletteItem = {
|
|
id: string;
|
|
label: string;
|
|
group: string;
|
|
path: string;
|
|
};
|
|
|
|
interface CommandPaletteProps {
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
}
|
|
|
|
/** Looks like EventHub entity ids (base64url-ish). */
|
|
const ENTITY_ID_RE = /^[A-Za-z0-9_-]{16,40}$/;
|
|
|
|
export function CommandPalette({ open, onOpenChange }: CommandPaletteProps) {
|
|
const { t } = useTranslation();
|
|
const navigate = useNavigate();
|
|
const role = useAuthStore((s) => s.user?.role);
|
|
const [query, setQuery] = useState('');
|
|
const [activeIndex, setActiveIndex] = useState(0);
|
|
|
|
const items = useMemo<PaletteItem[]>(() => {
|
|
const groups = filterNavGroupsByRole(NAV_GROUPS, role);
|
|
const routes = groups.flatMap((g) =>
|
|
g.items.map((item) => ({
|
|
id: item.key,
|
|
label: t(item.label),
|
|
group: t(g.label),
|
|
path: item.key,
|
|
}))
|
|
);
|
|
routes.push(
|
|
{ id: '/profile', label: t('nav.profile'), group: t('navGroup.account'), path: '/profile' },
|
|
{ id: '/dashboard', label: t('nav.dashboard'), group: t('navGroup.overview'), path: '/dashboard' }
|
|
);
|
|
const seen = new Set<string>();
|
|
return routes.filter((r) => {
|
|
if (seen.has(r.path)) return false;
|
|
seen.add(r.path);
|
|
return true;
|
|
});
|
|
}, [role, t]);
|
|
|
|
const filtered = useMemo(() => {
|
|
const q = query.trim();
|
|
const qLower = q.toLowerCase();
|
|
const navHits = !qLower
|
|
? items
|
|
: items.filter(
|
|
(item) =>
|
|
item.label.toLowerCase().includes(qLower) ||
|
|
item.group.toLowerCase().includes(qLower) ||
|
|
item.path.toLowerCase().includes(qLower)
|
|
);
|
|
|
|
if (!ENTITY_ID_RE.test(q)) return navHits;
|
|
|
|
const jumps: PaletteItem[] = [];
|
|
const pushIf = (path: string, labelKey: string, id: string) => {
|
|
if (canAccessRoute(path.split('?')[0], role)) {
|
|
jumps.push({
|
|
id,
|
|
label: `${t(labelKey)} · ${q}`,
|
|
group: t('layout.paletteJumpGroup'),
|
|
path,
|
|
});
|
|
}
|
|
};
|
|
pushIf(`/inbox/reports?selected=${encodeURIComponent(q)}`, 'layout.paletteOpenReportInbox', `jump-report-inbox-${q}`);
|
|
pushIf(`/reports/${encodeURIComponent(q)}`, 'layout.paletteOpenReport', `jump-report-${q}`);
|
|
pushIf(`/inbox/tickets?selected=${encodeURIComponent(q)}`, 'layout.paletteOpenTicketInbox', `jump-ticket-inbox-${q}`);
|
|
pushIf(`/tickets/${encodeURIComponent(q)}`, 'layout.paletteOpenTicket', `jump-ticket-${q}`);
|
|
pushIf(`/events/${encodeURIComponent(q)}`, 'layout.paletteOpenEvent', `jump-event-${q}`);
|
|
pushIf(`/users/${encodeURIComponent(q)}`, 'layout.paletteOpenUser', `jump-user-${q}`);
|
|
return [...jumps, ...navHits];
|
|
}, [items, query, role, t]);
|
|
|
|
useEffect(() => {
|
|
setActiveIndex(0);
|
|
}, [query, open]);
|
|
|
|
useEffect(() => {
|
|
if (!open) setQuery('');
|
|
}, [open]);
|
|
|
|
const go = useCallback(
|
|
(path: string) => {
|
|
onOpenChange(false);
|
|
navigate(path);
|
|
},
|
|
[navigate, onOpenChange]
|
|
);
|
|
|
|
const onKeyDown = (e: React.KeyboardEvent) => {
|
|
if (e.key === 'ArrowDown') {
|
|
e.preventDefault();
|
|
setActiveIndex((i) => Math.min(i + 1, Math.max(filtered.length - 1, 0)));
|
|
} else if (e.key === 'ArrowUp') {
|
|
e.preventDefault();
|
|
setActiveIndex((i) => Math.max(i - 1, 0));
|
|
} else if (e.key === 'Enter' && filtered[activeIndex]) {
|
|
e.preventDefault();
|
|
go(filtered[activeIndex].path);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent
|
|
data-testid="command-palette"
|
|
className="gap-0 overflow-hidden p-0 sm:max-w-lg"
|
|
aria-describedby={undefined}
|
|
>
|
|
<DialogTitle className="sr-only">{t('layout.paletteTitle')}</DialogTitle>
|
|
<div className="flex items-center border-b px-3">
|
|
<Search className="mr-2 h-4 w-4 shrink-0 text-muted-foreground" />
|
|
<Input
|
|
data-testid="palette-input"
|
|
autoFocus
|
|
value={query}
|
|
onChange={(e) => setQuery(e.target.value)}
|
|
onKeyDown={onKeyDown}
|
|
placeholder={t('layout.palettePlaceholder')}
|
|
className="h-12 border-0 shadow-none focus-visible:ring-0"
|
|
/>
|
|
</div>
|
|
<ul className="max-h-72 overflow-auto py-2" role="listbox">
|
|
{filtered.length === 0 ? (
|
|
<li className="px-4 py-6 text-center text-sm text-muted-foreground">
|
|
{t('common.nothingFound')}
|
|
</li>
|
|
) : (
|
|
filtered.map((item, index) => (
|
|
<li key={item.id}>
|
|
<button
|
|
type="button"
|
|
role="option"
|
|
aria-selected={index === activeIndex}
|
|
className={cn(
|
|
'flex w-full items-center justify-between px-4 py-2 text-left text-sm',
|
|
index === activeIndex ? 'bg-accent' : 'hover:bg-muted/60'
|
|
)}
|
|
onMouseEnter={() => setActiveIndex(index)}
|
|
onClick={() => go(item.path)}
|
|
>
|
|
<span className="font-medium">{item.label}</span>
|
|
<span className="text-xs text-muted-foreground">{item.group}</span>
|
|
</button>
|
|
</li>
|
|
))
|
|
)}
|
|
</ul>
|
|
<div className="border-t px-3 py-2 text-[11px] text-muted-foreground">
|
|
{t('layout.paletteHints')}
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|