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 openBehaviour:
- 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)
| Collection | Icon |
|---|---|
blogs | FileText |
social_posts | Share2 |
landing_pages | Layout |
newsletters | Mail |
activities | CheckSquare |
campaigns | Megaphone |
content_briefs | ClipboardList |
contacts | User |
leads | UserPlus |
keywords | Tag |
reports | BarChart2 |
backlinks | Link |
tenants | Building2 |
States
| State | Display |
|---|---|
| Idle (< 2 chars) | Placeholder text: “Search blogs, contacts, campaigns…” |
| Loading | Spinner in the input right slot |
| Results | Grouped 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:
| Light | Dark |
|---|---|
bg-white | dark:bg-zinc-900 |
border-border | dark:border-zinc-700 |
text-foreground | dark:text-zinc-100 |
text-muted-foreground | dark:text-zinc-400 |
bg-muted | dark:bg-zinc-800 |
hover:bg-accent | dark:hover:bg-zinc-800 |
Deep-Link URLs per Portal
The href values returned by the API are dashboard-relative paths (e.g. /blog/clxxx).
Each portal prefixes them appropriately:
| Portal | Result type | Final URL |
|---|---|---|
| Dashboard | blogs | /blog/clxxx |
| DM | blogs | /blog/clxxx (DM has parity blog pages) |
| Manage | tenants | /tenants/clyyy |
| Manage | blogs | /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.