chore: auto-commit before merge (loop primary)
This commit is contained in:
+13
-2
@@ -44,12 +44,23 @@ function SkipButton({ onSkip }: { onSkip: () => void }) {
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [phase, setPhase] = useState<Phase>('boot')
|
||||
const [phase, setPhase] = useState<Phase>(() => {
|
||||
if (typeof window !== 'undefined' && sessionStorage.getItem('portfolio-visited')) {
|
||||
return 'pmr'
|
||||
}
|
||||
return 'boot'
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
initModel()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (phase === 'pmr') {
|
||||
sessionStorage.setItem('portfolio-visited', '1')
|
||||
}
|
||||
}, [phase])
|
||||
|
||||
const skipToDashboard = () => setPhase('pmr')
|
||||
|
||||
return (
|
||||
@@ -78,7 +89,7 @@ function App() {
|
||||
<LoginScreen onComplete={() => setPhase('pmr')} />
|
||||
)}
|
||||
|
||||
{phase === 'boot' && (
|
||||
{(phase === 'boot' || phase === 'login') && (
|
||||
<SkipButton onSkip={skipToDashboard} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -59,7 +59,7 @@ interface TypedLine {
|
||||
|
||||
// Global speed multiplier for typing animation.
|
||||
// 1.0 = default (~3.3s typing). Lower = faster, higher = slower.
|
||||
const TYPING_SPEED = 2
|
||||
const TYPING_SPEED = 1.0
|
||||
|
||||
const COLORS = {
|
||||
bright: '#00ff41',
|
||||
@@ -87,8 +87,8 @@ const BOOT_CONFIG: BootConfig = {
|
||||
timing: {
|
||||
lineDelay: 220,
|
||||
cursorBlinkInterval: 300,
|
||||
holdAfterComplete: 600,
|
||||
loadingDuration: 1200,
|
||||
holdAfterComplete: 200,
|
||||
loadingDuration: 600,
|
||||
fadeOutDuration: 500,
|
||||
cursorShrinkDuration: 400,
|
||||
},
|
||||
|
||||
@@ -299,6 +299,43 @@ export function DashboardLayout() {
|
||||
paddingBottom: isMobileNav ? 'calc(56px + env(safe-area-inset-bottom) + 16px)' : undefined,
|
||||
}}
|
||||
>
|
||||
{isMobileNav && (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '10px 16px',
|
||||
background: 'var(--accent)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
marginBottom: '12px',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
fontWeight: 700,
|
||||
color: '#FFFFFF',
|
||||
letterSpacing: '0.04em',
|
||||
fontFamily: 'var(--font-ui)',
|
||||
}}
|
||||
>
|
||||
CHARLWOOD, Andrew
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
color: 'rgba(255,255,255,0.75)',
|
||||
fontFamily: 'var(--font-geist-mono)',
|
||||
letterSpacing: '0.02em',
|
||||
}}
|
||||
>
|
||||
Informatics Pharmacist · NHS Norfolk & Waveney ICB
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="dashboard-grid">
|
||||
{/* PatientSummaryTile — full width (includes Latest Results subsection) */}
|
||||
<div ref={patientSummaryRef}>
|
||||
|
||||
@@ -62,7 +62,7 @@ function getDotColor(content: DetailPanelContent): CardHeaderProps['dotColor'] {
|
||||
}
|
||||
|
||||
export function DetailPanel() {
|
||||
const { content, closePanel, isOpen } = useDetailPanel()
|
||||
const { content, closePanel, isOpen, isClosing } = useDetailPanel()
|
||||
const panelRef = useRef<HTMLDivElement>(null)
|
||||
const titleId = 'detail-panel-title'
|
||||
|
||||
@@ -83,7 +83,7 @@ export function DetailPanel() {
|
||||
return () => document.removeEventListener('keydown', handleEscape)
|
||||
}, [isOpen, closePanel])
|
||||
|
||||
if (!isOpen || !content) return null
|
||||
if ((!isOpen && !isClosing) || !content) return null
|
||||
|
||||
const width = widthMap[content.type]
|
||||
const title = getPanelTitle(content)
|
||||
@@ -101,6 +101,8 @@ export function DetailPanel() {
|
||||
backdropFilter: 'blur(var(--backdrop-blur))',
|
||||
zIndex: 1000,
|
||||
animation: 'backdrop-fade-in 150ms ease-out',
|
||||
opacity: isClosing ? 0 : 1,
|
||||
transition: 'opacity 200ms ease-out',
|
||||
}}
|
||||
onClick={closePanel}
|
||||
aria-hidden="true"
|
||||
@@ -124,7 +126,7 @@ export function DetailPanel() {
|
||||
zIndex: 1001,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
animation: 'panel-slide-in 250ms ease-out',
|
||||
animation: isClosing ? 'panel-slide-out 250ms ease-in forwards' : 'panel-slide-in 250ms ease-out',
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
|
||||
@@ -86,7 +86,7 @@ export function LastConsultationCard({ highlightedRoleId, focusRelatedIds }: Las
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '20px',
|
||||
marginBottom: '1=px',
|
||||
marginBottom: '10px',
|
||||
paddingBottom: '14px',
|
||||
borderBottom: '1px solid var(--border-light)',
|
||||
cursor: 'pointer',
|
||||
@@ -126,52 +126,12 @@ export function LastConsultationCard({ highlightedRoleId, focusRelatedIds }: Las
|
||||
fontSize: '15px',
|
||||
fontWeight: 600,
|
||||
color: consultation.orgColor ?? 'var(--accent)',
|
||||
marginBottom: '12px',
|
||||
marginBottom: '4px',
|
||||
}}
|
||||
>
|
||||
{consultation.role}
|
||||
</div>
|
||||
|
||||
<ul
|
||||
style={{
|
||||
listStyle: 'none',
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '7px',
|
||||
marginBottom: '0px',
|
||||
}}
|
||||
>
|
||||
{consultation.examination.map((bullet, index) => (
|
||||
<li
|
||||
key={index}
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
color: 'var(--text-primary)',
|
||||
paddingLeft: '16px',
|
||||
lineHeight: '1.5',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: '0',
|
||||
top: '8px',
|
||||
width: '5px',
|
||||
height: '5px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: consultation.orgColor ?? 'var(--accent)',
|
||||
opacity: 0.5,
|
||||
}}
|
||||
/>
|
||||
{bullet}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<button
|
||||
onClick={handleOpenPanel}
|
||||
style={{
|
||||
|
||||
@@ -54,11 +54,11 @@ export function LoginScreen({ onComplete }: LoginScreenProps) {
|
||||
setIsLoading(true)
|
||||
addTimeout(() => {
|
||||
setIsExiting(true)
|
||||
// After dissolve completes (~600ms), remove overlay and reveal dashboard
|
||||
// After dissolve completes (~400ms), remove overlay and reveal dashboard
|
||||
addTimeout(() => {
|
||||
requestFocusAfterLogin()
|
||||
onComplete()
|
||||
}, prefersReducedMotion ? 0 : 600)
|
||||
}, prefersReducedMotion ? 0 : 400)
|
||||
}, prefersReducedMotion ? 0 : 600)
|
||||
}, 100)
|
||||
}, [canLogin, isExiting, isLoading, onComplete, requestFocusAfterLogin, prefersReducedMotion, addTimeout])
|
||||
@@ -100,10 +100,10 @@ export function LoginScreen({ onComplete }: LoginScreenProps) {
|
||||
setTypingComplete(true)
|
||||
// Button becomes interactive — user clicks to proceed
|
||||
}
|
||||
}, 60)
|
||||
}, 300)
|
||||
}, 40)
|
||||
}, 200)
|
||||
}
|
||||
}, 80)
|
||||
}, 55)
|
||||
}, [prefersReducedMotion, addTimeout])
|
||||
|
||||
// Focus the login button when login becomes available for keyboard accessibility
|
||||
@@ -147,7 +147,7 @@ export function LoginScreen({ onComplete }: LoginScreenProps) {
|
||||
// Full motion: 400ms card entrance + 1000ms logo animation + 100ms pause = 1500ms
|
||||
const startTimeout = addTimeout(() => {
|
||||
startLoginSequence()
|
||||
}, prefersReducedMotion ? 400 : 1500)
|
||||
}, prefersReducedMotion ? 400 : 600)
|
||||
|
||||
// Capture ref value for cleanup
|
||||
const pendingTimeouts = timeoutRefs.current
|
||||
|
||||
@@ -62,6 +62,24 @@ function TimelineInterventionItem({
|
||||
<span className={isEducation ? 'timeline-intervention-pill timeline-intervention-pill--education' : 'timeline-intervention-pill'}>
|
||||
{interventionLabel}
|
||||
</span>
|
||||
{entity.dateRange.end === null && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: '9px',
|
||||
fontWeight: 700,
|
||||
fontFamily: 'var(--font-geist-mono)',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
padding: '2px 7px',
|
||||
borderRadius: '9999px',
|
||||
background: 'rgba(34, 197, 94, 0.12)',
|
||||
color: '#16a34a',
|
||||
border: '1px solid rgba(34, 197, 94, 0.3)',
|
||||
}}
|
||||
>
|
||||
Current
|
||||
</span>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import React from 'react'
|
||||
import { FileText, ChevronRight } from 'lucide-react'
|
||||
import { FileText, ChevronRight, Mail, Linkedin, Github, Download } from 'lucide-react'
|
||||
import { CardHeader } from '../Card'
|
||||
import { ParentSection } from '../ParentSection'
|
||||
import { kpis } from '@/data/kpis'
|
||||
import type { KPI } from '@/types/pmr'
|
||||
import { useDetailPanel } from '@/contexts/DetailPanelContext'
|
||||
import { getLatestResultsCopy, getProfileSectionTitle, getProfileSummaryText } from '@/lib/profile-content'
|
||||
import { getLatestResultsCopy, getProfileSectionTitle, getStructuredProfile } from '@/lib/profile-content'
|
||||
import { KPI_COLORS } from '@/lib/theme-colors'
|
||||
import { useIsMobileNav } from '@/hooks/useIsMobileNav'
|
||||
import { ProjectsCarousel } from './ProjectsTile'
|
||||
|
||||
interface MetricCardProps {
|
||||
@@ -107,10 +108,37 @@ function MetricCard({ kpi }: MetricCardProps) {
|
||||
)
|
||||
}
|
||||
|
||||
const ACTION_LINKS = [
|
||||
{ label: 'Email', href: 'mailto:andy@charlwood.xyz', icon: Mail, external: false },
|
||||
{ label: 'LinkedIn', href: 'https://linkedin.com/in/andycharlwood', icon: Linkedin, external: true },
|
||||
{ label: 'GitHub', href: 'https://github.com/andycharlwood', icon: Github, external: true },
|
||||
{ label: 'Download CV', href: '/References/CV_v4.md', icon: Download, external: true },
|
||||
] as const
|
||||
|
||||
const actionButtonStyles: React.CSSProperties = {
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
padding: '6px 12px',
|
||||
fontSize: '12px',
|
||||
fontWeight: 600,
|
||||
fontFamily: 'var(--font-geist-mono)',
|
||||
letterSpacing: '0.03em',
|
||||
textTransform: 'uppercase',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
border: '1px solid var(--border)',
|
||||
background: 'var(--surface)',
|
||||
color: 'var(--accent)',
|
||||
cursor: 'pointer',
|
||||
transition: 'background-color 150ms, border-color 150ms',
|
||||
textDecoration: 'none',
|
||||
}
|
||||
|
||||
export function PatientSummaryTile() {
|
||||
const summaryText = getProfileSummaryText()
|
||||
const structuredProfile = getStructuredProfile()
|
||||
const latestResultsCopy = getLatestResultsCopy()
|
||||
const sectionTitle = getProfileSectionTitle()
|
||||
const isMobile = useIsMobileNav()
|
||||
|
||||
const profileTextStyles: React.CSSProperties = {
|
||||
fontSize: '15px',
|
||||
@@ -118,6 +146,30 @@ export function PatientSummaryTile() {
|
||||
color: 'var(--text-primary)',
|
||||
}
|
||||
|
||||
const fieldsGridStyles: React.CSSProperties = {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: isMobile ? '1fr' : 'auto 1fr',
|
||||
gap: isMobile ? '2px 0' : '6px 16px',
|
||||
borderTop: '1px solid var(--border-light)',
|
||||
paddingTop: '14px',
|
||||
marginTop: '14px',
|
||||
}
|
||||
|
||||
const fieldLabelStyles: React.CSSProperties = {
|
||||
fontSize: '12px',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.06em',
|
||||
color: 'var(--text-tertiary)',
|
||||
fontFamily: 'var(--font-geist-mono)',
|
||||
}
|
||||
|
||||
const fieldValueStyles: React.CSSProperties = {
|
||||
fontSize: '13px',
|
||||
fontWeight: 600,
|
||||
color: 'var(--text-primary)',
|
||||
marginBottom: isMobile ? '8px' : undefined,
|
||||
}
|
||||
|
||||
const kpiGridStyles: React.CSSProperties = {
|
||||
display: 'grid',
|
||||
gap: '10px',
|
||||
@@ -125,8 +177,33 @@ export function PatientSummaryTile() {
|
||||
|
||||
return (
|
||||
<ParentSection title={sectionTitle} tileId="patient-summary">
|
||||
{/* Profile text */}
|
||||
<div style={profileTextStyles}>{summaryText}</div>
|
||||
{/* Presenting complaint */}
|
||||
<div style={profileTextStyles}>{structuredProfile.presentingComplaint}</div>
|
||||
|
||||
{/* Structured profile fields */}
|
||||
<div style={fieldsGridStyles}>
|
||||
{structuredProfile.fields.map((field) => (
|
||||
<React.Fragment key={field.label}>
|
||||
<span style={fieldLabelStyles}>{field.label}</span>
|
||||
<span style={fieldValueStyles}>{field.value}</span>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Contact / CTA action bar */}
|
||||
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap', marginTop: '16px' }}>
|
||||
{ACTION_LINKS.map((action) => (
|
||||
<a
|
||||
key={action.label}
|
||||
href={action.href}
|
||||
{...(action.external ? { target: '_blank', rel: 'noopener noreferrer' } : {})}
|
||||
style={actionButtonStyles}
|
||||
>
|
||||
<action.icon size={13} aria-hidden="true" />
|
||||
{action.label}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Latest Results subsection */}
|
||||
<div style={{ marginTop: '28px' }}>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import useEmblaCarousel from 'embla-carousel-react'
|
||||
import Autoplay from 'embla-carousel-autoplay'
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
import { investigations } from '@/data/investigations'
|
||||
import { CardHeader } from '../Card'
|
||||
import { useDetailPanel } from '@/contexts/DetailPanelContext'
|
||||
@@ -168,6 +169,21 @@ function ProjectItem({
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{project.resultSummary && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
fontWeight: 700,
|
||||
fontFamily: 'var(--font-geist-mono)',
|
||||
color: 'var(--accent)',
|
||||
letterSpacing: '-0.01em',
|
||||
lineHeight: 1.3,
|
||||
}}
|
||||
>
|
||||
{project.resultSummary}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
@@ -391,6 +407,56 @@ function ContinuousScrollCarousel() {
|
||||
? window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
: false,
|
||||
)
|
||||
const resumeTimeoutRef = useRef<number>(0)
|
||||
|
||||
const jumpByCards = useCallback((direction: 1 | -1) => {
|
||||
const trackEl = trackRef.current
|
||||
const firstSetEl = firstSetRef.current
|
||||
if (!trackEl || !firstSetEl) return
|
||||
|
||||
const gap = 12
|
||||
const cardsPerView = 4
|
||||
const totalGap = (cardsPerView - 1) * gap
|
||||
const cardWidth = (viewportWidth - totalGap) / cardsPerView
|
||||
const jumpPx = cardWidth + gap
|
||||
|
||||
// Pause auto-scroll
|
||||
isPausedRef.current = true
|
||||
window.clearTimeout(resumeTimeoutRef.current)
|
||||
|
||||
// Apply CSS transition for smooth jump
|
||||
if (!prefersReducedMotion) {
|
||||
trackEl.style.transition = 'transform 0.4s ease'
|
||||
}
|
||||
|
||||
// Calculate new offset
|
||||
const setWidth = firstSetEl.offsetWidth
|
||||
let newOffset = offsetRef.current + (direction * jumpPx)
|
||||
if (setWidth > 0) {
|
||||
newOffset = ((newOffset % setWidth) + setWidth) % setWidth
|
||||
}
|
||||
offsetRef.current = newOffset
|
||||
trackEl.style.transform = `translate3d(-${newOffset}px, 0, 0)`
|
||||
|
||||
// Remove transition after completion
|
||||
if (!prefersReducedMotion) {
|
||||
const transitionEnd = () => {
|
||||
trackEl.style.transition = ''
|
||||
}
|
||||
trackEl.addEventListener('transitionend', transitionEnd, { once: true })
|
||||
}
|
||||
|
||||
// Resume auto-scroll after 6s
|
||||
resumeTimeoutRef.current = window.setTimeout(() => {
|
||||
isPausedRef.current = false
|
||||
}, 6000)
|
||||
}, [viewportWidth, prefersReducedMotion])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
window.clearTimeout(resumeTimeoutRef.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const viewportEl = viewportRef.current
|
||||
@@ -460,46 +526,91 @@ function ContinuousScrollCarousel() {
|
||||
isPausedRef.current = value
|
||||
}
|
||||
|
||||
const arrowStyle: React.CSSProperties = {
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: 'var(--surface)',
|
||||
border: '1px solid var(--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',
|
||||
zIndex: 2,
|
||||
opacity: 0.7,
|
||||
padding: 0,
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={viewportRef}
|
||||
style={{ overflow: 'hidden' }}
|
||||
onMouseEnter={() => setPaused(true)}
|
||||
onMouseLeave={() => setPaused(false)}
|
||||
onFocusCapture={() => setPaused(true)}
|
||||
onBlurCapture={(event) => {
|
||||
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
|
||||
setPaused(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<div
|
||||
ref={trackRef}
|
||||
style={{
|
||||
display: 'flex',
|
||||
width: 'max-content',
|
||||
willChange: 'transform',
|
||||
transform: 'translate3d(0, 0, 0)',
|
||||
ref={viewportRef}
|
||||
style={{ overflow: 'hidden' }}
|
||||
onMouseEnter={() => setPaused(true)}
|
||||
onMouseLeave={() => setPaused(false)}
|
||||
onFocusCapture={() => setPaused(true)}
|
||||
onBlurCapture={(event) => {
|
||||
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
|
||||
setPaused(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{[0, 1].map((setIndex) => (
|
||||
<div
|
||||
key={setIndex}
|
||||
ref={setIndex === 0 ? firstSetRef : undefined}
|
||||
style={{ display: 'flex', gap: '12px', paddingRight: '12px', flexShrink: 0 }}
|
||||
>
|
||||
{investigations.map((project) => (
|
||||
<ProjectItem
|
||||
key={`${setIndex}-${project.id}`}
|
||||
project={project}
|
||||
slideWidth={slideWidth}
|
||||
cardMinHeight={cardMinHeight}
|
||||
onClick={() => openPanel({ type: 'project', investigation: project })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
<div
|
||||
ref={trackRef}
|
||||
style={{
|
||||
display: 'flex',
|
||||
width: 'max-content',
|
||||
willChange: 'transform',
|
||||
transform: 'translate3d(0, 0, 0)',
|
||||
}}
|
||||
>
|
||||
{[0, 1].map((setIndex) => (
|
||||
<div
|
||||
key={setIndex}
|
||||
ref={setIndex === 0 ? firstSetRef : undefined}
|
||||
style={{ display: 'flex', gap: '12px', paddingRight: '12px', flexShrink: 0 }}
|
||||
>
|
||||
{investigations.map((project) => (
|
||||
<ProjectItem
|
||||
key={`${setIndex}-${project.id}`}
|
||||
project={project}
|
||||
slideWidth={slideWidth}
|
||||
cardMinHeight={cardMinHeight}
|
||||
onClick={() => openPanel({ type: 'project', investigation: project })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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' }}
|
||||
>
|
||||
<ChevronLeft size={16} />
|
||||
</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' }}
|
||||
>
|
||||
<ChevronRight size={16} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createContext, useContext, useState, ReactNode } from 'react'
|
||||
import { createContext, useContext, useState, useCallback, useRef, ReactNode } from 'react'
|
||||
import { DetailPanelContent } from '@/types/pmr'
|
||||
|
||||
interface DetailPanelContextValue {
|
||||
@@ -6,6 +6,7 @@ interface DetailPanelContextValue {
|
||||
openPanel: (content: DetailPanelContent) => void
|
||||
closePanel: () => void
|
||||
isOpen: boolean
|
||||
isClosing: boolean
|
||||
}
|
||||
|
||||
const DetailPanelContext = createContext<DetailPanelContextValue | undefined>(
|
||||
@@ -18,14 +19,27 @@ interface DetailPanelProviderProps {
|
||||
|
||||
export function DetailPanelProvider({ children }: DetailPanelProviderProps) {
|
||||
const [content, setContent] = useState<DetailPanelContent | null>(null)
|
||||
const [isClosing, setIsClosing] = useState(false)
|
||||
const closeTimerRef = useRef<number>(0)
|
||||
|
||||
const openPanel = (newContent: DetailPanelContent) => {
|
||||
const openPanel = useCallback((newContent: DetailPanelContent) => {
|
||||
// If we're in the middle of closing, cancel it
|
||||
if (closeTimerRef.current) {
|
||||
window.clearTimeout(closeTimerRef.current)
|
||||
closeTimerRef.current = 0
|
||||
}
|
||||
setIsClosing(false)
|
||||
setContent(newContent)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const closePanel = () => {
|
||||
setContent(null)
|
||||
}
|
||||
const closePanel = useCallback(() => {
|
||||
setIsClosing(true)
|
||||
closeTimerRef.current = window.setTimeout(() => {
|
||||
setIsClosing(false)
|
||||
setContent(null)
|
||||
closeTimerRef.current = 0
|
||||
}, 250) // match panel-slide-out duration
|
||||
}, [])
|
||||
|
||||
const isOpen = content !== null
|
||||
|
||||
@@ -34,6 +48,7 @@ export function DetailPanelProvider({ children }: DetailPanelProviderProps) {
|
||||
openPanel,
|
||||
closePanel,
|
||||
isOpen,
|
||||
isClosing,
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -4,8 +4,18 @@ export const profileContent: DeepReadonly<ProfileContent> = {
|
||||
profile: {
|
||||
sectionTitle: 'Patient Summary',
|
||||
patientSummaryNarrative: 'Healthcare leader combining clinical pharmacy expertise with proficiency in Python, SQL, and data analytics, self-taught over the past decade through a drive to find root causes in data and build the most efficient solutions to complex problems. Currently leading population health analytics for NHS Norfolk & Waveney ICB, serving a population of 1.2 million. Experienced in working with messy, real-world prescribing data at scale to deliver actionable insights — from financial scenario modelling and pharmaceutical rebate negotiation to algorithm design and population-level pathway development. Proven track record of identifying and prioritising efficiency programmes worth £14.6M+ through automated, data-driven analysis. Skilled at translating complex clinical, financial, and analytical requirements into clear recommendations for executive stakeholders.',
|
||||
structuredProfile: {
|
||||
presentingComplaint: 'Healthcare leader combining clinical pharmacy expertise with proficiency in Python, SQL, and data analytics. Currently leading population health analytics for NHS Norfolk & Waveney ICB, serving 1.2 million.',
|
||||
fields: [
|
||||
{ label: 'Specialisation', value: 'Population Health Analytics & Medicines Optimisation' },
|
||||
{ label: 'Current System', value: 'NHS Norfolk & Waveney ICB' },
|
||||
{ label: 'Population', value: '1.2 million' },
|
||||
{ label: 'Focus Areas', value: 'Prescribing analytics, financial modelling, algorithm design, data pipelines' },
|
||||
{ label: 'Key Achievement', value: '£14.6M+ efficiency programmes identified' },
|
||||
],
|
||||
},
|
||||
latestResults: {
|
||||
title: 'LATEST RESULTS (CLICK TO VIEW FULL REFERENCE RANGE)',
|
||||
title: 'KEY METRICS',
|
||||
rightText: 'Updated February 2026',
|
||||
helperText: 'Select a metric to inspect methodology, impact, and outcomes.',
|
||||
evidenceCta: 'Click to view evidence',
|
||||
|
||||
+1
-1
@@ -103,7 +103,7 @@
|
||||
--sidebar-bg: #F7FAFA;
|
||||
--text-primary: #1A2B2A;
|
||||
--text-secondary: #5B7A78;
|
||||
--text-tertiary: #8DA8A5;
|
||||
--text-tertiary: #6B8886;
|
||||
--accent: #0D6E6E;
|
||||
--accent-hover: #0A8080;
|
||||
--accent-pressed: #085858;
|
||||
|
||||
@@ -8,12 +8,9 @@ import type {
|
||||
QuickActionCopyEntry,
|
||||
SidebarCopy,
|
||||
SkillsUICopy,
|
||||
StructuredProfile,
|
||||
} from '@/types/profile-content'
|
||||
|
||||
export function getProfileSummaryText(): string {
|
||||
return profileContent.profile.patientSummaryNarrative
|
||||
}
|
||||
|
||||
export function getProfileSectionTitle(): string {
|
||||
return profileContent.profile.sectionTitle
|
||||
}
|
||||
@@ -46,4 +43,6 @@ export function getEducationEntries(): ReadonlyArray<EducationCopyEntry> {
|
||||
return profileContent.experienceEducation.educationEntries
|
||||
}
|
||||
|
||||
|
||||
export function getStructuredProfile(): DeepReadonly<StructuredProfile> {
|
||||
return profileContent.profile.structuredProfile
|
||||
}
|
||||
|
||||
@@ -80,10 +80,21 @@ export interface SkillsUICopy {
|
||||
readonly categories: ReadonlyArray<SkillsCategoryCopyEntry>
|
||||
}
|
||||
|
||||
export interface StructuredProfileField {
|
||||
readonly label: string
|
||||
readonly value: string
|
||||
}
|
||||
|
||||
export interface StructuredProfile {
|
||||
readonly presentingComplaint: string
|
||||
readonly fields: ReadonlyArray<StructuredProfileField>
|
||||
}
|
||||
|
||||
export interface ProfileContent {
|
||||
readonly profile: {
|
||||
readonly sectionTitle: string
|
||||
readonly patientSummaryNarrative: string
|
||||
readonly structuredProfile: StructuredProfile
|
||||
readonly latestResults: LatestResultsCopy
|
||||
readonly sidebar: SidebarCopy
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user