diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx new file mode 100644 index 0000000..a24b68e --- /dev/null +++ b/apps/web/src/components/CommandPalette.tsx @@ -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> = {}; +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 ( + + {shortcut[0]} + {shortcut[1]} + + ); +} + +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(null); + const chordTimerRef = useRef | 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>( + shouldSearch ? `/contacts?search=${encodeURIComponent(debouncedQuery)}&limit=5` : null, + {revalidateOnFocus: false}, + ); + const {data: templatesData} = useSWR>( + shouldSearch ? `/templates?search=${encodeURIComponent(debouncedQuery)}&pageSize=5` : null, + {revalidateOnFocus: false}, + ); + const {data: workflowsData} = useSWR>( + shouldSearch ? `/workflows?search=${encodeURIComponent(debouncedQuery)}&pageSize=5` : null, + {revalidateOnFocus: false}, + ); + const {data: segmentsData} = useSWR(shouldSearch ? '/segments' : null, { + revalidateOnFocus: false, + }); + const {data: campaignsData} = useSWR>(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 ( + + + + {!hasResults && No results for “{query}”} + + {!shouldSearch && recentPages.length > 0 && ( + + {recentPages.map(page => ( + navigate(page.href, page.label)} + > + + {page.label} + + ))} + + )} + + {shouldSearch && contacts.length > 0 && ( + <> + + {contacts.map(contact => ( + navigate(`/contacts/${contact.id}`, contact.email)} + > + + {contact.email} + + ))} + + + + )} + + {shouldSearch && campaigns.length > 0 && ( + <> + + {campaigns.map(campaign => ( + navigate(`/campaigns/${campaign.id}`, campaign.name)} + > + + {campaign.name} + + ))} + + + + )} + + {shouldSearch && templates.length > 0 && ( + <> + + {templates.map(template => ( + navigate(`/templates/${template.id}`, template.name)} + > + + {template.name} + + ))} + + + + )} + + {shouldSearch && workflows.length > 0 && ( + <> + + {workflows.map(workflow => ( + navigate(`/workflows/${workflow.id}`, workflow.name)} + > + + {workflow.name} + + ))} + + + + )} + + {shouldSearch && segments.length > 0 && ( + <> + + {segments.map(segment => ( + navigate(`/segments/${segment.id}`, segment.name)} + > + + {segment.name} + + ))} + + + + )} + + {filteredNavActions.length > 0 && ( + + {filteredNavActions.map(action => { + const Icon = action.icon; + return ( + navigate(action.href, action.label)} + > + + {action.label} + + + ); + })} + + )} + + {filteredCreateActions.length > 0 && ( + <> + + + {filteredCreateActions.map(action => { + const Icon = action.icon; + return ( + navigate(action.href, action.label)} + > + + {action.label} + + + ); + })} + + + )} + + {(!shouldSearch || matches('api key copy code developer', query)) && ( + <> + + + {activeProject?.secret && (!shouldSearch || matches('copy secret key private developer', query)) && ( + + + Copy secret key + + {activeProject.secret.slice(0, 8)}… + + + )} + {activeProject?.public && + (!shouldSearch || matches('copy public key frontend client developer', query)) && ( + + + Copy public key + + {activeProject.public.slice(0, 8)}… + + + )} + {(!shouldSearch || matches('documentation docs wiki help', query)) && ( + { + window.open(WIKI_URI, '_blank'); + setOpen(false); + }} + > + + Documentation + + )} + + + )} + + +
+ + G go to · N new + + + + ↑↓ navigate + + + open + + + esc close + + +
+
+ ); +} diff --git a/apps/web/src/components/DashboardLayout.tsx b/apps/web/src/components/DashboardLayout.tsx index e127437..c49a920 100644 --- a/apps/web/src/components/DashboardLayout.tsx +++ b/apps/web/src/components/DashboardLayout.tsx @@ -159,9 +159,17 @@ export function DashboardLayout({children}: DashboardLayoutProps) { ) => ( <> {/* Logo */} -
- Plunk -

Plunk

+
+
+ Plunk +

Plunk

+
+
{/* Project Switcher */} diff --git a/apps/web/src/lib/recentPages.ts b/apps/web/src/lib/recentPages.ts new file mode 100644 index 0000000..347b0ec --- /dev/null +++ b/apps/web/src/lib/recentPages.ts @@ -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 = { + '/': '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; +} diff --git a/apps/web/src/pages/_app.tsx b/apps/web/src/pages/_app.tsx index 461dbcd..135db14 100644 --- a/apps/web/src/pages/_app.tsx +++ b/apps/web/src/pages/_app.tsx @@ -8,9 +8,11 @@ import {DefaultSeo} from 'next-seo'; import {NuqsAdapter} from 'nuqs/adapters/next/pages'; import {Loader} from '@plunk/ui'; import {ActiveProjectProvider} from '../lib/contexts/ActiveProjectProvider'; +import {CommandPalette} from '../components/CommandPalette'; import {useProjects} from '../lib/hooks/useProject'; import {useUser} from '../lib/hooks/useUser'; import {network} from '../lib/network'; +import {addRecentPage, getFallbackLabel} from '../lib/recentPages'; import dayjs from 'dayjs'; import relativeTime from 'dayjs/plugin/relativeTime'; import advancedFormat from 'dayjs/plugin/advancedFormat'; @@ -135,6 +137,31 @@ function Root(props: AppProps) { 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 ( <> @@ -145,6 +172,7 @@ function Root(props: AppProps) { ) : ( + )} diff --git a/apps/web/src/pages/campaigns/[id].tsx b/apps/web/src/pages/campaigns/[id].tsx index 1aa696a..fb0a382 100644 --- a/apps/web/src/pages/campaigns/[id].tsx +++ b/apps/web/src/pages/campaigns/[id].tsx @@ -59,6 +59,7 @@ import {useRouter} from 'next/router'; import {useEffect, useState} from 'react'; import {toast} from 'sonner'; import useSWR from 'swr'; +import {NextSeo} from 'next-seo'; import {useActiveProject} from '../../lib/contexts/ActiveProjectProvider'; interface CampaignStats { @@ -338,6 +339,7 @@ export default function CampaignDetailsPage() { if (isEditMode) { return ( +
{/* Header */}
@@ -842,6 +844,7 @@ export default function CampaignDetailsPage() { // Render stats view for sent/scheduled campaigns return ( +
{/* Header */}
diff --git a/apps/web/src/pages/segments/[id].tsx b/apps/web/src/pages/segments/[id].tsx index 6f5e57d..d35bb27 100644 --- a/apps/web/src/pages/segments/[id].tsx +++ b/apps/web/src/pages/segments/[id].tsx @@ -22,6 +22,7 @@ import {useRouter} from 'next/router'; import {useEffect, useState} from 'react'; import {toast} from 'sonner'; import useSWR from 'swr'; +import {NextSeo} from 'next-seo'; import type {FilterCondition} from '@plunk/types'; import {SegmentSchemas} from '@plunk/shared'; import {SegmentFilterBuilder} from '../../components/SegmentFilterBuilder'; @@ -210,6 +211,7 @@ export default function SegmentDetailPage() { return ( +
{/* Header */}
diff --git a/apps/web/src/pages/templates/[id].tsx b/apps/web/src/pages/templates/[id].tsx index 96d17db..7b3de40 100644 --- a/apps/web/src/pages/templates/[id].tsx +++ b/apps/web/src/pages/templates/[id].tsx @@ -19,6 +19,7 @@ import {network} from '../../lib/network'; import {useChangeTracking} from '../../lib/hooks/useChangeTracking'; import {ArrowLeft, Save, Trash2, TriangleAlert} from 'lucide-react'; import Link from 'next/link'; +import {NextSeo} from 'next-seo'; import {useRouter} from 'next/router'; import {useEffect, useState} from 'react'; import {toast} from 'sonner'; @@ -126,6 +127,7 @@ export default function TemplateEditorPage() { return ( + {/* Header */}
diff --git a/apps/web/src/pages/workflows/[id].tsx b/apps/web/src/pages/workflows/[id].tsx index 9e63f47..10fdf59 100644 --- a/apps/web/src/pages/workflows/[id].tsx +++ b/apps/web/src/pages/workflows/[id].tsx @@ -62,6 +62,7 @@ import {useRouter} from 'next/router'; import {useEffect, useState} from 'react'; import {toast} from 'sonner'; import useSWR from 'swr'; +import {NextSeo} from 'next-seo'; import {WorkflowBuilder} from '../../components/WorkflowBuilder'; import {KeyValueEditor} from '../../components/KeyValueEditor'; import {TemplateSearchPicker} from '../../components/TemplateSearchPicker'; @@ -440,6 +441,7 @@ export default function WorkflowEditorPage() { return ( +
{/* Header */}
diff --git a/apps/web/src/styles/globals.css b/apps/web/src/styles/globals.css index e06fc07..5b4dd96 100644 --- a/apps/web/src/styles/globals.css +++ b/apps/web/src/styles/globals.css @@ -2,6 +2,7 @@ @plugin '@tailwindcss/forms'; @plugin '@tailwindcss/typography'; +@plugin 'tailwindcss-animate'; @source '../../../../packages/ui/src/**/*.{ts,tsx}'; diff --git a/packages/ui/src/components/atoms/Command.tsx b/packages/ui/src/components/atoms/Command.tsx index c5e286f..403efeb 100644 --- a/packages/ui/src/components/atoms/Command.tsx +++ b/packages/ui/src/components/atoms/Command.tsx @@ -1,11 +1,11 @@ 'use client'; import * as React from 'react'; +import * as DialogPrimitive from '@radix-ui/react-dialog'; import {Command as CommandPrimitive} from 'cmdk'; import {Search} from 'lucide-react'; import {cn} from '../../lib'; -import {Dialog, DialogContent} from './Dialog'; const Command = React.forwardRef< React.ElementRef, @@ -13,24 +13,34 @@ const Command = React.forwardRef< >(({className, ...props}, ref) => ( )); Command.displayName = CommandPrimitive.displayName; -const CommandDialog = ({children, ...props}: React.ComponentProps) => { +const CommandDialog = ({children, ...props}: React.ComponentProps) => { return ( - - - - {children} - - - + + + + e.preventDefault()} + 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', + )} + > + + {children} + + + + ); }; @@ -38,12 +48,12 @@ const CommandInput = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({className, ...props}, ref) => ( -
- +
+ (({className, ...props}, ref) => ( )); @@ -70,11 +80,7 @@ const CommandEmpty = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >((props, ref) => ( - + )); CommandEmpty.displayName = CommandPrimitive.Empty.displayName; @@ -86,7 +92,7 @@ const CommandGroup = React.forwardRef< , React.ComponentPropsWithoutRef >(({className, ...props}, ref) => ( - + )); CommandSeparator.displayName = CommandPrimitive.Separator.displayName; @@ -114,7 +116,7 @@ const CommandItem = React.forwardRef< ) => { - return ( - - ); +const CommandShortcut = ({className, ...props}: React.HTMLAttributes) => { + return ; }; CommandShortcut.displayName = 'CommandShortcut'; diff --git a/packages/ui/src/components/atoms/Kbd.tsx b/packages/ui/src/components/atoms/Kbd.tsx new file mode 100644 index 0000000..52e7709 --- /dev/null +++ b/packages/ui/src/components/atoms/Kbd.tsx @@ -0,0 +1,16 @@ +import * as React from 'react'; +import {cn} from '../../lib'; + +export const Kbd = React.forwardRef>( + ({className, ...props}, ref) => ( + + ), +); +Kbd.displayName = 'Kbd'; diff --git a/packages/ui/src/components/atoms/index.ts b/packages/ui/src/components/atoms/index.ts index b81cf71..3deca2e 100644 --- a/packages/ui/src/components/atoms/index.ts +++ b/packages/ui/src/components/atoms/index.ts @@ -10,6 +10,7 @@ export * from './Dialog'; export * from './DropdownMenu'; export * from './Form'; export * from './IconSpinner'; +export * from './Kbd'; export * from './Input'; export * from './Label'; export * from './Loader';