chore: auto-commit before merge (loop primary)
This commit is contained in:
@@ -28,7 +28,7 @@ export function Card({ children, full, className, tileId }: CardProps) {
|
||||
return (
|
||||
<article
|
||||
style={baseStyles}
|
||||
className={className}
|
||||
className={['card-base', className].filter(Boolean).join(' ')}
|
||||
data-tile-id={tileId}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { buildPaletteData } from '@/lib/search'
|
||||
import type { PaletteItem, PaletteAction } from '@/lib/search'
|
||||
import { iconByType, iconColorStyles } from '@/lib/palette-icons'
|
||||
import { prefersReducedMotion, motionSafeTransition } from '@/lib/utils'
|
||||
import { useIsMobileNav } from '@/hooks/useIsMobileNav'
|
||||
|
||||
const MAX_HISTORY = 10
|
||||
|
||||
@@ -52,6 +53,7 @@ interface ChatWidgetProps {
|
||||
}
|
||||
|
||||
export function ChatWidget({ onAction }: ChatWidgetProps) {
|
||||
const isMobileNav = useIsMobileNav()
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([])
|
||||
const [inputValue, setInputValue] = useState('')
|
||||
@@ -610,8 +612,9 @@ export function ChatWidget({ onAction }: ChatWidgetProps) {
|
||||
variants={buttonVariants}
|
||||
onClick={() => setIsOpen((prev) => !prev)}
|
||||
aria-label={isOpen ? 'Close chat' : 'Open chat'}
|
||||
className={`fixed z-[90] cursor-pointer flex items-center justify-center bottom-4 right-4 h-10 w-10 md:bottom-6 md:right-6 md:h-12 md:w-12${isOpen ? ' max-md:!hidden' : ''}`}
|
||||
className={`fixed z-[101] cursor-pointer flex items-center justify-center bottom-4 right-4 h-10 w-10 md:bottom-6 md:right-6 md:h-12 md:w-12${isOpen ? ' max-md:!hidden' : ''}`}
|
||||
style={{
|
||||
bottom: isMobileNav ? 'calc(56px + env(safe-area-inset-bottom) + 16px)' : undefined,
|
||||
borderRadius: '50%',
|
||||
border: 'none',
|
||||
background: 'var(--accent)',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect, useCallback, useRef, useMemo } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import Sidebar from './Sidebar'
|
||||
import { MobileBottomNav } from './MobileBottomNav'
|
||||
import { CommandPalette } from './CommandPalette'
|
||||
import { DetailPanel } from './DetailPanel'
|
||||
import { PatientSummaryTile } from './tiles/PatientSummaryTile'
|
||||
@@ -11,6 +12,7 @@ import { RepeatMedicationsSubsection } from './RepeatMedicationsSubsection'
|
||||
import { LastConsultationCard } from './LastConsultationCard'
|
||||
import { ChatWidget } from './ChatWidget'
|
||||
import { useActiveSection } from '@/hooks/useActiveSection'
|
||||
import { useIsMobileNav } from '@/hooks/useIsMobileNav'
|
||||
import { useDetailPanel } from '@/contexts/DetailPanelContext'
|
||||
import { timelineConsultations, timelineEntities } from '@/data/timeline'
|
||||
import { skills } from '@/data/skills'
|
||||
@@ -41,6 +43,7 @@ export function DashboardLayout() {
|
||||
const [highlightedRoleId, setHighlightedRoleId] = useState<string | null>(null)
|
||||
const [chronologyHeight, setChronologyHeight] = useState<number | null>(null)
|
||||
const [constellationReady, setConstellationReady] = useState(false)
|
||||
const isMobileNav = useIsMobileNav()
|
||||
const chronologyRef = useRef<HTMLDivElement>(null)
|
||||
const patientSummaryRef = useRef<HTMLDivElement>(null)
|
||||
const activeSection = useActiveSection()
|
||||
@@ -250,18 +253,20 @@ export function DashboardLayout() {
|
||||
height: '100%',
|
||||
}}
|
||||
>
|
||||
<motion.div
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
variants={sidebarVariants}
|
||||
style={{ flexShrink: 0, height: '100%' }}
|
||||
>
|
||||
<Sidebar
|
||||
activeSection={activeSection}
|
||||
onNavigate={scrollToSection}
|
||||
onSearchClick={handleSearchClick}
|
||||
/>
|
||||
</motion.div>
|
||||
{!isMobileNav && (
|
||||
<motion.div
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
variants={sidebarVariants}
|
||||
style={{ flexShrink: 0, height: '100%' }}
|
||||
>
|
||||
<Sidebar
|
||||
activeSection={activeSection}
|
||||
onNavigate={scrollToSection}
|
||||
onSearchClick={handleSearchClick}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
<motion.main
|
||||
id="main-content"
|
||||
@@ -269,10 +274,11 @@ export function DashboardLayout() {
|
||||
animate="visible"
|
||||
variants={contentVariants}
|
||||
aria-label="Dashboard content"
|
||||
className="dashboard-main pmr-scrollbar p-5 pb-10 md:p-7 md:pb-12 lg:px-8 lg:pt-7 lg:pb-12"
|
||||
className="dashboard-main pmr-scrollbar p-3 xs:p-5 pb-10 md:p-7 md:pb-12 lg:px-8 lg:pt-7 lg:pb-12"
|
||||
style={{
|
||||
flex: 1,
|
||||
overflowY: 'auto',
|
||||
paddingBottom: isMobileNav ? 'calc(56px + env(safe-area-inset-bottom) + 16px)' : undefined,
|
||||
}}
|
||||
>
|
||||
<div className="dashboard-grid">
|
||||
@@ -330,6 +336,13 @@ export function DashboardLayout() {
|
||||
|
||||
{/* Floating chat widget */}
|
||||
<ChatWidget onAction={handlePaletteAction} />
|
||||
|
||||
{/* Mobile bottom navigation */}
|
||||
<MobileBottomNav
|
||||
activeSection={activeSection}
|
||||
onNavigate={scrollToSection}
|
||||
onSearchClick={handleSearchClick}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -129,6 +129,7 @@ export function DetailPanel() {
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
data-panel-header=""
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
@@ -199,6 +200,7 @@ export function DetailPanel() {
|
||||
|
||||
{/* Body (scrollable) */}
|
||||
<div
|
||||
data-panel-body=""
|
||||
style={{
|
||||
flex: 1,
|
||||
overflowY: 'auto',
|
||||
|
||||
@@ -0,0 +1,388 @@
|
||||
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 { 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: '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) {
|
||||
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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
Wrench,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
import { useIsMobileNav } from '@/hooks/useIsMobileNav'
|
||||
import { CvmisLogo } from './CvmisLogo'
|
||||
import { PhoneCaptcha } from './PhoneCaptcha'
|
||||
import { patient } from '@/data/patient'
|
||||
@@ -163,6 +164,7 @@ function AlertFlag({ alert }: AlertFlagProps) {
|
||||
export default function Sidebar({ activeSection, onNavigate, onSearchClick }: SidebarProps) {
|
||||
const sidebarCopy = getSidebarCopy()
|
||||
const [isDesktop, setIsDesktop] = useState(() => window.matchMedia('(min-width: 1024px)').matches)
|
||||
const isMobileNav = useIsMobileNav()
|
||||
const [isMobileExpanded, setIsMobileExpanded] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -180,7 +182,9 @@ export default function Sidebar({ activeSection, onNavigate, onSearchClick }: Si
|
||||
const listener = (event: MediaQueryListEvent) => updateDesktopState(event)
|
||||
mediaQuery.addEventListener('change', listener)
|
||||
|
||||
return () => mediaQuery.removeEventListener('change', listener)
|
||||
return () => {
|
||||
mediaQuery.removeEventListener('change', listener)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const isExpanded = isDesktop || isMobileExpanded
|
||||
@@ -192,6 +196,8 @@ export default function Sidebar({ activeSection, onNavigate, onSearchClick }: Si
|
||||
}
|
||||
}
|
||||
|
||||
if (isMobileNav) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
{!isDesktop && isMobileExpanded && (
|
||||
|
||||
@@ -44,7 +44,7 @@ function MetricCard({ kpi }: MetricCardProps) {
|
||||
}
|
||||
|
||||
const valueStyles: React.CSSProperties = {
|
||||
fontSize: '30px',
|
||||
fontSize: 'clamp(22px, 6vw, 30px)',
|
||||
fontWeight: 700,
|
||||
letterSpacing: '-0.02em',
|
||||
lineHeight: 1.2,
|
||||
@@ -121,7 +121,6 @@ export function PatientSummaryTile() {
|
||||
const kpiGridStyles: React.CSSProperties = {
|
||||
display: 'grid',
|
||||
gap: '10px',
|
||||
gridTemplateColumns: 'repeat(2, minmax(0, 1fr))',
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -144,7 +143,7 @@ export function PatientSummaryTile() {
|
||||
{latestResultsCopy.helperText}
|
||||
</p>
|
||||
</div>
|
||||
<div className="latest-results-grid" style={kpiGridStyles}>
|
||||
<div className="kpi-grid latest-results-grid" style={kpiGridStyles}>
|
||||
{kpis.map((kpi) => (
|
||||
<MetricCard key={kpi.id} kpi={kpi} />
|
||||
))}
|
||||
|
||||
@@ -318,9 +318,8 @@ export function ProjectsCarousel() {
|
||||
}, [prefersReducedMotion, viewportWidth])
|
||||
|
||||
const cardsPerView = useMemo(() => {
|
||||
if (viewportWidth < 768) {
|
||||
return 2
|
||||
}
|
||||
if (viewportWidth < 480) return 1
|
||||
if (viewportWidth < 768) return 2
|
||||
return 4
|
||||
}, [viewportWidth])
|
||||
|
||||
@@ -332,15 +331,10 @@ export function ProjectsCarousel() {
|
||||
}, [cardsPerView, viewportWidth])
|
||||
|
||||
const cardMinHeight = useMemo(() => {
|
||||
if (viewportWidth < 640) {
|
||||
return 168
|
||||
}
|
||||
if (viewportWidth < 1024) {
|
||||
return 182
|
||||
}
|
||||
if (viewportWidth < 1440) {
|
||||
return 196
|
||||
}
|
||||
if (viewportWidth < 480) return 148
|
||||
if (viewportWidth < 640) return 168
|
||||
if (viewportWidth < 1024) return 182
|
||||
if (viewportWidth < 1440) return 196
|
||||
return 214
|
||||
}, [viewportWidth])
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ function fractionalYear(node: { startDate?: string; startYear?: number }): numbe
|
||||
}
|
||||
|
||||
function getHeight(width: number, containerHeight?: number | null): number {
|
||||
if (width < 480) return 380
|
||||
if (width < 768) return 520
|
||||
if (containerHeight && containerHeight > 0) return Math.max(400, containerHeight)
|
||||
return 400
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
const MOBILE_NAV_QUERY = '(max-width: 599px)'
|
||||
|
||||
export function useIsMobileNav(): boolean {
|
||||
const [isMobileNav, setIsMobileNav] = useState(
|
||||
() => window.matchMedia(MOBILE_NAV_QUERY).matches,
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia(MOBILE_NAV_QUERY)
|
||||
const handler = (e: MediaQueryListEvent) => setIsMobileNav(e.matches)
|
||||
mq.addEventListener('change', handler)
|
||||
return () => mq.removeEventListener('change', handler)
|
||||
}, [])
|
||||
|
||||
return isMobileNav
|
||||
}
|
||||
@@ -591,6 +591,46 @@ textarea:focus-visible {
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== MOBILE RESPONSIVE FIXES (<600px) ===== */
|
||||
@media (max-width: 599px) {
|
||||
.dashboard-main {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== SMALL VIEWPORT FIXES (<480px) ===== */
|
||||
@media (max-width: 479px) {
|
||||
.card-base {
|
||||
padding: 16px !important;
|
||||
}
|
||||
|
||||
.chronology-item {
|
||||
padding: 8px 8px 10px;
|
||||
}
|
||||
}
|
||||
|
||||
/* KPI grid — responsive columns */
|
||||
.kpi-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
@media (max-width: 359px) {
|
||||
.kpi-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* Detail panel responsive padding */
|
||||
@media (max-width: 479px) {
|
||||
.detail-panel [data-panel-body] {
|
||||
padding: 16px !important;
|
||||
}
|
||||
|
||||
.detail-panel [data-panel-header] {
|
||||
padding: 16px !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
/* Disable pulse animation on status badge dot */
|
||||
@keyframes pulse {
|
||||
|
||||
Reference in New Issue
Block a user