Mobile overview changes

This commit is contained in:
2026-02-18 12:25:53 +00:00
parent 8b79f7b273
commit 9baa6e605b
56 changed files with 3956 additions and 7000 deletions
+7 -3
View File
@@ -45,8 +45,12 @@ function SkipButton({ onSkip }: { onSkip: () => void }) {
function App() {
const [phase, setPhase] = useState<Phase>(() => {
if (typeof window !== 'undefined' && sessionStorage.getItem('portfolio-visited')) {
return 'pmr'
if (typeof window !== 'undefined') {
const visitedAt = sessionStorage.getItem('portfolio-visited')
if (visitedAt && Date.now() - Number(visitedAt) < 60 * 60 * 1000) {
return 'pmr'
}
sessionStorage.removeItem('portfolio-visited')
}
return 'boot'
})
@@ -57,7 +61,7 @@ function App() {
useEffect(() => {
if (phase === 'pmr') {
sessionStorage.setItem('portfolio-visited', '1')
sessionStorage.setItem('portfolio-visited', String(Date.now()))
}
}, [phase])
+60 -35
View File
@@ -87,8 +87,8 @@ const BOOT_CONFIG: BootConfig = {
timing: {
lineDelay: 220,
cursorBlinkInterval: 300,
holdAfterComplete: 200,
loadingDuration: 600,
holdAfterComplete: 1000,
loadingDuration: 2000,
fadeOutDuration: 500,
cursorShrinkDuration: 400,
},
@@ -190,10 +190,10 @@ const TYPED_LINES = buildTypedLines()
const TOTAL_CHARS = TYPED_LINES.reduce((sum, l) => sum + l.totalChars, 0)
// =============================================================================
// Progress Bar Component
// ASCII Loading Screen Component
// =============================================================================
function ProgressBar({ active }: { active: boolean }) {
function LoadingBar({ active }: { active: boolean }) {
const [progress, setProgress] = useState(0)
useEffect(() => {
@@ -204,7 +204,6 @@ function ProgressBar({ active }: { active: boolean }) {
const tick = (now: number) => {
const elapsed = now - start
const pct = Math.min(elapsed / BOOT_CONFIG.timing.loadingDuration, 1)
// Ease-out curve for natural feel
setProgress(1 - Math.pow(1 - pct, 2.5))
if (pct < 1) raf = requestAnimationFrame(tick)
}
@@ -216,24 +215,41 @@ function ProgressBar({ active }: { active: boolean }) {
return (
<div
style={{
marginTop: 16,
height: 2,
backgroundColor: 'rgba(0, 255, 65, 0.1)',
borderRadius: 1,
width: 'calc(100vw - 48px)',
position: 'relative',
overflow: 'hidden',
maxWidth: 280,
height: '1.2em',
fontFamily: 'monospace',
fontSize: 14,
letterSpacing: '0.02em',
}}
>
<div
style={{
height: '100%',
width: `${progress * 100}%`,
backgroundColor: COLORS.bright,
boxShadow: `0 0 8px ${COLORS.bright}40`,
borderRadius: 1,
transition: 'none',
position: 'absolute',
inset: 0,
color: `${COLORS.bright}30`,
overflow: 'hidden',
whiteSpace: 'nowrap',
}}
/>
>
{'\u2591'.repeat(500)}
</div>
<div
style={{
position: 'absolute',
top: 0,
left: 0,
bottom: 0,
width: `${progress * 100}%`,
color: COLORS.bright,
overflow: 'hidden',
whiteSpace: 'nowrap',
textShadow: `0 0 4px ${COLORS.bright}30`,
}}
>
{'\u2588'.repeat(500)}
</div>
</div>
)
}
@@ -369,10 +385,11 @@ export function BootSequence({ onComplete }: BootSequenceProps) {
const charsForLine = Math.min(Math.max(0, remaining), line.totalChars)
remaining -= charsForLine
// Cursor goes on the line currently being typed, or the last line in non-typing phases
// During typing: cursor inline on the line being typed
// During holding/loading: cursor handled after the loop (on a new line)
const isCursorLine = phase === 'typing'
? !cursorPlaced && (charsForLine < line.totalChars || remaining <= 0)
: lineIdx === TYPED_LINES.length - 1
: false
// Render segments
let charBudget = phase === 'typing' ? charsForLine : line.totalChars
@@ -430,6 +447,25 @@ export function BootSequence({ onComplete }: BootSequenceProps) {
)
}
// After typing completes: cursor on new line, or loading bar replacing it
if (phase === 'holding') {
renderedLines.push(
<div key="cursor-line" className="font-mono text-sm leading-relaxed">
<span
ref={cursorAnchorRef}
className="inline-block align-middle"
style={{ width: 8, height: 16 }}
/>
</div>
)
} else if (phase === 'loading' || phase === 'fading') {
renderedLines.push(
<div key="bar-line" style={{ marginTop: 4 }}>
<LoadingBar active={phase === 'loading'} />
</div>
)
}
return renderedLines
}
@@ -496,9 +532,8 @@ export function BootSequence({ onComplete }: BootSequenceProps) {
}}
/>
{/* Content container */}
<div ref={containerRef} className="flex flex-col gap-1 max-w-[640px] transform -translate-y-1/2 relative z-10">
{/* Text content — slides up and fades during exit */}
{/* Content container — text always visible, bar appears below during loading */}
<div ref={containerRef} className="flex flex-col gap-1 transform -translate-y-1/2 relative z-10">
<motion.div
animate={{
opacity: isFadingOut ? 0 : 1,
@@ -510,28 +545,18 @@ export function BootSequence({ onComplete }: BootSequenceProps) {
}}
>
{renderLines()}
{/* Progress bar — appears during loading phase */}
{(phase === 'loading' || phase === 'fading') && (
<ProgressBar active={phase === 'loading'} />
)}
</motion.div>
{/* Cursor rendered outside fading wrapper — shrinks into progress bar */}
{cursorPos && !isFadingOut && (
{/* Cursor — blinks during typing/holding, hidden when bar takes over */}
{cursorPos && phase !== 'loading' && !isFadingOut && (
<span
className="absolute animate-blink"
style={{
left: cursorPos.left,
top: cursorPos.top,
width: 8,
height: phase === 'loading' ? 4 : 16,
height: 16,
backgroundColor: COLORS.bright,
filter: phase === 'loading' ? 'blur(1px)' : 'none',
boxShadow: phase === 'loading' ? `0 0 12px ${COLORS.bright}E6` : 'none',
transition: phase === 'loading'
? `height ${BOOT_CONFIG.timing.cursorShrinkDuration}ms ease-out, filter ${BOOT_CONFIG.timing.cursorShrinkDuration}ms ease-out, box-shadow ${BOOT_CONFIG.timing.cursorShrinkDuration}ms ease-out`
: 'none',
animationDuration: `${BOOT_CONFIG.timing.cursorBlinkInterval}ms`,
}}
/>
+2 -3
View File
@@ -11,7 +11,7 @@ import { TimelineInterventionsSubsection } from './TimelineInterventionsSubsecti
import { RepeatMedicationsSubsection } from './RepeatMedicationsSubsection'
import { LastConsultationCard } from './LastConsultationCard'
import { ChatWidget } from './ChatWidget'
import { MobilePatientBanner } from './MobilePatientBanner'
import { MobileOverviewHeader } from './MobileOverviewHeader'
import { useActiveSection } from '@/hooks/useActiveSection'
import { useIsMobileNav } from '@/hooks/useIsMobileNav'
import { useDetailPanel } from '@/contexts/DetailPanelContext'
@@ -300,7 +300,7 @@ export function DashboardLayout() {
paddingBottom: isMobileNav ? 'calc(56px + env(safe-area-inset-bottom) + 16px)' : undefined,
}}
>
{isMobileNav && <MobilePatientBanner />}
{isMobileNav && <MobileOverviewHeader onSearchClick={handleSearchClick} />}
<div className="dashboard-grid">
{/* PatientSummaryTile — full width (includes Latest Results subsection) */}
<div ref={patientSummaryRef}>
@@ -361,7 +361,6 @@ export function DashboardLayout() {
<MobileBottomNav
activeSection={activeSection}
onNavigate={scrollToSection}
onSearchClick={handleSearchClick}
/>
</div>
)
+6 -46
View File
@@ -17,7 +17,6 @@ export function LoginScreen({ onComplete }: LoginScreenProps) {
const [activeField, setActiveField] = useState<'username' | 'password' | 'done' | null>('username')
const [buttonPressed, setButtonPressed] = useState(false)
const [isExiting, setIsExiting] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const [typingComplete, setTypingComplete] = useState(false)
const [buttonHovered, setButtonHovered] = useState(false)
const [connectionState, setConnectionState] = useState<'connecting' | 'connected'>('connecting')
@@ -48,20 +47,16 @@ export function LoginScreen({ onComplete }: LoginScreenProps) {
const canLogin = typingComplete && connectionState === 'connected'
const handleLogin = useCallback(() => {
if (!canLogin || isExiting || isLoading) return
if (!canLogin || isExiting) return
setButtonPressed(true)
addTimeout(() => {
setIsLoading(true)
setIsExiting(true)
addTimeout(() => {
setIsExiting(true)
// After dissolve completes (~400ms), remove overlay and reveal dashboard
addTimeout(() => {
requestFocusAfterLogin()
onComplete()
}, prefersReducedMotion ? 0 : 400)
}, prefersReducedMotion ? 0 : 600)
requestFocusAfterLogin()
onComplete()
}, prefersReducedMotion ? 0 : 400)
}, 100)
}, [canLogin, isExiting, isLoading, onComplete, requestFocusAfterLogin, prefersReducedMotion, addTimeout])
}, [canLogin, isExiting, onComplete, requestFocusAfterLogin, prefersReducedMotion, addTimeout])
const startLoginSequence = useCallback(() => {
if (prefersReducedMotion) {
@@ -201,40 +196,6 @@ export function LoginScreen({ onComplete }: LoginScreenProps) {
animate={isExiting ? { scale: 1.03, opacity: 0 } : { scale: 1, opacity: 1 }}
transition={isExiting ? { duration: 0.4, ease: 'easeOut' } : { duration: 0.2, ease: 'easeOut' }}
>
{isLoading ? (
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
padding: '48px 0',
gap: '16px',
}}
>
<div
className="login-spinner"
style={{
width: '32px',
height: '32px',
border: '3px solid var(--border-light, #E4EDEB)',
borderTopColor: 'var(--accent, #0D6E6E)',
borderRadius: '50%',
}}
role="status"
aria-label="Loading clinical records"
/>
<span
style={{
fontFamily: "var(--font-ui)",
fontSize: '12px',
color: 'var(--text-secondary, #5B7A78)',
}}
>
Loading clinical records...
</span>
</div>
) : (
<>
{/* Branding Header */}
<div
@@ -442,7 +403,6 @@ export function LoginScreen({ onComplete }: LoginScreenProps) {
</p>
</div>
</>
)}
</motion.div>
</motion.div>
)
+51 -369
View File
@@ -1,388 +1,70 @@
import { useState, useEffect, useCallback } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import {
Menu,
Search,
UserRound,
Workflow,
Wrench,
X,
AlertCircle,
AlertTriangle,
} from 'lucide-react'
import { CvmisLogo } from './CvmisLogo'
import { PhoneCaptcha } from './PhoneCaptcha'
import { patient } from '@/data/patient'
import { tags } from '@/data/tags'
import { alerts } from '@/data/alerts'
import { getSidebarCopy } from '@/lib/profile-content'
import type { Tag, Alert } from '@/types/pmr'
import { prefersReducedMotion } from '@/lib/utils'
import { ClipboardList, UserRound, Workflow, Wrench } from 'lucide-react'
import { useIsMobileNav } from '@/hooks/useIsMobileNav'
interface MobileBottomNavProps {
activeSection: string
onNavigate: (tileId: string) => void
onSearchClick: () => void
}
const navItems = [
{ id: 'overview', label: 'Overview', tileId: 'patient-summary', Icon: UserRound },
{ id: 'overview', label: 'Overview', tileId: 'mobile-overview', Icon: UserRound },
{ id: 'summary', label: 'Summary', tileId: 'patient-summary', Icon: ClipboardList },
{ id: 'experience', label: 'Experience', tileId: 'section-experience', Icon: Workflow },
{ id: 'skills', label: 'Skills', tileId: 'section-skills', Icon: Wrench },
]
function TagPill({ tag }: { tag: Tag }) {
const styles: Record<Tag['colorVariant'], React.CSSProperties> = {
teal: {
background: 'var(--accent-light)',
color: 'var(--accent)',
border: '1px solid var(--accent-border)',
},
amber: {
background: 'var(--amber-light)',
color: 'var(--amber)',
border: '1px solid var(--amber-border)',
},
green: {
background: 'var(--success-light)',
color: 'var(--success)',
border: '1px solid var(--success-border)',
},
}
return (
<span
style={{
display: 'inline-flex',
fontSize: '12px',
fontWeight: 500,
padding: '4px 10px',
borderRadius: '4px',
lineHeight: 1.3,
...styles[tag.colorVariant],
}}
>
{tag.label}
</span>
)
}
function AlertFlag({ alert }: { alert: Alert }) {
const Icon = alert.icon === 'AlertTriangle' ? AlertTriangle : AlertCircle
const styles: Record<Alert['severity'], React.CSSProperties> = {
alert: {
background: 'var(--alert-light)',
color: 'var(--alert)',
border: '1px solid var(--alert-border)',
},
amber: {
background: 'var(--amber-light)',
color: 'var(--amber)',
border: '1px solid var(--amber-border)',
},
}
return (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
fontSize: '13px',
fontWeight: 700,
padding: '8px 12px',
borderRadius: 'var(--radius-sm)',
letterSpacing: '0.02em',
...styles[alert.severity],
}}
>
<div style={{ width: '18px', height: '18px', flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Icon size={16} strokeWidth={2.5} />
</div>
<span>{alert.message}</span>
</div>
)
}
export function MobileBottomNav({ activeSection, onNavigate, onSearchClick }: MobileBottomNavProps) {
export function MobileBottomNav({ activeSection, onNavigate }: MobileBottomNavProps) {
const isMobileNav = useIsMobileNav()
const [drawerOpen, setDrawerOpen] = useState(false)
const sidebarCopy = getSidebarCopy()
useEffect(() => {
if (!isMobileNav) setDrawerOpen(false)
}, [isMobileNav])
const handleDrawerKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Escape') setDrawerOpen(false)
}, [])
if (!isMobileNav) return null
const handleNav = (tileId: string) => {
onNavigate(tileId)
setDrawerOpen(false)
}
return (
<>
{/* Bottom tab bar */}
<nav
aria-label="Mobile navigation"
style={{
position: 'fixed',
bottom: 0,
left: 0,
right: 0,
height: '56px',
background: 'var(--sidebar-bg)',
borderTop: '1px solid var(--border)',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-around',
zIndex: 100,
paddingBottom: 'env(safe-area-inset-bottom)',
}}
>
{navItems.map((item) => {
const isActive = activeSection === item.id
return (
<button
key={item.id}
type="button"
onClick={() => handleNav(item.tileId)}
aria-current={isActive ? 'page' : undefined}
aria-label={item.label}
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: '2px',
width: '44px',
height: '44px',
background: 'transparent',
border: 'none',
cursor: 'pointer',
color: isActive ? 'var(--accent)' : 'var(--text-tertiary)',
transition: 'color 150ms',
}}
>
<item.Icon size={20} strokeWidth={isActive ? 2.4 : 2} />
<span style={{ fontSize: '10px', fontWeight: isActive ? 600 : 400 }}>{item.label}</span>
</button>
)
})}
<button
type="button"
onClick={() => setDrawerOpen(true)}
aria-label="Open menu"
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: '2px',
width: '44px',
height: '44px',
background: 'transparent',
border: 'none',
cursor: 'pointer',
color: 'var(--text-tertiary)',
transition: 'color 150ms',
}}
>
<Menu size={20} strokeWidth={2} />
<span style={{ fontSize: '10px', fontWeight: 400 }}>More</span>
</button>
</nav>
{/* Drawer */}
<AnimatePresence>
{drawerOpen && (
<>
<motion.button
type="button"
aria-label="Close menu"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={prefersReducedMotion ? { duration: 0 } : { duration: 0.2 }}
onClick={() => setDrawerOpen(false)}
style={{
position: 'fixed',
inset: 0,
background: 'rgba(26,43,42,0.28)',
border: 'none',
cursor: 'pointer',
zIndex: 200,
}}
/>
<motion.div
initial={prefersReducedMotion ? { y: 0 } : { y: '100%' }}
animate={{ y: 0 }}
exit={prefersReducedMotion ? { y: 0 } : { y: '100%' }}
transition={prefersReducedMotion ? { duration: 0 } : { type: 'spring', damping: 28, stiffness: 300 }}
className="pmr-scrollbar"
onKeyDown={handleDrawerKeyDown}
style={{
position: 'fixed',
bottom: 0,
left: 0,
right: 0,
maxHeight: '70vh',
background: 'var(--sidebar-bg)',
borderTop: '1px solid var(--border)',
borderRadius: '16px 16px 0 0',
overflowY: 'auto',
padding: '16px',
zIndex: 201,
}}
>
{/* Drawer handle */}
<div style={{ display: 'flex', justifyContent: 'center', marginBottom: '12px' }}>
<div style={{ width: '36px', height: '4px', borderRadius: '2px', background: 'var(--border)' }} />
</div>
{/* Close button */}
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: '8px' }}>
<button
type="button"
onClick={() => setDrawerOpen(false)}
aria-label="Close menu"
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '44px',
height: '44px',
background: 'transparent',
border: '1px solid var(--border-light)',
borderRadius: 'var(--radius-sm)',
cursor: 'pointer',
color: 'var(--text-secondary)',
}}
>
<X size={18} />
</button>
</div>
{/* Logo + search */}
<div style={{ display: 'flex', alignItems: 'flex-end', gap: '6px', marginBottom: '12px' }}>
<CvmisLogo cssHeight="40px" />
<button
type="button"
onClick={() => { onSearchClick(); setDrawerOpen(false) }}
className="sidebar-control"
aria-label={sidebarCopy.searchAriaLabel}
style={{
width: '100%',
minHeight: '44px',
border: '1px solid var(--border)',
borderRadius: 'var(--radius-sm)',
background: 'var(--surface)',
color: 'var(--text-secondary)',
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '0 10px',
cursor: 'pointer',
}}
>
<Search size={16} style={{ color: 'var(--text-tertiary)', flexShrink: 0 }} />
<span style={{ flex: 1, textAlign: 'left', fontSize: '13px' }}>{sidebarCopy.searchLabel}</span>
</button>
</div>
{/* Patient info */}
<section style={{ borderBottom: '2px solid var(--accent)', paddingBottom: '12px', marginBottom: '12px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '8px' }}>
<div
style={{
width: '44px',
height: '44px',
borderRadius: '50%',
background: 'linear-gradient(135deg, var(--accent), #0A8080)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#FFFFFF',
fontSize: '16px',
fontWeight: 700,
flexShrink: 0,
}}
>
AC
</div>
<div>
<div style={{ fontSize: '15px', fontWeight: 700, color: 'var(--text-primary)' }}>
CHARLWOOD, Andrew
</div>
<div style={{ fontSize: '12px', fontFamily: 'Geist Mono, monospace', color: 'var(--text-secondary)' }}>
{sidebarCopy.roleTitle}
</div>
</div>
</div>
<div style={{ display: 'grid', gap: '6px' }}>
{[
{ label: sidebarCopy.gphcLabel, value: patient.nhsNumber.replace(/\s/g, ''), mono: true },
{ label: sidebarCopy.educationLabel, value: patient.qualification },
{ label: sidebarCopy.locationLabel, value: patient.address },
{ label: sidebarCopy.registeredLabel, value: patient.registrationYear },
].map(({ label, value, mono }) => (
<div key={label} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontSize: '13px', padding: '2px 0' }}>
<span style={{ color: 'var(--text-tertiary)' }}>{label}</span>
<span style={{ color: 'var(--text-primary)', fontWeight: 500, textAlign: 'right', fontFamily: mono ? 'Geist Mono, monospace' : undefined, fontSize: mono ? '12px' : undefined, letterSpacing: mono ? '0.12em' : undefined }}>
{value}
</span>
</div>
))}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontSize: '13px', padding: '2px 0' }}>
<span style={{ color: 'var(--text-tertiary)' }}>{sidebarCopy.phoneLabel}</span>
<PhoneCaptcha phone={patient.phone} />
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontSize: '13px', padding: '2px 0' }}>
<span style={{ color: 'var(--text-tertiary)' }}>{sidebarCopy.emailLabel}</span>
<a
href={`mailto:${patient.email}`}
style={{ color: 'var(--accent)', fontWeight: 500, textDecoration: 'none', textAlign: 'right' }}
>
{patient.email}
</a>
</div>
</div>
</section>
{/* Tags */}
<section style={{ marginBottom: '12px' }}>
<div style={{ fontSize: '11px', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.08em', color: 'var(--text-tertiary)', marginBottom: '8px' }}>
{sidebarCopy.tagsTitle}
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '5px' }}>
{tags.map((tag) => (
<TagPill key={tag.label} tag={tag} />
))}
</div>
</section>
{/* Alerts */}
<section>
<div style={{ fontSize: '11px', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.08em', color: 'var(--text-tertiary)', marginBottom: '8px' }}>
{sidebarCopy.alertsTitle}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
{alerts.map((alert, index) => (
<AlertFlag key={index} alert={alert} />
))}
</div>
</section>
</motion.div>
</>
)}
</AnimatePresence>
</>
<nav
aria-label="Mobile navigation"
style={{
position: 'fixed',
bottom: 0,
left: 0,
right: 0,
height: '56px',
background: 'var(--sidebar-bg)',
borderTop: '1px solid var(--border)',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-around',
zIndex: 100,
paddingBottom: 'env(safe-area-inset-bottom)',
}}
>
{navItems.map((item) => {
const isActive = activeSection === item.id
return (
<button
key={item.id}
type="button"
onClick={() => onNavigate(item.tileId)}
aria-current={isActive ? 'page' : undefined}
aria-label={item.label}
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: '2px',
width: '44px',
height: '44px',
background: 'transparent',
border: 'none',
cursor: 'pointer',
color: isActive ? 'var(--accent)' : 'var(--text-tertiary)',
transition: 'color 150ms',
}}
>
<item.Icon size={20} strokeWidth={isActive ? 2.4 : 2} />
<span style={{ fontSize: '10px', fontWeight: isActive ? 600 : 400 }}>{item.label}</span>
</button>
)
})}
</nav>
)
}
+260
View File
@@ -0,0 +1,260 @@
import { useState } from 'react'
import type { CSSProperties } from 'react'
import { Download, Github, Linkedin, Search, Send } from 'lucide-react'
import { CvmisLogo } from './CvmisLogo'
import { PhoneCaptcha } from './PhoneCaptcha'
import { ReferralFormModal } from './ReferralFormModal'
import { patient } from '@/data/patient'
import { tags } from '@/data/tags'
import { getSidebarCopy } from '@/lib/profile-content'
import type { Tag } from '@/types/pmr'
interface MobileOverviewHeaderProps {
onSearchClick: () => void
}
function TagPill({ tag }: { tag: Tag }) {
const styles: Record<Tag['colorVariant'], CSSProperties> = {
teal: {
background: 'var(--accent-light)',
color: 'var(--accent)',
border: '1px solid var(--accent-border)',
},
amber: {
background: 'var(--amber-light)',
color: 'var(--amber)',
border: '1px solid var(--amber-border)',
},
green: {
background: 'var(--success-light)',
color: 'var(--success)',
border: '1px solid var(--success-border)',
},
}
return (
<span
style={{
display: 'inline-flex',
fontSize: '12px',
fontWeight: 500,
padding: '4px 10px',
borderRadius: '4px',
lineHeight: 1.3,
...styles[tag.colorVariant],
}}
>
{tag.label}
</span>
)
}
export function MobileOverviewHeader({ onSearchClick }: MobileOverviewHeaderProps) {
const sidebarCopy = getSidebarCopy()
const [showReferralForm, setShowReferralForm] = useState(false)
return (
<div
data-tile-id="mobile-overview"
style={{
padding: '16px',
background: 'var(--sidebar-bg)',
borderRadius: 'var(--radius-sm)',
border: '1px solid var(--border)',
marginBottom: '16px',
}}
>
{/* Logo + Search row */}
<div style={{ display: 'flex', alignItems: 'flex-end', gap: '6px', marginBottom: '12px' }}>
<CvmisLogo cssHeight="40px" />
<button
type="button"
onClick={onSearchClick}
className="sidebar-control"
aria-label={sidebarCopy.searchAriaLabel}
style={{
width: '100%',
minHeight: '44px',
border: '1px solid var(--border)',
borderRadius: 'var(--radius-sm)',
background: 'var(--surface)',
color: 'var(--text-secondary)',
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '0 10px',
cursor: 'pointer',
}}
>
<Search size={16} style={{ color: 'var(--text-tertiary)', flexShrink: 0 }} />
<span style={{ flex: 1, textAlign: 'left', fontSize: '13px' }}>{sidebarCopy.searchLabel}</span>
</button>
</div>
{/* Patient info */}
<section style={{ borderBottom: '2px solid var(--accent)', paddingBottom: '12px', marginBottom: '12px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '8px' }}>
<div
style={{
width: '44px',
height: '44px',
borderRadius: '50%',
background: 'linear-gradient(135deg, var(--accent), #0A8080)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#FFFFFF',
fontSize: '16px',
fontWeight: 700,
flexShrink: 0,
}}
>
AC
</div>
<div>
<div style={{ fontSize: '15px', fontWeight: 700, color: 'var(--text-primary)' }}>
CHARLWOOD, Andrew
</div>
<div style={{ fontSize: '12px', fontFamily: 'Geist Mono, monospace', color: 'var(--text-secondary)' }}>
{sidebarCopy.roleTitle}
</div>
</div>
</div>
<div style={{ display: 'grid', gap: '6px' }}>
{[
{ label: sidebarCopy.gphcLabel, value: patient.nhsNumber.replace(/\s/g, ''), mono: true },
{ label: sidebarCopy.educationLabel, value: patient.qualification },
{ label: sidebarCopy.locationLabel, value: patient.address },
{ label: sidebarCopy.registeredLabel, value: patient.registrationYear },
].map(({ label, value, mono }) => (
<div key={label} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontSize: '13px', padding: '2px 0' }}>
<span style={{ color: 'var(--text-tertiary)' }}>{label}</span>
<span style={{ color: 'var(--text-primary)', fontWeight: 500, textAlign: 'right', fontFamily: mono ? 'Geist Mono, monospace' : undefined, fontSize: mono ? '12px' : undefined, letterSpacing: mono ? '0.12em' : undefined }}>
{value}
</span>
</div>
))}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontSize: '13px', padding: '2px 0' }}>
<span style={{ color: 'var(--text-tertiary)' }}>{sidebarCopy.phoneLabel}</span>
<PhoneCaptcha phone={patient.phone} />
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontSize: '13px', padding: '2px 0' }}>
<span style={{ color: 'var(--text-tertiary)' }}>{sidebarCopy.emailLabel}</span>
<a
href={`mailto:${patient.email}`}
style={{ color: 'var(--accent)', fontWeight: 500, textDecoration: 'none', textAlign: 'right' }}
>
{patient.email}
</a>
</div>
</div>
</section>
{/* Tags */}
<section style={{ marginBottom: '12px' }}>
<div style={{ fontSize: '11px', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.08em', color: 'var(--text-tertiary)', marginBottom: '8px' }}>
{sidebarCopy.tagsTitle}
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '5px' }}>
{tags.map((tag) => (
<TagPill key={tag.label} tag={tag} />
))}
</div>
</section>
{/* Action buttons */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
{/* Download CV — full width */}
<a
href="/References/CV_v4.md"
target="_blank"
rel="noopener noreferrer"
aria-label="Download CV"
style={{
width: '100%',
minHeight: '40px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '8px',
border: '1px solid var(--accent-border)',
background: 'var(--surface)',
color: 'var(--accent)',
borderRadius: 'var(--radius-sm)',
cursor: 'pointer',
fontSize: '13px',
fontWeight: 600,
letterSpacing: '0.03em',
textDecoration: 'none',
}}
>
<Download size={14} />
Download CV
</a>
{/* Three icon buttons row */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '6px' }}>
<button
type="button"
onClick={() => setShowReferralForm(true)}
aria-label="Contact patient"
style={{
minHeight: '40px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
border: '1px solid var(--accent-border)',
background: 'var(--surface)',
color: 'var(--accent)',
borderRadius: 'var(--radius-sm)',
cursor: 'pointer',
}}
>
<Send size={16} />
</button>
<a
href="https://linkedin.com/in/andycharlwood"
target="_blank"
rel="noopener noreferrer"
aria-label="LinkedIn profile"
style={{
minHeight: '40px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
border: '1px solid var(--border-light)',
background: 'var(--surface)',
color: 'var(--text-secondary)',
borderRadius: 'var(--radius-sm)',
textDecoration: 'none',
}}
>
<Linkedin size={16} />
</a>
<a
href="https://github.com/andycharlwood"
target="_blank"
rel="noopener noreferrer"
aria-label="GitHub profile"
style={{
minHeight: '40px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
border: '1px solid var(--border-light)',
background: 'var(--surface)',
color: 'var(--text-secondary)',
borderRadius: 'var(--radius-sm)',
textDecoration: 'none',
}}
>
<Github size={16} />
</a>
</div>
</div>
<ReferralFormModal isOpen={showReferralForm} onClose={() => setShowReferralForm(false)} />
</div>
)
}
-225
View File
@@ -1,225 +0,0 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import type { ReactNode } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { ChevronDown } from 'lucide-react'
import { patient } from '@/data/patient'
import { getSidebarCopy } from '@/lib/profile-content'
import { PhoneCaptcha } from './PhoneCaptcha'
function DataRow({ label, children }: { label: string; children: ReactNode }) {
return (
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
fontSize: '13px',
padding: '2px 0',
}}
>
<span style={{ color: 'var(--text-tertiary)', fontWeight: 400 }}>{label}</span>
{children}
</div>
)
}
export function MobilePatientBanner() {
const sidebarCopy = getSidebarCopy()
const [expanded, setExpanded] = useState(true)
const expandedByClickRef = useRef(false)
const clickExpandScrollRef = useRef(0)
useEffect(() => {
const scrollContainer = document.querySelector('.dashboard-main')
if (!scrollContainer) return
let prevScrollTop = scrollContainer.scrollTop
const handleScroll = () => {
const currentScroll = scrollContainer.scrollTop
const delta = currentScroll - prevScrollTop
prevScrollTop = currentScroll
if (delta <= 0) return
if (expandedByClickRef.current) {
// After click-expand, collapse once user scrolls 20px from where they expanded
const scrollSinceExpand = currentScroll - clickExpandScrollRef.current
if (scrollSinceExpand > 20) {
setExpanded(false)
expandedByClickRef.current = false
}
} else if (currentScroll > 40) {
// Initial collapse after scrolling 40px from top
setExpanded(false)
}
}
scrollContainer.addEventListener('scroll', handleScroll, { passive: true })
return () => scrollContainer.removeEventListener('scroll', handleScroll)
}, [])
const handleToggle = useCallback(() => {
setExpanded((prev) => {
if (!prev) {
expandedByClickRef.current = true
const container = document.querySelector('.dashboard-main')
if (container) clickExpandScrollRef.current = container.scrollTop
return true
}
return prev
})
}, [])
return (
<div
className="-mx-3 xs:-mx-5 -mt-3 xs:-mt-5"
style={{
position: 'sticky',
top: 0,
zIndex: 20,
marginBottom: '12px',
overflow: 'hidden',
boxShadow: expanded ? 'none' : '0 2px 8px rgba(0,0,0,0.1)',
transition: 'box-shadow 0.25s ease',
}}
>
{/* Green header — always visible */}
<button
type="button"
onClick={handleToggle}
aria-expanded={expanded}
aria-label={expanded ? 'Patient summary expanded' : 'Tap to view patient details'}
style={{
width: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '10px 16px',
background: 'var(--accent)',
border: 'none',
cursor: expanded ? 'default' : 'pointer',
textAlign: 'left',
color: '#FFFFFF',
}}
>
<div>
<div
style={{
fontSize: '14px',
fontWeight: 700,
letterSpacing: '0.04em',
fontFamily: 'var(--font-ui)',
}}
>
CHARLWOOD, Andrew
</div>
<div
style={{
fontSize: '11px',
opacity: 0.75,
fontFamily: 'var(--font-geist-mono)',
letterSpacing: '0.02em',
}}
>
Informatics Pharmacist · NHS Norfolk & Waveney ICB
</div>
</div>
<motion.div
animate={
expanded
? { rotate: 180, opacity: 0.3 }
: { rotate: 0, opacity: 0.65, y: [0, 2, 0] }
}
transition={
expanded
? { duration: 0.2 }
: {
rotate: { duration: 0.2 },
opacity: { duration: 0.2 },
y: { duration: 1.2, repeat: 2, ease: 'easeInOut', delay: 0.3 },
}
}
style={{ flexShrink: 0, marginLeft: '8px', display: 'flex' }}
>
<ChevronDown size={16} />
</motion.div>
</button>
{/* Expandable patient data panel */}
<AnimatePresence initial={false}>
{expanded && (
<motion.div
key="patient-data-panel"
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.25, ease: [0.4, 0, 0.2, 1] }}
style={{ overflow: 'hidden' }}
>
<div
style={{
background: 'var(--surface)',
borderTop: '1px solid var(--border-light)',
padding: '10px 16px 12px',
display: 'grid',
gap: '4px',
}}
>
<DataRow label={sidebarCopy.gphcLabel}>
<span
style={{
color: 'var(--text-primary)',
fontWeight: 500,
fontFamily: 'Geist Mono, monospace',
fontSize: '12px',
letterSpacing: '0.12em',
}}
>
{patient.nhsNumber.replace(/\s/g, '')}
</span>
</DataRow>
<DataRow label={sidebarCopy.educationLabel}>
<span style={{ color: 'var(--text-primary)', fontWeight: 500 }}>
{patient.qualification}
</span>
</DataRow>
<DataRow label={sidebarCopy.locationLabel}>
<span style={{ color: 'var(--text-primary)', fontWeight: 500 }}>
{patient.address}
</span>
</DataRow>
<DataRow label={sidebarCopy.phoneLabel}>
<PhoneCaptcha phone={patient.phone} />
</DataRow>
<DataRow label={sidebarCopy.emailLabel}>
<a
href={`mailto:${patient.email}`}
style={{
color: 'var(--accent)',
fontWeight: 500,
textDecoration: 'none',
textAlign: 'right',
}}
>
{patient.email}
</a>
</DataRow>
<DataRow label={sidebarCopy.registeredLabel}>
<span style={{ color: 'var(--text-primary)', fontWeight: 500, textAlign: 'right' }}>
{patient.registrationYear}
</span>
</DataRow>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
)
}
+8 -6
View File
@@ -84,6 +84,7 @@ export function ReferralFormModal({ isOpen, onClose }: ReferralFormModalProps) {
const inputStyle: React.CSSProperties = {
width: '100%',
padding: '10px 12px',
minHeight: '44px',
fontFamily: 'var(--font-ui)',
fontSize: '14px',
color: 'var(--text-primary, #1A2B2A)',
@@ -133,7 +134,7 @@ export function ReferralFormModal({ isOpen, onClose }: ReferralFormModalProps) {
transition={{ duration: 0.25, ease: 'easeOut' }}
style={{
width: '100%',
maxWidth: '540px',
maxWidth: 'min(540px, calc(100vw - 32px))',
maxHeight: 'calc(100vh - 32px)',
overflowY: 'auto',
backgroundColor: 'var(--surface, #FFFFFF)',
@@ -151,7 +152,7 @@ export function ReferralFormModal({ isOpen, onClose }: ReferralFormModalProps) {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '16px 24px',
padding: '14px 16px',
borderBottom: '2px solid var(--accent, #0D6E6E)',
backgroundColor: 'var(--bg-dashboard, #F0F5F4)',
}}
@@ -190,8 +191,8 @@ export function ReferralFormModal({ isOpen, onClose }: ReferralFormModalProps) {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '32px',
height: '32px',
width: '44px',
height: '44px',
border: 'none',
background: 'transparent',
borderRadius: 'var(--radius-sm, 6px)',
@@ -215,7 +216,7 @@ export function ReferralFormModal({ isOpen, onClose }: ReferralFormModalProps) {
{/* Form body */}
<form
onSubmit={handleSubmit}
style={{ padding: '24px', display: 'flex', flexDirection: 'column', gap: '18px' }}
style={{ padding: '16px', display: 'flex', flexDirection: 'column', gap: '18px' }}
>
{/* Referring Clinician */}
<div>
@@ -261,7 +262,7 @@ export function ReferralFormModal({ isOpen, onClose }: ReferralFormModalProps) {
id="organisationTo"
type="text"
readOnly
value="A. Charlwood"
value="CV Managment Information System"
style={readOnlyStyle}
tabIndex={-1}
/>
@@ -383,6 +384,7 @@ export function ReferralFormModal({ isOpen, onClose }: ReferralFormModalProps) {
style={{
width: '100%',
padding: '12px 16px',
minHeight: '44px',
fontFamily: 'var(--font-ui)',
fontSize: '14px',
fontWeight: 600,
+2 -2
View File
@@ -532,14 +532,14 @@ export default function Sidebar({ activeSection, onNavigate, onSearchClick }: Si
borderRadius: 'var(--radius-sm)',
cursor: 'pointer',
fontSize: '13px',
fontWeight: 600,
fontWeight: 500,
//fontFamily: 'var(--font-geist-mono)',
letterSpacing: '0.03em',
transition: 'border-color 150ms, color 150ms',
}}
>
<Send size={14} />
Refer Patient
Contact patient
</button>
<div style={{ display: 'flex', gap: '6px' }}>
<a
+48 -16
View File
@@ -624,20 +624,20 @@ function ContinuousScrollCarousel() {
position: 'absolute',
top: '50%',
transform: 'translateY(-50%)',
width: '32px',
height: '32px',
width: '40px',
height: '40px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'var(--surface)',
border: '1px solid var(--border)',
background: 'var(--accent-light)',
border: '1px solid var(--accent-border)',
borderRadius: '50%',
cursor: 'pointer',
boxShadow: '0 1px 4px rgba(0,0,0,0.08)',
color: 'var(--text-secondary)',
transition: 'opacity 150ms, background-color 150ms',
boxShadow: '0 2px 8px rgba(0,0,0,0.12)',
color: 'var(--accent)',
transition: 'opacity 150ms, background-color 150ms, border-color 150ms',
zIndex: 2,
opacity: 0.7,
opacity: 0.85,
padding: 0,
}
@@ -684,26 +684,58 @@ function ContinuousScrollCarousel() {
</div>
</div>
{/* Edge fade masks */}
<div style={{
position: 'absolute', top: 0, left: 0, bottom: 0, width: '48px',
background: 'linear-gradient(to right, var(--surface), transparent)',
pointerEvents: 'none', zIndex: 1,
}} />
<div style={{
position: 'absolute', top: 0, right: 0, bottom: 0, width: '48px',
background: 'linear-gradient(to left, var(--surface), transparent)',
pointerEvents: 'none', zIndex: 1,
}} />
{/* Left arrow */}
<button
onClick={() => jumpByCards(-1)}
aria-label="Previous project"
style={{ ...arrowStyle, left: '-4px' }}
onMouseEnter={(e) => { e.currentTarget.style.opacity = '1' }}
onMouseLeave={(e) => { e.currentTarget.style.opacity = '0.7' }}
style={{ ...arrowStyle, left: '2px' }}
onMouseEnter={(e) => {
e.currentTarget.style.opacity = '1'
e.currentTarget.style.background = 'var(--accent)'
e.currentTarget.style.color = '#fff'
e.currentTarget.style.borderColor = 'var(--accent)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.opacity = '0.85'
e.currentTarget.style.background = 'var(--accent-light)'
e.currentTarget.style.color = 'var(--accent)'
e.currentTarget.style.borderColor = 'var(--accent-border)'
}}
>
<ChevronLeft size={16} />
<ChevronLeft size={20} />
</button>
{/* Right arrow */}
<button
onClick={() => jumpByCards(1)}
aria-label="Next project"
style={{ ...arrowStyle, right: '-4px' }}
onMouseEnter={(e) => { e.currentTarget.style.opacity = '1' }}
onMouseLeave={(e) => { e.currentTarget.style.opacity = '0.7' }}
style={{ ...arrowStyle, right: '2px' }}
onMouseEnter={(e) => {
e.currentTarget.style.opacity = '1'
e.currentTarget.style.background = 'var(--accent)'
e.currentTarget.style.color = '#fff'
e.currentTarget.style.borderColor = 'var(--accent)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.opacity = '0.85'
e.currentTarget.style.background = 'var(--accent-light)'
e.currentTarget.style.color = 'var(--accent)'
e.currentTarget.style.borderColor = 'var(--accent-border)'
}}
>
<ChevronRight size={16} />
<ChevronRight size={20} />
</button>
</div>
)
-15
View File
@@ -238,15 +238,6 @@ html {
}
}
/* Login spinner */
@keyframes login-spin {
to { transform: rotate(360deg); }
}
.login-spinner {
animation: login-spin 0.8s linear infinite;
}
/* Login button pulse — draws attention when button becomes clickable */
@keyframes login-pulse {
0%, 60%, 100% { transform: scale(1); }
@@ -682,12 +673,6 @@ textarea:focus-visible {
to { transform: none; opacity: 1; }
}
/* Static login spinner indicator */
.login-spinner {
animation: none;
border-top-color: #0D6E6E;
}
/* No pulse animation */
.login-pulse-active {
animation: none;