Task 18: Add command palette (Ctrl+K)
- Create CommandPalette.tsx with overlay, search input, grouped results, keyboard navigation (arrows, Enter, Escape), and footer hints - Rebuild search.ts with PaletteItem model: 24 entries across 6 sections (Experience, Core Skills, Active Projects, Achievements, Education, Quick Actions) matching concept HTML structure - Fuzzy search via fuse.js with weighted keys (title, subtitle, keywords) - Wire into DashboardLayout with global Ctrl+K listener and TopBar click - Action system: scroll-to-tile, expand-item, external links, download CV - Add data-tile-id to all Card/tile components for scroll targeting - CSS animations: palette-overlay-in, palette-modal-in with prefers-reduced-motion support - Maintain backward-compatible legacy exports for ClinicalSidebar (will be removed in Task 21) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -4,9 +4,10 @@ interface CardProps {
|
||||
children: React.ReactNode
|
||||
full?: boolean // spans both grid columns
|
||||
className?: string
|
||||
tileId?: string // data-tile-id for command palette scroll targeting
|
||||
}
|
||||
|
||||
export function Card({ children, full, className }: CardProps) {
|
||||
export function Card({ children, full, className, tileId }: CardProps) {
|
||||
const [isHovered, setIsHovered] = React.useState(false)
|
||||
|
||||
const baseStyles: React.CSSProperties = {
|
||||
@@ -25,6 +26,7 @@ export function Card({ children, full, className }: CardProps) {
|
||||
<div
|
||||
style={baseStyles}
|
||||
className={className}
|
||||
data-tile-id={tileId}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
} from 'lucide-react'
|
||||
import type { ViewId } from '../types/pmr'
|
||||
import { useAccessibility } from '../contexts/AccessibilityContext'
|
||||
import { buildSearchIndex, groupResultsBySection, type SearchResult } from '../lib/search'
|
||||
import { buildLegacySearchIndex, groupResultsBySection, type SearchResult } from '../lib/search'
|
||||
import type { FuseResult } from 'fuse.js'
|
||||
|
||||
interface NavItem {
|
||||
@@ -55,7 +55,7 @@ export function ClinicalSidebar({ activeView, onViewChange, isTablet = false }:
|
||||
const { focusAfterLoginRef, setExpandedItem } = useAccessibility()
|
||||
|
||||
// Build search index once on mount
|
||||
const searchIndex = useMemo(() => buildSearchIndex(), [])
|
||||
const searchIndex = useMemo(() => buildLegacySearchIndex(), [])
|
||||
|
||||
const handleNavClick = useCallback(
|
||||
(view: ViewId) => {
|
||||
|
||||
@@ -0,0 +1,432 @@
|
||||
import { useState, useEffect, useRef, useMemo, useCallback } from 'react'
|
||||
import {
|
||||
Search,
|
||||
User,
|
||||
Activity,
|
||||
Monitor,
|
||||
Award,
|
||||
GraduationCap,
|
||||
Zap,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
buildPaletteData,
|
||||
buildSearchIndex,
|
||||
groupBySection,
|
||||
} from '@/lib/search'
|
||||
import type { PaletteItem, PaletteAction, IconColorVariant } from '@/lib/search'
|
||||
|
||||
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
|
||||
interface CommandPaletteProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onAction?: (action: PaletteAction) => void
|
||||
}
|
||||
|
||||
// Icon mapping by type
|
||||
const iconByType: Record<string, LucideIcon> = {
|
||||
role: User,
|
||||
skill: Activity,
|
||||
project: Monitor,
|
||||
achievement: Award,
|
||||
edu: GraduationCap,
|
||||
action: Zap,
|
||||
}
|
||||
|
||||
// Color variant → CSS variable mapping for icon containers
|
||||
const iconColorStyles: Record<IconColorVariant, { background: string; color: string }> = {
|
||||
teal: { background: 'var(--accent-light)', color: 'var(--accent)' },
|
||||
green: { background: 'var(--success-light)', color: 'var(--success)' },
|
||||
amber: { background: 'var(--amber-light)', color: 'var(--amber)' },
|
||||
purple: { background: 'rgba(124,58,237,0.08)', color: '#7C3AED' },
|
||||
}
|
||||
|
||||
export function CommandPalette({ isOpen, onClose, onAction }: CommandPaletteProps) {
|
||||
const [query, setQuery] = useState('')
|
||||
const [selectedIndex, setSelectedIndex] = useState(-1)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const resultsRef = useRef<HTMLDivElement>(null)
|
||||
const overlayRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Build data and search index once
|
||||
const paletteData = useMemo(() => buildPaletteData(), [])
|
||||
const searchIndex = useMemo(() => buildSearchIndex(paletteData), [paletteData])
|
||||
|
||||
// Compute visible items based on query
|
||||
const visibleItems = useMemo(() => {
|
||||
if (!query.trim()) {
|
||||
return paletteData
|
||||
}
|
||||
return searchIndex.search(query).map(result => result.item)
|
||||
}, [query, paletteData, searchIndex])
|
||||
|
||||
// Group visible items by section
|
||||
const groupedResults = useMemo(() => groupBySection(visibleItems), [visibleItems])
|
||||
|
||||
// Flat list for keyboard navigation
|
||||
const flatItems = useMemo(() => {
|
||||
const flat: PaletteItem[] = []
|
||||
for (const group of groupedResults) {
|
||||
for (const item of group.items) {
|
||||
flat.push(item)
|
||||
}
|
||||
}
|
||||
return flat
|
||||
}, [groupedResults])
|
||||
|
||||
// Reset state when opening/closing
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setQuery('')
|
||||
setSelectedIndex(-1)
|
||||
// Focus input on next frame
|
||||
requestAnimationFrame(() => {
|
||||
inputRef.current?.focus()
|
||||
})
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
// Reset selection when query changes
|
||||
useEffect(() => {
|
||||
setSelectedIndex(-1)
|
||||
}, [query])
|
||||
|
||||
// Global Ctrl+K listener
|
||||
useEffect(() => {
|
||||
function handleGlobalKeyDown(e: KeyboardEvent) {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
|
||||
e.preventDefault()
|
||||
if (!isOpen) {
|
||||
// Parent controls isOpen, so we need onAction or an onOpen callback
|
||||
// For now, the parent will handle Ctrl+K via its own listener
|
||||
}
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', handleGlobalKeyDown)
|
||||
return () => document.removeEventListener('keydown', handleGlobalKeyDown)
|
||||
}, [isOpen])
|
||||
|
||||
// Execute action for a palette item
|
||||
const executeAction = useCallback((item: PaletteItem) => {
|
||||
onClose()
|
||||
if (onAction) {
|
||||
onAction(item.action)
|
||||
} else {
|
||||
// Fallback: handle link and download actions directly
|
||||
const { action } = item
|
||||
if (action.type === 'link') {
|
||||
window.open(action.url, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
}
|
||||
}, [onClose, onAction])
|
||||
|
||||
// Keyboard navigation within the palette
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
switch (e.key) {
|
||||
case 'ArrowDown': {
|
||||
e.preventDefault()
|
||||
setSelectedIndex(prev => {
|
||||
const next = prev + 1
|
||||
return next >= flatItems.length ? 0 : next
|
||||
})
|
||||
break
|
||||
}
|
||||
case 'ArrowUp': {
|
||||
e.preventDefault()
|
||||
setSelectedIndex(prev => {
|
||||
const next = prev - 1
|
||||
return next < 0 ? flatItems.length - 1 : next
|
||||
})
|
||||
break
|
||||
}
|
||||
case 'Enter': {
|
||||
e.preventDefault()
|
||||
if (selectedIndex >= 0 && selectedIndex < flatItems.length) {
|
||||
executeAction(flatItems[selectedIndex])
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'Escape': {
|
||||
e.preventDefault()
|
||||
onClose()
|
||||
break
|
||||
}
|
||||
}
|
||||
}, [flatItems, selectedIndex, executeAction, onClose])
|
||||
|
||||
// Auto-scroll selected item into view
|
||||
useEffect(() => {
|
||||
if (selectedIndex < 0 || !resultsRef.current) return
|
||||
const selectedEl = resultsRef.current.querySelector(`[data-palette-index="${selectedIndex}"]`)
|
||||
if (selectedEl) {
|
||||
selectedEl.scrollIntoView({ block: 'nearest' })
|
||||
}
|
||||
}, [selectedIndex])
|
||||
|
||||
// Click on overlay (outside modal) to close
|
||||
const handleOverlayClick = useCallback((e: React.MouseEvent) => {
|
||||
if (e.target === overlayRef.current) {
|
||||
onClose()
|
||||
}
|
||||
}, [onClose])
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
// Track flat index across groups
|
||||
let flatIndex = 0
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={overlayRef}
|
||||
onClick={handleOverlayClick}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Command palette"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
background: 'rgba(26,43,42,0.45)',
|
||||
zIndex: 1000,
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'center',
|
||||
paddingTop: '12vh',
|
||||
backdropFilter: 'blur(4px)',
|
||||
WebkitBackdropFilter: 'blur(4px)',
|
||||
animation: prefersReducedMotion ? 'none' : 'palette-overlay-in 0.2s ease-out forwards',
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
{/* Palette modal */}
|
||||
<div
|
||||
style={{
|
||||
width: '580px',
|
||||
maxWidth: 'calc(100vw - 32px)',
|
||||
maxHeight: '520px',
|
||||
background: 'var(--surface)',
|
||||
borderRadius: '12px',
|
||||
boxShadow: '0 20px 60px rgba(26,43,42,0.2), 0 0 0 1px rgba(26,43,42,0.08)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
animation: prefersReducedMotion ? 'none' : 'palette-modal-in 0.2s cubic-bezier(0.4,0,0.2,1) forwards',
|
||||
}}
|
||||
>
|
||||
{/* Search input row */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
padding: '14px 18px',
|
||||
borderBottom: '1px solid var(--border-light)',
|
||||
}}
|
||||
>
|
||||
<Search
|
||||
size={18}
|
||||
style={{ color: 'var(--accent)', flexShrink: 0 }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search records, experience, skills..."
|
||||
autoComplete="off"
|
||||
className="font-ui"
|
||||
style={{
|
||||
flex: 1,
|
||||
border: 'none',
|
||||
outline: 'none',
|
||||
background: 'transparent',
|
||||
fontSize: '15px',
|
||||
color: 'var(--text-primary)',
|
||||
}}
|
||||
aria-label="Search"
|
||||
aria-activedescendant={
|
||||
selectedIndex >= 0 ? `palette-item-${flatItems[selectedIndex]?.id}` : undefined
|
||||
}
|
||||
role="combobox"
|
||||
aria-expanded="true"
|
||||
aria-controls="palette-results"
|
||||
aria-autocomplete="list"
|
||||
/>
|
||||
<kbd
|
||||
className="font-geist"
|
||||
style={{
|
||||
fontSize: '10px',
|
||||
color: 'var(--text-tertiary)',
|
||||
background: 'var(--bg-dashboard)',
|
||||
border: '1px solid var(--border)',
|
||||
padding: '2px 7px',
|
||||
borderRadius: '4px',
|
||||
flexShrink: 0,
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
ESC
|
||||
</kbd>
|
||||
</div>
|
||||
|
||||
{/* Results area */}
|
||||
<div
|
||||
id="palette-results"
|
||||
ref={resultsRef}
|
||||
role="listbox"
|
||||
aria-label="Search results"
|
||||
className="pmr-scrollbar"
|
||||
style={{
|
||||
overflowY: 'auto',
|
||||
padding: '8px',
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
{flatItems.length === 0 ? (
|
||||
<div
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
padding: '32px 16px',
|
||||
color: 'var(--text-tertiary)',
|
||||
fontSize: '13px',
|
||||
}}
|
||||
>
|
||||
No results found for “{query}”
|
||||
</div>
|
||||
) : (
|
||||
groupedResults.map((group) => {
|
||||
const sectionItems = group.items.map((item) => {
|
||||
const currentIndex = flatIndex
|
||||
flatIndex++
|
||||
const isSelected = currentIndex === selectedIndex
|
||||
const IconComponent = iconByType[item.iconType]
|
||||
const colorStyle = iconColorStyles[item.iconVariant]
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
id={`palette-item-${item.id}`}
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
data-palette-index={currentIndex}
|
||||
onClick={() => executeAction(item)}
|
||||
onMouseEnter={() => setSelectedIndex(currentIndex)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
padding: '9px 10px',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
cursor: 'pointer',
|
||||
transition: 'background 0.1s',
|
||||
fontSize: '13px',
|
||||
color: 'var(--text-primary)',
|
||||
background: isSelected ? 'var(--accent-light)' : 'transparent',
|
||||
outline: isSelected ? '1.5px solid var(--accent-border)' : 'none',
|
||||
}}
|
||||
>
|
||||
{/* Icon container */}
|
||||
<div
|
||||
style={{
|
||||
width: '28px',
|
||||
height: '28px',
|
||||
borderRadius: '6px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
background: colorStyle.background,
|
||||
color: colorStyle.color,
|
||||
}}
|
||||
>
|
||||
{IconComponent && <IconComponent size={14} />}
|
||||
</div>
|
||||
|
||||
{/* Text */}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 500 }}>{item.title}</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
color: 'var(--text-tertiary)',
|
||||
marginTop: '1px',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
>
|
||||
{item.subtitle}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
return (
|
||||
<div key={group.section}>
|
||||
{/* Section label */}
|
||||
<div
|
||||
style={{
|
||||
fontSize: '10px',
|
||||
fontWeight: 600,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.08em',
|
||||
color: 'var(--text-tertiary)',
|
||||
padding: '8px 10px 5px',
|
||||
}}
|
||||
>
|
||||
{group.section}
|
||||
</div>
|
||||
{sectionItems}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer with keyboard hints */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
padding: '10px 18px',
|
||||
borderTop: '1px solid var(--border-light)',
|
||||
fontSize: '11px',
|
||||
color: 'var(--text-tertiary)',
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
<Kbd>\u2191</Kbd> <Kbd>\u2193</Kbd> Navigate
|
||||
</span>
|
||||
<span>
|
||||
<Kbd>Enter</Kbd> Select
|
||||
</span>
|
||||
<span>
|
||||
<Kbd>Esc</Kbd> Close
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Small kbd element for the footer
|
||||
function Kbd({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<kbd
|
||||
className="font-geist"
|
||||
style={{
|
||||
fontSize: '10px',
|
||||
background: 'var(--bg-dashboard)',
|
||||
border: '1px solid var(--border)',
|
||||
padding: '1px 5px',
|
||||
borderRadius: '3px',
|
||||
color: 'var(--text-secondary)',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</kbd>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useState } from 'react'
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import { TopBar } from './TopBar'
|
||||
import Sidebar from './Sidebar'
|
||||
import { CommandPalette } from './CommandPalette'
|
||||
import { PatientSummaryTile } from './tiles/PatientSummaryTile'
|
||||
import { LatestResultsTile } from './tiles/LatestResultsTile'
|
||||
import { CoreSkillsTile } from './tiles/CoreSkillsTile'
|
||||
@@ -9,6 +10,7 @@ import { LastConsultationTile } from './tiles/LastConsultationTile'
|
||||
import { CareerActivityTile } from './tiles/CareerActivityTile'
|
||||
import { EducationTile } from './tiles/EducationTile'
|
||||
import { ProjectsTile } from './tiles/ProjectsTile'
|
||||
import type { PaletteAction } from '@/lib/search'
|
||||
|
||||
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
|
||||
@@ -45,12 +47,63 @@ const contentVariants = {
|
||||
}
|
||||
|
||||
export function DashboardLayout() {
|
||||
const [, setCommandPaletteOpen] = useState(false)
|
||||
const [commandPaletteOpen, setCommandPaletteOpen] = useState(false)
|
||||
|
||||
const handleSearchClick = () => {
|
||||
setCommandPaletteOpen(true)
|
||||
}
|
||||
|
||||
const handlePaletteClose = useCallback(() => {
|
||||
setCommandPaletteOpen(false)
|
||||
}, [])
|
||||
|
||||
// Global Ctrl+K listener to open command palette
|
||||
useEffect(() => {
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
|
||||
e.preventDefault()
|
||||
setCommandPaletteOpen(prev => !prev)
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||
}, [])
|
||||
|
||||
// Handle palette actions (scroll to tile, expand item, open link, download)
|
||||
const handlePaletteAction = useCallback((action: PaletteAction) => {
|
||||
switch (action.type) {
|
||||
case 'scroll': {
|
||||
const tileEl = document.querySelector(`[data-tile-id="${action.tileId}"]`)
|
||||
if (tileEl) {
|
||||
tileEl.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'expand': {
|
||||
const tileEl = document.querySelector(`[data-tile-id="${action.tileId}"]`)
|
||||
if (tileEl) {
|
||||
tileEl.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
// Dispatch a custom event that the tile can listen for to expand the item
|
||||
const expandEvent = new CustomEvent('palette-expand', {
|
||||
detail: { tileId: action.tileId, itemId: action.itemId },
|
||||
})
|
||||
document.dispatchEvent(expandEvent)
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'link': {
|
||||
window.open(action.url, '_blank', 'noopener,noreferrer')
|
||||
break
|
||||
}
|
||||
case 'download': {
|
||||
// For now, open the CV file or trigger a download
|
||||
// This can be wired to an actual PDF when available
|
||||
window.open('/References/CV_v4.md', '_blank')
|
||||
break
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div
|
||||
className="font-ui"
|
||||
@@ -123,7 +176,12 @@ export function DashboardLayout() {
|
||||
</motion.main>
|
||||
</div>
|
||||
|
||||
{/* Command palette will be rendered here (Task 18) */}
|
||||
{/* Command palette overlay */}
|
||||
<CommandPalette
|
||||
isOpen={commandPaletteOpen}
|
||||
onClose={handlePaletteClose}
|
||||
onAction={handlePaletteAction}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -374,7 +374,7 @@ export const CareerActivityTile: React.FC = () => {
|
||||
)
|
||||
|
||||
return (
|
||||
<Card full>
|
||||
<Card full tileId="career-activity">
|
||||
<CardHeader dotColor="teal" title="CAREER ACTIVITY" rightText="Full timeline" />
|
||||
|
||||
<div className="activity-grid">
|
||||
|
||||
@@ -233,7 +233,7 @@ export function CoreSkillsTile() {
|
||||
)
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Card tileId="core-skills">
|
||||
<CardHeader dotColor="amber" title="REPEAT MEDICATIONS" />
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
|
||||
{skills.map((skill) => (
|
||||
|
||||
@@ -22,7 +22,7 @@ export function EducationTile() {
|
||||
]
|
||||
|
||||
return (
|
||||
<Card full>
|
||||
<Card full tileId="education">
|
||||
<CardHeader dotColor="purple" title="EDUCATION" />
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
|
||||
|
||||
@@ -30,7 +30,7 @@ export const LastConsultationTile: React.FC = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<Card full>
|
||||
<Card full tileId="last-consultation">
|
||||
<CardHeader dotColor="green" title="LAST CONSULTATION" rightText="Most recent role" />
|
||||
|
||||
{/* Header info row */}
|
||||
|
||||
@@ -109,7 +109,7 @@ export function LatestResultsTile() {
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Card tileId="latest-results">
|
||||
<CardHeader dotColor="teal" title="LATEST RESULTS" rightText="Updated May 2025" />
|
||||
<div style={gridStyles}>
|
||||
{kpis.map((kpi) => (
|
||||
|
||||
@@ -10,7 +10,7 @@ export function PatientSummaryTile() {
|
||||
}
|
||||
|
||||
return (
|
||||
<Card full>
|
||||
<Card full tileId="patient-summary">
|
||||
<CardHeader dotColor="teal" title="PATIENT SUMMARY" />
|
||||
<div style={bodyStyles}>{personalStatement}</div>
|
||||
</Card>
|
||||
|
||||
@@ -257,7 +257,7 @@ export function ProjectsTile() {
|
||||
)
|
||||
|
||||
return (
|
||||
<Card full>
|
||||
<Card full tileId="projects">
|
||||
<CardHeader dotColor="amber" title="ACTIVE PROJECTS" />
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
|
||||
@@ -337,3 +337,25 @@ html {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== COMMAND PALETTE ANIMATIONS ===== */
|
||||
@keyframes palette-overlay-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes palette-modal-in {
|
||||
from { transform: scale(0.97) translateY(-8px); opacity: 0; }
|
||||
to { transform: scale(1) translateY(0); opacity: 1; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
@keyframes palette-overlay-in {
|
||||
from { opacity: 1; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
@keyframes palette-modal-in {
|
||||
from { transform: none; opacity: 1; }
|
||||
to { transform: none; opacity: 1; }
|
||||
}
|
||||
}
|
||||
|
||||
+309
-67
@@ -1,12 +1,298 @@
|
||||
import Fuse, { type FuseResult } from 'fuse.js'
|
||||
import type { ViewId } from '@/types/pmr'
|
||||
|
||||
// Import all data sources
|
||||
import { consultations } from '@/data/consultations'
|
||||
import { medications } from '@/data/medications'
|
||||
import { problems } from '@/data/problems'
|
||||
import { investigations } from '@/data/investigations'
|
||||
import { documents } from '@/data/documents'
|
||||
import { skills } from '@/data/skills'
|
||||
|
||||
export type PaletteSection = 'Experience' | 'Core Skills' | 'Active Projects' | 'Achievements' | 'Education' | 'Quick Actions'
|
||||
|
||||
export type PaletteAction =
|
||||
| { type: 'scroll'; tileId: string }
|
||||
| { type: 'expand'; tileId: string; itemId: string }
|
||||
| { type: 'link'; url: string }
|
||||
| { type: 'download' }
|
||||
|
||||
export type IconColorVariant = 'teal' | 'green' | 'amber' | 'purple'
|
||||
|
||||
export interface PaletteItem {
|
||||
id: string
|
||||
title: string
|
||||
subtitle: string
|
||||
section: PaletteSection
|
||||
iconVariant: IconColorVariant
|
||||
iconType: 'role' | 'skill' | 'project' | 'achievement' | 'edu' | 'action'
|
||||
keywords: string
|
||||
action: PaletteAction
|
||||
}
|
||||
|
||||
// Build the full palette dataset matching the concept HTML structure
|
||||
export function buildPaletteData(): PaletteItem[] {
|
||||
const items: PaletteItem[] = []
|
||||
|
||||
// Experience — matching concept HTML entries
|
||||
const experienceEntries: Array<{ title: string; sub: string; keywords: string; activityId: string }> = [
|
||||
{
|
||||
title: 'Interim Head, Population Health & Data Analysis',
|
||||
sub: 'NHS Norfolk & Waveney ICB \u00b7 2024\u20132025',
|
||||
keywords: 'head interim population health data analysis nhs norfolk waveney icb 2024 2025 latest current',
|
||||
activityId: 'interim-head',
|
||||
},
|
||||
{
|
||||
title: 'Senior Data Analyst \u2014 Medicines Optimisation',
|
||||
sub: 'NHS Norfolk & Waveney ICB \u00b7 2021\u20132024',
|
||||
keywords: 'senior data analyst medicines optimisation nhs norfolk waveney icb 2021 2024',
|
||||
activityId: 'senior-analyst',
|
||||
},
|
||||
{
|
||||
title: 'Prescribing Data Pharmacist',
|
||||
sub: 'NHS Norwich CCG \u00b7 2018\u20132021',
|
||||
keywords: 'prescribing data pharmacist nhs norwich ccg 2018 2021',
|
||||
activityId: 'prescribing-pharmacist',
|
||||
},
|
||||
{
|
||||
title: 'Community Pharmacist',
|
||||
sub: 'Boots UK \u00b7 2016\u20132018',
|
||||
keywords: 'community pharmacist boots uk 2016 2018',
|
||||
activityId: 'community-pharmacist',
|
||||
},
|
||||
]
|
||||
|
||||
experienceEntries.forEach((entry, i) => {
|
||||
items.push({
|
||||
id: `exp-${i}`,
|
||||
title: entry.title,
|
||||
subtitle: entry.sub,
|
||||
section: 'Experience',
|
||||
iconVariant: 'teal',
|
||||
iconType: 'role',
|
||||
keywords: entry.keywords,
|
||||
action: { type: 'expand', tileId: 'career-activity', itemId: entry.activityId },
|
||||
})
|
||||
})
|
||||
|
||||
// Core Skills — from skills.ts, matching concept format with proficiency %
|
||||
const skillDescriptions: Record<string, string> = {
|
||||
'Data Analysis': 'Primary expertise \u00b7 NHS population data',
|
||||
'Python': 'Data pipelines, automation, analytics',
|
||||
'SQL': 'Advanced queries, database migration',
|
||||
'Power BI': 'Dashboard design & deployment',
|
||||
'JavaScript / TypeScript': 'Web development & tooling',
|
||||
}
|
||||
|
||||
skills.forEach((skill) => {
|
||||
items.push({
|
||||
id: `skill-${skill.id}`,
|
||||
title: `${skill.name} \u2014 ${skill.proficiency}%`,
|
||||
subtitle: skillDescriptions[skill.name] ?? `${skill.frequency} \u00b7 Since ${skill.startYear}`,
|
||||
section: 'Core Skills',
|
||||
iconVariant: 'green',
|
||||
iconType: 'skill',
|
||||
keywords: `${skill.name.toLowerCase()} ${skill.proficiency} ${skill.frequency.toLowerCase()}`,
|
||||
action: { type: 'expand', tileId: 'core-skills', itemId: skill.id },
|
||||
})
|
||||
})
|
||||
|
||||
// Active Projects — matching concept HTML entries
|
||||
const projectEntries: Array<{ name: string; sub: string; keywords: string; investigationId: string }> = [
|
||||
{
|
||||
name: '\u00a3220M Prescribing Budget',
|
||||
sub: 'Budget oversight & analytical accountability \u00b7 2024',
|
||||
keywords: '220m prescribing budget oversight analytical accountability 2024',
|
||||
investigationId: 'inv-pharmetrics',
|
||||
},
|
||||
{
|
||||
name: 'SQL Analytics Transformation',
|
||||
sub: 'Legacy migration to modern data stack \u00b7 2025',
|
||||
keywords: 'sql analytics transformation legacy migration modern data stack 2025',
|
||||
investigationId: 'inv-switching-algorithm',
|
||||
},
|
||||
{
|
||||
name: 'Team Data Literacy Programme',
|
||||
sub: 'Upskilling 30+ non-technical staff \u00b7 2024',
|
||||
keywords: 'team data literacy programme upskilling non-technical staff 2024 training',
|
||||
investigationId: 'inv-blueteq-gen',
|
||||
},
|
||||
]
|
||||
|
||||
projectEntries.forEach((entry) => {
|
||||
items.push({
|
||||
id: `proj-${entry.investigationId}`,
|
||||
title: entry.name,
|
||||
subtitle: entry.sub,
|
||||
section: 'Active Projects',
|
||||
iconVariant: 'amber',
|
||||
iconType: 'project',
|
||||
keywords: entry.keywords,
|
||||
action: { type: 'expand', tileId: 'projects', itemId: entry.investigationId },
|
||||
})
|
||||
})
|
||||
|
||||
// Achievements — matching concept HTML entries
|
||||
const achievementEntries: Array<{ title: string; sub: string; keywords: string }> = [
|
||||
{
|
||||
title: '\u00a314.6M Efficiency Savings Identified',
|
||||
sub: 'Data-driven prescribing interventions',
|
||||
keywords: '14.6m efficiency savings identified data-driven prescribing interventions money cost',
|
||||
},
|
||||
{
|
||||
title: '\u00a3220M Budget Oversight',
|
||||
sub: 'Full analytical accountability to ICB board',
|
||||
keywords: '220m budget oversight analytical accountability icb board',
|
||||
},
|
||||
{
|
||||
title: 'Power BI Dashboards for 200+ Users',
|
||||
sub: 'Clinicians & commissioners across ICB',
|
||||
keywords: 'power bi dashboards 200 users clinicians commissioners',
|
||||
},
|
||||
{
|
||||
title: 'Team of 12 Led',
|
||||
sub: 'Cross-functional data & population health',
|
||||
keywords: 'team 12 led cross-functional data population health leadership management',
|
||||
},
|
||||
]
|
||||
|
||||
achievementEntries.forEach((entry, i) => {
|
||||
items.push({
|
||||
id: `ach-${i}`,
|
||||
title: entry.title,
|
||||
subtitle: entry.sub,
|
||||
section: 'Achievements',
|
||||
iconVariant: 'amber',
|
||||
iconType: 'achievement',
|
||||
keywords: entry.keywords,
|
||||
action: { type: 'scroll', tileId: 'latest-results' },
|
||||
})
|
||||
})
|
||||
|
||||
// Education — matching concept HTML entries
|
||||
const educationEntries: Array<{ title: string; sub: string; keywords: string }> = [
|
||||
{
|
||||
title: 'MPharm (Hons) \u2014 2:1',
|
||||
sub: 'University of East Anglia \u00b7 2011\u20132015',
|
||||
keywords: 'mpharm hons 2:1 university east anglia uea 2011 2015 pharmacy degree',
|
||||
},
|
||||
{
|
||||
title: 'GPhC Registration',
|
||||
sub: 'General Pharmaceutical Council \u00b7 August 2016',
|
||||
keywords: 'gphc registration general pharmaceutical council 2016 registered',
|
||||
},
|
||||
{
|
||||
title: 'Power BI Data Analyst Associate',
|
||||
sub: 'Microsoft Certified \u00b7 2023',
|
||||
keywords: 'power bi data analyst associate microsoft certified 2023 certification',
|
||||
},
|
||||
{
|
||||
title: 'Clinical Pharmacy Diploma',
|
||||
sub: 'Professional development \u00b7 2019',
|
||||
keywords: 'clinical pharmacy diploma professional development 2019',
|
||||
},
|
||||
]
|
||||
|
||||
educationEntries.forEach((entry, i) => {
|
||||
items.push({
|
||||
id: `edu-${i}`,
|
||||
title: entry.title,
|
||||
subtitle: entry.sub,
|
||||
section: 'Education',
|
||||
iconVariant: 'purple',
|
||||
iconType: 'edu',
|
||||
keywords: entry.keywords,
|
||||
action: { type: 'scroll', tileId: 'education' },
|
||||
})
|
||||
})
|
||||
|
||||
// Quick Actions
|
||||
const quickActions: Array<{ title: string; sub: string; keywords: string; action: PaletteAction }> = [
|
||||
{
|
||||
title: 'Download CV',
|
||||
sub: 'Export as PDF',
|
||||
keywords: 'download cv export pdf resume',
|
||||
action: { type: 'download' },
|
||||
},
|
||||
{
|
||||
title: 'Send Email',
|
||||
sub: 'andy@charlwood.xyz',
|
||||
keywords: 'send email contact andy charlwood',
|
||||
action: { type: 'link', url: 'mailto:andy@charlwood.xyz' },
|
||||
},
|
||||
{
|
||||
title: 'View LinkedIn',
|
||||
sub: 'Professional profile',
|
||||
keywords: 'view linkedin professional profile social',
|
||||
action: { type: 'link', url: 'https://linkedin.com/in/andycharlwood' },
|
||||
},
|
||||
{
|
||||
title: 'View Projects',
|
||||
sub: 'GitHub & portfolio',
|
||||
keywords: 'view projects github portfolio code repositories',
|
||||
action: { type: 'link', url: 'https://github.com/andycharlwood' },
|
||||
},
|
||||
]
|
||||
|
||||
quickActions.forEach((entry, i) => {
|
||||
items.push({
|
||||
id: `action-${i}`,
|
||||
title: entry.title,
|
||||
subtitle: entry.sub,
|
||||
section: 'Quick Actions',
|
||||
iconVariant: 'teal',
|
||||
iconType: 'action',
|
||||
keywords: entry.keywords,
|
||||
action: entry.action,
|
||||
})
|
||||
})
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
// Build a fuse.js search index from palette items
|
||||
export function buildSearchIndex(items: PaletteItem[]): Fuse<PaletteItem> {
|
||||
return new Fuse(items, {
|
||||
keys: [
|
||||
{ name: 'title', weight: 2 },
|
||||
{ name: 'subtitle', weight: 1 },
|
||||
{ name: 'keywords', weight: 1.5 },
|
||||
],
|
||||
threshold: 0.3,
|
||||
includeScore: true,
|
||||
minMatchCharLength: 2,
|
||||
})
|
||||
}
|
||||
|
||||
// Section ordering for grouped display
|
||||
const SECTION_ORDER: PaletteSection[] = [
|
||||
'Experience',
|
||||
'Core Skills',
|
||||
'Active Projects',
|
||||
'Achievements',
|
||||
'Education',
|
||||
'Quick Actions',
|
||||
]
|
||||
|
||||
// Group palette items by section, maintaining defined order
|
||||
export function groupBySection(items: PaletteItem[]): Array<{ section: PaletteSection; items: PaletteItem[] }> {
|
||||
const groups = new Map<PaletteSection, PaletteItem[]>()
|
||||
|
||||
for (const item of items) {
|
||||
const existing = groups.get(item.section)
|
||||
if (existing) {
|
||||
existing.push(item)
|
||||
} else {
|
||||
groups.set(item.section, [item])
|
||||
}
|
||||
}
|
||||
|
||||
return SECTION_ORDER
|
||||
.filter(section => groups.has(section))
|
||||
.map(section => ({ section, items: groups.get(section)! }))
|
||||
}
|
||||
|
||||
// ===== LEGACY EXPORTS =====
|
||||
// Used by ClinicalSidebar.tsx (old component, will be removed in Task 21)
|
||||
|
||||
export interface SearchResult {
|
||||
id: string
|
||||
@@ -17,83 +303,40 @@ export interface SearchResult {
|
||||
score?: number
|
||||
}
|
||||
|
||||
// Build a unified search index from all PMR content
|
||||
export function buildSearchIndex(): Fuse<SearchResult> {
|
||||
/** @deprecated Use buildPaletteData() + buildSearchIndex() instead */
|
||||
export function buildLegacySearchIndex(): Fuse<SearchResult> {
|
||||
const searchableItems: SearchResult[] = []
|
||||
|
||||
// Index consultations (Experience)
|
||||
consultations.forEach(consultation => {
|
||||
searchableItems.push({
|
||||
id: consultation.id,
|
||||
title: consultation.role,
|
||||
section: 'consultations',
|
||||
sectionLabel: 'Experience',
|
||||
highlight: `${consultation.role} at ${consultation.organization} — ${consultation.history}`,
|
||||
})
|
||||
consultations.forEach(c => {
|
||||
searchableItems.push({ id: c.id, title: c.role, section: 'consultations', sectionLabel: 'Experience', highlight: `${c.role} at ${c.organization} — ${c.history}` })
|
||||
})
|
||||
medications.forEach(m => {
|
||||
searchableItems.push({ id: m.id, title: m.name, section: 'medications', sectionLabel: 'Skills', highlight: `${m.name} — ${m.frequency} use since ${m.startYear}` })
|
||||
})
|
||||
problems.forEach(p => {
|
||||
searchableItems.push({ id: p.id, title: p.description, section: 'problems', sectionLabel: 'Achievements', highlight: `[${p.code}] ${p.description} — ${p.narrative}` })
|
||||
})
|
||||
investigations.forEach(inv => {
|
||||
searchableItems.push({ id: inv.id, title: inv.name, section: 'investigations', sectionLabel: 'Projects', highlight: `${inv.name} — ${inv.methodology}` })
|
||||
})
|
||||
documents.forEach(doc => {
|
||||
searchableItems.push({ id: doc.id, title: doc.title, section: 'documents', sectionLabel: 'Education', highlight: `${doc.title} from ${doc.source} (${doc.date})` })
|
||||
})
|
||||
|
||||
// Index medications (Skills)
|
||||
medications.forEach(medication => {
|
||||
searchableItems.push({
|
||||
id: medication.id,
|
||||
title: medication.name,
|
||||
section: 'medications',
|
||||
sectionLabel: 'Skills',
|
||||
highlight: `${medication.name} — ${medication.frequency} use since ${medication.startYear}`,
|
||||
})
|
||||
})
|
||||
|
||||
// Index problems (Achievements)
|
||||
problems.forEach(problem => {
|
||||
searchableItems.push({
|
||||
id: problem.id,
|
||||
title: problem.description,
|
||||
section: 'problems',
|
||||
sectionLabel: 'Achievements',
|
||||
highlight: `[${problem.code}] ${problem.description} — ${problem.narrative}`,
|
||||
})
|
||||
})
|
||||
|
||||
// Index investigations (Projects)
|
||||
investigations.forEach(investigation => {
|
||||
searchableItems.push({
|
||||
id: investigation.id,
|
||||
title: investigation.name,
|
||||
section: 'investigations',
|
||||
sectionLabel: 'Projects',
|
||||
highlight: `${investigation.name} — ${investigation.methodology}`,
|
||||
})
|
||||
})
|
||||
|
||||
// Index documents (Education)
|
||||
documents.forEach(document => {
|
||||
searchableItems.push({
|
||||
id: document.id,
|
||||
title: document.title,
|
||||
section: 'documents',
|
||||
sectionLabel: 'Education',
|
||||
highlight: `${document.title} from ${document.source} (${document.date})`,
|
||||
})
|
||||
})
|
||||
|
||||
// Fuse.js configuration for fuzzy search
|
||||
const fuseOptions = {
|
||||
return new Fuse(searchableItems, {
|
||||
keys: [
|
||||
{ name: 'title', weight: 2 }, // Primary match on title
|
||||
{ name: 'highlight', weight: 1 }, // Secondary match on full text
|
||||
{ name: 'title', weight: 2 },
|
||||
{ name: 'highlight', weight: 1 },
|
||||
],
|
||||
threshold: 0.3, // 0 = exact match, 1 = match anything
|
||||
threshold: 0.3,
|
||||
includeScore: true,
|
||||
minMatchCharLength: 2,
|
||||
}
|
||||
|
||||
return new Fuse(searchableItems, fuseOptions)
|
||||
})
|
||||
}
|
||||
|
||||
// Group search results by section
|
||||
/** @deprecated Use groupBySection() instead */
|
||||
export function groupResultsBySection(results: FuseResult<SearchResult>[]): Map<string, FuseResult<SearchResult>[]> {
|
||||
const grouped = new Map<string, FuseResult<SearchResult>[]>()
|
||||
|
||||
results.forEach(result => {
|
||||
const sectionLabel = result.item.sectionLabel
|
||||
if (!grouped.has(sectionLabel)) {
|
||||
@@ -101,6 +344,5 @@ export function groupResultsBySection(results: FuseResult<SearchResult>[]): Map<
|
||||
}
|
||||
grouped.get(sectionLabel)!.push(result)
|
||||
})
|
||||
|
||||
return grouped
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user