Substantial refinement/polish on content of webpage (not just structural/coding elements)
This commit is contained in:
@@ -4,7 +4,6 @@ import Sidebar from './Sidebar'
|
||||
import { CommandPalette } from './CommandPalette'
|
||||
import { DetailPanel } from './DetailPanel'
|
||||
import { PatientSummaryTile } from './tiles/PatientSummaryTile'
|
||||
import { ProjectsTile } from './tiles/ProjectsTile'
|
||||
import { ParentSection } from './ParentSection'
|
||||
import CareerConstellation from './constellation/CareerConstellation'
|
||||
import { TimelineInterventionsSubsection } from './TimelineInterventionsSubsection'
|
||||
@@ -210,7 +209,7 @@ export function DashboardLayout() {
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
variants={sidebarVariants}
|
||||
style={{ flexShrink: 0 }}
|
||||
style={{ flexShrink: 0, height: '100%' }}
|
||||
>
|
||||
<Sidebar
|
||||
activeSection={activeSection}
|
||||
@@ -237,9 +236,6 @@ export function DashboardLayout() {
|
||||
<PatientSummaryTile />
|
||||
</div>
|
||||
|
||||
{/* ProjectsTile — full width */}
|
||||
<ProjectsTile />
|
||||
|
||||
{/* Patient Pathway — parent section with constellation graph + subsections */}
|
||||
<ParentSection title="Patient Pathway" tileId="patient-pathway">
|
||||
<div className="pathway-columns">
|
||||
|
||||
@@ -12,7 +12,7 @@ interface LastConsultationCardProps {
|
||||
|
||||
export function LastConsultationCard({ highlightedRoleId }: LastConsultationCardProps) {
|
||||
const { openPanel } = useDetailPanel()
|
||||
const consultation = timelineConsultations[0]
|
||||
const consultation = timelineConsultations.find(c => c.isCurrent) ?? timelineConsultations[0]
|
||||
if (!consultation) {
|
||||
return null
|
||||
}
|
||||
@@ -42,10 +42,7 @@ export function LastConsultationCard({ highlightedRoleId }: LastConsultationCard
|
||||
}
|
||||
|
||||
const getBand = (): string => {
|
||||
if (consultation.role.includes('Head')) {
|
||||
return '8a'
|
||||
}
|
||||
return '—'
|
||||
return consultation.band ?? '—'
|
||||
}
|
||||
|
||||
const fieldLabelStyle: React.CSSProperties = {
|
||||
@@ -75,7 +72,7 @@ export function LastConsultationCard({ highlightedRoleId }: LastConsultationCard
|
||||
margin: '-8px',
|
||||
}}
|
||||
>
|
||||
<CardHeader dotColor="green" title="LAST CONSULTATION" rightText="Most recent role" />
|
||||
<CardHeader dotColor="green" title="LAST CONSULTATION" rightText="Current role" />
|
||||
|
||||
<div
|
||||
role="button"
|
||||
|
||||
@@ -11,7 +11,7 @@ import { CardHeader } from './Card'
|
||||
import { skills } from '@/data/skills'
|
||||
import { useDetailPanel } from '@/contexts/DetailPanelContext'
|
||||
import { getSkillsUICopy } from '@/lib/profile-content'
|
||||
import type { SkillMedication, SkillCategory } from '@/types/pmr'
|
||||
import type { SkillMedication } from '@/types/pmr'
|
||||
|
||||
const iconMap: Record<string, LucideIcon> = {
|
||||
BarChart3, Code2, Database, PieChart, FileCode2,
|
||||
@@ -20,7 +20,6 @@ const iconMap: Record<string, LucideIcon> = {
|
||||
MessageSquare, UserPlus, RefreshCw, Calculator, Presentation,
|
||||
}
|
||||
|
||||
const SKILLS_PER_CATEGORY = 4
|
||||
|
||||
interface SkillRowProps {
|
||||
skill: SkillMedication
|
||||
@@ -129,32 +128,23 @@ function SkillRow({ skill, yearsSuffix, onClick, onHighlight }: SkillRowProps) {
|
||||
|
||||
interface CategorySectionProps {
|
||||
label: string
|
||||
categoryId: SkillCategory
|
||||
skills: SkillMedication[]
|
||||
itemCountSuffix: string
|
||||
yearsSuffix: string
|
||||
viewAllLabel: string
|
||||
onSkillClick: (skill: SkillMedication) => void
|
||||
onViewAll: (category: SkillCategory) => void
|
||||
isFirst: boolean
|
||||
onNodeHighlight?: (id: string | null) => void
|
||||
}
|
||||
|
||||
function CategorySection({
|
||||
label,
|
||||
categoryId,
|
||||
skills: categorySkills,
|
||||
itemCountSuffix,
|
||||
yearsSuffix,
|
||||
viewAllLabel,
|
||||
onSkillClick,
|
||||
onViewAll,
|
||||
isFirst,
|
||||
onNodeHighlight,
|
||||
}: CategorySectionProps) {
|
||||
const visibleSkills = categorySkills.slice(0, SKILLS_PER_CATEGORY)
|
||||
const remainingCount = categorySkills.length - SKILLS_PER_CATEGORY
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: isFirst ? 0 : '16px' }}>
|
||||
<div
|
||||
@@ -196,7 +186,7 @@ function CategorySection({
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
{visibleSkills.map((skill) => (
|
||||
{categorySkills.map((skill) => (
|
||||
<SkillRow
|
||||
key={skill.id}
|
||||
skill={skill}
|
||||
@@ -206,37 +196,6 @@ function CategorySection({
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{remainingCount > 0 && (
|
||||
<button
|
||||
onClick={() => onViewAll(categoryId)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
marginTop: '8px',
|
||||
padding: '4px 0',
|
||||
minHeight: '44px',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px',
|
||||
fontWeight: 500,
|
||||
color: 'var(--accent)',
|
||||
fontFamily: 'inherit',
|
||||
transition: 'color 0.15s',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.color = 'var(--accent-hover)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.color = 'var(--accent)'
|
||||
}}
|
||||
aria-label={`${viewAllLabel} ${categorySkills.length} ${label} skills`}
|
||||
>
|
||||
{viewAllLabel} ({categorySkills.length})
|
||||
<ChevronRight size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -254,17 +213,13 @@ export function RepeatMedicationsSubsection({ onNodeHighlight }: RepeatMedicatio
|
||||
label,
|
||||
skills: skills
|
||||
.filter((s) => s.category === id)
|
||||
.sort((a, b) => b.proficiency - a.proficiency),
|
||||
.sort((a, b) => b.yearsOfExperience - a.yearsOfExperience),
|
||||
}))
|
||||
|
||||
const handleSkillClick = (skill: SkillMedication) => {
|
||||
openPanel({ type: 'skill', skill })
|
||||
}
|
||||
|
||||
const handleViewAll = (category: SkillCategory) => {
|
||||
openPanel({ type: 'skills-all', category })
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<CardHeader
|
||||
@@ -277,13 +232,10 @@ export function RepeatMedicationsSubsection({ onNodeHighlight }: RepeatMedicatio
|
||||
<CategorySection
|
||||
key={group.id}
|
||||
label={group.label}
|
||||
categoryId={group.id}
|
||||
skills={group.skills}
|
||||
itemCountSuffix={skillsCopy.itemCountSuffix}
|
||||
yearsSuffix={skillsCopy.yearsSuffix}
|
||||
viewAllLabel={skillsCopy.viewAllLabel}
|
||||
onSkillClick={handleSkillClick}
|
||||
onViewAll={handleViewAll}
|
||||
isFirst
|
||||
onNodeHighlight={onNodeHighlight}
|
||||
/>
|
||||
|
||||
@@ -3,10 +3,8 @@ import type { CSSProperties, ReactNode } from 'react'
|
||||
import {
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
GraduationCap,
|
||||
type LucideIcon,
|
||||
Menu,
|
||||
Pill,
|
||||
Search,
|
||||
UserRound,
|
||||
Workflow,
|
||||
@@ -34,10 +32,8 @@ interface NavSection {
|
||||
}
|
||||
|
||||
const navSections: NavSection[] = [
|
||||
{ id: 'overview', label: 'Overview', tileId: 'patient-summary', Icon: UserRound },
|
||||
{ id: 'projects', label: 'Projects', tileId: 'projects', Icon: Pill },
|
||||
{ id: 'overview', label: 'Overview / Highlights', tileId: 'patient-summary', Icon: UserRound },
|
||||
{ id: 'experience', label: 'Experience', tileId: 'section-experience', Icon: Workflow },
|
||||
{ id: 'education', label: 'Education', tileId: 'section-education', Icon: GraduationCap },
|
||||
{ id: 'skills', label: 'Skills', tileId: 'section-skills', Icon: Wrench },
|
||||
]
|
||||
|
||||
@@ -221,6 +217,7 @@ export default function Sidebar({ activeSection, onNavigate, onSearchClick }: Si
|
||||
top: 0,
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
height: isDesktop ? '100%' : undefined,
|
||||
width: isExpanded ? 'var(--sidebar-width)' : 'var(--sidebar-rail-width)',
|
||||
minWidth: isExpanded ? 'var(--sidebar-width)' : 'var(--sidebar-rail-width)',
|
||||
background: 'var(--sidebar-bg)',
|
||||
|
||||
@@ -43,53 +43,113 @@ function TimelineInterventionItem({
|
||||
onMouseEnter={() => onHighlight?.(entity.id)}
|
||||
onMouseLeave={() => onHighlight?.(null)}
|
||||
renderHeader={() => (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
}}
|
||||
>
|
||||
<span className={isEducation ? 'timeline-intervention-pill timeline-intervention-pill--education' : 'timeline-intervention-pill'}>
|
||||
{interventionLabel}
|
||||
</span>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: '8px' }}>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
fontWeight: 600,
|
||||
color: 'var(--text-primary)',
|
||||
lineHeight: 1.3,
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
}}
|
||||
>
|
||||
{entity.title}
|
||||
<span className={isEducation ? 'timeline-intervention-pill timeline-intervention-pill--education' : 'timeline-intervention-pill'}>
|
||||
{interventionLabel}
|
||||
</span>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
fontWeight: 600,
|
||||
color: 'var(--text-primary)',
|
||||
lineHeight: 1.3,
|
||||
}}
|
||||
>
|
||||
{entity.title}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
color: 'var(--text-secondary)',
|
||||
marginTop: '2px',
|
||||
}}
|
||||
>
|
||||
{entity.organization}
|
||||
<span
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
paddingLeft: '6px',
|
||||
fontFamily: 'var(--font-geist-mono)',
|
||||
color: 'var(--text-tertiary)',
|
||||
marginTop: '3px',
|
||||
}}
|
||||
>
|
||||
{entity.dateRange.display}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
color: 'var(--text-secondary)',
|
||||
marginTop: '2px',
|
||||
}}
|
||||
>
|
||||
{entity.organization}
|
||||
<span
|
||||
{(entity.band || entity.employmentBasis) && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
paddingLeft: '6px',
|
||||
fontFamily: 'var(--font-geist-mono)',
|
||||
color: 'var(--text-tertiary)',
|
||||
marginTop: '3px',
|
||||
display: 'flex',
|
||||
flexShrink: 0,
|
||||
alignItems: 'center',
|
||||
gap: '5px',
|
||||
}}
|
||||
>
|
||||
{entity.dateRange.display}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
{entity.band && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: '10px',
|
||||
fontFamily: 'var(--font-geist-mono)',
|
||||
padding: '2px 6px',
|
||||
borderRadius: '3px',
|
||||
background: hexToRgba(entity.orgColor, 0.1),
|
||||
color: entity.orgColor,
|
||||
border: `1px solid ${hexToRgba(entity.orgColor, 0.25)}`,
|
||||
lineHeight: 1.4,
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
Band {entity.band.toUpperCase()}
|
||||
</span>
|
||||
)}
|
||||
{entity.employmentBasis && (
|
||||
<span
|
||||
title={entity.contextNote}
|
||||
style={{
|
||||
fontSize: '10px',
|
||||
padding: '2px 6px',
|
||||
borderRadius: '3px',
|
||||
background: 'rgba(245, 158, 11, 0.1)',
|
||||
color: '#b45309',
|
||||
border: '1px solid rgba(245, 158, 11, 0.25)',
|
||||
cursor: 'default',
|
||||
lineHeight: 1.4,
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{entity.employmentBasis}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
renderBody={() => (
|
||||
<>
|
||||
{entity.contextNote && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
fontStyle: 'italic',
|
||||
color: 'var(--text-tertiary)',
|
||||
marginBottom: '10px',
|
||||
}}
|
||||
>
|
||||
{entity.contextNote}
|
||||
</div>
|
||||
)}
|
||||
<ul
|
||||
style={{
|
||||
listStyle: 'none',
|
||||
|
||||
@@ -1,207 +0,0 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
import { ChevronRight } from 'lucide-react'
|
||||
import { CardHeader } from './Card'
|
||||
import { ExpandableCardShell } from './ExpandableCardShell'
|
||||
import { timelineConsultations } from '@/data/timeline'
|
||||
import { useDetailPanel } from '@/contexts/DetailPanelContext'
|
||||
import { hexToRgba } from '@/lib/utils'
|
||||
import { DEFAULT_ORG_COLOR } from '@/lib/theme-colors'
|
||||
|
||||
interface RoleItemProps {
|
||||
consultation: typeof timelineConsultations[0]
|
||||
isExpanded: boolean
|
||||
isHighlightedFromGraph: boolean
|
||||
onToggle: () => void
|
||||
onViewFull: () => void
|
||||
onHighlight?: (id: string | null) => void
|
||||
}
|
||||
|
||||
function RoleItem({ consultation, isExpanded, isHighlightedFromGraph, onToggle, onViewFull, onHighlight }: RoleItemProps) {
|
||||
const orgColor = consultation.orgColor ?? DEFAULT_ORG_COLOR
|
||||
|
||||
return (
|
||||
<ExpandableCardShell
|
||||
isExpanded={isExpanded}
|
||||
isHighlighted={isHighlightedFromGraph}
|
||||
accentColor={orgColor}
|
||||
onToggle={onToggle}
|
||||
ariaLabel={`${consultation.role} at ${consultation.organization}, ${consultation.duration}. Click to ${isExpanded ? 'collapse' : 'expand'} details.`}
|
||||
headerPadding="12px 14px"
|
||||
onMouseEnter={() => onHighlight?.(consultation.id)}
|
||||
onMouseLeave={() => onHighlight?.(null)}
|
||||
renderHeader={() => (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
fontWeight: 600,
|
||||
color: 'var(--text-primary)',
|
||||
lineHeight: 1.3,
|
||||
}}
|
||||
>
|
||||
{consultation.role}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
color: 'var(--text-secondary)',
|
||||
marginTop: '2px',
|
||||
}}
|
||||
>
|
||||
{consultation.organization}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
fontFamily: 'var(--font-geist-mono)',
|
||||
color: 'var(--text-tertiary)',
|
||||
marginTop: '3px',
|
||||
}}
|
||||
>
|
||||
{consultation.duration}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
renderBody={() => (
|
||||
<>
|
||||
{/* Examination bullets */}
|
||||
<ul
|
||||
style={{
|
||||
listStyle: 'none',
|
||||
padding: 0,
|
||||
margin: '0 0 10px 0',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '5px',
|
||||
}}
|
||||
>
|
||||
{consultation.examination.map((bullet, i) => (
|
||||
<li
|
||||
key={i}
|
||||
style={{
|
||||
fontSize: '13px',
|
||||
color: 'var(--text-primary)',
|
||||
lineHeight: 1.5,
|
||||
paddingLeft: '12px',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
top: '6px',
|
||||
width: '4px',
|
||||
height: '4px',
|
||||
borderRadius: '50%',
|
||||
background: consultation.orgColor ?? 'var(--accent)',
|
||||
opacity: 0.5,
|
||||
}}
|
||||
/>
|
||||
{bullet}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{/* Coded entries */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '6px',
|
||||
marginBottom: '10px',
|
||||
}}
|
||||
>
|
||||
{consultation.codedEntries.map((entry) => (
|
||||
<span
|
||||
key={entry.code}
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
fontFamily: 'var(--font-geist-mono)',
|
||||
padding: '3px 8px',
|
||||
borderRadius: '4px',
|
||||
background: hexToRgba(orgColor, 0.08),
|
||||
color: consultation.orgColor ?? 'var(--accent)',
|
||||
border: `1px solid ${hexToRgba(orgColor, 0.2)}`,
|
||||
}}
|
||||
>
|
||||
{entry.code}: {entry.description}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* View full record link */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onViewFull()
|
||||
}}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
fontSize: '12px',
|
||||
fontWeight: 500,
|
||||
color: consultation.orgColor ?? 'var(--accent)',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
padding: '4px 0',
|
||||
cursor: 'pointer',
|
||||
fontFamily: 'inherit',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.opacity = '0.7'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.opacity = '1'
|
||||
}}
|
||||
>
|
||||
View full record
|
||||
<ChevronRight size={12} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
interface WorkExperienceSubsectionProps {
|
||||
onNodeHighlight?: (id: string | null) => void
|
||||
highlightedRoleId?: string | null
|
||||
}
|
||||
|
||||
export function WorkExperienceSubsection({ onNodeHighlight, highlightedRoleId }: WorkExperienceSubsectionProps) {
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null)
|
||||
const { openPanel } = useDetailPanel()
|
||||
|
||||
const handleToggle = useCallback((id: string) => {
|
||||
setExpandedId((prev) => (prev === id ? null : id))
|
||||
}, [])
|
||||
|
||||
const handleViewFull = useCallback(
|
||||
(consultation: typeof timelineConsultations[0]) => {
|
||||
openPanel({ type: 'career-role', consultation })
|
||||
},
|
||||
[openPanel],
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<CardHeader dotColor="teal" title="WORK EXPERIENCE" rightText={`${timelineConsultations.length} roles`} />
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
|
||||
{timelineConsultations.map((c) => (
|
||||
<RoleItem
|
||||
key={c.id}
|
||||
consultation={c}
|
||||
isExpanded={expandedId === c.id}
|
||||
isHighlightedFromGraph={highlightedRoleId === c.id}
|
||||
onToggle={() => handleToggle(c.id)}
|
||||
onViewFull={() => handleViewFull(c)}
|
||||
onHighlight={onNodeHighlight}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -6,10 +6,12 @@ import { useForceSimulation, getHeight } from '@/hooks/useForceSimulation'
|
||||
import { useConstellationHighlight } from '@/hooks/useConstellationHighlight'
|
||||
import { useConstellationInteraction } from '@/hooks/useConstellationInteraction'
|
||||
import { useTimelineAnimation } from '@/hooks/useTimelineAnimation'
|
||||
import { useFocusTrap } from '@/hooks/useFocusTrap'
|
||||
import { MobileAccordion } from './MobileAccordion'
|
||||
import { ConstellationLegend } from './ConstellationLegend'
|
||||
import { AccessibleNodeOverlay } from './AccessibleNodeOverlay'
|
||||
import { PlayPauseButton } from './PlayPauseButton'
|
||||
import { FullscreenButton } from './FullscreenButton'
|
||||
import { srDescription } from './screen-reader-description'
|
||||
import {
|
||||
MIN_HEIGHT,
|
||||
@@ -45,6 +47,7 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
|
||||
const [dimensions, setDimensions] = useState({ width: 800, height: MIN_HEIGHT, scaleFactor: 1 })
|
||||
const [focusedNodeId, setFocusedNodeId] = useState<string | null>(null)
|
||||
const [chartInView, setChartInView] = useState(true)
|
||||
const [isFullscreen, setIsFullscreen] = useState(false)
|
||||
|
||||
callbacksRef.current = { onRoleClick, onSkillClick, onNodeHover }
|
||||
|
||||
@@ -69,27 +72,27 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
|
||||
if (!container) return
|
||||
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
const CHANGE_THRESHOLD = 0.3
|
||||
const X_CHANGE_THRESHOLD = 0.3
|
||||
|
||||
const updateDimensions = () => {
|
||||
const width = container.clientWidth
|
||||
const viewportWidth = window.innerWidth
|
||||
const height = getHeight(viewportWidth, containerHeight)
|
||||
const height = isFullscreen ? window.innerHeight : getHeight(viewportWidth, containerHeight)
|
||||
const scaleFactor = viewportWidth >= 1024
|
||||
? Math.max(1, Math.min(1.6, viewportWidth / 1440))
|
||||
: 1
|
||||
setDimensions(prev => {
|
||||
const widthDelta = Math.abs(prev.width - width) / prev.width
|
||||
const heightDelta = Math.abs(prev.height - height) / prev.height
|
||||
if (widthDelta < CHANGE_THRESHOLD && heightDelta < CHANGE_THRESHOLD) {
|
||||
const heightRatio = Math.max(height / prev.height, prev.height / height)
|
||||
if (widthDelta < X_CHANGE_THRESHOLD && heightRatio < 2) {
|
||||
return prev
|
||||
}
|
||||
return { width, height, scaleFactor }
|
||||
})
|
||||
}
|
||||
|
||||
// Initial measurement (no debounce)
|
||||
updateDimensions()
|
||||
// Use rAF for fullscreen toggle so CSS layout settles before measuring
|
||||
requestAnimationFrame(updateDimensions)
|
||||
|
||||
const observer = new ResizeObserver(() => {
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
@@ -100,7 +103,29 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
|
||||
observer.disconnect()
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
}
|
||||
}, [containerHeight])
|
||||
}, [containerHeight, isFullscreen])
|
||||
|
||||
const toggleFullscreen = useCallback(() => setIsFullscreen(prev => !prev), [])
|
||||
|
||||
// ESC key to exit fullscreen
|
||||
useEffect(() => {
|
||||
if (!isFullscreen) return
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') { e.stopPropagation(); setIsFullscreen(false) }
|
||||
}
|
||||
document.addEventListener('keydown', handleKey)
|
||||
return () => document.removeEventListener('keydown', handleKey)
|
||||
}, [isFullscreen])
|
||||
|
||||
// Body scroll lock while fullscreen
|
||||
useEffect(() => {
|
||||
if (!isFullscreen) return
|
||||
document.body.style.overflow = 'hidden'
|
||||
return () => { document.body.style.overflow = '' }
|
||||
}, [isFullscreen])
|
||||
|
||||
// Focus trap when fullscreen
|
||||
useFocusTrap(containerRef, isFullscreen)
|
||||
|
||||
const isMobile = typeof window !== 'undefined' && window.innerWidth < 640
|
||||
const sf = isMobile ? 1 : dimensions.scaleFactor
|
||||
@@ -240,84 +265,112 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
|
||||
const showAccordion = supportsCoarsePointer && pinnedCareerEntity !== null
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
style={{
|
||||
width: '100%',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
border: '1px solid var(--border-light)',
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
ref={svgRef}
|
||||
viewBox={`0 0 ${dimensions.width} ${dimensions.height}`}
|
||||
role="img"
|
||||
aria-label="Clinical pathway constellation showing career roles and skills in reverse-chronological order along a vertical timeline"
|
||||
style={{
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
height: dimensions.height,
|
||||
opacity: 1,
|
||||
}}
|
||||
/>
|
||||
|
||||
<ConstellationLegend isTouch={supportsCoarsePointer} domainCounts={domainCounts} />
|
||||
|
||||
<MobileAccordion pinnedCareerEntity={pinnedCareerEntity} show={showAccordion} />
|
||||
|
||||
{!prefersReducedMotion && (
|
||||
<PlayPauseButton
|
||||
isPlaying={animation.isPlaying}
|
||||
onToggle={animation.togglePlayPause}
|
||||
isMobile={isMobile}
|
||||
visible={chartInView}
|
||||
containerRef={containerRef}
|
||||
<>
|
||||
{isFullscreen && (
|
||||
<div
|
||||
onClick={toggleFullscreen}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
zIndex: 899,
|
||||
background: 'var(--backdrop-bg)',
|
||||
backdropFilter: 'blur(var(--backdrop-blur))',
|
||||
animation: 'backdrop-fade-in 200ms ease-out',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<p
|
||||
<div
|
||||
ref={containerRef}
|
||||
{...(isFullscreen ? {
|
||||
role: 'dialog',
|
||||
'aria-modal': true,
|
||||
'aria-label': 'Career constellation fullscreen view',
|
||||
} : {})}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
width: 1, height: 1, padding: 0, margin: -1,
|
||||
overflow: 'hidden', clip: 'rect(0,0,0,0)',
|
||||
whiteSpace: 'nowrap', border: 0,
|
||||
width: '100%',
|
||||
borderRadius: isFullscreen ? 0 : 'var(--radius-sm)',
|
||||
border: isFullscreen ? 'none' : '1px solid var(--border-light)',
|
||||
overflow: 'hidden',
|
||||
position: isFullscreen ? 'fixed' : 'relative',
|
||||
...(isFullscreen ? { inset: 0, zIndex: 900, background: 'var(--surface)' } : {}),
|
||||
animation: isFullscreen ? 'constellation-fullscreen-in 200ms ease-out' : undefined,
|
||||
}}
|
||||
>
|
||||
{srDescription}
|
||||
</p>
|
||||
<svg
|
||||
ref={svgRef}
|
||||
viewBox={`0 0 ${dimensions.width} ${dimensions.height}`}
|
||||
role="img"
|
||||
aria-label="Clinical pathway constellation showing career roles and skills in reverse-chronological order along a vertical timeline"
|
||||
style={{
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
height: dimensions.height,
|
||||
opacity: 1,
|
||||
}}
|
||||
/>
|
||||
|
||||
<AccessibleNodeOverlay
|
||||
nodes={constellationNodes}
|
||||
nodeButtonPositions={sim.nodeButtonPositions}
|
||||
dimensions={dimensions}
|
||||
onFocus={(nodeId) => {
|
||||
setFocusedNodeId(nodeId)
|
||||
highlightGraphRef.current?.(nodeId)
|
||||
const node = nodeById.get(nodeId)
|
||||
if (node?.type !== 'skill') onNodeHover?.(nodeId)
|
||||
}}
|
||||
onBlur={() => {
|
||||
setFocusedNodeId(null)
|
||||
highlightGraphRef.current?.(resolveGraphFallback())
|
||||
onNodeHover?.(resolveRoleFallback())
|
||||
}}
|
||||
onClick={(nodeId, nodeType) => {
|
||||
setPinnedNodeId(nodeId)
|
||||
pinnedNodeIdRef.current = nodeId
|
||||
highlightGraphRef.current?.(nodeId)
|
||||
if (nodeType !== 'skill') {
|
||||
onNodeHover?.(nodeId)
|
||||
onRoleClick(nodeId)
|
||||
} else {
|
||||
<ConstellationLegend isTouch={supportsCoarsePointer} domainCounts={domainCounts} />
|
||||
|
||||
<MobileAccordion pinnedCareerEntity={pinnedCareerEntity} show={showAccordion} />
|
||||
|
||||
{!prefersReducedMotion && (
|
||||
<PlayPauseButton
|
||||
isPlaying={animation.isPlaying}
|
||||
onToggle={animation.togglePlayPause}
|
||||
isMobile={isMobile}
|
||||
visible={chartInView}
|
||||
containerRef={containerRef}
|
||||
/>
|
||||
)}
|
||||
|
||||
<FullscreenButton
|
||||
isFullscreen={isFullscreen}
|
||||
onToggle={toggleFullscreen}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
|
||||
<p
|
||||
style={{
|
||||
position: 'absolute',
|
||||
width: 1, height: 1, padding: 0, margin: -1,
|
||||
overflow: 'hidden', clip: 'rect(0,0,0,0)',
|
||||
whiteSpace: 'nowrap', border: 0,
|
||||
}}
|
||||
>
|
||||
{srDescription}
|
||||
</p>
|
||||
|
||||
<AccessibleNodeOverlay
|
||||
nodes={constellationNodes}
|
||||
nodeButtonPositions={sim.nodeButtonPositions}
|
||||
dimensions={dimensions}
|
||||
onFocus={(nodeId) => {
|
||||
setFocusedNodeId(nodeId)
|
||||
highlightGraphRef.current?.(nodeId)
|
||||
const node = nodeById.get(nodeId)
|
||||
if (node?.type !== 'skill') onNodeHover?.(nodeId)
|
||||
}}
|
||||
onBlur={() => {
|
||||
setFocusedNodeId(null)
|
||||
highlightGraphRef.current?.(resolveGraphFallback())
|
||||
onNodeHover?.(resolveRoleFallback())
|
||||
onSkillClick(nodeId)
|
||||
}
|
||||
}}
|
||||
onKeyDown={handleNodeKeyDown}
|
||||
/>
|
||||
</div>
|
||||
}}
|
||||
onClick={(nodeId, nodeType) => {
|
||||
setPinnedNodeId(nodeId)
|
||||
pinnedNodeIdRef.current = nodeId
|
||||
highlightGraphRef.current?.(nodeId)
|
||||
if (nodeType !== 'skill') {
|
||||
onNodeHover?.(nodeId)
|
||||
onRoleClick(nodeId)
|
||||
} else {
|
||||
onNodeHover?.(resolveRoleFallback())
|
||||
onSkillClick(nodeId)
|
||||
}
|
||||
}}
|
||||
onKeyDown={handleNodeKeyDown}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import React from 'react'
|
||||
import { Maximize2, Minimize2 } from 'lucide-react'
|
||||
|
||||
interface FullscreenButtonProps {
|
||||
isFullscreen: boolean
|
||||
onToggle: () => void
|
||||
isMobile: boolean
|
||||
}
|
||||
|
||||
export const FullscreenButton: React.FC<FullscreenButtonProps> = ({
|
||||
isFullscreen, onToggle, isMobile,
|
||||
}) => {
|
||||
const vw = typeof window !== 'undefined' ? window.innerWidth : 1024
|
||||
const scale = vw >= 1440 ? 1.75 : vw >= 1280 ? 1.5 : vw >= 1080 ? 1.25 : 1
|
||||
const size = isMobile ? 44 : Math.round(36 * scale)
|
||||
const offset = isMobile ? 8 : Math.round(12 * scale)
|
||||
const iconSize = isMobile ? 16 : Math.round(14 * scale)
|
||||
const Icon = isFullscreen ? Minimize2 : Maximize2
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onToggle}
|
||||
aria-label={isFullscreen ? 'Exit fullscreen' : 'Enter fullscreen'}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: offset,
|
||||
top: offset,
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: '50%',
|
||||
border: '1.5px solid var(--border)',
|
||||
background: 'var(--surface)',
|
||||
boxShadow: '0 1px 4px rgba(26,43,42,0.10)',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
opacity: 0.85,
|
||||
transition: 'opacity 200ms ease',
|
||||
zIndex: 5,
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.opacity = '1' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.opacity = '0.85' }}
|
||||
>
|
||||
<Icon size={iconSize} color="var(--text-secondary)" strokeWidth={2} />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -94,6 +94,32 @@ export function ProjectDetail({ investigation }: ProjectDetailProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Domain skills */}
|
||||
{investigation.skills && investigation.skills.length > 0 && (
|
||||
<div>
|
||||
<h3 style={sectionHeadingStyle}>Domain Skills</h3>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
|
||||
{investigation.skills.map((skill) => (
|
||||
<span
|
||||
key={skill}
|
||||
style={{
|
||||
padding: '3px 10px',
|
||||
fontSize: '11px',
|
||||
fontWeight: 500,
|
||||
fontFamily: 'var(--font-geist-mono)',
|
||||
color: '#0D9488',
|
||||
backgroundColor: 'rgba(13,148,136,0.08)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
border: '1px solid rgba(13,148,136,0.2)',
|
||||
}}
|
||||
>
|
||||
{skill}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Results */}
|
||||
<div>
|
||||
<h3 style={sectionHeadingStyle}>Results</h3>
|
||||
@@ -106,37 +132,85 @@ export function ProjectDetail({ investigation }: ProjectDetailProps) {
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* External link */}
|
||||
{investigation.externalUrl && (
|
||||
<a
|
||||
href={investigation.externalUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
{/* Action buttons */}
|
||||
{(investigation.externalUrl || investigation.demoUrl) && (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', alignSelf: 'flex-start' }}>
|
||||
{investigation.externalUrl && (
|
||||
<a
|
||||
href={investigation.externalUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
padding: '8px 16px',
|
||||
fontSize: '13px',
|
||||
fontWeight: 600,
|
||||
fontFamily: 'var(--font-ui)',
|
||||
color: 'var(--surface)',
|
||||
backgroundColor: 'var(--accent)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
textDecoration: 'none',
|
||||
transition: 'background-color 150ms',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'var(--accent-hover)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'var(--accent)'
|
||||
}}
|
||||
>
|
||||
<ExternalLink size={14} />
|
||||
View Live Project
|
||||
</a>
|
||||
)}
|
||||
{investigation.demoUrl && (
|
||||
<a
|
||||
href={investigation.demoUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
padding: '8px 16px',
|
||||
fontSize: '13px',
|
||||
fontWeight: 600,
|
||||
fontFamily: 'var(--font-ui)',
|
||||
color: '#0D9488',
|
||||
backgroundColor: 'transparent',
|
||||
border: '1px solid #0D9488',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
textDecoration: 'none',
|
||||
transition: 'background-color 150ms, color 150ms',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'rgba(13,148,136,0.08)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'transparent'
|
||||
}}
|
||||
>
|
||||
<ExternalLink size={14} />
|
||||
Interactive Demo
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Thumbnail */}
|
||||
{investigation.thumbnail && (
|
||||
<img
|
||||
src={investigation.thumbnail}
|
||||
alt={`${investigation.name} screenshot`}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
padding: '8px 16px',
|
||||
fontSize: '13px',
|
||||
fontWeight: 600,
|
||||
fontFamily: 'var(--font-ui)',
|
||||
color: 'var(--surface)',
|
||||
backgroundColor: 'var(--accent)',
|
||||
width: '100%',
|
||||
height: 'auto',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
textDecoration: 'none',
|
||||
alignSelf: 'flex-start',
|
||||
transition: 'background-color 150ms',
|
||||
border: '1px solid var(--border-light)',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'var(--accent-hover)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'var(--accent)'
|
||||
}}
|
||||
>
|
||||
<ExternalLink size={14} />
|
||||
View Live Project
|
||||
</a>
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -13,13 +13,6 @@ const categoryLabels: Record<SkillMedication['category'], string> = {
|
||||
Leadership: 'Strategic & Leadership',
|
||||
}
|
||||
|
||||
// Proficiency bar color based on value
|
||||
function getProficiencyColor(proficiency: number): string {
|
||||
if (proficiency >= 90) return 'var(--success)'
|
||||
if (proficiency >= 75) return 'var(--accent)'
|
||||
return 'var(--amber)'
|
||||
}
|
||||
|
||||
export function SkillDetail({ skill }: SkillDetailProps) {
|
||||
// Find roles that use this skill from constellation data
|
||||
const usedInRoles = roleSkillMappings
|
||||
@@ -96,44 +89,6 @@ export function SkillDetail({ skill }: SkillDetailProps) {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Proficiency bar */}
|
||||
<div>
|
||||
<h3 style={sectionHeadingStyle}>Proficiency</h3>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
height: '6px',
|
||||
backgroundColor: 'var(--border-light)',
|
||||
borderRadius: '3px',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: `${skill.proficiency}%`,
|
||||
height: '100%',
|
||||
backgroundColor: getProficiencyColor(skill.proficiency),
|
||||
borderRadius: '3px',
|
||||
transition: 'width 400ms ease-out',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '13px',
|
||||
fontWeight: 700,
|
||||
fontFamily: 'var(--font-geist)',
|
||||
color: getProficiencyColor(skill.proficiency),
|
||||
minWidth: '36px',
|
||||
textAlign: 'right',
|
||||
}}
|
||||
>
|
||||
{skill.proficiency}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Years of experience */}
|
||||
<div>
|
||||
<h3 style={sectionHeadingStyle}>Experience</h3>
|
||||
|
||||
@@ -40,7 +40,7 @@ export function SkillsAllDetail({ category }: SkillsAllDetailProps) {
|
||||
label,
|
||||
skills: skills
|
||||
.filter((s) => s.category === id)
|
||||
.sort((a, b) => b.proficiency - a.proficiency),
|
||||
.sort((a, b) => b.yearsOfExperience - a.yearsOfExperience),
|
||||
}))
|
||||
|
||||
const handleSkillClick = (skill: SkillMedication) => {
|
||||
@@ -200,46 +200,6 @@ function SkillRow({ skill, yearsSuffix, onClick }: SkillRowProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Proficiency */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '40px',
|
||||
height: '4px',
|
||||
backgroundColor: 'var(--border-light)',
|
||||
borderRadius: '2px',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: `${skill.proficiency}%`,
|
||||
height: '100%',
|
||||
backgroundColor: skill.proficiency >= 90 ? 'var(--success)' : skill.proficiency >= 75 ? 'var(--accent)' : 'var(--amber)',
|
||||
borderRadius: '2px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '10px',
|
||||
fontFamily: '"Geist Mono", monospace',
|
||||
color: 'var(--text-tertiary)',
|
||||
minWidth: '28px',
|
||||
textAlign: 'right',
|
||||
}}
|
||||
>
|
||||
{skill.proficiency}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Chevron */}
|
||||
<ChevronRight
|
||||
size={14}
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { KPI } from '@/types/pmr'
|
||||
import { useDetailPanel } from '@/contexts/DetailPanelContext'
|
||||
import { getLatestResultsCopy, getProfileSectionTitle, getProfileSummaryText } from '@/lib/profile-content'
|
||||
import { KPI_COLORS } from '@/lib/theme-colors'
|
||||
import { ProjectsCarousel } from './ProjectsTile'
|
||||
|
||||
interface MetricCardProps {
|
||||
kpi: KPI
|
||||
@@ -120,7 +121,7 @@ export function PatientSummaryTile() {
|
||||
const kpiGridStyles: React.CSSProperties = {
|
||||
display: 'grid',
|
||||
gap: '10px',
|
||||
gridTemplateColumns: '1fr',
|
||||
gridTemplateColumns: 'repeat(2, minmax(0, 1fr))',
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -149,6 +150,9 @@ export function PatientSummaryTile() {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Projects carousel */}
|
||||
<ProjectsCarousel />
|
||||
</ParentSection>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { investigations } from '@/data/investigations'
|
||||
import { Card, CardHeader } from '../Card'
|
||||
import { CardHeader } from '../Card'
|
||||
import { useDetailPanel } from '@/contexts/DetailPanelContext'
|
||||
import type { Investigation } from '@/types/pmr'
|
||||
import { PROJECT_STATUS_COLORS } from '@/lib/theme-colors'
|
||||
@@ -9,7 +9,6 @@ interface ProjectItemProps {
|
||||
project: Investigation
|
||||
slideWidth: string
|
||||
cardMinHeight: number
|
||||
thumbnailHeight: number
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
@@ -17,7 +16,6 @@ function ProjectItem({
|
||||
project,
|
||||
slideWidth,
|
||||
cardMinHeight,
|
||||
thumbnailHeight,
|
||||
onClick,
|
||||
}: ProjectItemProps) {
|
||||
const dotColor = PROJECT_STATUS_COLORS[project.status]
|
||||
@@ -78,15 +76,16 @@ function ProjectItem({
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
minHeight: `${thumbnailHeight}px`,
|
||||
flex: 1,
|
||||
aspectRatio: '16 / 9',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid var(--border-light)',
|
||||
background:
|
||||
'linear-gradient(135deg, rgba(19, 94, 94, 0.12), rgba(212, 171, 46, 0.18))',
|
||||
background: project.thumbnail
|
||||
? undefined
|
||||
: 'linear-gradient(135deg, rgba(19, 94, 94, 0.12), rgba(212, 171, 46, 0.18))',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
fontFamily: 'var(--font-geist-mono)',
|
||||
fontSize: '10px',
|
||||
letterSpacing: '0.08em',
|
||||
@@ -94,7 +93,20 @@ function ProjectItem({
|
||||
textTransform: 'uppercase',
|
||||
}}
|
||||
>
|
||||
Thumbnail Pending
|
||||
{project.thumbnail ? (
|
||||
<img
|
||||
src={project.thumbnail}
|
||||
alt={`${project.name} thumbnail`}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
objectPosition: 'top',
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
'Thumbnail Pending'
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -129,38 +141,95 @@ function ProjectItem({
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{project.techStack && project.techStack.length > 0 && (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '4px',
|
||||
}}
|
||||
>
|
||||
{project.techStack.map((tech) => (
|
||||
<span
|
||||
key={tech}
|
||||
style={{
|
||||
fontSize: '10px',
|
||||
fontFamily: 'var(--font-geist-mono)',
|
||||
padding: '3px 8px',
|
||||
borderRadius: '3px',
|
||||
background: 'var(--amber-light)',
|
||||
color: '#92400E',
|
||||
border: '1px solid var(--amber-border)',
|
||||
}}
|
||||
>
|
||||
{tech}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
gap: '8px',
|
||||
alignItems: 'flex-start',
|
||||
}}
|
||||
>
|
||||
{project.techStack && project.techStack.length > 0 && (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '4px', minWidth: 0 }}>
|
||||
{project.techStack.slice(0, 3).map((tech) => (
|
||||
<span
|
||||
key={tech}
|
||||
style={{
|
||||
fontSize: '10px',
|
||||
fontFamily: 'var(--font-geist-mono)',
|
||||
padding: '3px 8px',
|
||||
borderRadius: '3px',
|
||||
background: 'var(--amber-light)',
|
||||
color: '#92400E',
|
||||
border: '1px solid var(--amber-border)',
|
||||
}}
|
||||
>
|
||||
{tech}
|
||||
</span>
|
||||
))}
|
||||
{project.techStack.length > 3 && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: '10px',
|
||||
fontFamily: 'var(--font-geist-mono)',
|
||||
padding: '3px 6px',
|
||||
color: 'var(--text-tertiary)',
|
||||
}}
|
||||
>
|
||||
+{project.techStack.length - 3}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{project.skills && project.skills.length > 0 && (
|
||||
<div
|
||||
className="skills-tags"
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '4px',
|
||||
justifyContent: 'flex-end',
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
{project.skills.slice(0, 2).map((skill) => (
|
||||
<span
|
||||
key={skill}
|
||||
style={{
|
||||
fontSize: '10px',
|
||||
fontFamily: 'var(--font-geist-mono)',
|
||||
padding: '3px 8px',
|
||||
borderRadius: '3px',
|
||||
background: 'rgba(13,148,136,0.08)',
|
||||
color: '#0D9488',
|
||||
border: '1px solid rgba(13,148,136,0.2)',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{skill}
|
||||
</span>
|
||||
))}
|
||||
{project.skills.length > 2 && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: '10px',
|
||||
fontFamily: 'var(--font-geist-mono)',
|
||||
padding: '3px 6px',
|
||||
color: 'var(--text-tertiary)',
|
||||
}}
|
||||
>
|
||||
+{project.skills.length - 2}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ProjectsTile() {
|
||||
export function ProjectsCarousel() {
|
||||
const { openPanel } = useDetailPanel()
|
||||
const viewportRef = useRef<HTMLDivElement | null>(null)
|
||||
const trackRef = useRef<HTMLDivElement | null>(null)
|
||||
@@ -250,7 +319,7 @@ export function ProjectsTile() {
|
||||
|
||||
const cardsPerView = useMemo(() => {
|
||||
if (viewportWidth < 768) {
|
||||
return 1
|
||||
return 2
|
||||
}
|
||||
return 4
|
||||
}, [viewportWidth])
|
||||
@@ -275,25 +344,12 @@ export function ProjectsTile() {
|
||||
return 214
|
||||
}, [viewportWidth])
|
||||
|
||||
const thumbnailHeight = useMemo(() => {
|
||||
if (viewportWidth < 640) {
|
||||
return 62
|
||||
}
|
||||
if (viewportWidth < 1024) {
|
||||
return 68
|
||||
}
|
||||
if (viewportWidth < 1440) {
|
||||
return 76
|
||||
}
|
||||
return 84
|
||||
}, [viewportWidth])
|
||||
|
||||
const setPaused = (value: boolean) => {
|
||||
isPausedRef.current = value
|
||||
}
|
||||
|
||||
return (
|
||||
<Card full tileId="projects">
|
||||
<div style={{ marginTop: '28px' }}>
|
||||
<CardHeader dotColor="amber" title="SIGNIFICANT INTERVENTIONS" />
|
||||
|
||||
<div
|
||||
@@ -329,7 +385,6 @@ export function ProjectsTile() {
|
||||
project={project}
|
||||
slideWidth={slideWidth}
|
||||
cardMinHeight={cardMinHeight}
|
||||
thumbnailHeight={thumbnailHeight}
|
||||
onClick={() => openPanel({ type: 'project', investigation: project })}
|
||||
/>
|
||||
))}
|
||||
@@ -337,6 +392,6 @@ export function ProjectsTile() {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user