Task 5: Build ClinicalSidebar with navigation and search
- Create ClinicalSidebar component with 7 navigation items - NHS blue active state with 3px left border - Search input with basic filtering (fuse.js integration pending) - Keyboard shortcuts Alt+1-7 for navigation - URL hash routing (#summary, #consultations, etc.) - Session footer with current time - Create PMRInterface container component - Update App.tsx to use 'pmr' phase instead of 'content'
This commit is contained in:
+4
-29
@@ -3,20 +3,13 @@ import type { Phase } from './types'
|
|||||||
import { BootSequence } from './components/BootSequence'
|
import { BootSequence } from './components/BootSequence'
|
||||||
import { ECGAnimation } from './components/ECGAnimation'
|
import { ECGAnimation } from './components/ECGAnimation'
|
||||||
import { LoginScreen } from './components/LoginScreen'
|
import { LoginScreen } from './components/LoginScreen'
|
||||||
import { FloatingNav } from './components/FloatingNav'
|
import { PMRInterface } from './components/PMRInterface'
|
||||||
import { Hero } from './components/Hero'
|
|
||||||
import { Skills } from './components/Skills'
|
|
||||||
import { Experience } from './components/Experience'
|
|
||||||
import { Education } from './components/Education'
|
|
||||||
import { Projects } from './components/Projects'
|
|
||||||
import { Contact } from './components/Contact'
|
|
||||||
import { Footer } from './components/Footer'
|
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [phase, setPhase] = useState<Phase>('boot')
|
const [phase, setPhase] = useState<Phase>('boot')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-white">
|
<div className="min-h-screen">
|
||||||
{phase === 'boot' && (
|
{phase === 'boot' && (
|
||||||
<BootSequence onComplete={() => setPhase('ecg')} />
|
<BootSequence onComplete={() => setPhase('ecg')} />
|
||||||
)}
|
)}
|
||||||
@@ -26,28 +19,10 @@ function App() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{phase === 'login' && (
|
{phase === 'login' && (
|
||||||
<LoginScreen onComplete={() => setPhase('content')} />
|
<LoginScreen onComplete={() => setPhase('pmr')} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{phase === 'content' && (
|
{phase === 'pmr' && <PMRInterface />}
|
||||||
<>
|
|
||||||
<FloatingNav />
|
|
||||||
<main className="max-w-[1000px] mx-auto px-5 xs:px-6 md:px-8">
|
|
||||||
<Hero />
|
|
||||||
|
|
||||||
<Skills />
|
|
||||||
|
|
||||||
<Experience />
|
|
||||||
|
|
||||||
<Education />
|
|
||||||
|
|
||||||
<Projects />
|
|
||||||
|
|
||||||
<Contact />
|
|
||||||
</main>
|
|
||||||
<Footer />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,217 @@
|
|||||||
|
import { useState, useEffect, useCallback, useMemo } from 'react'
|
||||||
|
import {
|
||||||
|
ClipboardList,
|
||||||
|
FileText,
|
||||||
|
Pill,
|
||||||
|
AlertTriangle,
|
||||||
|
FlaskConical,
|
||||||
|
FolderOpen,
|
||||||
|
Send,
|
||||||
|
Search,
|
||||||
|
X,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import type { ViewId } from '../types/pmr'
|
||||||
|
|
||||||
|
interface NavItem {
|
||||||
|
id: ViewId
|
||||||
|
label: string
|
||||||
|
icon: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ClinicalSidebarProps {
|
||||||
|
activeView: ViewId
|
||||||
|
onViewChange: (view: ViewId) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const navItems: NavItem[] = [
|
||||||
|
{ id: 'summary', label: 'Summary', icon: <ClipboardList size={18} /> },
|
||||||
|
{ id: 'consultations', label: 'Consultations', icon: <FileText size={18} /> },
|
||||||
|
{ id: 'medications', label: 'Medications', icon: <Pill size={18} /> },
|
||||||
|
{ id: 'problems', label: 'Problems', icon: <AlertTriangle size={18} /> },
|
||||||
|
{ id: 'investigations', label: 'Investigations', icon: <FlaskConical size={18} /> },
|
||||||
|
{ id: 'documents', label: 'Documents', icon: <FolderOpen size={18} /> },
|
||||||
|
{ id: 'referrals', label: 'Referrals', icon: <Send size={18} /> },
|
||||||
|
]
|
||||||
|
|
||||||
|
function getCurrentTime(): string {
|
||||||
|
const now = new Date()
|
||||||
|
return now.toLocaleTimeString('en-GB', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ClinicalSidebar({ activeView, onViewChange }: ClinicalSidebarProps) {
|
||||||
|
const [currentTime, setCurrentTime] = useState(getCurrentTime)
|
||||||
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
|
const [isSearchFocused, setIsSearchFocused] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
setCurrentTime(getCurrentTime())
|
||||||
|
}, 60000)
|
||||||
|
return () => clearInterval(interval)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleHashChange = () => {
|
||||||
|
const hash = window.location.hash.slice(1) as ViewId
|
||||||
|
if (navItems.some(item => item.id === hash)) {
|
||||||
|
onViewChange(hash)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleHashChange()
|
||||||
|
window.addEventListener('hashchange', handleHashChange)
|
||||||
|
return () => window.removeEventListener('hashchange', handleHashChange)
|
||||||
|
}, [onViewChange])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.altKey && e.key >= '1' && e.key <= '7') {
|
||||||
|
e.preventDefault()
|
||||||
|
const index = parseInt(e.key) - 1
|
||||||
|
if (navItems[index]) {
|
||||||
|
const view = navItems[index].id
|
||||||
|
onViewChange(view)
|
||||||
|
window.location.hash = view
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (e.key === '/' && !isSearchFocused && document.activeElement?.tagName !== 'INPUT') {
|
||||||
|
e.preventDefault()
|
||||||
|
const searchInput = document.getElementById('sidebar-search')
|
||||||
|
searchInput?.focus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleKeyDown)
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||||
|
}, [onViewChange, isSearchFocused])
|
||||||
|
|
||||||
|
const handleNavClick = useCallback(
|
||||||
|
(view: ViewId) => {
|
||||||
|
onViewChange(view)
|
||||||
|
window.location.hash = view
|
||||||
|
},
|
||||||
|
[onViewChange]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleSearchKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
setSearchQuery('')
|
||||||
|
;(e.target as HTMLInputElement).blur()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearSearch = () => {
|
||||||
|
setSearchQuery('')
|
||||||
|
const searchInput = document.getElementById('sidebar-search')
|
||||||
|
searchInput?.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredItems = useMemo(() => {
|
||||||
|
if (!searchQuery.trim()) return []
|
||||||
|
const query = searchQuery.toLowerCase()
|
||||||
|
return navItems.filter(item =>
|
||||||
|
item.label.toLowerCase().includes(query)
|
||||||
|
)
|
||||||
|
}, [searchQuery])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside
|
||||||
|
role="navigation"
|
||||||
|
aria-label="Clinical record navigation"
|
||||||
|
className="hidden lg:flex flex-col w-[220px] h-screen sticky top-0 bg-pmr-sidebar text-white"
|
||||||
|
>
|
||||||
|
<div className="p-4 border-b border-white/10">
|
||||||
|
<div className="font-inter font-medium text-[13px] text-white/50 leading-tight">
|
||||||
|
CareerRecord PMR
|
||||||
|
</div>
|
||||||
|
<div className="font-inter text-[11px] text-white/40 mt-0.5">v1.0.0</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-3 border-b border-white/10">
|
||||||
|
<div className="relative">
|
||||||
|
<Search
|
||||||
|
size={14}
|
||||||
|
className="absolute left-2.5 top-1/2 -translate-y-1/2 text-white/40 pointer-events-none"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
id="sidebar-search"
|
||||||
|
type="text"
|
||||||
|
placeholder="Search record..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={e => setSearchQuery(e.target.value)}
|
||||||
|
onFocus={() => setIsSearchFocused(true)}
|
||||||
|
onBlur={() => setIsSearchFocused(false)}
|
||||||
|
onKeyDown={handleSearchKeyDown}
|
||||||
|
className="w-full h-9 pl-8 pr-7 bg-white/5 border border-white/10 rounded text-sm font-inter text-white placeholder-white/40 focus:outline-none focus:border-pmr-nhsblue focus:bg-white/10 transition-colors"
|
||||||
|
/>
|
||||||
|
{searchQuery && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={clearSearch}
|
||||||
|
className="absolute right-2 top-1/2 -translate-y-1/2 text-white/40 hover:text-white/70 transition-colors"
|
||||||
|
aria-label="Clear search"
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{searchQuery && filteredItems.length > 0 && (
|
||||||
|
<div className="absolute top-full left-0 right-0 mt-1 bg-pmr-sidebar border border-white/10 rounded overflow-hidden z-50">
|
||||||
|
{filteredItems.map(item => (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
handleNavClick(item.id)
|
||||||
|
setSearchQuery('')
|
||||||
|
}}
|
||||||
|
className="w-full flex items-center gap-3 px-3 py-2.5 text-left hover:bg-white/10 transition-colors"
|
||||||
|
>
|
||||||
|
<span className="text-white/60">{item.icon}</span>
|
||||||
|
<span className="font-inter text-sm">{item.label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="flex-1 py-2 overflow-y-auto">
|
||||||
|
<ul role="list">
|
||||||
|
{navItems.map((item, index) => (
|
||||||
|
<li key={item.id}>
|
||||||
|
{index === 1 && (
|
||||||
|
<div className="mx-3 my-1 border-t border-white/10" role="separator" />
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
aria-current={activeView === item.id ? 'page' : undefined}
|
||||||
|
onClick={() => handleNavClick(item.id)}
|
||||||
|
className={`w-full flex items-center gap-3 h-11 px-4 text-left transition-colors ${
|
||||||
|
activeView === item.id
|
||||||
|
? 'text-white bg-white/12 border-l-[3px] border-pmr-nhsblue font-semibold'
|
||||||
|
: 'text-white/70 hover:text-white hover:bg-white/8'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className={activeView === item.id ? 'text-white' : 'text-white/60'}>
|
||||||
|
{item.icon}
|
||||||
|
</span>
|
||||||
|
<span className="font-inter text-sm">{item.label}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="p-4 border-t border-white/10">
|
||||||
|
<div className="font-inter text-[11px] text-slate-400 leading-relaxed">
|
||||||
|
<div>Session: A.CHARLWOOD</div>
|
||||||
|
<div>Logged in: {currentTime}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import type { ViewId } from '../types/pmr'
|
||||||
|
import { ClinicalSidebar } from './ClinicalSidebar'
|
||||||
|
import { PatientBanner } from './PatientBanner'
|
||||||
|
|
||||||
|
interface PMRInterfaceProps {
|
||||||
|
children?: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PMRInterface({ children }: PMRInterfaceProps) {
|
||||||
|
const [activeView, setActiveView] = useState<ViewId>(() => {
|
||||||
|
const hash = window.location.hash.slice(1) as ViewId
|
||||||
|
const validViews: ViewId[] = [
|
||||||
|
'summary',
|
||||||
|
'consultations',
|
||||||
|
'medications',
|
||||||
|
'problems',
|
||||||
|
'investigations',
|
||||||
|
'documents',
|
||||||
|
'referrals',
|
||||||
|
]
|
||||||
|
return validViews.includes(hash) ? hash : 'summary'
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleViewChange = (view: ViewId) => {
|
||||||
|
setActiveView(view)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-pmr-content">
|
||||||
|
<PatientBanner />
|
||||||
|
<div className="flex">
|
||||||
|
<ClinicalSidebar activeView={activeView} onViewChange={handleViewChange} />
|
||||||
|
<main
|
||||||
|
role="main"
|
||||||
|
className="flex-1 min-h-[calc(100vh-80px)] p-6"
|
||||||
|
>
|
||||||
|
{children ? (
|
||||||
|
children
|
||||||
|
) : (
|
||||||
|
<div className="bg-white border border-gray-200 rounded p-6">
|
||||||
|
<h1 className="font-inter font-semibold text-lg text-gray-900 capitalize">
|
||||||
|
{activeView} View
|
||||||
|
</h1>
|
||||||
|
<p className="font-inter text-sm text-gray-500 mt-2">
|
||||||
|
Content for {activeView} will be implemented in a separate task.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
+1
-1
@@ -33,7 +33,7 @@ export interface ContactItem {
|
|||||||
href?: string
|
href?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Phase = 'boot' | 'ecg' | 'login' | 'content'
|
export type Phase = 'boot' | 'ecg' | 'login' | 'pmr'
|
||||||
|
|
||||||
export interface BootLine {
|
export interface BootLine {
|
||||||
html: string
|
html: string
|
||||||
|
|||||||
Reference in New Issue
Block a user