feat: implement CommandPalette for enhanced navigation and recent pages tracking

This commit is contained in:
Dries Augustyns
2026-04-24 18:42:04 +02:00
parent f40cc86cec
commit 1c89ed083e
12 changed files with 582 additions and 45 deletions
+431
View File
@@ -0,0 +1,431 @@
import type {Campaign, Contact, Segment, Template, Workflow} from '@plunk/db';
import type {CursorPaginatedResponse, PaginatedResponse} from '@plunk/types';
import {
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
Kbd,
} from '@plunk/ui';
import {
Activity,
BarChart3,
BookOpen,
Clock,
Copy,
FileText,
Layers,
LayoutDashboard,
Megaphone,
Plus,
Settings,
Users,
Workflow as WorkflowIcon,
} from 'lucide-react';
import {useRouter} from 'next/router';
import {useEffect, useMemo, useRef, useState} from 'react';
import {toast} from 'sonner';
import useSWR from 'swr';
import {useActiveProject} from '../lib/contexts/ActiveProjectProvider';
import {WIKI_URI} from '../lib/constants';
import {addRecentPage, getRecentPages} from '../lib/recentPages';
interface Action {
label: string;
href: string;
icon: React.ComponentType<{className?: string}>;
keywords: string;
shortcut: [string, string];
}
const NAV_ACTIONS: Action[] = [
{label: 'Dashboard', href: '/', icon: LayoutDashboard, keywords: 'home overview', shortcut: ['G', 'D']},
{label: 'Contacts', href: '/contacts', icon: Users, keywords: 'subscribers people', shortcut: ['G', 'C']},
{label: 'Segments', href: '/segments', icon: Layers, keywords: 'groups filters', shortcut: ['G', 'S']},
{label: 'Activity', href: '/activity', icon: Activity, keywords: 'log events', shortcut: ['G', 'L']},
{label: 'Analytics', href: '/analytics', icon: BarChart3, keywords: 'stats metrics', shortcut: ['G', 'A']},
{label: 'Templates', href: '/templates', icon: FileText, keywords: 'email design', shortcut: ['G', 'T']},
{label: 'Workflows', href: '/workflows', icon: WorkflowIcon, keywords: 'automation trigger', shortcut: ['G', 'W']},
{label: 'Campaigns', href: '/campaigns', icon: Megaphone, keywords: 'broadcast newsletter', shortcut: ['G', 'M']},
{label: 'Settings', href: '/settings', icon: Settings, keywords: 'config account billing', shortcut: ['G', ',']},
];
const CREATE_ACTIONS: Action[] = [
{label: 'New Campaign', href: '/campaigns/create', icon: Plus, keywords: 'create broadcast', shortcut: ['N', 'C']},
{label: 'New Template', href: '/templates/create', icon: Plus, keywords: 'create email design', shortcut: ['N', 'T']},
{label: 'New Segment', href: '/segments/new', icon: Plus, keywords: 'create group filter', shortcut: ['N', 'S']},
{label: 'New Workflow', href: '/workflows', icon: Plus, keywords: 'create automation trigger', shortcut: ['N', 'W']},
];
// Chord map: first-key → second-key → action
const CHORDS: Record<string, Record<string, Action>> = {};
for (const action of [...NAV_ACTIONS, ...CREATE_ACTIONS]) {
const [first, second] = action.shortcut;
const f = first.toLowerCase();
const s = second.toLowerCase();
CHORDS[f] ??= {};
CHORDS[f][s] = action;
}
function matches(text: string, query: string): boolean {
return text.toLowerCase().includes(query.toLowerCase());
}
function ShortcutHint({shortcut}: {shortcut: [string, string]}) {
return (
<span className="ml-auto flex items-center gap-0.5 shrink-0">
<Kbd>{shortcut[0]}</Kbd>
<Kbd>{shortcut[1]}</Kbd>
</span>
);
}
export function CommandPalette() {
const router = useRouter();
const {activeProject} = useActiveProject();
const [open, setOpen] = useState(false);
const [query, setQuery] = useState('');
const [debouncedQuery, setDebouncedQuery] = useState('');
const chordKeyRef = useRef<string | null>(null);
const chordTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const openRef = useRef(open);
openRef.current = open;
const recentPages = useMemo(() => (open ? getRecentPages() : []), [open]);
const fireChord = (key: string): boolean => {
if (chordKeyRef.current) {
const action = CHORDS[chordKeyRef.current]?.[key];
chordKeyRef.current = null;
if (chordTimerRef.current) clearTimeout(chordTimerRef.current);
if (action) {
void router.push(action.href);
addRecentPage({label: action.label, href: action.href});
setOpen(false);
return true;
}
} else if (CHORDS[key]) {
chordKeyRef.current = key;
chordTimerRef.current = setTimeout(() => {
chordKeyRef.current = null;
}, 1500);
return true;
}
return false;
};
// ⌘K — global toggle
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
if (e.key === 'k' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
setOpen(prev => !prev);
return;
}
// Chord shortcuts when focus is not in any input (e.g. user tabbed away or clicked body)
if (openRef.current || e.metaKey || e.ctrlKey || e.altKey) return;
const target = e.target as HTMLElement;
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) return;
if (fireChord(e.key.toLowerCase())) e.preventDefault();
};
document.addEventListener('keydown', handleKey);
return () => document.removeEventListener('keydown', handleKey);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [router]);
useEffect(() => {
if (!open) {
const timer = setTimeout(() => {
setQuery('');
setDebouncedQuery('');
}, 150);
return () => clearTimeout(timer);
}
}, [open]);
useEffect(() => {
const timer = setTimeout(() => setDebouncedQuery(query), 200);
return () => clearTimeout(timer);
}, [query]);
const shouldSearch = open && debouncedQuery.length > 0;
const {data: contactsData} = useSWR<CursorPaginatedResponse<Contact>>(
shouldSearch ? `/contacts?search=${encodeURIComponent(debouncedQuery)}&limit=5` : null,
{revalidateOnFocus: false},
);
const {data: templatesData} = useSWR<PaginatedResponse<Template>>(
shouldSearch ? `/templates?search=${encodeURIComponent(debouncedQuery)}&pageSize=5` : null,
{revalidateOnFocus: false},
);
const {data: workflowsData} = useSWR<PaginatedResponse<Workflow>>(
shouldSearch ? `/workflows?search=${encodeURIComponent(debouncedQuery)}&pageSize=5` : null,
{revalidateOnFocus: false},
);
const {data: segmentsData} = useSWR<Segment[]>(shouldSearch ? '/segments' : null, {
revalidateOnFocus: false,
});
const {data: campaignsData} = useSWR<PaginatedResponse<Campaign>>(shouldSearch ? '/campaigns?pageSize=20' : null, {
revalidateOnFocus: false,
});
const contacts = contactsData?.data ?? [];
const templates = templatesData?.data ?? [];
const workflows = workflowsData?.data ?? [];
const segments = (segmentsData ?? []).filter(s => matches(s.name, debouncedQuery));
const campaigns = (campaignsData?.data ?? []).filter(
c => matches(c.name, debouncedQuery) || matches(c.subject ?? '', debouncedQuery),
);
const filteredNavActions = shouldSearch
? NAV_ACTIONS.filter(a => matches(a.label, query) || matches(a.keywords, query))
: NAV_ACTIONS;
const filteredCreateActions = shouldSearch
? CREATE_ACTIONS.filter(a => matches(a.label, query) || matches(a.keywords, query))
: CREATE_ACTIONS;
const navigate = (href: string, label: string) => {
void router.push(href);
addRecentPage({label, href});
setOpen(false);
};
const copySecretKey = () => {
if (!activeProject?.secret) return;
void navigator.clipboard.writeText(activeProject.secret);
toast.success('Secret key copied');
setOpen(false);
};
const copyPublicKey = () => {
if (!activeProject?.public) return;
void navigator.clipboard.writeText(activeProject.public);
toast.success('Public key copied');
setOpen(false);
};
const hasResults = shouldSearch
? contacts.length > 0 ||
campaigns.length > 0 ||
templates.length > 0 ||
workflows.length > 0 ||
segments.length > 0 ||
filteredNavActions.length > 0 ||
filteredCreateActions.length > 0
: true;
return (
<CommandDialog open={open} onOpenChange={setOpen}>
<CommandInput placeholder="Search or jump to…" value={query} onValueChange={setQuery} />
<CommandList className="pb-2">
{!hasResults && <CommandEmpty>No results for &ldquo;{query}&rdquo;</CommandEmpty>}
{!shouldSearch && recentPages.length > 0 && (
<CommandGroup heading="Recent">
{recentPages.map(page => (
<CommandItem
key={page.href}
value={`recent-${page.href}`}
onSelect={() => navigate(page.href, page.label)}
>
<Clock className="mr-3 h-4 w-4 text-neutral-400 shrink-0" />
<span>{page.label}</span>
</CommandItem>
))}
</CommandGroup>
)}
{shouldSearch && contacts.length > 0 && (
<>
<CommandGroup heading="Contacts">
{contacts.map(contact => (
<CommandItem
key={contact.id}
value={`contact-${contact.id}-${contact.email}`}
onSelect={() => navigate(`/contacts/${contact.id}`, contact.email)}
>
<Users className="mr-3 h-4 w-4 text-neutral-400 shrink-0" />
<span>{contact.email}</span>
</CommandItem>
))}
</CommandGroup>
<CommandSeparator />
</>
)}
{shouldSearch && campaigns.length > 0 && (
<>
<CommandGroup heading="Campaigns">
{campaigns.map(campaign => (
<CommandItem
key={campaign.id}
value={`campaign-${campaign.id}-${campaign.name}`}
onSelect={() => navigate(`/campaigns/${campaign.id}`, campaign.name)}
>
<Megaphone className="mr-3 h-4 w-4 text-neutral-400 shrink-0" />
<span>{campaign.name}</span>
</CommandItem>
))}
</CommandGroup>
<CommandSeparator />
</>
)}
{shouldSearch && templates.length > 0 && (
<>
<CommandGroup heading="Templates">
{templates.map(template => (
<CommandItem
key={template.id}
value={`template-${template.id}-${template.name}`}
onSelect={() => navigate(`/templates/${template.id}`, template.name)}
>
<FileText className="mr-3 h-4 w-4 text-neutral-400 shrink-0" />
<span>{template.name}</span>
</CommandItem>
))}
</CommandGroup>
<CommandSeparator />
</>
)}
{shouldSearch && workflows.length > 0 && (
<>
<CommandGroup heading="Workflows">
{workflows.map(workflow => (
<CommandItem
key={workflow.id}
value={`workflow-${workflow.id}-${workflow.name}`}
onSelect={() => navigate(`/workflows/${workflow.id}`, workflow.name)}
>
<WorkflowIcon className="mr-3 h-4 w-4 text-neutral-400 shrink-0" />
<span>{workflow.name}</span>
</CommandItem>
))}
</CommandGroup>
<CommandSeparator />
</>
)}
{shouldSearch && segments.length > 0 && (
<>
<CommandGroup heading="Segments">
{segments.map(segment => (
<CommandItem
key={segment.id}
value={`segment-${segment.id}-${segment.name}`}
onSelect={() => navigate(`/segments/${segment.id}`, segment.name)}
>
<Layers className="mr-3 h-4 w-4 text-neutral-400 shrink-0" />
<span>{segment.name}</span>
</CommandItem>
))}
</CommandGroup>
<CommandSeparator />
</>
)}
{filteredNavActions.length > 0 && (
<CommandGroup heading="Go to">
{filteredNavActions.map(action => {
const Icon = action.icon;
return (
<CommandItem
key={action.href}
value={`nav-${action.href}-${action.label}-${action.keywords}`}
onSelect={() => navigate(action.href, action.label)}
>
<Icon className="mr-3 h-4 w-4 text-neutral-400 shrink-0" />
<span>{action.label}</span>
<ShortcutHint shortcut={action.shortcut} />
</CommandItem>
);
})}
</CommandGroup>
)}
{filteredCreateActions.length > 0 && (
<>
<CommandSeparator />
<CommandGroup heading="Create">
{filteredCreateActions.map(action => {
const Icon = action.icon;
return (
<CommandItem
key={action.href + action.label}
value={`create-${action.label}-${action.keywords}`}
onSelect={() => navigate(action.href, action.label)}
>
<Icon className="mr-3 h-4 w-4 text-neutral-400 shrink-0" />
<span>{action.label}</span>
<ShortcutHint shortcut={action.shortcut} />
</CommandItem>
);
})}
</CommandGroup>
</>
)}
{(!shouldSearch || matches('api key copy code developer', query)) && (
<>
<CommandSeparator />
<CommandGroup heading="Developer">
{activeProject?.secret && (!shouldSearch || matches('copy secret key private developer', query)) && (
<CommandItem value="copy-secret-key-private-developer" onSelect={copySecretKey}>
<Copy className="mr-3 h-4 w-4 text-neutral-400 shrink-0" />
<span>Copy secret key</span>
<span className="ml-auto font-mono text-xs text-neutral-400 truncate max-w-[120px]">
{activeProject.secret.slice(0, 8)}
</span>
</CommandItem>
)}
{activeProject?.public &&
(!shouldSearch || matches('copy public key frontend client developer', query)) && (
<CommandItem value="copy-public-key-frontend-client-developer" onSelect={copyPublicKey}>
<Copy className="mr-3 h-4 w-4 text-neutral-400 shrink-0" />
<span>Copy public key</span>
<span className="ml-auto font-mono text-xs text-neutral-400 truncate max-w-[120px]">
{activeProject.public.slice(0, 8)}
</span>
</CommandItem>
)}
{(!shouldSearch || matches('documentation docs wiki help', query)) && (
<CommandItem
value="open-documentation-docs-wiki-help"
onSelect={() => {
window.open(WIKI_URI, '_blank');
setOpen(false);
}}
>
<BookOpen className="mr-3 h-4 w-4 text-neutral-400 shrink-0" />
<span>Documentation</span>
</CommandItem>
)}
</CommandGroup>
</>
)}
</CommandList>
<div className="flex items-center justify-between border-t border-neutral-100 px-4 py-2.5">
<span className="text-xs text-neutral-400">
<Kbd>G</Kbd> go to · <Kbd>N</Kbd> new
</span>
<span className="flex items-center gap-3">
<span className="flex items-center gap-1 text-xs text-neutral-400">
<Kbd></Kbd> navigate
</span>
<span className="flex items-center gap-1 text-xs text-neutral-400">
<Kbd></Kbd> open
</span>
<span className="flex items-center gap-1 text-xs text-neutral-400">
<Kbd>esc</Kbd> close
</span>
</span>
</div>
</CommandDialog>
);
}
+11 -3
View File
@@ -159,9 +159,17 @@ export function DashboardLayout({children}: DashboardLayoutProps) {
) => (
<>
{/* Logo */}
<div className="h-16 flex items-center gap-2 px-6 border-b border-neutral-200">
<Image src="/assets/logo.png" alt="Plunk" width={28} height={28} className="rounded" />
<h1 className="text-xl font-bold text-neutral-900">Plunk</h1>
<div className="h-16 flex items-center justify-between px-6 border-b border-neutral-200">
<div className="flex items-center gap-2">
<Image src="/assets/logo.png" alt="Plunk" width={28} height={28} className="rounded" />
<h1 className="text-xl font-bold text-neutral-900">Plunk</h1>
</div>
<button
onClick={() => document.dispatchEvent(new KeyboardEvent('keydown', {key: 'k', metaKey: true, bubbles: true}))}
className="hidden lg:flex items-center gap-0.5 px-1.5 py-0.5 text-[10px] font-medium text-neutral-400 bg-neutral-100 border border-neutral-200 rounded hover:bg-neutral-200 hover:text-neutral-600 transition-colors cursor-pointer"
>
<span></span><span>K</span>
</button>
</div>
{/* Project Switcher */}