feat: implement CommandPalette for enhanced navigation and recent pages tracking
This commit is contained in:
@@ -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 “{query}”</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -159,9 +159,17 @@ export function DashboardLayout({children}: DashboardLayoutProps) {
|
|||||||
) => (
|
) => (
|
||||||
<>
|
<>
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
<div className="h-16 flex items-center gap-2 px-6 border-b border-neutral-200">
|
<div className="h-16 flex items-center justify-between px-6 border-b border-neutral-200">
|
||||||
<Image src="/assets/logo.png" alt="Plunk" width={28} height={28} className="rounded" />
|
<div className="flex items-center gap-2">
|
||||||
<h1 className="text-xl font-bold text-neutral-900">Plunk</h1>
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* Project Switcher */}
|
{/* Project Switcher */}
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
const RECENT_KEY = 'cmdPaletteRecents';
|
||||||
|
const MAX_RECENT = 5;
|
||||||
|
|
||||||
|
export interface RecentPage {
|
||||||
|
label: string;
|
||||||
|
href: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRecentPages(): RecentPage[] {
|
||||||
|
if (typeof window === 'undefined') return [];
|
||||||
|
try {
|
||||||
|
return JSON.parse(localStorage.getItem(RECENT_KEY) ?? '[]') as RecentPage[];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addRecentPage(page: RecentPage) {
|
||||||
|
const current = getRecentPages().filter(p => p.href !== page.href);
|
||||||
|
localStorage.setItem(RECENT_KEY, JSON.stringify([page, ...current].slice(0, MAX_RECENT)));
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATIC_LABELS: Record<string, string> = {
|
||||||
|
'/': 'Dashboard',
|
||||||
|
'/contacts': 'Contacts',
|
||||||
|
'/segments': 'Segments',
|
||||||
|
'/activity': 'Activity',
|
||||||
|
'/analytics': 'Analytics',
|
||||||
|
'/templates': 'Templates',
|
||||||
|
'/workflows': 'Workflows',
|
||||||
|
'/campaigns': 'Campaigns',
|
||||||
|
'/settings': 'Settings',
|
||||||
|
'/campaigns/create': 'New Campaign',
|
||||||
|
'/templates/create': 'New Template',
|
||||||
|
'/segments/new': 'New Segment',
|
||||||
|
};
|
||||||
|
|
||||||
|
const DYNAMIC_LABELS: [RegExp, string][] = [
|
||||||
|
[/^\/contacts\/[^/]+$/, 'Contact'],
|
||||||
|
[/^\/segments\/[^/]+$/, 'Segment'],
|
||||||
|
[/^\/workflows\/[^/]+$/, 'Workflow'],
|
||||||
|
[/^\/campaigns\/[^/]+$/, 'Campaign'],
|
||||||
|
[/^\/templates\/[^/]+$/, 'Template'],
|
||||||
|
];
|
||||||
|
|
||||||
|
export function getFallbackLabel(pathname: string): string | null {
|
||||||
|
if (STATIC_LABELS[pathname]) return STATIC_LABELS[pathname];
|
||||||
|
for (const [pattern, label] of DYNAMIC_LABELS) {
|
||||||
|
if (pattern.test(pathname)) return label;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -8,9 +8,11 @@ import {DefaultSeo} from 'next-seo';
|
|||||||
import {NuqsAdapter} from 'nuqs/adapters/next/pages';
|
import {NuqsAdapter} from 'nuqs/adapters/next/pages';
|
||||||
import {Loader} from '@plunk/ui';
|
import {Loader} from '@plunk/ui';
|
||||||
import {ActiveProjectProvider} from '../lib/contexts/ActiveProjectProvider';
|
import {ActiveProjectProvider} from '../lib/contexts/ActiveProjectProvider';
|
||||||
|
import {CommandPalette} from '../components/CommandPalette';
|
||||||
import {useProjects} from '../lib/hooks/useProject';
|
import {useProjects} from '../lib/hooks/useProject';
|
||||||
import {useUser} from '../lib/hooks/useUser';
|
import {useUser} from '../lib/hooks/useUser';
|
||||||
import {network} from '../lib/network';
|
import {network} from '../lib/network';
|
||||||
|
import {addRecentPage, getFallbackLabel} from '../lib/recentPages';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import relativeTime from 'dayjs/plugin/relativeTime';
|
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||||
import advancedFormat from 'dayjs/plugin/advancedFormat';
|
import advancedFormat from 'dayjs/plugin/advancedFormat';
|
||||||
@@ -135,6 +137,31 @@ function Root(props: AppProps) {
|
|||||||
route => router.pathname === route || router.pathname.startsWith(`${route}/`),
|
route => router.pathname === route || router.pathname.startsWith(`${route}/`),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleRouteChange = () => {
|
||||||
|
const pathname = window.location.pathname;
|
||||||
|
|
||||||
|
// Pass 1: record immediately with a route-pattern label so detail pages
|
||||||
|
// are always captured even before their data loads.
|
||||||
|
const fallback = getFallbackLabel(pathname);
|
||||||
|
if (fallback) {
|
||||||
|
addRecentPage({label: fallback, href: pathname});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pass 2: after data loads, replace the label with the page's own title
|
||||||
|
// (e.g. contact email set by NextSeo after the API response arrives).
|
||||||
|
setTimeout(() => {
|
||||||
|
const titleLabel = document.title.split(' | ')[0]?.trim();
|
||||||
|
if (titleLabel && titleLabel !== 'Plunk' && titleLabel !== fallback) {
|
||||||
|
addRecentPage({label: titleLabel, href: pathname});
|
||||||
|
}
|
||||||
|
}, 800);
|
||||||
|
};
|
||||||
|
|
||||||
|
router.events.on('routeChangeComplete', handleRouteChange);
|
||||||
|
return () => router.events.off('routeChangeComplete', handleRouteChange);
|
||||||
|
}, [router.events]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Toaster position={'top-right'} />
|
<Toaster position={'top-right'} />
|
||||||
@@ -145,6 +172,7 @@ function Root(props: AppProps) {
|
|||||||
<App {...props} />
|
<App {...props} />
|
||||||
) : (
|
) : (
|
||||||
<ProjectGuard>
|
<ProjectGuard>
|
||||||
|
<CommandPalette />
|
||||||
<App {...props} />
|
<App {...props} />
|
||||||
</ProjectGuard>
|
</ProjectGuard>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ import {useRouter} from 'next/router';
|
|||||||
import {useEffect, useState} from 'react';
|
import {useEffect, useState} from 'react';
|
||||||
import {toast} from 'sonner';
|
import {toast} from 'sonner';
|
||||||
import useSWR from 'swr';
|
import useSWR from 'swr';
|
||||||
|
import {NextSeo} from 'next-seo';
|
||||||
import {useActiveProject} from '../../lib/contexts/ActiveProjectProvider';
|
import {useActiveProject} from '../../lib/contexts/ActiveProjectProvider';
|
||||||
|
|
||||||
interface CampaignStats {
|
interface CampaignStats {
|
||||||
@@ -338,6 +339,7 @@ export default function CampaignDetailsPage() {
|
|||||||
if (isEditMode) {
|
if (isEditMode) {
|
||||||
return (
|
return (
|
||||||
<DashboardLayout>
|
<DashboardLayout>
|
||||||
|
<NextSeo title={campaign.data.name} />
|
||||||
<form onSubmit={handleSave} className={`space-y-6 ${hasChanges ? 'pb-32' : ''}`}>
|
<form onSubmit={handleSave} className={`space-y-6 ${hasChanges ? 'pb-32' : ''}`}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -842,6 +844,7 @@ export default function CampaignDetailsPage() {
|
|||||||
// Render stats view for sent/scheduled campaigns
|
// Render stats view for sent/scheduled campaigns
|
||||||
return (
|
return (
|
||||||
<DashboardLayout>
|
<DashboardLayout>
|
||||||
|
<NextSeo title={campaign.data.name} />
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {useRouter} from 'next/router';
|
|||||||
import {useEffect, useState} from 'react';
|
import {useEffect, useState} from 'react';
|
||||||
import {toast} from 'sonner';
|
import {toast} from 'sonner';
|
||||||
import useSWR from 'swr';
|
import useSWR from 'swr';
|
||||||
|
import {NextSeo} from 'next-seo';
|
||||||
import type {FilterCondition} from '@plunk/types';
|
import type {FilterCondition} from '@plunk/types';
|
||||||
import {SegmentSchemas} from '@plunk/shared';
|
import {SegmentSchemas} from '@plunk/shared';
|
||||||
import {SegmentFilterBuilder} from '../../components/SegmentFilterBuilder';
|
import {SegmentFilterBuilder} from '../../components/SegmentFilterBuilder';
|
||||||
@@ -210,6 +211,7 @@ export default function SegmentDetailPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<DashboardLayout>
|
<DashboardLayout>
|
||||||
|
<NextSeo title={segment.name} />
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {network} from '../../lib/network';
|
|||||||
import {useChangeTracking} from '../../lib/hooks/useChangeTracking';
|
import {useChangeTracking} from '../../lib/hooks/useChangeTracking';
|
||||||
import {ArrowLeft, Save, Trash2, TriangleAlert} from 'lucide-react';
|
import {ArrowLeft, Save, Trash2, TriangleAlert} from 'lucide-react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import {NextSeo} from 'next-seo';
|
||||||
import {useRouter} from 'next/router';
|
import {useRouter} from 'next/router';
|
||||||
import {useEffect, useState} from 'react';
|
import {useEffect, useState} from 'react';
|
||||||
import {toast} from 'sonner';
|
import {toast} from 'sonner';
|
||||||
@@ -126,6 +127,7 @@ export default function TemplateEditorPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<DashboardLayout>
|
<DashboardLayout>
|
||||||
|
<NextSeo title={template.name} />
|
||||||
<form onSubmit={handleSave} className={`max-w-5xl mx-auto space-y-6 ${hasChanges ? 'pb-32' : ''}`}>
|
<form onSubmit={handleSave} className={`max-w-5xl mx-auto space-y-6 ${hasChanges ? 'pb-32' : ''}`}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ import {useRouter} from 'next/router';
|
|||||||
import {useEffect, useState} from 'react';
|
import {useEffect, useState} from 'react';
|
||||||
import {toast} from 'sonner';
|
import {toast} from 'sonner';
|
||||||
import useSWR from 'swr';
|
import useSWR from 'swr';
|
||||||
|
import {NextSeo} from 'next-seo';
|
||||||
import {WorkflowBuilder} from '../../components/WorkflowBuilder';
|
import {WorkflowBuilder} from '../../components/WorkflowBuilder';
|
||||||
import {KeyValueEditor} from '../../components/KeyValueEditor';
|
import {KeyValueEditor} from '../../components/KeyValueEditor';
|
||||||
import {TemplateSearchPicker} from '../../components/TemplateSearchPicker';
|
import {TemplateSearchPicker} from '../../components/TemplateSearchPicker';
|
||||||
@@ -440,6 +441,7 @@ export default function WorkflowEditorPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<DashboardLayout>
|
<DashboardLayout>
|
||||||
|
<NextSeo title={workflow.name} />
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center gap-3 sm:gap-4">
|
<div className="flex items-center gap-3 sm:gap-4">
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
@plugin '@tailwindcss/forms';
|
@plugin '@tailwindcss/forms';
|
||||||
@plugin '@tailwindcss/typography';
|
@plugin '@tailwindcss/typography';
|
||||||
|
@plugin 'tailwindcss-animate';
|
||||||
|
|
||||||
@source '../../../../packages/ui/src/**/*.{ts,tsx}';
|
@source '../../../../packages/ui/src/**/*.{ts,tsx}';
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
|
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||||
import {Command as CommandPrimitive} from 'cmdk';
|
import {Command as CommandPrimitive} from 'cmdk';
|
||||||
import {Search} from 'lucide-react';
|
import {Search} from 'lucide-react';
|
||||||
|
|
||||||
import {cn} from '../../lib';
|
import {cn} from '../../lib';
|
||||||
import {Dialog, DialogContent} from './Dialog';
|
|
||||||
|
|
||||||
const Command = React.forwardRef<
|
const Command = React.forwardRef<
|
||||||
React.ElementRef<typeof CommandPrimitive>,
|
React.ElementRef<typeof CommandPrimitive>,
|
||||||
@@ -13,24 +13,34 @@ const Command = React.forwardRef<
|
|||||||
>(({className, ...props}, ref) => (
|
>(({className, ...props}, ref) => (
|
||||||
<CommandPrimitive
|
<CommandPrimitive
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn('flex h-full w-full flex-col overflow-hidden rounded-md bg-white text-neutral-950', className)}
|
||||||
'flex h-full w-full flex-col overflow-hidden rounded-md bg-white text-neutral-950',
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
));
|
));
|
||||||
Command.displayName = CommandPrimitive.displayName;
|
Command.displayName = CommandPrimitive.displayName;
|
||||||
|
|
||||||
const CommandDialog = ({children, ...props}: React.ComponentProps<typeof Dialog>) => {
|
const CommandDialog = ({children, ...props}: React.ComponentProps<typeof DialogPrimitive.Root>) => {
|
||||||
return (
|
return (
|
||||||
<Dialog {...props}>
|
<DialogPrimitive.Root {...props}>
|
||||||
<DialogContent className="overflow-hidden p-0 shadow-lg">
|
<DialogPrimitive.Portal>
|
||||||
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-neutral-500 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[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">
|
<DialogPrimitive.Overlay className="fixed inset-0 z-50 bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 duration-150" />
|
||||||
{children}
|
<DialogPrimitive.Content
|
||||||
</Command>
|
aria-describedby={undefined}
|
||||||
</DialogContent>
|
onCloseAutoFocus={e => e.preventDefault()}
|
||||||
</Dialog>
|
className={cn(
|
||||||
|
'fixed inset-x-0 top-[14%] z-50 mx-auto w-[92vw] max-w-2xl outline-none',
|
||||||
|
'overflow-hidden rounded-xl border border-neutral-200 bg-white shadow-2xl',
|
||||||
|
'duration-150',
|
||||||
|
'data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 data-[state=open]:slide-in-from-top-3',
|
||||||
|
'data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=closed]:slide-out-to-top-3',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Command className="[&_[cmdk-group-heading]]:px-3 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-neutral-400 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-14 [&_[cmdk-item]]:px-3 [&_[cmdk-item]]:py-2.5 [&_[cmdk-item]_svg]:h-4 [&_[cmdk-item]_svg]:w-4">
|
||||||
|
{children}
|
||||||
|
</Command>
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPrimitive.Portal>
|
||||||
|
</DialogPrimitive.Root>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -38,12 +48,12 @@ const CommandInput = React.forwardRef<
|
|||||||
React.ElementRef<typeof CommandPrimitive.Input>,
|
React.ElementRef<typeof CommandPrimitive.Input>,
|
||||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
|
||||||
>(({className, ...props}, ref) => (
|
>(({className, ...props}, ref) => (
|
||||||
<div className="flex items-center border-b border-neutral-200 px-3" cmdk-input-wrapper="">
|
<div className="flex items-center border-b border-neutral-200 px-4 focus-within:border-neutral-200" cmdk-input-wrapper="">
|
||||||
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
<Search className="mr-3 h-5 w-5 shrink-0 text-neutral-400" />
|
||||||
<CommandPrimitive.Input
|
<CommandPrimitive.Input
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-neutral-500 disabled:cursor-not-allowed disabled:opacity-50',
|
'flex h-14 w-full rounded-md bg-transparent text-sm outline-none! ring-0! shadow-none! border-transparent! placeholder:text-neutral-400 disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -59,7 +69,7 @@ const CommandList = React.forwardRef<
|
|||||||
>(({className, ...props}, ref) => (
|
>(({className, ...props}, ref) => (
|
||||||
<CommandPrimitive.List
|
<CommandPrimitive.List
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn('max-h-[300px] overflow-y-auto overflow-x-hidden', className)}
|
className={cn('max-h-[440px] overflow-y-auto overflow-x-hidden', className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
));
|
));
|
||||||
@@ -70,11 +80,7 @@ const CommandEmpty = React.forwardRef<
|
|||||||
React.ElementRef<typeof CommandPrimitive.Empty>,
|
React.ElementRef<typeof CommandPrimitive.Empty>,
|
||||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
|
||||||
>((props, ref) => (
|
>((props, ref) => (
|
||||||
<CommandPrimitive.Empty
|
<CommandPrimitive.Empty ref={ref} className="py-6 text-center text-sm text-neutral-500" {...props} />
|
||||||
ref={ref}
|
|
||||||
className="py-6 text-center text-sm text-neutral-500"
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
));
|
));
|
||||||
|
|
||||||
CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
|
CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
|
||||||
@@ -86,7 +92,7 @@ const CommandGroup = React.forwardRef<
|
|||||||
<CommandPrimitive.Group
|
<CommandPrimitive.Group
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
'overflow-hidden p-1 text-neutral-950 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-neutral-500',
|
'overflow-hidden p-2 text-neutral-950 [&_[cmdk-group-heading]]:px-3 [&_[cmdk-group-heading]]:py-2 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-neutral-400 [&_[cmdk-group-heading]]:uppercase [&_[cmdk-group-heading]]:tracking-wider',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -99,11 +105,7 @@ const CommandSeparator = React.forwardRef<
|
|||||||
React.ElementRef<typeof CommandPrimitive.Separator>,
|
React.ElementRef<typeof CommandPrimitive.Separator>,
|
||||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
|
||||||
>(({className, ...props}, ref) => (
|
>(({className, ...props}, ref) => (
|
||||||
<CommandPrimitive.Separator
|
<CommandPrimitive.Separator ref={ref} className={cn('-mx-1 h-px bg-neutral-200', className)} {...props} />
|
||||||
ref={ref}
|
|
||||||
className={cn('-mx-1 h-px bg-neutral-200', className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
));
|
));
|
||||||
CommandSeparator.displayName = CommandPrimitive.Separator.displayName;
|
CommandSeparator.displayName = CommandPrimitive.Separator.displayName;
|
||||||
|
|
||||||
@@ -114,7 +116,7 @@ const CommandItem = React.forwardRef<
|
|||||||
<CommandPrimitive.Item
|
<CommandPrimitive.Item
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
'relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none text-neutral-900 aria-selected:bg-neutral-100 aria-selected:text-neutral-900 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 hover:bg-neutral-50',
|
'relative flex w-full cursor-pointer select-none items-center rounded-md px-3 py-2.5 text-sm outline-none text-neutral-900 aria-selected:bg-neutral-100 aria-selected:text-neutral-900 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 hover:bg-neutral-50',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -123,19 +125,8 @@ const CommandItem = React.forwardRef<
|
|||||||
|
|
||||||
CommandItem.displayName = CommandPrimitive.Item.displayName;
|
CommandItem.displayName = CommandPrimitive.Item.displayName;
|
||||||
|
|
||||||
const CommandShortcut = ({
|
const CommandShortcut = ({className, ...props}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||||
className,
|
return <span className={cn('ml-auto text-xs tracking-widest text-neutral-500', className)} {...props} />;
|
||||||
...props
|
|
||||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
'ml-auto text-xs tracking-widest text-neutral-500',
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
CommandShortcut.displayName = 'CommandShortcut';
|
CommandShortcut.displayName = 'CommandShortcut';
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import {cn} from '../../lib';
|
||||||
|
|
||||||
|
export const Kbd = React.forwardRef<HTMLElement, React.HTMLAttributes<HTMLElement>>(
|
||||||
|
({className, ...props}, ref) => (
|
||||||
|
<kbd
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex h-5 items-center justify-center rounded border border-neutral-300 bg-neutral-100 px-1.5 font-mono text-[10px] font-medium text-neutral-600',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
Kbd.displayName = 'Kbd';
|
||||||
@@ -10,6 +10,7 @@ export * from './Dialog';
|
|||||||
export * from './DropdownMenu';
|
export * from './DropdownMenu';
|
||||||
export * from './Form';
|
export * from './Form';
|
||||||
export * from './IconSpinner';
|
export * from './IconSpinner';
|
||||||
|
export * from './Kbd';
|
||||||
export * from './Input';
|
export * from './Input';
|
||||||
export * from './Label';
|
export * from './Label';
|
||||||
export * from './Loader';
|
export * from './Loader';
|
||||||
|
|||||||
Reference in New Issue
Block a user