import { useEffect, useMemo, useState } from 'react'; import { Command } from 'cmdk'; import { useNavigate } from 'react-router-dom'; import { useQuery } from '@tanstack/react-query'; import toast from 'react-hot-toast'; import { Rss, Package, LayoutTemplate, FolderKanban, Mail, Users, Clock, Shield, Activity, ListChecks, RefreshCw, Heart, Calendar, Tag, Megaphone, LayoutDashboard, Sparkles, } from 'lucide-react'; import { api } from '../lib/api-client'; interface Props { open: boolean; onOpenChange: (open: boolean) => void; } interface NavCommand { id: string; label: string; hint?: string; icon: React.ComponentType<{ className?: string }>; run: () => void; } const ICONS = { dashboard: LayoutDashboard, feeds: Rss, products: Package, homepage: LayoutTemplate, collections: FolderKanban, categories: Tag, brands: Heart, calendar: Calendar, emails: Mail, subscribers: Users, push: Megaphone, crons: Clock, audit: Shield, health: Activity, jobs: ListChecks, sync: RefreshCw, regen: Sparkles, }; export function CommandPalette({ open, onOpenChange }: Props) { const navigate = useNavigate(); const [search, setSearch] = useState(''); const searchQuery = search.trim(); const dynamicActive = open && searchQuery.length >= 2; const { data: feedHits } = useQuery({ queryKey: ['cmdk-feeds', searchQuery], queryFn: () => api.get<{ feeds: Array<{ id: number; name: string; slug: string }> }>('/feeds'), enabled: dynamicActive, staleTime: 30_000, }); const { data: productHits } = useQuery({ queryKey: ['cmdk-products', searchQuery], queryFn: () => api.get<{ products: Array<{ id: number; title: string; brand: string | null; slug: string }> }>( '/products', { q: searchQuery, limit: 5 }, ), enabled: dynamicActive, staleTime: 10_000, }); useEffect(() => { const onKey = (e: KeyboardEvent) => { if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') { e.preventDefault(); onOpenChange(!open); } if (e.key === 'Escape' && open) onOpenChange(false); }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [open, onOpenChange]); const navCommands: NavCommand[] = useMemo( () => [ { id: 'dash', label: 'Dashboard', icon: ICONS.dashboard, run: () => navigate('/cms') }, { id: 'feeds', label: 'Feeds', hint: 'Beheer feeds', icon: ICONS.feeds, run: () => navigate('/cms/feeds') }, { id: 'products', label: 'Producten', icon: ICONS.products, run: () => navigate('/cms/products') }, { id: 'collections', label: 'Collecties', icon: ICONS.collections, run: () => navigate('/cms/collections') }, { id: 'categories', label: 'Categorieën', icon: ICONS.categories, run: () => navigate('/cms/categories') }, { id: 'homepage', label: 'Homepage bewerken', icon: ICONS.homepage, run: () => navigate('/cms/homepage') }, { id: 'brands', label: 'Merken', icon: ICONS.brands, run: () => navigate('/cms/brands') }, { id: 'calendar', label: 'Releasekalender', icon: ICONS.calendar, run: () => navigate('/cms/calendar') }, { id: 'emails', label: 'Emails', icon: ICONS.emails, run: () => navigate('/cms/emails') }, { id: 'subscribers', label: 'Subscribers', icon: ICONS.subscribers, run: () => navigate('/cms/subscribers') }, { id: 'push', label: 'Push notificaties', icon: ICONS.push, run: () => navigate('/cms/push') }, { id: 'jobs', label: 'Job queue', icon: ICONS.jobs, run: () => navigate('/cms/jobs') }, { id: 'audit', label: 'Audit log', icon: ICONS.audit, run: () => navigate('/cms/audit') }, { id: 'crons', label: 'Crons', icon: ICONS.crons, run: () => navigate('/cms/crons') }, { id: 'health', label: 'System health', icon: ICONS.health, run: () => navigate('/cms/health') }, { id: 'errors', label: 'Error log', icon: ICONS.audit, run: () => navigate('/cms/errors') }, ], [navigate], ); const actions: NavCommand[] = useMemo( () => [ { id: 'sync-all', label: 'Sync alle feeds', hint: 'Trigger handmatig', icon: ICONS.sync, run: async () => { await api.post('/feeds/sync-all').catch(() => {}); navigate('/cms/jobs'); }, }, { id: 'cache-clear', label: 'Leeg cache', icon: RefreshCw, run: async () => { await api.post('/system/cache-clear').catch(() => {}); }, }, ], [navigate], ); const close = (run: () => void | Promise) => { onOpenChange(false); setTimeout(() => void run(), 50); }; if (!open) return null; return (
onOpenChange(false)} > e.stopPropagation()} > Geen resultaten. {navCommands.map((cmd) => ( close(cmd.run)} className="flex items-center gap-3 px-3 py-2 text-sm text-[#F5F5F5] rounded cursor-pointer data-[selected=true]:bg-[#1A1A1A]" > {cmd.label} {cmd.hint && {cmd.hint}} ))} {actions.map((cmd) => ( close(cmd.run)} className="flex items-center gap-3 px-3 py-2 text-sm text-[#F5F5F5] rounded cursor-pointer data-[selected=true]:bg-[#1A1A1A]" > {cmd.label} {cmd.hint && {cmd.hint}} ))} {dynamicActive && (feedHits?.feeds?.length ?? 0) > 0 && ( {feedHits!.feeds .filter((f) => f.name.toLowerCase().includes(searchQuery.toLowerCase())) .slice(0, 5) .map((f) => ( close(async () => { await api.post(`/feeds/${f.id}/sync`).catch((e: Error) => toast.error(e.message)); toast.success(`Feed "${f.name}" in wachtrij`); navigate('/cms/jobs'); }) } className="flex items-center gap-3 px-3 py-2 text-sm text-[#F5F5F5] rounded cursor-pointer data-[selected=true]:bg-[#1A1A1A]" > Sync feed: {f.name} ))} )} {dynamicActive && (productHits?.products?.length ?? 0) > 0 && ( {productHits!.products.slice(0, 5).map((p) => ( close(() => navigate(`/cms/products?q=${encodeURIComponent(p.title)}`))} className="flex items-center gap-3 px-3 py-2 text-sm text-[#F5F5F5] rounded cursor-pointer data-[selected=true]:bg-[#1A1A1A]" > {p.title} {p.brand ?? '—'} ))} )}
↑↓ navigeer kies Esc sluit
); }