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 */}
|
||||
<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 */}
|
||||
|
||||
@@ -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 {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 (
|
||||
<>
|
||||
<Toaster position={'top-right'} />
|
||||
@@ -145,6 +172,7 @@ function Root(props: AppProps) {
|
||||
<App {...props} />
|
||||
) : (
|
||||
<ProjectGuard>
|
||||
<CommandPalette />
|
||||
<App {...props} />
|
||||
</ProjectGuard>
|
||||
)}
|
||||
|
||||
@@ -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 (
|
||||
<DashboardLayout>
|
||||
<NextSeo title={campaign.data.name} />
|
||||
<form onSubmit={handleSave} className={`space-y-6 ${hasChanges ? 'pb-32' : ''}`}>
|
||||
{/* Header */}
|
||||
<div className="space-y-4">
|
||||
@@ -842,6 +844,7 @@ export default function CampaignDetailsPage() {
|
||||
// Render stats view for sent/scheduled campaigns
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<NextSeo title={campaign.data.name} />
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="space-y-4">
|
||||
|
||||
@@ -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 (
|
||||
<DashboardLayout>
|
||||
<NextSeo title={segment.name} />
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
|
||||
@@ -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 (
|
||||
<DashboardLayout>
|
||||
<NextSeo title={template.name} />
|
||||
<form onSubmit={handleSave} className={`max-w-5xl mx-auto space-y-6 ${hasChanges ? 'pb-32' : ''}`}>
|
||||
{/* Header */}
|
||||
<div className="space-y-4">
|
||||
|
||||
@@ -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 (
|
||||
<DashboardLayout>
|
||||
<NextSeo title={workflow.name} />
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 sm:gap-4">
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
@plugin '@tailwindcss/forms';
|
||||
@plugin '@tailwindcss/typography';
|
||||
@plugin 'tailwindcss-animate';
|
||||
|
||||
@source '../../../../packages/ui/src/**/*.{ts,tsx}';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user