Skip to Content
Global SearchGlobal Search — UI

Global Search — UI

Overview

The search UI is a Ctrl+K command palette modal shared across all three portals via packages/ui. Each portal mounts it in its TopBar and passes a scope prop that controls which API endpoint is called and which entity types are shown.

For the API contract and result shape, see architecture.md.


UX Pattern

TopBar (all three portals) ┌──────────────────────────────────────────────────────┐ │ [≡ sidebar] 🔍 Search... Ctrl+K [icons...] │ └──────────────────────────────────────────────────────┘ ↓ click or Ctrl+K ┌──────────────────────────────────────────────────────┐ │ 🔍 Search blogs, contacts, campaigns... │ ← autofocused input ├──────────────────────────────────────────────────────┤ │ BLOGS │ │ 📝 Q2 SEO Strategy for Acme Corp draft │ │ 📝 10 Tips for Email Marketing published │ ├──────────────────────────────────────────────────────┤ │ CONTACTS │ │ 👤 John Smith · Acme Corp lead │ ├──────────────────────────────────────────────────────┤ │ CAMPAIGNS │ │ 📣 Q2 Launch Campaign active │ └──────────────────────────────────────────────────────┘ Showing 6 of 47 results · Press ↑↓ to navigate, Enter to open

Behaviour:

  • Opens on Ctrl+K (Windows/Linux) or Cmd+K (Mac) from anywhere in the portal
  • Closes on Escape, clicking the overlay, or after navigating to a result
  • Minimum 2 characters before a search fires
  • 300ms debounce on the input
  • Keyboard navigation: ↑↓ to move between results, Enter to open, Tab to move between sections
  • Results grouped by entity type, sorted by Typesense text-match score
  • Each result links to the correct deep-link URL for that entity in the current portal

Shared Component — packages/ui

GlobalSearch.tsx

Props:

interface GlobalSearchProps { scope: "tenant" | "admin" // "tenant" → POST /v1/search (injected tenantId from server session) // "admin" → POST /v1/admin/search (manage portal) apiBase: string // Base URL for the API, e.g. "http://localhost:3003" // Passed in so the component works across portals without hardcoding getAuthHeaders: () => Promise<Record<string, string>> // Async function that returns { Authorization: "Bearer ..." } // All portals use JWT cookies — pass empty headers; the API reads the cookie onNavigate: (href: string) => void // Called when user selects a result; portal passes router.push or similar }

Component structure

GlobalSearch.tsx ├── Ctrl+K keydown listener (document-level, on mount) ├── Modal overlay (z-50, backdrop-blur) │ ├── Input (autofocus, debounced onChange) │ ├── ResultsList │ │ └── ResultGroup (per entity type) │ │ └── GlobalSearchResult (one per hit) │ └── Footer hint ("↑↓ navigate · Enter open · Esc close") └── Loading / empty / error states GlobalSearchResult.tsx ├── Entity type icon (Lucide icon per collection) ├── Title (highlighted if Typesense returns highlight snippets) ├── Subtitle (status badge) └── Entity type label (small muted text)

Entity type icons (Lucide)

CollectionIcon
blogsFileText
social_postsShare2
landing_pagesLayout
newslettersMail
activitiesCheckSquare
campaignsMegaphone
content_briefsClipboardList
contactsUser
leadsUserPlus
keywordsTag
reportsBarChart2
backlinksLink
tenantsBuilding2

States

StateDisplay
Idle (< 2 chars)Placeholder text: “Search blogs, contacts, campaigns…”
LoadingSpinner in the input right slot
ResultsGrouped list (see layout above)
No results”No results for “{query}"" centred, no error icon
Search unavailable (503)“Search is temporarily unavailable” — muted, no crash

Portal Integration

Dashboard — apps/dashboard/src/components/topbar.tsx

Add a search trigger button between the left spacer and the right actions cluster:

import { GlobalSearch } from "@leadmetrics/ui" import { useRouter } from "next/navigation" // Inside TopBar: const router = useRouter() const [searchOpen, setSearchOpen] = useState(false) <button onClick={() => setSearchOpen(true)} className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors px-3 py-1.5 rounded-md border border-border/50 hover:border-border" > <Search className="h-4 w-4" /> <span className="hidden sm:inline">Search</span> <kbd className="hidden sm:inline text-xs bg-muted px-1.5 py-0.5 rounded"> Ctrl+K </kbd> </button> {searchOpen && ( <GlobalSearch scope="tenant" apiBase={process.env.NEXT_PUBLIC_API_URL!} getAuthHeaders={async () => { // Dashboard uses JWT cookie (dashboard_access_token) — sent automatically // Pass empty headers; the API middleware reads the cookie return {} }} onNavigate={(href) => { router.push(href) setSearchOpen(false) }} /> )}

The Ctrl+K keydown listener inside GlobalSearch handles opening from keyboard; the button above handles mouse/touch.

DM Portal — apps/dm/src/components/topbar.tsx

Same pattern as Dashboard. The DM portal uses JWT cookie auth, so getAuthHeaders returns {} too (cookie is sent automatically in same-origin requests). The scope="tenant" prop causes the API to use the active tenant from the JWT.

Manage Portal — apps/manage/src/components/topbar.tsx

<GlobalSearch scope="admin" apiBase={process.env.NEXT_PUBLIC_API_URL!} getAuthHeaders={async () => ({})} // JWT cookie onNavigate={(href) => { router.push(href) setSearchOpen(false) }} />

The scope="admin" prop routes requests to POST /v1/admin/search. Results include a tenantName field which GlobalSearchResult renders as a third line under the subtitle.


Dark Mode

All GlobalSearch classes must include dark: counterparts per the project Tailwind convention. Key pairs:

LightDark
bg-whitedark:bg-zinc-900
border-borderdark:border-zinc-700
text-foregrounddark:text-zinc-100
text-muted-foregrounddark:text-zinc-400
bg-muteddark:bg-zinc-800
hover:bg-accentdark:hover:bg-zinc-800

The href values returned by the API are dashboard-relative paths (e.g. /blog/clxxx). Each portal prefixes them appropriately:

PortalResult typeFinal URL
Dashboardblogs/blog/clxxx
DMblogs/blog/clxxx (DM has parity blog pages)
Managetenants/tenants/clyyy
Manageblogs/tenants/clyyy/content/blog/clxxx (if deep-link to content within tenant)

For Manage, the onNavigate handler should inspect whether the result has a tenantId and build the correct manage-portal path. The simplest initial implementation navigates to the tenant detail page (/tenants/{tenantId}) for non-tenant results.

© 2026 Leadmetrics — Internal use only