Compare commits
368 Commits
dfdf1d212d
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| d7888071b8 | |||
| 5040b9a9fd | |||
| c778d79aec | |||
| 28d2ae61ff | |||
| c651f0ed44 | |||
| d478276c3b | |||
| 46c049def0 | |||
| 98442c0f9f | |||
| 82fcd6bc94 | |||
| 9d153e95d1 | |||
| e452b66a7f | |||
| edc1327987 | |||
| 72d159484f | |||
| cb1c958f68 | |||
| 6bf5a6b6b2 | |||
| 3ddd4ecdbd | |||
| d403e96d34 | |||
| 3773268706 | |||
| b13252be71 | |||
| 3ae4abeb9f | |||
| 1fc2ba2385 | |||
| 30511cac81 | |||
| 95ea088a00 | |||
| a1f7088b48 | |||
| 012c905c90 | |||
| 5806f7a134 | |||
| 9f2be70fd6 | |||
| 9186be7e3e | |||
| 9baa6e605b | |||
| 8b79f7b273 | |||
| 134e41f4f9 | |||
| 62c0d2ea19 | |||
| 836305e2a3 | |||
| d51efb535d | |||
| 025f860815 | |||
| 06ca2a2b46 | |||
| 851d62fcbb | |||
| 0a337b41c2 | |||
| 47b52b5a93 | |||
| 82db5fda54 | |||
| 38e40d36c0 | |||
| 841c1869d6 | |||
| a867c75e9b | |||
| 150b452bb5 | |||
| b266f1f149 | |||
| 0fc7985a7c | |||
| 49bddeaa45 | |||
| e2ba2575b6 | |||
| 61299100d9 | |||
| abb4fcd909 | |||
| 0fba10d469 | |||
| 3c5f9a506c | |||
| de5b5939d6 | |||
| 661dba4b75 | |||
| 9e31843fc9 | |||
| f7469f487f | |||
| 9a58b3c312 | |||
| 01a48ce691 | |||
| 5eb46b02d8 | |||
| 1b19087782 | |||
| 49c9e0cecf | |||
| 7528935d2b | |||
| 8f4ddc454a | |||
| 296b18f025 | |||
| 45b87466be | |||
| bbe7900968 | |||
| 0ee7b5d44c | |||
| 83b327d58e | |||
| 6605966fab | |||
| 8178d03cb2 | |||
| e9a7581aa5 | |||
| aca57714e4 | |||
| 9276955fa8 | |||
| 8b674ffe14 | |||
| 7d7628c8a7 | |||
| 65b265733e | |||
| b34ecb89e2 | |||
| 4dfb1607c1 | |||
| 2e242a650a | |||
| 683275416e | |||
| 18d2704677 | |||
| c3a72d0bee | |||
| 5a657c4aac | |||
| 78e994ec5e | |||
| 68f92fb9a0 | |||
| be7a65ef8a | |||
| 5fa01b8d66 | |||
| 98d767fa7f | |||
| a6df900605 | |||
| 5637d56e02 | |||
| 24ffe03c0f | |||
| e5c7d9bb41 | |||
| 960c9b7729 | |||
| dad638e68e | |||
| b67c3b041f | |||
| ab80d65958 | |||
| 2306d2ec2e | |||
| b418338cd7 | |||
| c9dd93ac70 | |||
| a258706bf3 | |||
| 67fe5567a9 | |||
| f3e9b58e8d | |||
| 76692682da | |||
| f3e6f6670b | |||
| 354096fd70 | |||
| f48d98b7fc | |||
| 408cd9573c | |||
| 622baeb449 | |||
| 21233c98bb | |||
| 89d778b2df | |||
| 13b341abcd | |||
| 752f1c2947 | |||
| 743fb625d5 | |||
| 52238c5662 | |||
| 46cc22500b | |||
| 832c904376 | |||
| 8c8329f6e3 | |||
| 634eb10b2c | |||
| 5fcc59414f | |||
| 68b293dc6d | |||
| c9c69d2417 | |||
| b41a422cf0 | |||
| d2efc7030a | |||
| c9cc832382 | |||
| f0870cf320 | |||
| 194f83f490 | |||
| 8cc7038942 | |||
| 4bab9b369c | |||
| 7f3428184f | |||
| be443907ee | |||
| 0bcdc89427 | |||
| 0fbbf9e46f | |||
| 4580ca9c84 | |||
| 667e5b249c | |||
| 9e9dd1ae4b | |||
| ab5444ee94 | |||
| 657d2f299e | |||
| 5f3e0db712 | |||
| 29e1728e11 | |||
| 273c143d5e | |||
| 7ee1a2d9de | |||
| 2fca61b43a | |||
| c4480d7c99 | |||
| ae15ccf961 | |||
| 91f8dac261 | |||
| aa1774320a | |||
| 219a3f04be | |||
| 384e393963 | |||
| 489e306b0a | |||
| 19a4360a8c | |||
| 0e450c4b17 | |||
| 4fe68aa1b2 | |||
| 526ee7dd90 | |||
| 615198b080 | |||
| fc1581a9ff | |||
| 274188b6aa | |||
| cd7184cfd4 | |||
| 42293c5336 | |||
| 49f0f1aaf8 | |||
| c8eb38f083 | |||
| a56a4dd848 | |||
| e5be969308 | |||
| 83b941262e | |||
| 7fbf1dcb95 | |||
| 598b1c11f6 | |||
| c4d73f970d | |||
| 1bd735e90a | |||
| 939b2cddf2 | |||
| 73a390ce76 | |||
| cfc1c5797d | |||
| 8c04e517bc | |||
| 09af29f32d | |||
| 3f6ac5ef8d | |||
| 0ffacf8a0a | |||
| 38fdb6fa27 | |||
| f28693b0ad | |||
| 05b48b995e | |||
| 962729ae92 | |||
| 6e66cd631b | |||
| c5b0f7da43 | |||
| 649f4d7c68 | |||
| b515b3d70c | |||
| 2d7ff7e77a | |||
| f88cbf6e08 | |||
| daabfb7fd2 | |||
| 3f026a0701 | |||
| cf1f466452 | |||
| 64973176fb | |||
| 364efb8805 | |||
| a7537083e6 | |||
| 645088bbc1 | |||
| f6463cc4b1 | |||
| 5ef7cdb259 | |||
| a961518ebf | |||
| 1ca6175b93 | |||
| f9b4062dd5 | |||
| 6bd12dd776 | |||
| 9e9962f114 | |||
| b90706a3f6 | |||
| fcc1232d9b | |||
| 9ffed8d153 | |||
| b5de609cd5 | |||
| 0e7bef0206 | |||
| 7285ea8f45 | |||
| c86b252629 | |||
| 9be2fb9017 | |||
| 4bfc4de956 | |||
| 80d4cc9d7a | |||
| 13131e4c3e | |||
| 1a3d3515f8 | |||
| fa64c98406 | |||
| 0d42db7111 | |||
| 088b783731 | |||
| 071b1b78ae | |||
| 97d353930c | |||
| dbdd51243d | |||
| a8c7d5b41d | |||
| 120d8a7a7b | |||
| 4c92a3a559 | |||
| 24e0f8963f | |||
| 6956ad001b | |||
| 75c03029bf | |||
| 2f8db26cc4 | |||
| a5deb0ea8b | |||
| bbe17fc66a | |||
| 9ec71ae0ed | |||
| 9d61d2c8ca | |||
| fbfd25ffff | |||
| f38e67252b | |||
| 0c87d9f5a4 | |||
| 8830c223aa | |||
| 52ee98d8aa | |||
| 03b4c6cafb | |||
| 9ed77f99a8 | |||
| afc3876210 | |||
| c37fdab8fa | |||
| 980297ea92 | |||
| 8bdb162a07 | |||
| 2886685573 | |||
| b18746ecee | |||
| 6c26518806 | |||
| f4a6b5e32c | |||
| 92502beb03 | |||
| a596b5ac82 | |||
| cf5399a767 | |||
| f7e9c88762 | |||
| ee73efce11 | |||
| 72c75fd1a9 | |||
| 7c31ec07ba | |||
| 6a4fc86387 | |||
| 8dc27ff8a9 | |||
| 29956665ac | |||
| f65bf2ef5c | |||
| aafdeba93e | |||
| acee97a579 | |||
| 38b8e36fab | |||
| 3ad368f935 | |||
| d89ae0c64a | |||
| 7dae67d954 | |||
| 334ea2c02f | |||
| 2c360176c8 | |||
| 4be1b10137 | |||
| 905b3d957a | |||
| c8032f80df | |||
| e2409183f3 | |||
| d0df9137f9 | |||
| dec8ec9769 | |||
| 6d47f2a948 | |||
| 5ad67a512f | |||
| 040e46cbea | |||
| 6501439cef | |||
| 41ddbf6d1d | |||
| 00a5dd0105 | |||
| 69e322af28 | |||
| a2e01270a1 | |||
| adc32b9005 | |||
| a60b0701c2 | |||
| 670c9cc74c | |||
| 37c08387af | |||
| 62b6718cc3 | |||
| 2e48cefc6f | |||
| 2b9a6210ec | |||
| c88ceba136 | |||
| 3176761d9c | |||
| e13a073a6f | |||
| 000df670a3 | |||
| b9db2f5401 | |||
| c3316b9c45 | |||
| b3ebff26bf | |||
| 85ac1b879f | |||
| 4db3be0abb | |||
| f96c6a99d1 | |||
| 7461a83b9d | |||
| b480b742c8 | |||
| bfd17a3e80 | |||
| bba61f73b6 | |||
| 8765470627 | |||
| 43aa836317 | |||
| f0cb6b924f | |||
| 06f0d658b0 | |||
| ad1ce81948 | |||
| 2be346144c | |||
| 1d8cb78143 | |||
| cd4aa1e240 | |||
| fd9dd7d00e | |||
| 8f6bfd0b5e | |||
| 803c4f8a48 | |||
| 5533cded82 | |||
| 86e0015393 | |||
| d16656b954 | |||
| b7471c5cf8 | |||
| 5579e2741a | |||
| f75a6b9a5f | |||
| 8094f74800 | |||
| 4324f06186 | |||
| 5e1c96edfa | |||
| 556940c3c8 | |||
| b8c1aedb5a | |||
| 5a000d6457 | |||
| 3afadbdc73 | |||
| 4eeeb05744 | |||
| 959f0e1842 | |||
| cfd0283c78 | |||
| 192d629125 | |||
| 1a1f1f1938 | |||
| 93051021fc | |||
| a52cb9f84b | |||
| 06ebef80c1 | |||
| ef5bc9c3a6 | |||
| ac113f23c7 | |||
| 4ec108484e | |||
| a7df2d0037 | |||
| f7f7e0db8c | |||
| fc3c0659b2 | |||
| 89e93b805e | |||
| 4104dd32d8 | |||
| 47afa9171e | |||
| f648e7a4fc | |||
| 315259f44e | |||
| a0bc1e34f1 | |||
| e73a1c6cb8 | |||
| 53b633bfd7 | |||
| 3bce29efe4 | |||
| f20791a7ff | |||
| 81e8fdf7c7 | |||
| 511691ee78 | |||
| 1e1ba2d6a4 | |||
| 59962776df | |||
| a521b2ff2d | |||
| 4272ca4dfe | |||
| 4bf4d1171f | |||
| 8ca61c6afc | |||
| f40b98a6e5 | |||
| f73c626421 | |||
| 4434c6e437 | |||
| 65fc23e79b | |||
| 8d26049b17 | |||
| de34ec48cc | |||
| a9300501fd | |||
| 8ee9046bb3 | |||
| 02d7dcabd9 | |||
| 9ec62e9c12 | |||
| 2692e7ee86 | |||
| 6a5de6ee33 | |||
| 9d078420bc | |||
| 2033a93ecb | |||
| 9e01356df8 | |||
| 27740968b7 |
@@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"permissions": {
|
|
||||||
"allow": [
|
|
||||||
"Bash(powershell -Command \"$lines = Get-Content ''4-vitals-monitor.html''; $before = $lines[0..1757]; $after = $lines[2028..\\($lines.Length-1\\)]; \\($before + $after\\) | Set-Content ''4-vitals-monitor.html'' -Encoding UTF8\")",
|
|
||||||
"Bash(powershell -Command \"$lines = Get-Content ''4-vitals-monitor.html''; $before = $lines[0..1757]; $after = $lines[2028..\\($lines.Length-1\\)]; $result = $before + $after; $result | Set-Content ''4-vitals-monitor.html'' -Encoding UTF8\")",
|
|
||||||
"Bash(powershell -ExecutionPolicy Bypass -File:*)",
|
|
||||||
"Bash(del \"C:\\\\Users\\\\Andy\\\\Ralph Local\\\\Tasks\\\\cv-4-vitals-monitor\\\\remove-lines.ps1\")",
|
|
||||||
"Bash(start \"\" \"C:\\\\Users\\\\Andy\\\\Ralph Local\\\\Tasks\\\\cv-4-vitals-monitor\\\\4-vitals-monitor.html\")"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -7,9 +7,16 @@ yarn-error.log*
|
|||||||
pnpm-debug.log*
|
pnpm-debug.log*
|
||||||
lerna-debug.log*
|
lerna-debug.log*
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
.env
|
||||||
|
|
||||||
|
# Dependencies
|
||||||
node_modules
|
node_modules
|
||||||
|
|
||||||
|
# Build output
|
||||||
dist
|
dist
|
||||||
dist-ssr
|
dist-ssr
|
||||||
|
dist-server
|
||||||
*.local
|
*.local
|
||||||
|
|
||||||
# Editor directories and files
|
# Editor directories and files
|
||||||
@@ -25,3 +32,41 @@ dist-ssr
|
|||||||
|
|
||||||
# TypeScript
|
# TypeScript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# Playwright screenshots
|
||||||
|
*.png
|
||||||
|
!public/meta.png
|
||||||
|
|
||||||
|
# AI agent tooling
|
||||||
|
.claude/
|
||||||
|
.codex/
|
||||||
|
.ralph/
|
||||||
|
.playwright-mcp/
|
||||||
|
AGENTS.md
|
||||||
|
PROMPT.md
|
||||||
|
hats.yml
|
||||||
|
ralph.yml
|
||||||
|
scripts/ralph/
|
||||||
|
scripts/benchmark-results/
|
||||||
|
|
||||||
|
# Reference / personal materials
|
||||||
|
References/
|
||||||
|
|
||||||
|
# Font source archives (used fonts are in public/fonts/)
|
||||||
|
Fonts/
|
||||||
|
|
||||||
|
# Logo animation source (Remotion)
|
||||||
|
LogoAnimation/
|
||||||
|
|
||||||
|
# Design notes
|
||||||
|
carousel-design-debate*.md
|
||||||
|
|
||||||
|
# Misc
|
||||||
|
*:Zone.Identifier
|
||||||
|
__MACOSX
|
||||||
|
andy-charlwood-cv@0.0.0
|
||||||
|
lighthouse.pdf
|
||||||
|
logo/
|
||||||
|
graph.png
|
||||||
|
node
|
||||||
|
nul
|
||||||
|
|||||||
@@ -1,215 +0,0 @@
|
|||||||
{
|
|
||||||
"iterations": [
|
|
||||||
{
|
|
||||||
"iteration": 1,
|
|
||||||
"startedAt": "2026-02-10T15:16:12.277Z",
|
|
||||||
"endedAt": "2026-02-10T15:48:45.761Z",
|
|
||||||
"durationMs": 1951644,
|
|
||||||
"toolsUsed": {},
|
|
||||||
"filesModified": [
|
|
||||||
".gitignore",
|
|
||||||
"Ralph/IMPLEMENTATION_PLAN.md",
|
|
||||||
"Ralph/progress.txt",
|
|
||||||
"eslint.config.js",
|
|
||||||
"index.html",
|
|
||||||
"package-lock.json",
|
|
||||||
"package.json",
|
|
||||||
"postcss.config.js",
|
|
||||||
"public/vite.svg",
|
|
||||||
"src/App.tsx",
|
|
||||||
"src/index.css",
|
|
||||||
"src/lib/utils.ts",
|
|
||||||
"src/main.tsx",
|
|
||||||
"src/types/index.ts",
|
|
||||||
"src/vite-env.d.ts",
|
|
||||||
"tailwind.config.js",
|
|
||||||
"tsconfig.app.json",
|
|
||||||
"tsconfig.build.json",
|
|
||||||
"tsconfig.json",
|
|
||||||
"tsconfig.node.json",
|
|
||||||
"vite.config.ts"
|
|
||||||
],
|
|
||||||
"exitCode": 0,
|
|
||||||
"completionDetected": false,
|
|
||||||
"errors": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"iteration": 2,
|
|
||||||
"startedAt": "2026-02-10T15:48:48.223Z",
|
|
||||||
"endedAt": "2026-02-10T16:00:56.982Z",
|
|
||||||
"durationMs": 727496,
|
|
||||||
"toolsUsed": {},
|
|
||||||
"filesModified": [
|
|
||||||
"Ralph/IMPLEMENTATION_PLAN.md",
|
|
||||||
"Ralph/progress.txt",
|
|
||||||
"src/App.tsx",
|
|
||||||
"src/components/BootSequence.tsx"
|
|
||||||
],
|
|
||||||
"exitCode": 0,
|
|
||||||
"completionDetected": false,
|
|
||||||
"errors": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"iteration": 3,
|
|
||||||
"startedAt": "2026-02-10T16:00:59.367Z",
|
|
||||||
"endedAt": "2026-02-10T16:13:49.296Z",
|
|
||||||
"durationMs": 768766,
|
|
||||||
"toolsUsed": {},
|
|
||||||
"filesModified": [
|
|
||||||
"Ralph/IMPLEMENTATION_PLAN.md",
|
|
||||||
"Ralph/progress.txt",
|
|
||||||
"src/App.tsx",
|
|
||||||
"src/components/ECGAnimation.tsx"
|
|
||||||
],
|
|
||||||
"exitCode": 0,
|
|
||||||
"completionDetected": false,
|
|
||||||
"errors": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"iteration": 4,
|
|
||||||
"startedAt": "2026-02-10T16:13:51.563Z",
|
|
||||||
"endedAt": "2026-02-10T16:27:46.934Z",
|
|
||||||
"durationMs": 834192,
|
|
||||||
"toolsUsed": {},
|
|
||||||
"filesModified": [
|
|
||||||
"Ralph/IMPLEMENTATION_PLAN.md",
|
|
||||||
"Ralph/progress.txt",
|
|
||||||
"src/App.tsx",
|
|
||||||
"src/components/FloatingNav.tsx",
|
|
||||||
"src/hooks/useActiveSection.ts",
|
|
||||||
"src/index.css"
|
|
||||||
],
|
|
||||||
"exitCode": 0,
|
|
||||||
"completionDetected": false,
|
|
||||||
"errors": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"iteration": 5,
|
|
||||||
"startedAt": "2026-02-10T16:27:49.261Z",
|
|
||||||
"endedAt": "2026-02-10T16:34:23.835Z",
|
|
||||||
"durationMs": 393418,
|
|
||||||
"toolsUsed": {},
|
|
||||||
"filesModified": [
|
|
||||||
"Ralph/IMPLEMENTATION_PLAN.md",
|
|
||||||
"Ralph/progress.txt",
|
|
||||||
"src/App.tsx",
|
|
||||||
"src/components/Hero.tsx"
|
|
||||||
],
|
|
||||||
"exitCode": 0,
|
|
||||||
"completionDetected": false,
|
|
||||||
"errors": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"iteration": 6,
|
|
||||||
"startedAt": "2026-02-10T16:34:26.160Z",
|
|
||||||
"endedAt": "2026-02-10T16:42:15.177Z",
|
|
||||||
"durationMs": 467801,
|
|
||||||
"toolsUsed": {},
|
|
||||||
"filesModified": [
|
|
||||||
"Ralph/IMPLEMENTATION_PLAN.md",
|
|
||||||
"Ralph/progress.txt",
|
|
||||||
"src/App.tsx",
|
|
||||||
"src/components/Skills.tsx",
|
|
||||||
"src/hooks/useScrollReveal.ts"
|
|
||||||
],
|
|
||||||
"exitCode": 0,
|
|
||||||
"completionDetected": false,
|
|
||||||
"errors": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"iteration": 7,
|
|
||||||
"startedAt": "2026-02-10T16:42:17.521Z",
|
|
||||||
"endedAt": "2026-02-10T16:49:57.593Z",
|
|
||||||
"durationMs": 458586,
|
|
||||||
"toolsUsed": {},
|
|
||||||
"filesModified": [
|
|
||||||
"Ralph/IMPLEMENTATION_PLAN.md",
|
|
||||||
"Ralph/progress.txt",
|
|
||||||
"src/App.tsx",
|
|
||||||
"src/components/Experience.tsx",
|
|
||||||
"src/hooks/useScrollReveal.ts"
|
|
||||||
],
|
|
||||||
"exitCode": 0,
|
|
||||||
"completionDetected": false,
|
|
||||||
"errors": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"iteration": 8,
|
|
||||||
"startedAt": "2026-02-10T16:50:00.205Z",
|
|
||||||
"endedAt": "2026-02-10T16:57:05.682Z",
|
|
||||||
"durationMs": 423801,
|
|
||||||
"toolsUsed": {},
|
|
||||||
"filesModified": [
|
|
||||||
"Ralph/IMPLEMENTATION_PLAN.md",
|
|
||||||
"Ralph/progress.txt",
|
|
||||||
"src/App.tsx",
|
|
||||||
"src/components/Contact.tsx",
|
|
||||||
"src/components/Education.tsx",
|
|
||||||
"src/components/Projects.tsx"
|
|
||||||
],
|
|
||||||
"exitCode": 0,
|
|
||||||
"completionDetected": false,
|
|
||||||
"errors": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"iteration": 9,
|
|
||||||
"startedAt": "2026-02-10T16:57:08.484Z",
|
|
||||||
"endedAt": "2026-02-10T17:04:38.178Z",
|
|
||||||
"durationMs": 447958,
|
|
||||||
"toolsUsed": {},
|
|
||||||
"filesModified": [
|
|
||||||
"Ralph/IMPLEMENTATION_PLAN.md",
|
|
||||||
"Ralph/progress.txt",
|
|
||||||
"src/App.tsx",
|
|
||||||
"src/components/Footer.tsx"
|
|
||||||
],
|
|
||||||
"exitCode": 0,
|
|
||||||
"completionDetected": false,
|
|
||||||
"errors": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"iteration": 10,
|
|
||||||
"startedAt": "2026-02-10T17:04:41.051Z",
|
|
||||||
"endedAt": "2026-02-10T17:21:39.404Z",
|
|
||||||
"durationMs": 1016825,
|
|
||||||
"toolsUsed": {},
|
|
||||||
"filesModified": [
|
|
||||||
"Ralph/IMPLEMENTATION_PLAN.md",
|
|
||||||
"Ralph/progress.txt",
|
|
||||||
"src/App.tsx",
|
|
||||||
"src/components/Contact.tsx",
|
|
||||||
"src/components/Education.tsx",
|
|
||||||
"src/components/Experience.tsx",
|
|
||||||
"src/components/FloatingNav.tsx",
|
|
||||||
"src/components/Footer.tsx",
|
|
||||||
"src/components/Hero.tsx",
|
|
||||||
"src/components/Projects.tsx",
|
|
||||||
"src/components/Skills.tsx",
|
|
||||||
"tailwind.config.js"
|
|
||||||
],
|
|
||||||
"exitCode": 0,
|
|
||||||
"completionDetected": false,
|
|
||||||
"errors": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"iteration": 11,
|
|
||||||
"startedAt": "2026-02-10T17:21:42.101Z",
|
|
||||||
"endedAt": "2026-02-10T17:52:45.446Z",
|
|
||||||
"durationMs": 1861725,
|
|
||||||
"toolsUsed": {},
|
|
||||||
"filesModified": [
|
|
||||||
"Ralph/IMPLEMENTATION_PLAN.md",
|
|
||||||
"Ralph/progress.txt"
|
|
||||||
],
|
|
||||||
"exitCode": 0,
|
|
||||||
"completionDetected": false,
|
|
||||||
"errors": []
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"totalDurationMs": 9352212,
|
|
||||||
"struggleIndicators": {
|
|
||||||
"repeatedErrors": {},
|
|
||||||
"noProgressIterations": 0,
|
|
||||||
"shortIterations": 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
{
|
|
||||||
"active": true,
|
|
||||||
"iteration": 11,
|
|
||||||
"minIterations": 1,
|
|
||||||
"maxIterations": 0,
|
|
||||||
"completionPromise": "COMPLETE",
|
|
||||||
"tasksMode": false,
|
|
||||||
"taskPromise": "READY_FOR_NEXT_TASK",
|
|
||||||
"prompt": "# Ralph Wiggum Loop - Iteration Prompt\n\nYou are operating inside an automated loop. Each iteration you receive fresh context - you have NO memory of previous iterations. Your only persistence is the filesystem.\n\nYou are converting the completed `concept.html` (ECG Heartbeat CV Website) into a modern React application with TypeScript, Vite, and Tailwind CSS. The goal is a portfolio-grade React implementation that preserves all animations, interactions, and design details from the HTML concept.\n\n## Your Task This Iteration\n\n1. **Use the /frontend-design skill** (REQUIRED for visual components): Before writing ANY code for components that involve visual design, styling, animations, or UI elements, you MUST invoke the `/frontend-design` skill. This includes: BootSequence, ECGAnimation, FloatingNav, Hero, Skills, Experience, Education, Projects, Contact, Footer, and any component with CSS/styling. This skill gives you access to specialized frontend design capabilities for higher quality, polished output.\n\n2. **Read the plan**: Open `IMPLEMENTATION_PLAN.md` and find the highest-priority unchecked item (`- [ ]`). Items are listed in priority order - pick the first unchecked one.\n\n3. **Read accumulated learnings**: Open `progress.txt` and read the \"Codebase Patterns\" section. This contains learnings from previous iterations.\n\n4. **Read guardrails**: Open `guardrails.md` and read ALL guardrails. These are hard rules you MUST follow. Violating a guardrail is a quality check failure.\n\n5. **Implement the item**: Complete the single task you selected. Keep changes focused - one task per iteration. Write production-quality React/TypeScript code that is artistic, creative, and visually polished. This is a design showcase - the output should make someone say \"wow, that's slick.\"\n\n6. **Run quality checks**: Execute the quality check commands listed in `IMPLEMENTATION_PLAN.md` under \"Quality Checks\". Fix any issues before proceeding.\n\n7. **Commit your changes**: Stage and commit all changes with a descriptive message referencing the task you completed.\n\n8. **Mark the item complete**: In `IMPLEMENTATION_PLAN.md`, change the item from `- [ ]` to `- [x]`.\n\n9. **Update progress.txt**: Append to the \"Iteration Log\" section with:\n - Which task you completed\n - Any learnings or codebase patterns discovered (add to \"Codebase Patterns\" section)\n - Any issues encountered\n - Design decisions made (if visual component)\n\n10. **Commit the progress update**: Stage and commit the updated `IMPLEMENTATION_PLAN.md` and `progress.txt`.\n\n11. **Check for completion**: If ALL items in the task checklist are now checked (`- [x]`), output the following completion signal on its own line:\n\n```\n<promise>COMPLETE</promise>\n```\n\n## Critical Rules\n\n- **ALWAYS invoke /frontend-design skill before writing visual component code** — this is mandatory for BootSequence, ECGAnimation, FloatingNav, Hero, Skills, Experience, Education, Projects, Contact, Footer, and any styled component\n- **Only work on ONE task per iteration**\n- **Always read progress.txt AND guardrails.md before starting** — previous iterations may have left important context\n- **If a task is blocked or unclear**, document why in progress.txt and move to the next unchecked item\n- **Keep commits atomic and well-described**\n- **If quality checks fail, fix the issues before committing**\n- **The visual quality bar is HIGH** — this is a design portfolio piece\n- **Preserve all animations exactly** — timing, easing, and visual effects must match concept.html\n- **Use TypeScript strictly** — no `any` types, proper interfaces for all data structures\n- **Follow the established project structure** — components in `src/components/`, hooks in `src/hooks/`, etc.\n\n## Reference Files\n\n- `References/concept.html` — The complete working HTML implementation (your source of truth for animations, styling, timing)\n- `References/CV_v4.md` — CV content to populate sections\n- `References/ECGVideo/` — Remotion video project with ECG animation patterns\n",
|
|
||||||
"startedAt": "2026-02-10T15:16:11.835Z",
|
|
||||||
"model": "openrouter/openrouter/pony-alpha",
|
|
||||||
"agent": "opencode"
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "https://opencode.ai/config.json",
|
|
||||||
"permission": {
|
|
||||||
"read": "allow",
|
|
||||||
"edit": "allow",
|
|
||||||
"glob": "allow",
|
|
||||||
"grep": "allow",
|
|
||||||
"list": "allow",
|
|
||||||
"bash": "allow",
|
|
||||||
"task": "allow",
|
|
||||||
"webfetch": "allow",
|
|
||||||
"websearch": "allow",
|
|
||||||
"codesearch": "allow",
|
|
||||||
"todowrite": "allow",
|
|
||||||
"todoread": "allow",
|
|
||||||
"question": "allow",
|
|
||||||
"lsp": "allow",
|
|
||||||
"external_directory": "allow"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev # Vite dev server (localhost:5173)
|
||||||
|
npm run build # TypeScript compile + Vite production build
|
||||||
|
npm run preview # Preview production build locally
|
||||||
|
npm run lint # ESLint
|
||||||
|
npm run typecheck # TypeScript checks (no emit)
|
||||||
|
npm run generate-embeddings # Regenerate semantic search embeddings (src/data/embeddings.json)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Validation gate (run before any PR):** `npm run lint && npm run typecheck && npm run build`
|
||||||
|
|
||||||
|
No automated test framework — lint, typecheck, and build are the quality gates. For UI changes, verify manually (responsive behavior, accessibility, keyboard navigation).
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
**Interactive CV/portfolio** with a PMR (patient medical record) interface aesthetic. Three-phase UX: terminal boot → ECG heartbeat → dashboard.
|
||||||
|
|
||||||
|
### App lifecycle (`src/App.tsx`)
|
||||||
|
Phase orchestrator managing: BootSequence → ECGAnimation → LoginScreen → DashboardLayout
|
||||||
|
|
||||||
|
### Data flow
|
||||||
|
- **Canonical source:** `src/data/timeline.ts` — all career + education entities live here
|
||||||
|
- **Derived data:** `constellation.ts` builds D3 graph data from timeline; `consultations.ts` re-exports for legacy consumers; `tags.ts` derived from skills; `kpis.ts` standalone
|
||||||
|
- **Types:** `src/types/pmr.ts` has all domain types (Consultation, TimelineEntity, ConstellationNode, etc.)
|
||||||
|
|
||||||
|
### Key subsystems
|
||||||
|
|
||||||
|
| Subsystem | Entry point | Notes |
|
||||||
|
|-----------|-------------|-------|
|
||||||
|
| Dashboard | `DashboardLayout.tsx` | Orchestrates tiles, constellation, timeline, detail panel |
|
||||||
|
| Career Constellation | `CareerConstellation.tsx` | D3 force simulation; roles as clusters, skills as nodes; hover/click/tap/keyboard |
|
||||||
|
| Detail Panel | `DetailPanelContext.tsx` + `DetailPanel.tsx` | Right-side slide-out; context-aware views per entity type |
|
||||||
|
| Semantic Search | `lib/semantic-search.ts` + `lib/embedding-model.ts` | Pre-computed embeddings + local Xenova transformer model in browser |
|
||||||
|
| Command Palette | `CommandPalette.tsx` | Ctrl+K; fuzzy (Fuse.js) + semantic search |
|
||||||
|
| Chat Widget | `ChatWidget.tsx` + `lib/llm.ts` | Gemini/OpenRouter LLM integration; requires `.env` API keys |
|
||||||
|
| Accessibility | `AccessibilityContext.tsx` | Focus management, reduced motion, ARIA |
|
||||||
|
|
||||||
|
### D3 integration pattern
|
||||||
|
`CareerConstellation.tsx` manages D3 force simulation imperatively via refs. Highlight state tracked with refs (not React state) to avoid unnecessary re-renders. Touch: tap to pin, background tap to clear. Keyboard: Tab through nodes, Enter/Space activate, Escape reset.
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
- **TypeScript strict mode** — `noUnusedLocals`, `noUnusedParameters` enforced
|
||||||
|
- **Path alias:** `@/*` → `src/*` (configured in vite.config.ts + tsconfig.json)
|
||||||
|
- **Components:** PascalCase (`DashboardLayout.tsx`); Hooks: `useCamelCase`; Utilities: kebab-case (`semantic-search.ts`)
|
||||||
|
- **Styling:** Tailwind utility classes + inline `CSSProperties` for dynamic/theme values
|
||||||
|
- **Animations:** Framer Motion; respects `prefers-reduced-motion`
|
||||||
|
- **Commits:** Conventional Commit prefixes (`feat:`, `chore:`, `fix:`) + optional story IDs
|
||||||
|
|
||||||
|
## Design tokens
|
||||||
|
|
||||||
|
- **Primary:** Teal `#00897B` / **Accent:** Coral `#FF6B6B`
|
||||||
|
- **PMR palette:** GP system-inspired greens, teals, greys (defined in `tailwind.config.js`)
|
||||||
|
- **Font tokens (CSS custom properties):**
|
||||||
|
- `--font-ui`: Elvaro Grotesque (dashboard UI)
|
||||||
|
- `--font-geist-mono`: Geist Mono / Fira Code fallback (canonical mono token)
|
||||||
|
- `--font-primary` / `--font-secondary`: Plus Jakarta Sans / Inter Tight
|
||||||
|
- **Breakpoints:** xs 480, sm 640, md 768, lg 1024, xl 1280
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Andy Charlwood
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
# Andy Charlwood - Interactive CV
|
||||||
|
|
||||||
|
An interactive portfolio styled as a PMR (patient medical record) system — the kind of GP clinical interface NHS staff use daily. Features a cinematic boot sequence, D3 career constellation, semantic search, and an LLM-powered chat widget. Built with React, TypeScript, and Vite.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Four-Phase Loading**: Terminal boot → login screen → PMR dashboard (skippable; session-cached for returning visitors)
|
||||||
|
- **Career Constellation**: D3 force simulation mapping roles as clusters and skills as nodes — interactive via hover, click, tap, and keyboard
|
||||||
|
- **Semantic Search**: Pre-computed embeddings + local Xenova transformer model running in-browser
|
||||||
|
- **Command Palette**: `Ctrl+K` hybrid search (Fuse.js fuzzy + semantic)
|
||||||
|
- **Chat Widget**: Gemini/OpenRouter LLM integration for conversational Q&A about career history
|
||||||
|
- **Detail Panel**: Context-aware slide-out panel for deep-diving into any entity
|
||||||
|
- **Responsive Design**: Tailwind CSS with mobile-specific navigation and layout
|
||||||
|
- **Accessibility**: Focus management, reduced motion support, ARIA throughout
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
| Category | Technologies |
|
||||||
|
|----------|-------------|
|
||||||
|
| **Framework** | React 18 + TypeScript (strict mode) |
|
||||||
|
| **Build** | Vite 6 |
|
||||||
|
| **Styling** | Tailwind CSS 3 + CSS custom properties |
|
||||||
|
| **Animations** | Framer Motion + Canvas API |
|
||||||
|
| **Visualisation** | D3 v7 (force simulation) |
|
||||||
|
| **Search** | Fuse.js (fuzzy) + @xenova/transformers (semantic) |
|
||||||
|
| **Backend** | Express + Nodemailer (contact form, chat proxy) |
|
||||||
|
| **UI** | Lucide React, Embla Carousel, react-markdown |
|
||||||
|
| **Linting** | ESLint 9 |
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run dev # Starts Vite + Express backend concurrently
|
||||||
|
```
|
||||||
|
|
||||||
|
The chat widget and contact form require API keys in a `.env` file — see `.env.example` if available.
|
||||||
|
|
||||||
|
## Available Scripts
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `npm run dev` | Vite dev server + Express backend (concurrently) |
|
||||||
|
| `npm run dev:frontend` | Vite only (no backend) |
|
||||||
|
| `npm run build` | TypeScript compile + Vite production build |
|
||||||
|
| `npm run start` | Run production server |
|
||||||
|
| `npm run typecheck` | TypeScript type checking only |
|
||||||
|
| `npm run lint` | Run ESLint |
|
||||||
|
| `npm run preview` | Preview production build |
|
||||||
|
| `npm run generate-embeddings` | Regenerate semantic search embeddings |
|
||||||
|
| `npm run benchmark` | Run performance benchmarks |
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── components/ # React components (PascalCase)
|
||||||
|
│ ├── constellation/ # D3 career constellation + legend
|
||||||
|
│ ├── detail/ # Detail panel views per entity type
|
||||||
|
│ └── tiles/ # Dashboard tile components
|
||||||
|
├── contexts/ # React contexts (DetailPanel, Accessibility)
|
||||||
|
├── data/ # Canonical data sources (timeline, skills, kpis, etc.)
|
||||||
|
├── hooks/ # Custom hooks (use* prefix)
|
||||||
|
├── lib/ # Utilities (semantic-search, embedding-model, llm)
|
||||||
|
├── types/ # TypeScript interfaces (pmr.ts)
|
||||||
|
├── App.tsx # Phase orchestrator (boot → login → dashboard)
|
||||||
|
└── index.css # Global styles + Tailwind
|
||||||
|
```
|
||||||
|
|
||||||
|
### Data architecture
|
||||||
|
|
||||||
|
- **Canonical source**: `src/data/timeline.ts` — all career and education entities
|
||||||
|
- **Derived**: `constellation.ts` (D3 graph), `tags.ts` (from skills), `kpis.ts` (standalone)
|
||||||
|
- **Profile copy**: `src/data/profile-content.ts` with typed selectors in `src/lib/profile-content.ts`
|
||||||
|
|
||||||
|
## Design Tokens
|
||||||
|
|
||||||
|
- **Primary**: Teal `#00897B` / **Accent**: Coral `#FF6B6B`
|
||||||
|
- **Palette**: GP system-inspired greens, teals, and greys
|
||||||
|
- **Fonts**: Elvaro Grotesque (UI), Geist Mono / Fira Code (mono), Plus Jakarta Sans / Inter Tight (fallback)
|
||||||
|
- **Breakpoints**: xxs 360px, xs 480px, sm 640px, md 768px, lg 1024px, xl 1280px
|
||||||
@@ -1,137 +0,0 @@
|
|||||||
# Implementation Plan — React Conversion
|
|
||||||
|
|
||||||
## Project Overview
|
|
||||||
|
|
||||||
Convert the completed `concept.html` (ECG Heartbeat CV Website) into a modern React application with TypeScript, Vite, and Tailwind CSS. The project will be a portfolio-grade React implementation that preserves all animations, interactions, and design details from the HTML concept while following React best practices.
|
|
||||||
|
|
||||||
**Key Features to Port:**
|
|
||||||
- Boot sequence with terminal typing animation
|
|
||||||
- ECG flatline and heartbeat SVG animations
|
|
||||||
- Branching lines that trace UI elements into existence
|
|
||||||
- Color transition from green ECG to teal/coral design system
|
|
||||||
- Floating pill navigation with active section tracking
|
|
||||||
- SVG circular skill gauges with scroll-triggered animations
|
|
||||||
- Experience timeline with ECG decoration
|
|
||||||
- Scroll-reveal animations using IntersectionObserver
|
|
||||||
- Fully responsive design (desktop/tablet/mobile)
|
|
||||||
|
|
||||||
**Tech Stack:**
|
|
||||||
- React 18+ with TypeScript
|
|
||||||
- Vite for build tooling
|
|
||||||
- Tailwind CSS for styling
|
|
||||||
- Framer Motion for complex animations (boot sequence, ECG transitions)
|
|
||||||
- React Intersection Observer for scroll-triggered animations
|
|
||||||
- Lucide React for icons (replacing unicode symbols)
|
|
||||||
|
|
||||||
**Project Structure:**
|
|
||||||
```
|
|
||||||
src/
|
|
||||||
├── components/
|
|
||||||
│ ├── BootSequence.tsx # Terminal typing animation
|
|
||||||
│ ├── ECGAnimation.tsx # Flatline, heartbeats, branching
|
|
||||||
│ ├── FloatingNav.tsx # Pill navigation with active tracking
|
|
||||||
│ ├── Hero.tsx # About section with vitals
|
|
||||||
│ ├── Skills.tsx # Skill gauges with SVG circles
|
|
||||||
│ ├── Experience.tsx # Timeline layout
|
|
||||||
│ ├── Education.tsx # Education cards
|
|
||||||
│ ├── Projects.tsx # Project cards with gradient borders
|
|
||||||
│ ├── Contact.tsx # Contact grid
|
|
||||||
│ └── Footer.tsx # Footer with ECG decoration
|
|
||||||
├── hooks/
|
|
||||||
│ ├── useScrollReveal.ts # IntersectionObserver for scroll animations
|
|
||||||
│ └── useActiveSection.ts # Track active nav section
|
|
||||||
├── lib/
|
|
||||||
│ └── utils.ts # Utility functions (skill gauge math)
|
|
||||||
├── types/
|
|
||||||
│ └── index.ts # TypeScript interfaces
|
|
||||||
├── App.tsx # Main app with boot/ECG/CV phases
|
|
||||||
├── main.tsx # Entry point
|
|
||||||
└── index.css # Tailwind + custom CSS variables
|
|
||||||
```
|
|
||||||
|
|
||||||
**Reference Materials:**
|
|
||||||
- `References/concept.html` — Complete working HTML implementation with all animations
|
|
||||||
- `References/CV_v4.md` — Source CV content to populate sections
|
|
||||||
- `References/ECGVideo/` — Remotion video project with ECG animation patterns
|
|
||||||
|
|
||||||
## Quality Checks
|
|
||||||
|
|
||||||
- `npm run dev` — Development server starts without errors
|
|
||||||
- `npm run build` — Production build completes without errors
|
|
||||||
- `npm run lint` — No ESLint errors
|
|
||||||
- `npm run typecheck` — No TypeScript errors
|
|
||||||
- Open `http://localhost:5173` and verify:
|
|
||||||
- Boot sequence plays exactly as in concept.html (terminal typing, 4 second duration)
|
|
||||||
- ECG flatline draws left-to-right
|
|
||||||
- Three heartbeats animate with increasing amplitude
|
|
||||||
- Branching lines trace outward on third beat
|
|
||||||
- Background transitions from black to white
|
|
||||||
- Final CV design renders with all sections
|
|
||||||
- Floating pill nav tracks active section on scroll
|
|
||||||
- Skill gauges animate when scrolled into view
|
|
||||||
- All hover effects work (card elevation, gradient borders)
|
|
||||||
- Responsive layouts work at 768px and 480px
|
|
||||||
- No console errors
|
|
||||||
|
|
||||||
## Tasks
|
|
||||||
|
|
||||||
- [x] **Task 1: Initialize React project with Vite + TypeScript + Tailwind**
|
|
||||||
|
|
||||||
Run `npm create vite@latest . -- --template react-ts` to scaffold the project. Install dependencies: `npm install framer-motion lucide-react`. Initialize Tailwind: `npm install -D tailwindcss postcss autoprefixer && npx tailwindcss init -p`. Configure `tailwind.config.js` with custom colors (teal #00897B, coral #FF6B6B, etc.). Set up `src/index.css` with Tailwind directives and CSS custom properties matching concept.html.
|
|
||||||
|
|
||||||
- [x] **Task 2: Set up project structure and types**
|
|
||||||
|
|
||||||
Create the folder structure (`components/`, `hooks/`, `lib/`, `types/`). Define TypeScript interfaces in `types/index.ts` for: `Skill` (name, level, category, color), `Experience` (role, org, date, bullets), `Education` (degree, institution, period, detail), `Project` (title, description, link?). Create `lib/utils.ts` with helper function `calculateSkillOffset(level: number, radius: number): number` that returns `2 * Math.PI * radius * (1 - level / 100)`.
|
|
||||||
|
|
||||||
- [x] **Task 3: Build BootSequence component**
|
|
||||||
|
|
||||||
Create `components/BootSequence.tsx`. Implement terminal typing animation using Framer Motion or CSS transitions. Display boot lines with correct colors (cyan labels, green values, dim text). Use exact boot text from concept.html: "CLINICAL TERMINAL v3.2.1", "Initialising pharmacist profile...", SYSTEM/USER/ROLE/LOCATION, module loading, [OK] lines, READY. Duration: ~4 seconds. Emit `onComplete` callback when finished. Styling: black background, Fira Code font.
|
|
||||||
|
|
||||||
- [x] **Task 4: Build ECGAnimation component**
|
|
||||||
|
|
||||||
Create `components/ECGAnimation.tsx`. Port the ECG logic from concept.html:
|
|
||||||
- SVG flatline drawing left-to-right (1000ms)
|
|
||||||
- Three PQRST heartbeats with increasing amplitude (40px → 60px → 100px)
|
|
||||||
- Color interpolation: #00ff41 → #00C9A7 → #00897B
|
|
||||||
- Branching lines from third R peak tracing UI outlines (pill nav, hero, cards)
|
|
||||||
- Background transition from black to white
|
|
||||||
- Emit `onComplete` callback when animation finishes
|
|
||||||
Use Framer Motion for path drawing animations (pathLength).
|
|
||||||
|
|
||||||
- [x] **Task 5: Build FloatingNav component**
|
|
||||||
|
|
||||||
Create `components/FloatingNav.tsx`. Floating pill navigation bar fixed at top center. Links: About, Skills, Experience, Education, Projects, Contact. Active link tracking via `useActiveSection` hook (IntersectionObserver). Smooth scroll to sections on click. Responsive: horizontal scroll on mobile. Styling: white bg, rounded-full, shadow-md, teal active state with dot indicator.
|
|
||||||
|
|
||||||
- [x] **Task 6: Build Hero section component**
|
|
||||||
|
|
||||||
Create `components/Hero.tsx`. Port hero section from concept.html: centered layout, name (clamp 36-52px), job title (muted), location pill (teal border), summary paragraph (max-width 560px). Four vital sign metric cards in a row: "10+ Years Experience", "Python/SQL/BI Analytics Stack", "Pop. Health Focus Area", "NHS N&W System". Cards have teal border-top, hover elevation. Responsive: 2x2 grid on tablet, stacked on mobile.
|
|
||||||
|
|
||||||
- [x] **Task 7: Build Skills section with SVG gauges**
|
|
||||||
|
|
||||||
Create `components/Skills.tsx`. Three skill categories: Technical (8 skills, teal), Clinical (6 skills, coral), Strategic (4 skills, teal). Each skill has circular SVG progress gauge using calculated stroke-dashoffset. Scroll-triggered animation: gauges fill when section enters viewport, staggered by 100ms. Port all 18 skills with correct percentages from concept.html.
|
|
||||||
|
|
||||||
- [x] **Task 8: Build Experience section with timeline**
|
|
||||||
|
|
||||||
Create `components/Experience.tsx`. Vertical timeline with 5 roles: Interim Head (May-Nov 2025), Deputy Head (Jul 2024-Present), High-Cost Drugs (May 2022-Jul 2024), Pharmacy Manager (Nov 2017-May 2022), Duty Pharmacy Manager (Aug 2016-Nov 2017). Decorative ECG waveform SVG beside heading. Timeline dot filled for current roles. Cards with hover effect (scale, shadow, left border). Responsive: hide timeline line on mobile, stack cards.
|
|
||||||
|
|
||||||
- [x] **Task 9: Build Education, Projects, Contact sections**
|
|
||||||
|
|
||||||
Create `components/Education.tsx`, `components/Projects.tsx`, `components/Contact.tsx`.
|
|
||||||
|
|
||||||
**Education:** 2-column grid. MPharm (Hons) UEA 2011-2015 (2:1). Mary Seacole Leadership Programme 2018. Gradient top border (teal→coral). A-Levels line below.
|
|
||||||
|
|
||||||
**Projects:** 2x2 grid. PharMetrics (with link), Patient Pathway Analysis, Blueteq Generator, NMS Video. Gradient border hover effect.
|
|
||||||
|
|
||||||
**Contact:** 4-column grid. Phone, Email, LinkedIn, Location. Use Lucide icons (Phone, Mail, Linkedin, MapPin). Responsive: 2x2 on mobile.
|
|
||||||
|
|
||||||
- [x] **Task 10: Build Footer component and main App.tsx**
|
|
||||||
|
|
||||||
Create `components/Footer.tsx`. Decorative ECG waveform SVG, attribution text. Update `App.tsx` to orchestrate the three phases: 1) BootSequence (4s), 2) ECGAnimation (4s), 3) CV Content (with all sections). Use React state to track current phase. Ensure smooth transitions between phases.
|
|
||||||
|
|
||||||
- [x] **Task 11: Implement scroll animations and responsive design**
|
|
||||||
|
|
||||||
Create `hooks/useScrollReveal.ts`. IntersectionObserver-based hook for scroll-triggered section reveals. Add scroll-reveal animations to all sections (opacity 0→1, translateY 24px→0). Ensure animations only trigger once. Add responsive breakpoints: tablet (768px), mobile (480px). Test all layouts.
|
|
||||||
|
|
||||||
- [x] **Task 12: Final integration, testing, and polish**
|
|
||||||
|
|
||||||
Run all quality checks. Verify TypeScript compiles without errors. Verify no console errors. Test boot sequence timing matches concept.html (~4s). Test ECG animation timing and easing. Verify all CV content accuracy against CV_v4.md. Test all interactive elements (nav, hover effects, scroll animations). Verify responsive layouts at all breakpoints. Final build test.
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
# Ralph Wiggum Loop - Iteration Prompt
|
|
||||||
|
|
||||||
You are operating inside an automated loop. Each iteration you receive fresh context - you have NO memory of previous iterations. Your only persistence is the filesystem.
|
|
||||||
|
|
||||||
You are converting the completed `concept.html` (ECG Heartbeat CV Website) into a modern React application with TypeScript, Vite, and Tailwind CSS. The goal is a portfolio-grade React implementation that preserves all animations, interactions, and design details from the HTML concept.
|
|
||||||
|
|
||||||
## Your Task This Iteration
|
|
||||||
|
|
||||||
1. **Use the /frontend-design skill** (REQUIRED for visual components): Before writing ANY code for components that involve visual design, styling, animations, or UI elements, you MUST invoke the `/frontend-design` skill. This includes: BootSequence, ECGAnimation, FloatingNav, Hero, Skills, Experience, Education, Projects, Contact, Footer, and any component with CSS/styling. This skill gives you access to specialized frontend design capabilities for higher quality, polished output.
|
|
||||||
|
|
||||||
2. **Read the plan**: Open `IMPLEMENTATION_PLAN.md` and find the highest-priority unchecked item (`- [ ]`). Items are listed in priority order - pick the first unchecked one.
|
|
||||||
|
|
||||||
3. **Read accumulated learnings**: Open `progress.txt` and read the "Codebase Patterns" section. This contains learnings from previous iterations.
|
|
||||||
|
|
||||||
4. **Read guardrails**: Open `guardrails.md` and read ALL guardrails. These are hard rules you MUST follow. Violating a guardrail is a quality check failure.
|
|
||||||
|
|
||||||
5. **Implement the item**: Complete the single task you selected. Keep changes focused - one task per iteration. Write production-quality React/TypeScript code that is artistic, creative, and visually polished. This is a design showcase - the output should make someone say "wow, that's slick."
|
|
||||||
|
|
||||||
6. **Run quality checks**: Execute the quality check commands listed in `IMPLEMENTATION_PLAN.md` under "Quality Checks". Fix any issues before proceeding.
|
|
||||||
|
|
||||||
7. **Commit your changes**: Stage and commit all changes with a descriptive message referencing the task you completed.
|
|
||||||
|
|
||||||
8. **Mark the item complete**: In `IMPLEMENTATION_PLAN.md`, change the item from `- [ ]` to `- [x]`.
|
|
||||||
|
|
||||||
9. **Update progress.txt**: Append to the "Iteration Log" section with:
|
|
||||||
- Which task you completed
|
|
||||||
- Any learnings or codebase patterns discovered (add to "Codebase Patterns" section)
|
|
||||||
- Any issues encountered
|
|
||||||
- Design decisions made (if visual component)
|
|
||||||
|
|
||||||
10. **Commit the progress update**: Stage and commit the updated `IMPLEMENTATION_PLAN.md` and `progress.txt`.
|
|
||||||
|
|
||||||
11. **Check for completion**: If ALL items in the task checklist are now checked (`- [x]`), output the following completion signal on its own line:
|
|
||||||
|
|
||||||
```
|
|
||||||
<promise>COMPLETE</promise>
|
|
||||||
```
|
|
||||||
|
|
||||||
## Critical Rules
|
|
||||||
|
|
||||||
- **ALWAYS invoke /frontend-design skill before writing visual component code** — this is mandatory for BootSequence, ECGAnimation, FloatingNav, Hero, Skills, Experience, Education, Projects, Contact, Footer, and any styled component
|
|
||||||
- **Only work on ONE task per iteration**
|
|
||||||
- **Always read progress.txt AND guardrails.md before starting** — previous iterations may have left important context
|
|
||||||
- **If a task is blocked or unclear**, document why in progress.txt and move to the next unchecked item
|
|
||||||
- **Keep commits atomic and well-described**
|
|
||||||
- **If quality checks fail, fix the issues before committing**
|
|
||||||
- **The visual quality bar is HIGH** — this is a design portfolio piece
|
|
||||||
- **Preserve all animations exactly** — timing, easing, and visual effects must match concept.html
|
|
||||||
- **Use TypeScript strictly** — no `any` types, proper interfaces for all data structures
|
|
||||||
- **Follow the established project structure** — components in `src/components/`, hooks in `src/hooks/`, etc.
|
|
||||||
|
|
||||||
## Reference Files
|
|
||||||
|
|
||||||
- `References/concept.html` — The complete working HTML implementation (your source of truth for animations, styling, timing)
|
|
||||||
- `References/CV_v4.md` — CV content to populate sections
|
|
||||||
- `References/ECGVideo/` — Remotion video project with ECG animation patterns
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
# Guardrails — React Conversion
|
|
||||||
|
|
||||||
## Standard Guardrails
|
|
||||||
|
|
||||||
### Frontend-design skill requirement
|
|
||||||
- **When**: Writing ANY component with visual styling, animations, or UI elements
|
|
||||||
- **Rule**: You MUST invoke the `/frontend-design` skill before writing code. This applies to: BootSequence, ECGAnimation, FloatingNav, Hero, Skills, Experience, Education, Projects, Contact, Footer, and any styled component.
|
|
||||||
- **Why**: The frontend-design skill provides specialized capabilities for creating polished, professional-grade visual output. Skipping it results in lower quality design.
|
|
||||||
|
|
||||||
### Boot sequence consistency
|
|
||||||
- **When**: Implementing BootSequence component
|
|
||||||
- **Rule**: Boot text must match concept.html exactly: "CLINICAL TERMINAL v3.2.1", "Initialising pharmacist profile...", SYSTEM/USER/ROLE/LOCATION labels with values, loading modules line, three [OK] lines, "---", and final ready line. Use Fira Code font, green #00ff41 for [OK] and values, cyan #00e5ff for labels, dim green #3a6b45 for other text.
|
|
||||||
- **Why**: Boot sequence is the shared identity across all concepts. Must be identical.
|
|
||||||
|
|
||||||
### ECG animation fidelity
|
|
||||||
- **When**: Implementing ECGAnimation component
|
|
||||||
- **Rule**: Timing and visual effects must match concept.html exactly: flatline 1000ms, three heartbeats with amplitudes 40px→60px→100px, color shift #00ff41→#00C9A7→#00897B, branching lines from third R peak, background transition black→white.
|
|
||||||
- **Why**: The ECG animation is the signature visual effect. Any deviation breaks the experience.
|
|
||||||
|
|
||||||
### CV content accuracy
|
|
||||||
- **When**: Adding CV content to components
|
|
||||||
- **Rule**: Use the expanded CV_v4.md content. Key roles in order: Interim Head (May-Nov 2025), Deputy Head (Jul 2024-Present), High-Cost Drugs & Interface Pharmacist (May 2022-Jul 2024), Pharmacy Manager Tesco (Nov 2017-May 2022), Duty Pharmacy Manager Tesco (Aug 2016-Nov 2017). Include Mary Seacole Programme in education. Include key achievements with specific numbers (£14.6M, 14,000 patients, £2.6M, 70%, 200hrs, £1M).
|
|
||||||
- **Why**: The CV data must be accurate and complete. Missing roles or wrong dates would be a critical error.
|
|
||||||
|
|
||||||
### TypeScript strictness
|
|
||||||
- **When**: Writing any TypeScript code
|
|
||||||
- **Rule**: No `any` types. Define interfaces for all data structures. Use proper React.FC types or function component signatures with typed props. Enable strict mode in tsconfig.json.
|
|
||||||
- **Why**: Type safety is a core benefit of the React conversion. `any` defeats the purpose.
|
|
||||||
|
|
||||||
### Google Fonts loading
|
|
||||||
- **When**: Setting up index.html
|
|
||||||
- **Rule**: Use preconnect links to fonts.googleapis.com AND fonts.gstatic.com (with crossorigin), then the font CSS link. Load ALL fonts: Fira Code, Plus Jakarta Sans, Inter Tight. Test that fonts actually render.
|
|
||||||
- **Why**: Fonts are critical to the design identity. Missing fonts break the visual concept.
|
|
||||||
|
|
||||||
### Transition timing
|
|
||||||
- **When**: Building the boot-to-design transition
|
|
||||||
- **Rule**: Boot phase should take ~4 seconds. ECG animation should take ~5-6 seconds. Total time from page load to fully revealed design: no more than 10 seconds.
|
|
||||||
- **Why**: Too long and users will leave. Too short and the effect is lost.
|
|
||||||
|
|
||||||
### No console errors
|
|
||||||
- **When**: Writing JavaScript/TypeScript
|
|
||||||
- **Rule**: No errors in the browser console. Handle edge cases: elements that might not exist, animation cleanup on unmount, proper dependency arrays in hooks.
|
|
||||||
- **Why**: Console errors suggest broken functionality and are a quality check failure.
|
|
||||||
|
|
||||||
### Responsive breakpoints
|
|
||||||
- **When**: Adding responsive CSS/Tailwind classes
|
|
||||||
- **Rule**: Must work at 3 breakpoints: desktop (>768px), tablet (<=768px), mobile (<=480px). Navigation must be usable at all sizes. Content must not overflow horizontally. Touch targets must be reasonable size.
|
|
||||||
- **Why**: CVs are often viewed on mobile devices.
|
|
||||||
|
|
||||||
### Scroll animation observer
|
|
||||||
- **When**: Implementing scroll-triggered animations
|
|
||||||
- **Rule**: Use IntersectionObserver via custom hook (useScrollReveal), NOT scroll event listeners. Set appropriate threshold (0.1-0.15). Animations should only play once (don't re-trigger on scroll up).
|
|
||||||
- **Why**: IntersectionObserver is more performant and reliable than scroll listeners.
|
|
||||||
|
|
||||||
### Tailwind CSS usage
|
|
||||||
- **When**: Writing component styles
|
|
||||||
- **Rule**: Use Tailwind utility classes for all styling. Only use inline styles or CSS modules for dynamic values that can't be expressed with Tailwind (e.g., stroke-dashoffset calculations). Extend Tailwind config for custom colors.
|
|
||||||
- **Why**: Consistent styling approach, smaller bundle size, better maintainability.
|
|
||||||
|
|
||||||
## Project-Specific Guardrails
|
|
||||||
|
|
||||||
### Framer Motion for complex animations
|
|
||||||
- **When**: Animating the boot sequence, ECG paths, branching lines
|
|
||||||
- **Rule**: Use Framer Motion's `motion` components and props (initial, animate, transition). Use `pathLength` for SVG drawing animations. Use `AnimatePresence` for exit animations. Define transition objects with exact timing from concept.html.
|
|
||||||
- **Why**: Framer Motion provides declarative, performant animations that are easier to maintain than imperative JS.
|
|
||||||
|
|
||||||
### Skill circle calculation
|
|
||||||
- **When**: Building SVG circular progress gauges in Skills component
|
|
||||||
- **Rule**: The circumference formula is `2 * Math.PI * radius`. `strokeDasharray = circumference`. `strokeDashoffset = circumference * (1 - level / 100)`. The circle MUST have `transform: rotate(-90deg)` to start progress from 12 o'clock position.
|
|
||||||
- **Why**: Wrong math or missing rotation produces circles that fill from the wrong position or have incorrect percentages.
|
|
||||||
|
|
||||||
### Component file structure
|
|
||||||
- **When**: Creating new components
|
|
||||||
- **Rule**: One component per file in `src/components/`. Named exports for components. Props interface defined at top of file. Follow naming: PascalCase for components (BootSequence.tsx), camelCase for hooks (useScrollReveal.ts).
|
|
||||||
- **Why**: Consistent organization makes the codebase maintainable.
|
|
||||||
|
|
||||||
### Lucide React icons
|
|
||||||
- **When**: Adding icons to Contact or other sections
|
|
||||||
- **Rule**: Use Lucide React icons instead of unicode symbols. Import specific icons: `import { Phone, Mail, Linkedin, MapPin } from 'lucide-react'`. Size icons consistently (default 24px or specified size prop).
|
|
||||||
- **Why**: Lucide provides consistent, scalable SVG icons that match the design system.
|
|
||||||
|
|
||||||
### Custom hooks for reusable logic
|
|
||||||
- **When**: Implementing scroll reveal, active section tracking
|
|
||||||
- **Rule**: Extract reusable logic into custom hooks in `src/hooks/`. Hooks should be composable and return values/functions needed by components. Name with `use` prefix.
|
|
||||||
- **Why**: Keeps components clean, enables reuse, follows React best practices.
|
|
||||||
|
|
||||||
### Vite configuration
|
|
||||||
- **When**: Setting up the project build
|
|
||||||
- **Rule**: Use Vite's default React template. Configure path aliases in `vite.config.ts` for clean imports (e.g., `@/components/Hero`). Ensure `build.outDir` is set correctly.
|
|
||||||
- **Why**: Vite provides fast dev server and optimized production builds.
|
|
||||||
@@ -1,263 +0,0 @@
|
|||||||
# Progress Log — React Conversion Phase
|
|
||||||
|
|
||||||
## Codebase Patterns
|
|
||||||
- **Source of truth**: `References/concept.html` contains the complete working HTML implementation. All animations, timing, colors, and styling must be preserved exactly when porting to React.
|
|
||||||
- **Tech stack**: React 18+, TypeScript, Vite, Tailwind CSS, Framer Motion, Lucide React
|
|
||||||
- **Project structure**: Components in `src/components/`, hooks in `src/hooks/`, types in `src/types/`, utilities in `src/lib/`
|
|
||||||
- **Animation approach**: Framer Motion for complex sequences (boot, ECG), CSS transitions for simple hover effects, IntersectionObserver (via hook) for scroll-triggered animations
|
|
||||||
- **SVG animations**: Use Framer Motion's `pathLength` prop for drawing effects, or CSS `stroke-dasharray`/`stroke-dashoffset` for skill gauges
|
|
||||||
- **Skill gauge math**: `circumference = 2 * Math.PI * radius`, `strokeDashoffset = circumference * (1 - level / 100)`, rotate -90deg to start from top
|
|
||||||
- **Boot sequence timing**: 14 lines × 220ms = ~3080ms, plus 400ms pause, 800ms fade = ~4.28s total
|
|
||||||
- **ECG timing**: Flatline 1000ms + 3 beats × 600ms + holds 300ms + branching 1500ms + fade 500ms = ~5.5s
|
|
||||||
- **Color palette**:
|
|
||||||
- ECG phase: #000 (black), #00ff41 (green), #00e5ff (cyan), #3a6b45 (dim green)
|
|
||||||
- Final design: #00897B (teal), #FF6B6B (coral), #0F172A (heading), #334155 (text), #94A3B8 (muted)
|
|
||||||
- **Fonts**: Fira Code (boot), Plus Jakarta Sans (primary), Inter Tight (secondary)
|
|
||||||
- **Responsive breakpoints**: 768px (tablet), 480px (mobile)
|
|
||||||
|
|
||||||
## Iteration Log
|
|
||||||
|
|
||||||
### Phase Transition — React Conversion Setup
|
|
||||||
- Previous phase completed: Single HTML file `concept.html` fully built with all 9 tasks
|
|
||||||
- New phase started: Convert HTML concept to React + TypeScript + Vite project
|
|
||||||
- IMPLEMENTATION_PLAN.md updated with 12 React-specific tasks
|
|
||||||
- RALPH_PROMPT.md updated with explicit /frontend-design skill requirement for all visual components
|
|
||||||
- This progress.txt reset for new phase
|
|
||||||
|
|
||||||
### Iteration 4 — Task 5: Build FloatingNav component
|
|
||||||
- **Completed**: Task 5 - Build FloatingNav component
|
|
||||||
- **Files created**:
|
|
||||||
- `src/hooks/useActiveSection.ts` - IntersectionObserver hook for tracking active nav section
|
|
||||||
- `src/components/FloatingNav.tsx` - Floating pill navigation with active tracking
|
|
||||||
- **Files modified**:
|
|
||||||
- `src/index.css` - Added scrollbar-hide utility and smooth scroll behavior
|
|
||||||
- `src/App.tsx` - Integrated FloatingNav and added section IDs for scroll targets
|
|
||||||
- **Design decisions**:
|
|
||||||
- Used IntersectionObserver with rootMargin '-20% 0px -70% 0px' for accurate section detection
|
|
||||||
- Framer Motion layoutId for smooth indicator dot animation between nav items
|
|
||||||
- Active section is the topmost visible section (sorted by DOM order)
|
|
||||||
- Navigation uses button elements for accessibility and proper click handling
|
|
||||||
- Smooth scroll behavior via CSS `scroll-behavior: smooth` on html element
|
|
||||||
- Responsive: horizontal scroll with hidden scrollbar on mobile
|
|
||||||
- **Quality checks**: `npm run typecheck` ✓, `npm run lint` ✓, `npm run build` ✓
|
|
||||||
- **Learnings**:
|
|
||||||
- IntersectionObserver thresholds array allows precise tracking of section visibility
|
|
||||||
- Using a ref to track visible sections prevents React re-render race conditions
|
|
||||||
|
|
||||||
<!-- Iterations will be logged here as tasks are completed -->
|
|
||||||
|
|
||||||
### Iteration 5 — Task 6: Build Hero section component
|
|
||||||
- **Completed**: Task 6 - Build Hero section component
|
|
||||||
- **Files created**:
|
|
||||||
- `src/components/Hero.tsx` - Hero section with name, title, location, summary, and vital cards
|
|
||||||
- **Files modified**:
|
|
||||||
- `src/App.tsx` - Replaced inline hero section with Hero component import
|
|
||||||
- **Design decisions**:
|
|
||||||
- Used Framer Motion for staggered entrance animations (name first, then title, location, summary, then vital cards with 0.1s delays)
|
|
||||||
- VitalCard component with three value size variants: default (28px), small (16px), medium (18px)
|
|
||||||
- Hover effects: elevation (-translate-y-0.5) and shadow-md transition
|
|
||||||
- Responsive: flex-wrap with gap-4 for automatic wrapping on smaller screens
|
|
||||||
- Preserved exact content from concept.html including full summary paragraph
|
|
||||||
- **Quality checks**: `npm run typecheck` ✓, `npm run lint` ✓, `npm run build` ✓
|
|
||||||
- **Learnings**:
|
|
||||||
- CSS clamp() for responsive font sizing works well inline with Framer Motion
|
|
||||||
- Using a separate VitalCard component with delay prop creates clean staggered animation pattern
|
|
||||||
|
|
||||||
<!-- Iterations will be logged here as tasks are completed -->
|
|
||||||
|
|
||||||
### Iteration 6 — Task 7: Build Skills section with SVG gauges
|
|
||||||
- **Completed**: Task 7 - Build Skills section with SVG gauges
|
|
||||||
- **Files created**:
|
|
||||||
- `src/hooks/useScrollReveal.ts` - IntersectionObserver hook for scroll-triggered animations
|
|
||||||
- `src/components/Skills.tsx` - Skills section with SVG circular progress gauges
|
|
||||||
- **Files modified**:
|
|
||||||
- `src/App.tsx` - Replaced skills placeholder with Skills component
|
|
||||||
- **Design decisions**:
|
|
||||||
- SkillGauge component with SVG circular progress using stroke-dashoffset animation
|
|
||||||
- IntersectionObserver triggers when section is 15% visible
|
|
||||||
- Staggered animation: 100ms delay between each gauge
|
|
||||||
- Gauge radius 34px, circumference 213.628, rotates -90deg to start from top
|
|
||||||
- Transition duration 1.2s ease-out for gauge fill animation
|
|
||||||
- Framer Motion for card entrance animations (opacity 0→1, y 16→0)
|
|
||||||
- Color-coded: Technical (teal), Clinical (coral), Strategic (teal)
|
|
||||||
- Responsive grid: auto-fit with minmax(140px, 1fr)
|
|
||||||
- **Quality checks**: `npm run typecheck` ✓, `npm run lint` ✓, `npm run build` ✓
|
|
||||||
- **Learnings**:
|
|
||||||
- SVG stroke-dashoffset animation triggered via React state + CSS transition works smoothly
|
|
||||||
- IntersectionObserver cleanup is critical to avoid memory leaks
|
|
||||||
- Calculating baseDelay per category allows grouped stagger effects
|
|
||||||
|
|
||||||
### Iteration 3 — Task 4: Build ECGAnimation component
|
|
||||||
- **Completed**: Task 4 - Build ECGAnimation component
|
|
||||||
- **Files created**:
|
|
||||||
- `src/components/ECGAnimation.tsx` - Canvas-based ECG animation with heartbeat waveforms and name drawing
|
|
||||||
- **Files modified**:
|
|
||||||
- `src/App.tsx` - Updated to use ECGAnimation component instead of placeholder
|
|
||||||
- **Design decisions**:
|
|
||||||
- Used canvas API with requestAnimationFrame for smooth 60fps animation
|
|
||||||
- Ported exact ECG waveform generation from concept.html (PQRST pattern)
|
|
||||||
- Ported letter waveform interpolation for "ANDREW CHARLWOOD" name drawing
|
|
||||||
- Implemented glow effects using canvas shadowBlur
|
|
||||||
- Added scanline overlay (4px horizontal lines) for retro effect
|
|
||||||
- Added radial gradient vignette for atmosphere
|
|
||||||
- Background transitions from black to white during exit phase
|
|
||||||
- Used Framer Motion AnimatePresence for component-level exit animation
|
|
||||||
- **Animation timing preserved**:
|
|
||||||
- 4 heartbeat complexes with amplitudes: 0.3, 0.55, 0.85, 1.0
|
|
||||||
- Trace speed: 450px/s (scaled responsively)
|
|
||||||
- Hold time after text: 0.75s
|
|
||||||
- Exit fade: 0.8s
|
|
||||||
- Total duration: ~5-6 seconds
|
|
||||||
- **Quality checks**: `npm run typecheck` ✓, `npm run lint` ✓, `npm run build` ✓
|
|
||||||
- **Learnings**:
|
|
||||||
- Canvas-based animations need careful cleanup on unmount (cancelAnimationFrame)
|
|
||||||
- Device pixel ratio (dpr) handling required for crisp rendering on high-DPI displays
|
|
||||||
- Responsive scaling: `Math.min(1.2, Math.max(0.35, vw / 1400))`
|
|
||||||
|
|
||||||
### Iteration 1 — Task 1: Initialize React project
|
|
||||||
- **Completed**: Task 1 - Initialize React project with Vite + TypeScript + Tailwind
|
|
||||||
- **Files created**:
|
|
||||||
- `package.json` with dependencies: React 18, Framer Motion, Lucide React, Tailwind
|
|
||||||
- `tsconfig.json`, `tsconfig.app.json`, `tsconfig.node.json` for TypeScript
|
|
||||||
- `vite.config.ts` with path alias `@/` -> `./src/`
|
|
||||||
- `tailwind.config.js` with custom colors (teal, coral, ecg-green), fonts, shadows
|
|
||||||
- `postcss.config.js` for Tailwind processing
|
|
||||||
- `index.html` with Google Fonts (Fira Code, Plus Jakarta Sans, Inter Tight)
|
|
||||||
- `src/index.css` with Tailwind directives and CSS custom properties
|
|
||||||
- `src/main.tsx` entry point
|
|
||||||
- `src/App.tsx` placeholder component
|
|
||||||
- `src/types/index.ts` with TypeScript interfaces
|
|
||||||
- `src/lib/utils.ts` with skill gauge calculation helper
|
|
||||||
- `eslint.config.js` with React hooks and refresh rules
|
|
||||||
- **Project structure created**: `src/components/`, `src/hooks/`, `src/lib/`, `src/types/`
|
|
||||||
- **Quality checks**: `npm run typecheck` ✓, `npm run build` ✓, `npm run lint` ✓
|
|
||||||
- **Learnings**:
|
|
||||||
- Need `src/vite-env.d.ts` with `/// <reference types="vite/client" />` for CSS imports
|
|
||||||
- Vite refuses to scaffold in non-empty directory, so manual setup was needed
|
|
||||||
|
|
||||||
### Iteration 2 — Task 2 & 3: Project structure and BootSequence
|
|
||||||
- **Completed**: Task 2 (Set up project structure and types) - was already done in Task 1
|
|
||||||
- **Completed**: Task 3 - Build BootSequence component
|
|
||||||
- **Files created**:
|
|
||||||
- `src/components/BootSequence.tsx` - Terminal typing animation using Framer Motion
|
|
||||||
- **Design decisions**:
|
|
||||||
- Used Framer Motion's `motion.div` with `initial`/`animate` props for line reveals
|
|
||||||
- Each line animates with opacity 0→1, translateY 8px→0 over 400ms
|
|
||||||
- Staggered delays calculated from cumulative 220ms per line
|
|
||||||
- Blinking cursor implemented with CSS animation class `animate-blink`
|
|
||||||
- Used `AnimatePresence` for smooth exit fade (800ms)
|
|
||||||
- **Boot sequence timing preserved**: 14 lines × 220ms + 400ms pause + 800ms fade = ~4.28s
|
|
||||||
- **Quality checks**: `npm run typecheck` ✓, `npm run build` ✓, `npm run lint` ✓
|
|
||||||
- **Learnings**:
|
|
||||||
- Framer Motion's delay prop uses seconds, not milliseconds
|
|
||||||
- Used `dangerouslySetInnerHTML` for colored spans within boot lines (matches concept.html structure)
|
|
||||||
- CSS classes for blink/seed-pulse animations already existed in index.css from Task 1
|
|
||||||
|
|
||||||
### Iteration 7 — Task 8: Build Experience section with timeline
|
|
||||||
- **Completed**: Task 8 - Build Experience section with timeline
|
|
||||||
- **Files created**:
|
|
||||||
- `src/components/Experience.tsx` - Timeline component with 5 roles and ECG decoration
|
|
||||||
- **Files modified**:
|
|
||||||
- `src/App.tsx` - Replaced Experience placeholder with Experience component
|
|
||||||
- `src/hooks/useScrollReveal.ts` - Fixed ref type for React 18+ compatibility
|
|
||||||
- **Design decisions**:
|
|
||||||
- Vertical timeline with 20% left offset for timeline line and dots
|
|
||||||
- ECG waveform SVG decoration beside heading (matches concept.html)
|
|
||||||
- Timeline dots filled (bg-teal) for current roles, outline for past roles
|
|
||||||
- Cards have hover effects: scale(1.01), shadow-md, left border teal/30
|
|
||||||
- Framer Motion for staggered entry animations (100ms delay per card)
|
|
||||||
- useScrollReveal hook triggers animations when section is 10% visible
|
|
||||||
- Responsive: timeline line and dots hidden on mobile (md:block)
|
|
||||||
- **Experience data**: 5 roles from Interim Head (May-Nov 2025) to Duty Pharmacy Manager (Aug 2016-Nov 2017)
|
|
||||||
- **Quality checks**: `npm run typecheck` ✓, `npm run lint` ✓, `npm run build` ✓
|
|
||||||
- **Learnings**:
|
|
||||||
- React 18+ RefObject types require non-nullable type param for ref props
|
|
||||||
- Fixed useScrollReveal to return `RefObject<T>` instead of `RefObject<T | null>`
|
|
||||||
- data-visible attribute pattern works well for CSS transitions based on JS state
|
|
||||||
|
|
||||||
### Iteration 8 — Task 9: Build Education, Projects, Contact sections
|
|
||||||
- **Completed**: Task 9 - Build Education, Projects, Contact sections
|
|
||||||
- **Files created**:
|
|
||||||
- `src/components/Education.tsx` - Education cards with gradient top border
|
|
||||||
- `src/components/Projects.tsx` - Project cards with gradient border hover effect
|
|
||||||
- `src/components/Contact.tsx` - Contact grid with Lucide icons
|
|
||||||
- **Files modified**:
|
|
||||||
- `src/App.tsx` - Replaced placeholder sections with actual components
|
|
||||||
- **Design decisions**:
|
|
||||||
- **Education**: 2-column grid with gradient top border (teal→coral), hover elevation effect
|
|
||||||
- **Projects**: 2x2 grid with gradient border hover effect using CSS mask technique
|
|
||||||
- **Contact**: 4-column grid (2x2 on mobile), Lucide icons (Phone, Mail, Linkedin, MapPin)
|
|
||||||
- Framer Motion for staggered entry animations (100ms delay per card)
|
|
||||||
- useScrollReveal hook for scroll-triggered visibility
|
|
||||||
- ExternalLink icon from Lucide for project links
|
|
||||||
- Contact links use teal color with hover transition
|
|
||||||
- **Quality checks**: `npm run typecheck` ✓, `npm run lint` ✓, `npm run build` ✓
|
|
||||||
- **Learnings**:
|
|
||||||
- CSS gradient border hover effect uses mask-composite: exclude technique
|
|
||||||
- Lucide icons are tree-shakable - import only what's needed
|
|
||||||
- Contact items with href conditionally render as anchor tags
|
|
||||||
|
|
||||||
### Iteration 9 — Task 10: Build Footer component and main App.tsx
|
|
||||||
- **Completed**: Task 10 - Build Footer component and main App.tsx
|
|
||||||
- **Files created**:
|
|
||||||
- `src/components/Footer.tsx` - Footer with decorative ECG waveform SVG
|
|
||||||
- **Files modified**:
|
|
||||||
- `src/App.tsx` - Added Footer import and component to content phase
|
|
||||||
- **Design decisions**:
|
|
||||||
- ECG waveform SVG matches concept.html: 120x20 viewBox with PQRST pattern
|
|
||||||
- Framer Motion for scroll-triggered entrance (opacity 0→1, y 16→0)
|
|
||||||
- Teal stroke at 30% opacity for subtle branding
|
|
||||||
- Font-secondary for text-xs muted attribution
|
|
||||||
- Footer placed outside main element as per semantic HTML
|
|
||||||
- **Quality checks**: `npm run typecheck` ✓, `npm run lint` ✓, `npm run build` ✓
|
|
||||||
- **Learnings**:
|
|
||||||
- App.tsx already had three-phase orchestration working correctly
|
|
||||||
- Footer scroll animation uses whileInView with once:true and margin:'-50px'
|
|
||||||
|
|
||||||
### Iteration 10 — Task 11: Implement scroll animations and responsive design
|
|
||||||
- **Completed**: Task 11 - Implement scroll animations and responsive design
|
|
||||||
- **Files modified**:
|
|
||||||
- `tailwind.config.js` - Added custom 'xs' screen at 480px for mobile breakpoint
|
|
||||||
- `src/App.tsx` - Added responsive padding (px-5 xs:px-6 md:px-8)
|
|
||||||
- `src/components/FloatingNav.tsx` - Responsive width and font/padding on mobile
|
|
||||||
- `src/components/Hero.tsx` - Responsive section padding, vitals grid, title font size
|
|
||||||
- `src/components/Skills.tsx` - Responsive grid (2→3→auto-fit), gauge size, padding
|
|
||||||
- `src/components/Experience.tsx` - Responsive card padding, ECG decoration size
|
|
||||||
- `src/components/Education.tsx` - Responsive section padding
|
|
||||||
- `src/components/Projects.tsx` - Responsive grid (1 col at tablet, 2 cols at desktop)
|
|
||||||
- `src/components/Contact.tsx` - Responsive section padding
|
|
||||||
- `src/components/Footer.tsx` - Responsive padding
|
|
||||||
- **Design decisions**:
|
|
||||||
- Added 'xs' breakpoint at 480px to match concept.html mobile breakpoint
|
|
||||||
- Scroll-reveal animations standardized to opacity 0→1, translateY 24px→0 across all sections
|
|
||||||
- Responsive patterns from concept.html:
|
|
||||||
- 768px (md): 2-col grids, smaller nav padding, vitals 2-col grid
|
|
||||||
- 480px (xs): 1-col grids, smaller fonts, smaller gauges (64px), reduced padding
|
|
||||||
- Main container uses px-5 xs:px-6 md:px-8 for responsive horizontal padding
|
|
||||||
- Section padding uses py-12 xs:py-16 md:py-20 for consistent vertical rhythm
|
|
||||||
- Skills grid: 2 cols mobile, 3 cols tablet, auto-fit desktop
|
|
||||||
- Hero vitals: stacked mobile, 2-col tablet, flex row desktop
|
|
||||||
- **Quality checks**: `npm run typecheck` ✓, `npm run lint` ✓, `npm run build` ✓
|
|
||||||
- **Learnings**:
|
|
||||||
- Tailwind custom screens allow precise breakpoint matching to design specs
|
|
||||||
- Using w-16 h-16 xs:w-20 xs:h-20 for SVG gauges maintains aspect ratio while scaling
|
|
||||||
- Grid-based responsive layouts more reliable than flex-wrap for consistent card sizing
|
|
||||||
|
|
||||||
### Iteration 11 — Task 12: Final integration, testing, and polish
|
|
||||||
- **Completed**: Task 12 - Final integration, testing, and polish
|
|
||||||
- **Quality checks verified**:
|
|
||||||
- `npm run typecheck` ✓ - No TypeScript errors
|
|
||||||
- `npm run lint` ✓ - No ESLint errors
|
|
||||||
- `npm run build` ✓ - Production build completes (290KB JS, 18KB CSS gzipped to 94KB/4.5KB)
|
|
||||||
- **CV content accuracy verified** against CV_v4.md:
|
|
||||||
- Hero: Name, title, location, summary all match
|
|
||||||
- Experience: 5 roles in correct order with accurate dates and bullet points
|
|
||||||
- Education: MPharm UEA, Mary Seacole Programme with correct details
|
|
||||||
- Skills: 18 skills across Technical/Clinical/Strategic categories
|
|
||||||
- Projects: 4 projects with descriptions and PharMetrics link
|
|
||||||
- Contact: Phone, email, LinkedIn, location all accurate
|
|
||||||
- **All 12 tasks completed** - React conversion finished
|
|
||||||
- **Learnings**:
|
|
||||||
- Production build size is reasonable at ~94KB gzipped for JS
|
|
||||||
- All components properly typed with TypeScript strict mode
|
|
||||||
- IntersectionObserver hooks cleanup correctly on unmount
|
|
||||||
@@ -1,362 +0,0 @@
|
|||||||
<#
|
|
||||||
.SYNOPSIS
|
|
||||||
Ralph Wiggum Loop - Visualization Improvements variant.
|
|
||||||
|
|
||||||
.DESCRIPTION
|
|
||||||
Outer loop for iterative chart improvement (bug fixes, polish, new analytics).
|
|
||||||
Each iteration spawns a fresh `claude --print` invocation.
|
|
||||||
Memory persists via filesystem only: git commits, progress.txt, IMPLEMENTATION_PLAN.md, guardrails.md.
|
|
||||||
|
|
||||||
Runs until completion (<promise>COMPLETE</promise>) or circuit breaker trips.
|
|
||||||
No arbitrary iteration limit — the loop continues until done.
|
|
||||||
|
|
||||||
Circuit breakers prevent runaway costs:
|
|
||||||
- No git changes for N consecutive iterations (stalled)
|
|
||||||
- Same error repeated N consecutive iterations (stuck)
|
|
||||||
|
|
||||||
.PARAMETER Model
|
|
||||||
Claude model to use. Default: "opus".
|
|
||||||
|
|
||||||
.PARAMETER BranchName
|
|
||||||
Optional git branch name. If provided, creates/checks out the branch before starting.
|
|
||||||
|
|
||||||
.PARAMETER MaxNoProgress
|
|
||||||
Number of consecutive iterations with no git changes before circuit breaker trips. Default: 3.
|
|
||||||
|
|
||||||
.PARAMETER MaxSameError
|
|
||||||
Number of consecutive iterations with the same error before circuit breaker trips. Default: 3.
|
|
||||||
|
|
||||||
.EXAMPLE
|
|
||||||
.\ralph.ps1 -Model "opus" -BranchName "feature/dash-migration"
|
|
||||||
|
|
||||||
.EXAMPLE
|
|
||||||
.\ralph.ps1 -Model "sonnet" -MaxNoProgress 2
|
|
||||||
#>
|
|
||||||
|
|
||||||
param(
|
|
||||||
[string]$Model = "opus",
|
|
||||||
[string]$BranchName,
|
|
||||||
[int]$MaxNoProgress = 3,
|
|
||||||
[int]$MaxSameError = 3
|
|
||||||
)
|
|
||||||
|
|
||||||
$ErrorActionPreference = "Stop"
|
|
||||||
|
|
||||||
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
|
||||||
$promptFile = Join-Path $scriptDir "RALPH_PROMPT.md"
|
|
||||||
$planFile = Join-Path $scriptDir "IMPLEMENTATION_PLAN.md"
|
|
||||||
$guardrailsFile = Join-Path $scriptDir "guardrails.md"
|
|
||||||
$progressFile = Join-Path $scriptDir "progress.txt"
|
|
||||||
$logDir = Join-Path $scriptDir "logs"
|
|
||||||
|
|
||||||
# --- Validation ---
|
|
||||||
|
|
||||||
if (-not (Test-Path $promptFile)) {
|
|
||||||
Write-Error "RALPH_PROMPT.md not found at $promptFile"
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
if (-not (Test-Path $planFile)) {
|
|
||||||
Write-Error "IMPLEMENTATION_PLAN.md not found at $planFile"
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
if (-not (Test-Path $guardrailsFile)) {
|
|
||||||
Write-Warning "guardrails.md not found at $guardrailsFile - loop may miss known failure patterns"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Ensure progress.txt exists
|
|
||||||
if (-not (Test-Path $progressFile)) {
|
|
||||||
@"
|
|
||||||
# Progress Log
|
|
||||||
|
|
||||||
## Design Context
|
|
||||||
<!-- Design decisions and context go here -->
|
|
||||||
|
|
||||||
## Reflex Patterns
|
|
||||||
<!-- Reusable Reflex patterns discovered during development -->
|
|
||||||
|
|
||||||
## Iteration Log
|
|
||||||
<!-- Each iteration appends a structured entry below. See RALPH_PROMPT.md for format. -->
|
|
||||||
"@ | Set-Content -Path $progressFile -Encoding UTF8
|
|
||||||
Write-Host "Created progress.txt"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Ensure logs directory exists
|
|
||||||
if (-not (Test-Path $logDir)) {
|
|
||||||
New-Item -ItemType Directory -Path $logDir | Out-Null
|
|
||||||
Write-Host "Created logs directory"
|
|
||||||
}
|
|
||||||
|
|
||||||
# --- Git Setup ---
|
|
||||||
|
|
||||||
$gitInitialised = $false
|
|
||||||
try {
|
|
||||||
$result = git rev-parse --is-inside-work-tree 2>&1
|
|
||||||
if ($LASTEXITCODE -eq 0 -and $result -eq "true") {
|
|
||||||
$gitInitialised = $true
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
# Not a git repo — expected on first run
|
|
||||||
}
|
|
||||||
|
|
||||||
if (-not $gitInitialised) {
|
|
||||||
Write-Host "Initialising git repository..."
|
|
||||||
git init
|
|
||||||
git add -A
|
|
||||||
git commit -m "Initial commit before Ralph loop"
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($BranchName) {
|
|
||||||
$currentBranch = git branch --show-current
|
|
||||||
if ($currentBranch -ne $BranchName) {
|
|
||||||
$branchExists = git branch --list $BranchName
|
|
||||||
if ($branchExists) {
|
|
||||||
Write-Host "Switching to existing branch: $BranchName"
|
|
||||||
git checkout $BranchName
|
|
||||||
} else {
|
|
||||||
Write-Host "Creating branch: $BranchName"
|
|
||||||
git checkout -b $BranchName
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# --- Circuit Breaker State ---
|
|
||||||
|
|
||||||
$noProgressCount = 0
|
|
||||||
$lastErrorSignature = ""
|
|
||||||
$sameErrorCount = 0
|
|
||||||
|
|
||||||
# Capture the HEAD commit hash before the loop starts
|
|
||||||
$preLoopHead = git rev-parse HEAD 2>$null
|
|
||||||
|
|
||||||
# --- Main Loop ---
|
|
||||||
|
|
||||||
$promptContent = Get-Content -Path $promptFile -Raw
|
|
||||||
|
|
||||||
# Count existing iterations from progress.txt to track total across runs
|
|
||||||
$existingIterations = 0
|
|
||||||
if (Test-Path $progressFile) {
|
|
||||||
$existingIterations = (Select-String -Path $progressFile -Pattern "## Iteration" -AllMatches | Measure-Object).Count
|
|
||||||
}
|
|
||||||
|
|
||||||
Write-Host ""
|
|
||||||
Write-Host "===== Ralph Wiggum Loop (Visualization Improvements) =====" -ForegroundColor Cyan
|
|
||||||
Write-Host "Model: $Model | Runs until COMPLETE" -ForegroundColor Cyan
|
|
||||||
Write-Host "Circuit breakers: no-progress=$MaxNoProgress, same-error=$MaxSameError" -ForegroundColor Cyan
|
|
||||||
if ($BranchName) { Write-Host "Branch: $BranchName" -ForegroundColor Cyan }
|
|
||||||
if ($existingIterations -gt 0) { Write-Host "Previous iterations: $existingIterations" -ForegroundColor Cyan }
|
|
||||||
Write-Host "===========================================" -ForegroundColor Cyan
|
|
||||||
Write-Host ""
|
|
||||||
|
|
||||||
$i = 0
|
|
||||||
while ($true) {
|
|
||||||
$i++
|
|
||||||
$totalIteration = $existingIterations + $i
|
|
||||||
Write-Host ""
|
|
||||||
Write-Host "--- Iteration $i (Total: $totalIteration) ---" -ForegroundColor Yellow
|
|
||||||
|
|
||||||
# Record HEAD before this iteration
|
|
||||||
$headBefore = git rev-parse HEAD 2>$null
|
|
||||||
|
|
||||||
# Show start time and status
|
|
||||||
$iterStart = Get-Date
|
|
||||||
Write-Host " Started: $($iterStart.ToString('HH:mm:ss'))" -ForegroundColor DarkGray
|
|
||||||
Write-Host " Spawning Claude ($Model)..." -ForegroundColor DarkGray
|
|
||||||
Write-Host ""
|
|
||||||
|
|
||||||
# Spawn fresh Claude instance with stream-json for tool call visibility
|
|
||||||
$logFile = Join-Path $logDir "iteration_$totalIteration.log"
|
|
||||||
$rawLogFile = Join-Path $logDir "iteration_$totalIteration.raw.jsonl"
|
|
||||||
$maxRetries = 10
|
|
||||||
$retryCount = 0
|
|
||||||
$outputString = ""
|
|
||||||
$apiOverloaded = $false
|
|
||||||
|
|
||||||
do {
|
|
||||||
$apiOverloaded = $false
|
|
||||||
$textBuilder = [System.Text.StringBuilder]::new()
|
|
||||||
$toolCount = 0
|
|
||||||
|
|
||||||
# Clear raw log file for this attempt
|
|
||||||
if (Test-Path $rawLogFile) { Remove-Item $rawLogFile -Force }
|
|
||||||
|
|
||||||
if ($retryCount -gt 0) {
|
|
||||||
$backoffSeconds = [Math]::Pow(2, $retryCount - 1)
|
|
||||||
Write-Host " [Retry $retryCount/$maxRetries] API overloaded, waiting $backoffSeconds seconds..." -ForegroundColor DarkYellow
|
|
||||||
Start-Sleep -Seconds $backoffSeconds
|
|
||||||
Write-Host " Retrying Claude invocation..." -ForegroundColor DarkGray
|
|
||||||
}
|
|
||||||
|
|
||||||
$promptContent | claude --print --verbose --dangerously-skip-permissions --model $Model --output-format stream-json 2>&1 | ForEach-Object {
|
|
||||||
$line = $_.ToString().Trim()
|
|
||||||
if (-not $line) { return }
|
|
||||||
|
|
||||||
# Save raw event for debugging (with error handling for stream closure)
|
|
||||||
try {
|
|
||||||
Add-Content -Path $rawLogFile -Value $line -Encoding UTF8 -ErrorAction SilentlyContinue
|
|
||||||
} catch {
|
|
||||||
# Stream closed or file locked - ignore and continue
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
$evt = $line | ConvertFrom-Json -ErrorAction Stop
|
|
||||||
|
|
||||||
# --- Tool use start (show tool name) ---
|
|
||||||
if ($evt.type -eq 'content_block_start' -and $evt.content_block.type -eq 'tool_use') {
|
|
||||||
$toolCount++
|
|
||||||
$toolName = $evt.content_block.name
|
|
||||||
Write-Host " [$toolName]" -ForegroundColor DarkCyan
|
|
||||||
}
|
|
||||||
# --- Assistant text content (streaming deltas) ---
|
|
||||||
elseif ($evt.type -eq 'content_block_delta' -and $evt.delta.type -eq 'text_delta' -and $evt.delta.text) {
|
|
||||||
Write-Host -NoNewline $evt.delta.text
|
|
||||||
[void]$textBuilder.Append($evt.delta.text)
|
|
||||||
}
|
|
||||||
# --- Result event (error display + text capture for circuit breakers) ---
|
|
||||||
elseif ($evt.type -eq 'result') {
|
|
||||||
if ($evt.subtype -eq 'error_result' -and $evt.error) {
|
|
||||||
Write-Host " [ERROR] $($evt.error)" -ForegroundColor Red
|
|
||||||
[void]$textBuilder.AppendLine("ERROR: $($evt.error)")
|
|
||||||
}
|
|
||||||
elseif ($evt.result) {
|
|
||||||
# Capture for circuit breaker detection; don't print
|
|
||||||
# (text already displayed via streaming deltas above)
|
|
||||||
[void]$textBuilder.AppendLine($evt.result)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
# --- Message-level content (final message summary) ---
|
|
||||||
elseif ($evt.message -and $evt.message.content) {
|
|
||||||
foreach ($block in $evt.message.content) {
|
|
||||||
if ($block.type -eq 'text' -and $block.text) {
|
|
||||||
Write-Host $block.text
|
|
||||||
[void]$textBuilder.AppendLine($block.text)
|
|
||||||
}
|
|
||||||
elseif ($block.type -eq 'tool_use') {
|
|
||||||
$toolCount++
|
|
||||||
Write-Host " [$($block.name)]" -ForegroundColor DarkCyan
|
|
||||||
}
|
|
||||||
# Silently ignore tool_result and other block types
|
|
||||||
}
|
|
||||||
}
|
|
||||||
# All other JSON events (input_json_delta, content_block_stop,
|
|
||||||
# message_start, message_stop, ping, etc.) are silently ignored
|
|
||||||
|
|
||||||
} catch {
|
|
||||||
# Not valid JSON — only print if it looks like meaningful stderr
|
|
||||||
# (filter out JSON fragments from multi-line events)
|
|
||||||
if ($line -and $line -notmatch '^\s*[\{\[\}\]"]') {
|
|
||||||
Write-Host $line -ForegroundColor DarkYellow
|
|
||||||
[void]$textBuilder.AppendLine($line)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$outputString = $textBuilder.ToString()
|
|
||||||
|
|
||||||
# Check for 529 overloaded error
|
|
||||||
if ($outputString -match "529.*overloaded|overloaded_error") {
|
|
||||||
$apiOverloaded = $true
|
|
||||||
$retryCount++
|
|
||||||
if ($retryCount -ge $maxRetries) {
|
|
||||||
Write-Host " [ERROR] API overloaded after $maxRetries retries, giving up." -ForegroundColor Red
|
|
||||||
}
|
|
||||||
}
|
|
||||||
# Check for usage limit with cooldown (e.g. "Usage limit reached. Reset at 3 pm")
|
|
||||||
elseif ($outputString -match "(?i)usage limit reached.*reset at (\d{1,2})(?::(\d{2}))?\s*(am|pm)") {
|
|
||||||
$resetHour = [int]$Matches[1]
|
|
||||||
$resetMinute = if ($Matches[2]) { [int]$Matches[2] } else { 0 }
|
|
||||||
$resetAmPm = $Matches[3]
|
|
||||||
|
|
||||||
if ($resetAmPm -ieq "pm" -and $resetHour -ne 12) { $resetHour += 12 }
|
|
||||||
elseif ($resetAmPm -ieq "am" -and $resetHour -eq 12) { $resetHour = 0 }
|
|
||||||
|
|
||||||
$now = Get-Date
|
|
||||||
$resetTime = Get-Date -Hour $resetHour -Minute $resetMinute -Second 0
|
|
||||||
if ($resetTime -le $now) { $resetTime = $resetTime.AddDays(1) }
|
|
||||||
$resetTime = $resetTime.AddMinutes(2)
|
|
||||||
|
|
||||||
$waitSeconds = [Math]::Ceiling(($resetTime - $now).TotalSeconds)
|
|
||||||
$waitMinutes = [Math]::Ceiling($waitSeconds / 60)
|
|
||||||
|
|
||||||
Write-Host ""
|
|
||||||
Write-Host " [USAGE LIMIT] Reset at $($Matches[1]) $resetAmPm. Cooling down ~$waitMinutes minutes (until $($resetTime.ToString('HH:mm')))..." -ForegroundColor Yellow
|
|
||||||
Start-Sleep -Seconds $waitSeconds
|
|
||||||
Write-Host " [USAGE LIMIT] Cooldown complete. Retrying iteration..." -ForegroundColor Green
|
|
||||||
|
|
||||||
$apiOverloaded = $true
|
|
||||||
# Don't increment retryCount — deterministic wait, not a flaky error
|
|
||||||
}
|
|
||||||
} while ($apiOverloaded -and $retryCount -lt $maxRetries)
|
|
||||||
|
|
||||||
$outputString | Set-Content -Path $logFile -Encoding UTF8
|
|
||||||
|
|
||||||
# Show elapsed time and tool count
|
|
||||||
$elapsed = (Get-Date) - $iterStart
|
|
||||||
Write-Host ""
|
|
||||||
Write-Host " Finished: $(Get-Date -Format 'HH:mm:ss') (elapsed: $($elapsed.ToString('mm\:ss')), tools: $toolCount)" -ForegroundColor DarkGray
|
|
||||||
|
|
||||||
# --- Circuit Breaker: No Progress ---
|
|
||||||
$headAfter = git rev-parse HEAD 2>$null
|
|
||||||
if ($headAfter -eq $headBefore) {
|
|
||||||
$noProgressCount++
|
|
||||||
Write-Host " [Circuit Breaker] No git commits this iteration ($noProgressCount/$MaxNoProgress)" -ForegroundColor DarkYellow
|
|
||||||
if ($noProgressCount -ge $MaxNoProgress) {
|
|
||||||
Write-Host ""
|
|
||||||
Write-Host "===== CIRCUIT BREAKER: NO PROGRESS =====" -ForegroundColor Red
|
|
||||||
Write-Host "No git commits for $MaxNoProgress consecutive iterations. The loop is stalled." -ForegroundColor Red
|
|
||||||
Write-Host "Check progress.txt and logs/ for details on what went wrong." -ForegroundColor Red
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
$noProgressCount = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
# --- Circuit Breaker: Repeated Error ---
|
|
||||||
$errorLines = $outputString | Select-String -Pattern "(?i)(error|exception|failed|fatal)[:.].*" -AllMatches
|
|
||||||
if ($errorLines) {
|
|
||||||
$filteredErrors = $errorLines.Matches | Where-Object { $_.Value -notmatch "529|overloaded" } | Select-Object -First 3
|
|
||||||
$currentErrorSignature = ($filteredErrors | ForEach-Object { $_.Value }) -join "|"
|
|
||||||
if ($currentErrorSignature -and $currentErrorSignature -eq $lastErrorSignature) {
|
|
||||||
$sameErrorCount++
|
|
||||||
Write-Host " [Circuit Breaker] Same error pattern repeated ($sameErrorCount/$MaxSameError)" -ForegroundColor DarkYellow
|
|
||||||
if ($sameErrorCount -ge $MaxSameError) {
|
|
||||||
Write-Host ""
|
|
||||||
Write-Host "===== CIRCUIT BREAKER: REPEATED ERROR =====" -ForegroundColor Red
|
|
||||||
Write-Host "Same error pattern for $MaxSameError consecutive iterations:" -ForegroundColor Red
|
|
||||||
Write-Host " $currentErrorSignature" -ForegroundColor Red
|
|
||||||
Write-Host "Check progress.txt and logs/ for details." -ForegroundColor Red
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
} elseif ($currentErrorSignature) {
|
|
||||||
$sameErrorCount = 0
|
|
||||||
}
|
|
||||||
$lastErrorSignature = $currentErrorSignature
|
|
||||||
} else {
|
|
||||||
$sameErrorCount = 0
|
|
||||||
$lastErrorSignature = ""
|
|
||||||
}
|
|
||||||
|
|
||||||
# --- Push to Remote ---
|
|
||||||
$hasRemote = git remote 2>$null
|
|
||||||
if ($hasRemote) {
|
|
||||||
$currentBranch = git branch --show-current
|
|
||||||
git push origin $currentBranch 2>$null
|
|
||||||
if ($LASTEXITCODE -eq 0) {
|
|
||||||
Write-Host " Pushed to remote." -ForegroundColor Green
|
|
||||||
} else {
|
|
||||||
Write-Host " Push failed or no remote configured - continuing." -ForegroundColor DarkYellow
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# --- Check for Completion ---
|
|
||||||
if ($outputString -match "<promise>COMPLETE</promise>") {
|
|
||||||
Write-Host ""
|
|
||||||
Write-Host "===== COMPLETE =====" -ForegroundColor Green
|
|
||||||
Write-Host "Visualization improvements finished after $i iteration(s) this run ($totalIteration total)." -ForegroundColor Green
|
|
||||||
exit 0
|
|
||||||
}
|
|
||||||
|
|
||||||
# Brief pause between iterations
|
|
||||||
Start-Sleep -Seconds 2
|
|
||||||
}
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
# Andy Charlwood
|
|
||||||
|
|
||||||
**MPharm, GPhC Registered Pharmacist**
|
|
||||||
|
|
||||||
Norwich, UK • 07795553088 • andy@charlwood.xyz
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Profile
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Core Competencies
|
|
||||||
|
|
||||||
**Technical:** Python • SQL • Power BI • JavaScript/TypeScript • Real-world data analysis • Dashboard and tool development • Algorithm design • Data pipeline development
|
|
||||||
|
|
||||||
**Healthcare Domain:** Medicines optimisation • Population health analytics • NICE technology appraisal implementation • Health economics and outcomes • Clinical pathway development • Controlled drug assurance
|
|
||||||
|
|
||||||
**Strategic & Leadership:** Budget management (£220M) • Stakeholder engagement • Pharmaceutical negotiation • Team development and training • Change management • Financial scenario modelling • Executive communication
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Professional Experience
|
|
||||||
|
|
||||||
### Interim Head, Population Health & Data Analysis
|
|
||||||
|
|
||||||
**NHS Norfolk & Waveney ICB** | May–Nov 2025 | Norwich, England
|
|
||||||
|
|
||||||
Returned to substantive Deputy Head role following commencement of ICB-wide organisational consultation.
|
|
||||||
|
|
||||||
Led strategic delivery of population health initiatives and data-driven medicines optimisation across Norfolk & Waveney ICS, reporting to Associate Director of Pharmacy with presentation accountability to Chief Medical Officer and system-level programme boards.
|
|
||||||
|
|
||||||
- Identified and prioritised a £14.6M efficiency programme through comprehensive data analysis; achieved over-target performance by October 2025 through targeted, evidence-based interventions across the integrated care system.
|
|
||||||
- Built Python-based switching algorithm using real-world GP prescribing data to automatically identify patients on expensive drugs suitable for cost-effective alternatives—compressing months of manual analysis into 3 days, identifying 14,000 patients and £2.6M in annual savings, of which £2M is on target for delivery this financial year.
|
|
||||||
- Automated incentive scheme analysis, improving accuracy and targeting precision whilst enabling a novel GP payment system linking rewards to delivered savings; achieved 50% reduction in targeted prescribing within the first two months of deployment.
|
|
||||||
- Presented strategy, programme progress, and financial position to Chief Medical Officer on a bimonthly basis, providing evidence-based recommendations to inform executive decision-making.
|
|
||||||
- Led transformation from practice-level data to patient-level SQL analytics, enabling targeted interventions and a self-serve model for the wider team.
|
|
||||||
|
|
||||||
### Deputy Head, Population Health & Data Analysis
|
|
||||||
|
|
||||||
**NHS Norfolk & Waveney ICB** | Jul 2024–Present | Norwich, England
|
|
||||||
|
|
||||||
Driving data analytics strategy for medicines optimisation, developing bespoke datasets and analytical frameworks from messy, real-world GP prescribing data to identify efficiency opportunities and address health inequalities across the integrated care system.
|
|
||||||
|
|
||||||
- Managed £220M prescribing budget with sophisticated forecasting models identifying cost pressures and enabling proactive financial planning.
|
|
||||||
- Collaborated with the ICB data engineering team to create a comprehensive medicines data table integrating all dm+d products with standardised strength calculations, morphine equivalent conversions, and Anticholinergic Burden scoring—providing a single source of truth for all medicines analytics across the system.
|
|
||||||
- Led financial scenario modelling for a system-wide DOAC switching programme, building an interactive dashboard incorporating rebate mechanics, clinician switching capacity, workforce constraints, and patent expiry timelines to quantify risk trade-offs for senior decision-makers.
|
|
||||||
- Led renegotiation of pharmaceutical rebate terms ahead of patent expiry, securing improved commercial position for the ICB.
|
|
||||||
- Supported commissioning of tirzepatide (NICE TA1026) including financial projections identifying eligible cohorts from real-world data; authored the initial executive paper advocating a primary care delivery model over a specialist provider on cost-effectiveness and accessibility grounds, driving the system's shift to a GP-led model following executive sign-off.
|
|
||||||
- Developed Python-based controlled drug monitoring system calculating oral morphine equivalents across all opioid prescriptions to track patient-level exposure over time, identifying high-risk patients and potential diversion—enabling previously impossible patient safety analysis at population scale.
|
|
||||||
- Educated colleagues on data interpretation and analytics best practices, improving data fluency across the team through training, documentation, and self-serve tools.
|
|
||||||
|
|
||||||
### High-Cost Drugs & Interface Pharmacist
|
|
||||||
|
|
||||||
**NHS Norfolk & Waveney ICB** | May 2022–Jul 2024 | Norwich, England
|
|
||||||
|
|
||||||
Led implementation of NICE technology appraisals and high-cost drug pathways across the ICS. Wrote most of the system's high-cost drug pathways—spanning rheumatology, ophthalmology (wet AMD, DMO, RVO), dermatology, gastroenterology, neurology, and migraine—balancing legal requirements to implement TAs against financial costs and local clinical preferences. Engaged clinical leads across all sectors of care to agree pathways and secure system-wide adoption.
|
|
||||||
|
|
||||||
- Developed software automating Blueteq prior approval form creation: 70% reduction in required forms, 200 hours immediate savings, and ongoing 7–8 hours weekly efficiency gains.
|
|
||||||
- Integrated Blueteq data with secondary care activity databases, resolving critical data-matching limitations and enabling accurate high-cost drug to spend tracking.
|
|
||||||
- Created Python-based Sankey chart analysis tool visualising patient journeys through high-cost drug pathways, enabling trusts to audit compliance and identify improvement opportunities.
|
|
||||||
|
|
||||||
### Pharmacy Manager
|
|
||||||
|
|
||||||
**Tesco PLC** | Nov 2017–May 2022 | Great Yarmouth, Norfolk
|
|
||||||
|
|
||||||
Managed all pharmacy operations with full autonomy across a 100-hour contract, leading regional KPI delivery initiatives and contributing to national operational improvements. Served as Local Pharmaceutical Committee representative for Norfolk.
|
|
||||||
|
|
||||||
- Identified and shared an asthma screening process that was adopted nationally across the Tesco pharmacy estate (~300 branches), reducing pharmacist time from approximately 60 hours to 6 hours per store per month and enabling the network to claim approximately £1M in revenue.
|
|
||||||
- Led creation of national induction training plan and eLearning modules for all new pharmacy staff, with enhanced focus on leadership development for non-pharmacist team members.
|
|
||||||
- Supervised two staff members through NVQ3 qualifications to pharmacy technician registration: full HR responsibilities including recruitment, performance management, and grievances.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Education, Professional Development & Registration
|
|
||||||
|
|
||||||
**Master of Pharmacy (MPharm), 2:1 Honours** | University of East Anglia | 2011–2015
|
|
||||||
Research project on drug delivery and cocrystals: 75.1% (Distinction)
|
|
||||||
|
|
||||||
**NHS Leadership Academy – Mary Seacole Programme** | 2018 | 78%
|
|
||||||
NHS leadership qualification: change management, healthcare leadership, system-level thinking
|
|
||||||
|
|
||||||
**A-Levels:** Mathematics (A\*), Chemistry (B), Politics (C) | Highworth Grammar School | 2009–2011
|
|
||||||
|
|
||||||
**GPhC Registered Pharmacist** | General Pharmaceutical Council | August 2016–Present
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
*References available upon request.*
|
|
||||||
@@ -5,7 +5,7 @@ import reactRefresh from 'eslint-plugin-react-refresh'
|
|||||||
import tseslint from 'typescript-eslint'
|
import tseslint from 'typescript-eslint'
|
||||||
|
|
||||||
export default tseslint.config(
|
export default tseslint.config(
|
||||||
{ ignores: ['dist'] },
|
{ ignores: ['dist', 'dist-server', 'server.ts'] },
|
||||||
{
|
{
|
||||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||||
files: ['**/*.{ts,tsx}'],
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
|||||||
@@ -2,12 +2,24 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Andy Charlwood — MPharm | CV</title>
|
<meta name="description" content="Andy Charlwood — Deputy Head of Population Health & Data Analysis. Interactive CV and portfolio showcasing pharmacist expertise, data analytics, and population health management.">
|
||||||
|
<meta property="og:title" content="CVMIS: CHARLWOOD, A.">
|
||||||
|
<meta property="og:description" content="Interactive CV presented as a clinical management information system. Explore Andy Charlwood's career in pharmacy, population health, and data analytics.">
|
||||||
|
<meta property="og:image" content="https://andy.charlwood.xyz/meta.png">
|
||||||
|
<meta property="og:type" content="website">
|
||||||
|
<meta name="twitter:card" content="summary_large_image">
|
||||||
|
<meta property="og:url" content="https://andy.charlwood.xyz">
|
||||||
|
<meta name="twitter:image" content="https://andy.charlwood.xyz/meta.png">
|
||||||
|
<title>CVMIS: CHARLWOOD, A.</title>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;700&family=Plus+Jakarta+Sans:wght@400;500;600;700&family=Inter+Tight:wght@400;500;600&display=swap" rel="stylesheet">
|
<link rel="preconnect" href="https://analytics.charlwood.xyz" crossorigin>
|
||||||
|
<link rel="preload" as="style" href="https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;700&family=Plus+Jakarta+Sans:wght@400;500;600;700&family=Inter:wght@400;500;600&display=swap" onload="this.onload=null;this.rel='stylesheet'">
|
||||||
|
<noscript><link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;700&family=Plus+Jakarta+Sans:wght@400;500;600;700&family=Inter:wght@400;500;600&display=swap"></noscript>
|
||||||
|
<link rel="preload" href="/fonts/elvaro/TBJElvaro-Regular.woff2" as="font" type="font/woff2" crossorigin>
|
||||||
|
<script defer src="https://analytics.charlwood.xyz/script.js" data-website-id="075e79d5-433a-4192-91c0-0b5b9c4334ab"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -4,20 +4,37 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "concurrently \"vite\" \"npx tsx server.ts\"",
|
||||||
|
"dev:frontend": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"generate-embeddings": "npx tsx scripts/generate-embeddings.ts",
|
||||||
|
"benchmark": "npx tsx scripts/benchmark.ts",
|
||||||
|
"start": "node dist-server/server.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@types/d3": "^7.4.3",
|
||||||
|
"@xenova/transformers": "^2.17.2",
|
||||||
|
"concurrently": "^9.2.1",
|
||||||
|
"d3": "^7.9.0",
|
||||||
|
"dotenv": "^17.3.1",
|
||||||
|
"embla-carousel-autoplay": "^8.6.0",
|
||||||
|
"embla-carousel-react": "^8.6.0",
|
||||||
|
"express": "^4.21.0",
|
||||||
"framer-motion": "^11.15.0",
|
"framer-motion": "^11.15.0",
|
||||||
|
"fuse.js": "^7.1.0",
|
||||||
"lucide-react": "^0.468.0",
|
"lucide-react": "^0.468.0",
|
||||||
|
"nodemailer": "^6.9.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1"
|
"react-dom": "^18.3.1",
|
||||||
|
"react-markdown": "^10.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.17.0",
|
"@eslint/js": "^9.17.0",
|
||||||
|
"@types/express": "^5.0.0",
|
||||||
|
"@types/nodemailer": "^6.4.0",
|
||||||
"@types/react": "^18.3.18",
|
"@types/react": "^18.3.18",
|
||||||
"@types/react-dom": "^18.3.5",
|
"@types/react-dom": "^18.3.5",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
/assets/*
|
||||||
|
Cache-Control: public, max-age=31536000, immutable
|
||||||
|
|
||||||
|
/Fonts/*
|
||||||
|
Cache-Control: public, max-age=31536000, immutable
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 600 300">
|
||||||
|
<defs>
|
||||||
|
<clipPath id="cp">
|
||||||
|
<rect x="250" y="50" width="100" height="225" rx="50"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!-- Teal pill — fanned left: translate(-10,0) translate(300,275) rotate(-55) translate(-300,-275) -->
|
||||||
|
<g transform="translate(-10,0) translate(300,275) rotate(-55) translate(-300,-275)">
|
||||||
|
<g transform="translate(250,50)">
|
||||||
|
<rect width="100" height="225" rx="50" fill="#0E7A7D"/>
|
||||||
|
<g transform="translate(21,50) scale(0.6)">
|
||||||
|
<path d="M25 70 V0 H55 C80 0 80 35 55 35 H25 M55 35 L85 70 M53 67 L87 38" stroke="white" stroke-width="10" stroke-linecap="butt" stroke-linejoin="miter" fill="none"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Green pill — fanned right: translate(10,0) translate(300,275) rotate(55) translate(-300,-275) -->
|
||||||
|
<g transform="translate(10,0) translate(300,275) rotate(55) translate(-300,-275)">
|
||||||
|
<g transform="translate(250,50)">
|
||||||
|
<rect width="100" height="225" rx="50" fill="#109E6C"/>
|
||||||
|
<g transform="translate(22.5,50) scale(0.5)">
|
||||||
|
<rect x="0" y="60" width="20" height="40" fill="white"/>
|
||||||
|
<rect x="30" y="40" width="20" height="60" fill="white"/>
|
||||||
|
<rect x="60" y="20" width="20" height="80" fill="white"/>
|
||||||
|
<rect x="90" y="0" width="20" height="100" fill="white"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Amber pill — center (no fan) -->
|
||||||
|
<g transform="translate(250,50)">
|
||||||
|
<rect width="100" height="225" rx="50" fill="#E38B16"/>
|
||||||
|
<g transform="translate(25,50) scale(0.6)">
|
||||||
|
<path d="M10 0 L50 30 L10 60" stroke="white" stroke-width="10" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
||||||
|
<line x1="55" y1="65" x2="85" y2="65" stroke="white" stroke-width="10" stroke-linecap="round"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Blend overlays clipped to center pill -->
|
||||||
|
<g clip-path="url(#cp)">
|
||||||
|
<g transform="translate(-10,0) translate(300,275) rotate(-55) translate(-300,-275)" style="mix-blend-mode:multiply" opacity="0.3">
|
||||||
|
<g transform="translate(250,50)">
|
||||||
|
<rect width="100" height="225" rx="50" fill="#0E7A7D"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<g clip-path="url(#cp)">
|
||||||
|
<g transform="translate(10,0) translate(300,275) rotate(55) translate(-300,-275)" style="mix-blend-mode:multiply" opacity="0.3">
|
||||||
|
<g transform="translate(250,50)">
|
||||||
|
<rect width="100" height="225" rx="50" fill="#109E6C"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 66 KiB |
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"_name_or_path": "sentence-transformers/all-MiniLM-L6-v2",
|
||||||
|
"architectures": [
|
||||||
|
"BertModel"
|
||||||
|
],
|
||||||
|
"attention_probs_dropout_prob": 0.1,
|
||||||
|
"classifier_dropout": null,
|
||||||
|
"gradient_checkpointing": false,
|
||||||
|
"hidden_act": "gelu",
|
||||||
|
"hidden_dropout_prob": 0.1,
|
||||||
|
"hidden_size": 384,
|
||||||
|
"initializer_range": 0.02,
|
||||||
|
"intermediate_size": 1536,
|
||||||
|
"layer_norm_eps": 1e-12,
|
||||||
|
"max_position_embeddings": 512,
|
||||||
|
"model_type": "bert",
|
||||||
|
"num_attention_heads": 12,
|
||||||
|
"num_hidden_layers": 6,
|
||||||
|
"pad_token_id": 0,
|
||||||
|
"position_embedding_type": "absolute",
|
||||||
|
"transformers_version": "4.29.2",
|
||||||
|
"type_vocab_size": 2,
|
||||||
|
"use_cache": true,
|
||||||
|
"vocab_size": 30522
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"clean_up_tokenization_spaces": true,
|
||||||
|
"cls_token": "[CLS]",
|
||||||
|
"do_basic_tokenize": true,
|
||||||
|
"do_lower_case": true,
|
||||||
|
"mask_token": "[MASK]",
|
||||||
|
"model_max_length": 512,
|
||||||
|
"never_split": null,
|
||||||
|
"pad_token": "[PAD]",
|
||||||
|
"sep_token": "[SEP]",
|
||||||
|
"strip_accents": null,
|
||||||
|
"tokenize_chinese_chars": true,
|
||||||
|
"tokenizer_class": "BertTokenizer",
|
||||||
|
"unk_token": "[UNK]"
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 119 KiB |
|
After Width: | Height: | Size: 256 KiB |
|
After Width: | Height: | Size: 147 KiB |
|
After Width: | Height: | Size: 192 KiB |
@@ -0,0 +1,120 @@
|
|||||||
|
{
|
||||||
|
"passThreshold": 18,
|
||||||
|
"maxScore": 20,
|
||||||
|
"questions": [
|
||||||
|
{
|
||||||
|
"id": "Q01",
|
||||||
|
"question": "How many years has Andy been employed by the NHS?",
|
||||||
|
"expectedAnswer": "Approximately 3-4 years. Andy's NHS employment started in May 2022 when he joined NHS Norfolk and Waveney ICB. His previous role at Tesco PLC was in the private sector, not the NHS.",
|
||||||
|
"keyFacts": [
|
||||||
|
"NHS employment started May 2022",
|
||||||
|
"Tesco was private employer",
|
||||||
|
"approximately 3-4 years NHS employment"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "Q02",
|
||||||
|
"question": "What was Andy's involvement with tirzepatide?",
|
||||||
|
"expectedAnswer": "Andy supported commissioning of NICE TA1026 (tirzepatide). He authored the initial executive paper advocating a primary care delivery model over specialist provider, which drove a system shift to GP-led model.",
|
||||||
|
"keyFacts": [
|
||||||
|
"NICE TA1026",
|
||||||
|
"authored executive paper",
|
||||||
|
"primary care model",
|
||||||
|
"GP-led delivery"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "Q03",
|
||||||
|
"question": "What specific tools and software has Andy built?",
|
||||||
|
"expectedAnswer": "Andy has built 5 notable projects: a patient switching algorithm (Python, 14000 patients, £2.6M savings), a Blueteq generator for high-cost drug forms, a controlled drugs monitoring system, a Sankey chart tool for visualising patient flows, and PharMetrics — a Power BI analytics dashboard.",
|
||||||
|
"keyFacts": [
|
||||||
|
"patient switching algorithm",
|
||||||
|
"Blueteq generator",
|
||||||
|
"CD monitoring system",
|
||||||
|
"Sankey chart tool",
|
||||||
|
"PharMetrics dashboard"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "Q04",
|
||||||
|
"question": "What were Andy's A-level subjects and grades?",
|
||||||
|
"expectedAnswer": "Andy achieved Mathematics A*, Chemistry B, and Politics C at Highworth Grammar School between 2009-2011.",
|
||||||
|
"keyFacts": [
|
||||||
|
"Mathematics A*",
|
||||||
|
"Chemistry B",
|
||||||
|
"Politics C",
|
||||||
|
"Highworth Grammar School"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "Q05",
|
||||||
|
"question": "Was Andy's Tesco role part of the NHS?",
|
||||||
|
"expectedAnswer": "No. Andy's role at Tesco PLC was in the private sector as a community pharmacist. Tesco PLC is a private employer. He was an LPC representative during this time.",
|
||||||
|
"keyFacts": [
|
||||||
|
"Tesco PLC is private/not NHS",
|
||||||
|
"community pharmacy",
|
||||||
|
"LPC representative"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "Q06",
|
||||||
|
"question": "How did the patient switching algorithm work?",
|
||||||
|
"expectedAnswer": "It was Python-based and used real-world GP prescribing data to auto-identify patients eligible for cost-effective medication alternatives. It compressed months of manual work into 3 days, covered 14,000 patients, and identified £2.6M in savings.",
|
||||||
|
"keyFacts": [
|
||||||
|
"Python",
|
||||||
|
"GP prescribing data",
|
||||||
|
"14000 patients",
|
||||||
|
"£2.6M savings",
|
||||||
|
"compressed months to 3 days"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "Q07",
|
||||||
|
"question": "What clinical specialties has Andy worked across?",
|
||||||
|
"expectedAnswer": "Andy has worked across rheumatology, ophthalmology (wet AMD, DMO, RVO), dermatology, gastroenterology, neurology, and migraine through his high-cost drugs role.",
|
||||||
|
"keyFacts": [
|
||||||
|
"rheumatology",
|
||||||
|
"ophthalmology",
|
||||||
|
"dermatology",
|
||||||
|
"gastroenterology",
|
||||||
|
"neurology",
|
||||||
|
"migraine"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "Q08",
|
||||||
|
"question": "What is Andy's experience with the dm+d?",
|
||||||
|
"expectedAnswer": "Andy created a comprehensive medicines data table integrating all dm+d products with standardised strengths, morphine equivalents, and Anticholinergic Burden scoring, serving as a single source of truth.",
|
||||||
|
"keyFacts": [
|
||||||
|
"dm+d integration",
|
||||||
|
"standardised strengths",
|
||||||
|
"morphine equivalents",
|
||||||
|
"Anticholinergic Burden",
|
||||||
|
"single source of truth"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "Q09",
|
||||||
|
"question": "What budget does Andy manage and how?",
|
||||||
|
"expectedAnswer": "Andy manages a £220M prescribing budget using forecasting models, variance analysis, and financial reporting to the executive team, enabling proactive financial planning.",
|
||||||
|
"keyFacts": [
|
||||||
|
"£220M",
|
||||||
|
"forecasting models",
|
||||||
|
"variance analysis",
|
||||||
|
"proactive financial planning"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "Q10",
|
||||||
|
"question": "What leadership training does Andy have?",
|
||||||
|
"expectedAnswer": "Andy completed the NHS Mary Seacole Programme in 2018 (scoring 78%). At Tesco, he created a national induction training plan and eLearning modules, and supervised two staff through NVQ3 to pharmacy technician registration.",
|
||||||
|
"keyFacts": [
|
||||||
|
"Mary Seacole Programme",
|
||||||
|
"2018",
|
||||||
|
"78%",
|
||||||
|
"created national induction training at Tesco",
|
||||||
|
"supervised staff through NVQ3"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,454 @@
|
|||||||
|
import { readFileSync, writeFileSync, readdirSync, mkdirSync, existsSync } from 'node:fs'
|
||||||
|
import { resolve } from 'node:path'
|
||||||
|
// Load .env file manually (avoid adding dotenv dependency)
|
||||||
|
function loadEnvFile(): void {
|
||||||
|
const envPath = resolve(import.meta.dirname, '..', '.env')
|
||||||
|
if (!existsSync(envPath)) return
|
||||||
|
const content = readFileSync(envPath, 'utf-8')
|
||||||
|
for (const line of content.split('\n')) {
|
||||||
|
const trimmed = line.trim()
|
||||||
|
if (!trimmed || trimmed.startsWith('#')) continue
|
||||||
|
const eqIndex = trimmed.indexOf('=')
|
||||||
|
if (eqIndex === -1) continue
|
||||||
|
const key = trimmed.slice(0, eqIndex)
|
||||||
|
const value = trimmed.slice(eqIndex + 1)
|
||||||
|
if (!process.env[key]) {
|
||||||
|
process.env[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loadEnvFile()
|
||||||
|
|
||||||
|
// --- Types ---
|
||||||
|
|
||||||
|
interface BenchmarkQuestion {
|
||||||
|
id: string
|
||||||
|
question: string
|
||||||
|
expectedAnswer: string
|
||||||
|
keyFacts: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BenchmarkConfig {
|
||||||
|
passThreshold: number
|
||||||
|
maxScore: number
|
||||||
|
questions: BenchmarkQuestion[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ScoringResult {
|
||||||
|
score: 0 | 1 | 2
|
||||||
|
justification: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface QuestionResult {
|
||||||
|
id: string
|
||||||
|
question: string
|
||||||
|
expectedAnswer: string
|
||||||
|
actualAnswer: string
|
||||||
|
score: number
|
||||||
|
justification: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BenchmarkResults {
|
||||||
|
iteration: number
|
||||||
|
timestamp: string
|
||||||
|
model: string
|
||||||
|
totalScore: number
|
||||||
|
maxPossibleScore: number
|
||||||
|
passThreshold: number
|
||||||
|
passed: boolean
|
||||||
|
hasZeros: boolean
|
||||||
|
results: QuestionResult[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- OpenRouter API ---
|
||||||
|
|
||||||
|
const LLM_MODEL = 'z-ai/glm-5'
|
||||||
|
const OPENROUTER_API_URL = 'https://openrouter.ai/api/v1/chat/completions'
|
||||||
|
|
||||||
|
function getApiKey(): string {
|
||||||
|
const key = process.env.VITE_OPEN_ROUTER_API_KEY
|
||||||
|
if (!key) {
|
||||||
|
throw new Error('VITE_OPEN_ROUTER_API_KEY not set. Ensure .env file exists with this key.')
|
||||||
|
}
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mirrors buildSystemPrompt() from src/lib/llm.ts — kept in sync manually
|
||||||
|
// because llm.ts uses import.meta.env (Vite) and window.location (browser)
|
||||||
|
function buildSystemPrompt(): string {
|
||||||
|
return `You are a helpful assistant on Andy Charlwood's portfolio website. Answer questions about Andy's professional background using ONLY the information below.
|
||||||
|
|
||||||
|
## Profile
|
||||||
|
Andy Charlwood — MPharm, GPhC Registered Pharmacist. Norwich, UK.
|
||||||
|
Healthcare leader combining clinical pharmacy with Python, SQL, and data analytics (self-taught). Leading population health analytics for NHS Norfolk & Waveney ICB, serving 1.2M people. Specialises in prescribing data at scale — financial modelling, algorithm design, pathway development. Identified efficiency programmes worth £14.6M+ through automated analysis.
|
||||||
|
|
||||||
|
## Employment Timeline (IMPORTANT)
|
||||||
|
- **NHS employment**: May 2022–present (all roles at NHS Norfolk & Waveney ICB). Total NHS service: ~4 years.
|
||||||
|
- **Private sector**: Nov 2017–May 2022 at Tesco PLC (community pharmacy). This was NOT NHS employment.
|
||||||
|
- GPhC registration (Aug 2016) is a professional licence, NOT an employer or NHS role.
|
||||||
|
|
||||||
|
## Career History
|
||||||
|
|
||||||
|
### [exp-interim-head-2025] Interim Head, Population Health & Data Analysis
|
||||||
|
NHS Norfolk & Waveney ICB | May–Nov 2025
|
||||||
|
Led population health initiatives and data-driven medicines optimisation, reporting to Associate Director of Pharmacy with accountability to CMO.
|
||||||
|
- Identified £14.6M efficiency programme; achieved over-target performance by October 2025
|
||||||
|
- Built Python switching algorithm: real-world GP prescribing data, 14,000 patients, £2.6M annual savings (£2M on target), compressed months into 3 days
|
||||||
|
- Novel GP payment system linking rewards to savings; 50% prescribing reduction within 2 months
|
||||||
|
- Presented to CMO bimonthly; led transformation to patient-level SQL analytics
|
||||||
|
|
||||||
|
### [exp-deputy-head-2024] Deputy Head, Population Health & Data Analysis
|
||||||
|
NHS Norfolk & Waveney ICB | Jul 2024–Present (substantive role)
|
||||||
|
Data analytics strategy for medicines optimisation from real-world GP prescribing data.
|
||||||
|
- Managed £220M prescribing budget with forecasting models for proactive financial planning
|
||||||
|
- Created comprehensive dm+d medicines data table: standardised strengths, morphine equivalents, Anticholinergic Burden scoring — single source of truth for all medicines analytics
|
||||||
|
- Led DOAC switching financial modelling: interactive dashboard with rebate mechanics, patent expiry timelines
|
||||||
|
- Renegotiated pharmaceutical rebate terms ahead of patent expiry
|
||||||
|
- Tirzepatide commissioning (NICE TA1026): financial projections, cohort identification; authored executive paper advocating primary care model, driving system shift to GP-led delivery
|
||||||
|
- Built Python controlled drug monitoring: oral morphine equivalents across all opioid prescriptions, patient-level tracking, high-risk identification, diversion detection
|
||||||
|
- Improved team data fluency through training and self-serve tools
|
||||||
|
|
||||||
|
### [exp-high-cost-drugs-2022] High-Cost Drugs & Interface Pharmacist
|
||||||
|
NHS Norfolk & Waveney ICB | May 2022–Jul 2024
|
||||||
|
Led NICE TA implementation and high-cost drug pathways across the ICS. Pathways spanning: rheumatology, ophthalmology (wet AMD, DMO, RVO), dermatology, gastroenterology, neurology, migraine.
|
||||||
|
- Blueteq automation: 70% form reduction, 200 hours immediate savings, 7–8 hours ongoing weekly gains
|
||||||
|
- Integrated Blueteq with secondary care databases for accurate high-cost drug spend tracking
|
||||||
|
- Python Sankey chart tool for patient pathway visualisation and trust compliance auditing
|
||||||
|
|
||||||
|
### [exp-pharmacy-manager-2017] Pharmacy Manager
|
||||||
|
Tesco PLC (private sector, NOT NHS) | Nov 2017–May 2022
|
||||||
|
Community pharmacy with full operational autonomy (100-hour contract). LPC representative for Norfolk.
|
||||||
|
- Asthma screening process adopted nationally (~300 branches): reduced pharmacist time 60→6 hours/store/month, ~£1M revenue
|
||||||
|
- Leadership training: Created national induction training plan and eLearning modules for Tesco pharmacy staff
|
||||||
|
- Leadership development: Supervised two staff through NVQ3 to pharmacy technician registration; full HR responsibilities
|
||||||
|
|
||||||
|
## Projects
|
||||||
|
|
||||||
|
### [proj-inv-pharmetrics] PharMetrics Interactive Platform (2024, Live)
|
||||||
|
Real-time medicines expenditure dashboard for NHS decision-makers. Tech: Power BI, SQL, DAX. Tracks £220M prescribing budget.
|
||||||
|
|
||||||
|
### [proj-inv-switching-algorithm] Patient Switching Algorithm (2025, Complete)
|
||||||
|
Python algorithm using GP prescribing data to auto-identify patients for cost-effective alternatives. Tech: Python, Pandas, SQL. 14,000 patients, £2.6M annual savings, novel GP payment system.
|
||||||
|
|
||||||
|
### [proj-inv-blueteq-gen] Blueteq Generator (2023, Complete)
|
||||||
|
Automated Blueteq prior approval form creation. Tech: Python, SQL. 70% form reduction, 200 hours immediate savings, 7–8 hours ongoing weekly gains.
|
||||||
|
|
||||||
|
### [proj-inv-cd-monitoring] CD Monitoring System (2024, Complete)
|
||||||
|
Controlled drug monitoring calculating oral morphine equivalents (OME) across all opioid prescriptions. Tech: Python, SQL. Patient-level tracking, high-risk identification, diversion detection.
|
||||||
|
|
||||||
|
### [proj-inv-sankey-tool] Sankey Chart Analysis Tool (2023, Complete)
|
||||||
|
Patient journey visualisation through high-cost drug pathways. Tech: Python, Matplotlib, SQL. Trust compliance auditing.
|
||||||
|
|
||||||
|
## Education
|
||||||
|
|
||||||
|
### [edu-0] NHS Mary Seacole Programme (2018)
|
||||||
|
NHS Leadership Academy. Score: 78%. Covers change management, healthcare leadership, system-level thinking.
|
||||||
|
|
||||||
|
### [edu-1] MPharm (Hons) 2:1 — University of East Anglia (2011–2015)
|
||||||
|
4-year integrated Master's degree. Research project on drug delivery and cocrystals: 75.1% (Distinction).
|
||||||
|
|
||||||
|
### [edu-2] A-Levels — Highworth Grammar School (2009–2011)
|
||||||
|
Mathematics A*, Chemistry B, Politics C.
|
||||||
|
|
||||||
|
### [edu-3] GPhC Registration — General Pharmaceutical Council (August 2016–Present)
|
||||||
|
Professional registration required to practise as a pharmacist in Great Britain.
|
||||||
|
|
||||||
|
## Skills
|
||||||
|
Technical: [skill-data-analysis] Data Analysis (9yr, 95%), [skill-python] Python (6yr, 90%), [skill-sql] SQL (7yr, 88%), [skill-power-bi] Power BI (5yr, 92%), [skill-javascript-typescript] JavaScript/TypeScript (3yr, 70%), [skill-excel] Excel (9yr, 85%), [skill-algorithm-design] Algorithm Design (3yr, 82%), [skill-data-pipelines] Data Pipelines (2yr, 75%)
|
||||||
|
Domain: [skill-medicines-optimisation] Medicines Optimisation (9yr, 95%), [skill-population-health] Population Health (3yr, 90%), [skill-nice-ta] NICE TA Implementation (3yr, 92%), [skill-health-economics] Health Economics (3yr, 80%), [skill-clinical-pathways] Clinical Pathways (3yr, 88%), [skill-controlled-drugs] Controlled Drugs (1yr, 85%)
|
||||||
|
Leadership: [skill-budget-management] Budget Management (1yr, 90%), [skill-stakeholder-engagement] Stakeholder Engagement (3yr, 88%), [skill-pharma-negotiation] Pharmaceutical Negotiation (1yr, 82%), [skill-team-development] Team Development (8yr, 85%), [skill-change-management] Change Management (7yr, 80%), [skill-financial-modelling] Financial Modelling (1yr, 78%), [skill-executive-comms] Executive Communication (1yr, 85%)
|
||||||
|
|
||||||
|
## Response Rules
|
||||||
|
1. Answer ONLY from the data above. If the answer is not in the data, say "I don't have that information" — never invent facts, roles, dates, achievements, URLs, or contact details.
|
||||||
|
2. Distinguish NHS employment (May 2022–present, ~4 years, all at Norfolk & Waveney ICB) from private sector (Tesco PLC, Nov 2017–May 2022, community pharmacy). Never conflate the two. GPhC registration is a professional licence, not NHS employment.
|
||||||
|
3. When asked broad questions about tools, skills, projects, or achievements across Andy's career, aggregate from ALL roles — do not limit your answer to one position.
|
||||||
|
4. Cite exact numbers, dates, percentages, and outcomes. Never say "approximately" or "around" when exact figures exist in the data.
|
||||||
|
5. For detailed or list-based questions, give a thorough answer covering all relevant items. For simple questions, be concise (2-4 sentences).
|
||||||
|
|
||||||
|
## Item References
|
||||||
|
End your response with a single line listing relevant item IDs from the square-bracketed IDs above:
|
||||||
|
[ITEMS: exp-deputy-head-2024, skill-python]
|
||||||
|
Only include IDs that directly support your answer. Omit the line if none are relevant.`
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function callLLM(
|
||||||
|
systemPrompt: string,
|
||||||
|
userMessage: string,
|
||||||
|
temperature = 0.4,
|
||||||
|
maxTokens = 800,
|
||||||
|
): Promise<string> {
|
||||||
|
const apiKey = getApiKey()
|
||||||
|
const maxRetries = 5
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||||||
|
const response = await fetch(OPENROUTER_API_URL, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${apiKey}`,
|
||||||
|
'HTTP-Referer': 'https://andycharlwood.co.uk',
|
||||||
|
'X-Title': 'Andy Charlwood Portfolio',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: LLM_MODEL,
|
||||||
|
temperature,
|
||||||
|
max_tokens: maxTokens,
|
||||||
|
messages: [
|
||||||
|
{ role: 'system', content: systemPrompt },
|
||||||
|
{ role: 'user', content: userMessage },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.status === 429 || response.status === 503) {
|
||||||
|
const errorBody = await response.text()
|
||||||
|
const retryMatch = errorBody.match(/retry in ([\d.]+)s/)
|
||||||
|
const waitSeconds = retryMatch ? Math.ceil(parseFloat(retryMatch[1])) + 2 : (attempt + 1) * 15
|
||||||
|
const reason = response.status === 429 ? 'Rate limited' : 'Service unavailable'
|
||||||
|
console.log(` ${reason}. Waiting ${waitSeconds}s (attempt ${attempt + 1}/${maxRetries})...`)
|
||||||
|
await sleep(waitSeconds * 1000)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorBody = await response.text()
|
||||||
|
throw new Error(`OpenRouter API error ${response.status}: ${errorBody}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
const text = data?.choices?.[0]?.message?.content
|
||||||
|
if (!text) {
|
||||||
|
throw new Error(`No text in OpenRouter response: ${JSON.stringify(data)}`)
|
||||||
|
}
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Max retries exceeded for rate limiting')
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Scoring ---
|
||||||
|
|
||||||
|
function extractJson(text: string): string | null {
|
||||||
|
// Try parsing directly first
|
||||||
|
try {
|
||||||
|
JSON.parse(text)
|
||||||
|
return text
|
||||||
|
} catch { /* not direct JSON, continue extraction */ }
|
||||||
|
|
||||||
|
// Strip markdown code fences
|
||||||
|
const fenceMatch = text.match(/```(?:json)?\s*([\s\S]*?)```/)
|
||||||
|
if (fenceMatch) {
|
||||||
|
return fenceMatch[1].trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find first { ... } block
|
||||||
|
const braceStart = text.indexOf('{')
|
||||||
|
if (braceStart === -1) return null
|
||||||
|
|
||||||
|
// Find matching closing brace
|
||||||
|
let depth = 0
|
||||||
|
let inString = false
|
||||||
|
let escaped = false
|
||||||
|
for (let i = braceStart; i < text.length; i++) {
|
||||||
|
const ch = text[i]
|
||||||
|
if (escaped) { escaped = false; continue }
|
||||||
|
if (ch === '\\') { escaped = true; continue }
|
||||||
|
if (ch === '"') { inString = !inString; continue }
|
||||||
|
if (inString) continue
|
||||||
|
if (ch === '{') depth++
|
||||||
|
if (ch === '}') { depth--; if (depth === 0) return text.slice(braceStart, i + 1) }
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function scoreAnswer(
|
||||||
|
question: string,
|
||||||
|
expectedAnswer: string,
|
||||||
|
keyFacts: string[],
|
||||||
|
actualAnswer: string,
|
||||||
|
): Promise<ScoringResult> {
|
||||||
|
const scoringPrompt = `You are a strict evaluator. Compare an ACTUAL answer to an EXPECTED answer about a person's CV.
|
||||||
|
|
||||||
|
Rubric:
|
||||||
|
- 2 = ACCURATE: Covers key facts correctly. Minor omissions OK if no errors.
|
||||||
|
- 1 = PARTIAL: Some key facts right but misses important details or is vague.
|
||||||
|
- 0 = INCORRECT: Contains factual errors, contradicts expected answer, or misses the point.
|
||||||
|
|
||||||
|
Key facts for score 2:
|
||||||
|
${keyFacts.map((f) => `- ${f}`).join('\n')}
|
||||||
|
|
||||||
|
IMPORTANT: Respond with ONLY a single-line JSON object. No markdown, no code fences, no extra text.
|
||||||
|
Example: {"score":2,"justification":"Covers all key facts accurately"}
|
||||||
|
Keep justification under 30 words.`
|
||||||
|
|
||||||
|
const userMessage = `QUESTION: ${question}
|
||||||
|
|
||||||
|
EXPECTED ANSWER: ${expectedAnswer}
|
||||||
|
|
||||||
|
ACTUAL ANSWER: ${actualAnswer}`
|
||||||
|
|
||||||
|
const rawResponse = await callLLM(scoringPrompt, userMessage, 0, 512)
|
||||||
|
|
||||||
|
// Extract JSON — handle code fences, preamble text, multiline responses
|
||||||
|
const extracted = extractJson(rawResponse)
|
||||||
|
if (!extracted) {
|
||||||
|
console.warn(` Warning: Could not extract JSON from scoring response: ${rawResponse.slice(0, 200)}`)
|
||||||
|
return { score: 0, justification: `Failed to parse scoring response` }
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(extracted) as ScoringResult
|
||||||
|
if (![0, 1, 2].includes(parsed.score)) {
|
||||||
|
console.warn(` Warning: Invalid score value: ${parsed.score}`)
|
||||||
|
return { score: 0, justification: `Invalid score value: ${parsed.score}` }
|
||||||
|
}
|
||||||
|
return parsed
|
||||||
|
} catch {
|
||||||
|
console.warn(` Warning: Invalid JSON: ${extracted.slice(0, 150)}`)
|
||||||
|
return { score: 0, justification: `Invalid JSON in response` }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Iteration Management ---
|
||||||
|
|
||||||
|
function getNextIteration(resultsDir: string): number {
|
||||||
|
if (!existsSync(resultsDir)) return 0
|
||||||
|
|
||||||
|
const files = readdirSync(resultsDir).filter((f) => f.startsWith('iteration-') && f.endsWith('.json'))
|
||||||
|
if (files.length === 0) return 0
|
||||||
|
|
||||||
|
const iterations = files.map((f) => {
|
||||||
|
const match = f.match(/iteration-(\d+)\.json/)
|
||||||
|
return match ? parseInt(match[1], 10) : -1
|
||||||
|
})
|
||||||
|
return Math.max(...iterations) + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Console Output ---
|
||||||
|
|
||||||
|
function printSummary(results: BenchmarkResults): void {
|
||||||
|
console.log('\n' + '='.repeat(80))
|
||||||
|
console.log(`BENCHMARK RESULTS — Iteration ${results.iteration}`)
|
||||||
|
console.log(`Model: ${results.model} | ${results.timestamp}`)
|
||||||
|
console.log('='.repeat(80))
|
||||||
|
|
||||||
|
// Table header
|
||||||
|
console.log(
|
||||||
|
'ID'.padEnd(6) +
|
||||||
|
'Score'.padEnd(8) +
|
||||||
|
'Question'.padEnd(50) +
|
||||||
|
'Justification'
|
||||||
|
)
|
||||||
|
console.log('-'.repeat(80))
|
||||||
|
|
||||||
|
for (const r of results.results) {
|
||||||
|
const scoreLabel = r.score === 2 ? '2 ✓' : r.score === 1 ? '1 ~' : '0 ✗'
|
||||||
|
const questionTruncated = r.question.length > 47 ? r.question.slice(0, 44) + '...' : r.question
|
||||||
|
const justTruncated = r.justification.length > 60 ? r.justification.slice(0, 57) + '...' : r.justification
|
||||||
|
console.log(
|
||||||
|
r.id.padEnd(6) +
|
||||||
|
scoreLabel.padEnd(8) +
|
||||||
|
questionTruncated.padEnd(50) +
|
||||||
|
justTruncated
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('-'.repeat(80))
|
||||||
|
console.log(
|
||||||
|
`TOTAL: ${results.totalScore}/${results.maxPossibleScore}` +
|
||||||
|
` | Threshold: ${results.passThreshold}/${results.maxPossibleScore}` +
|
||||||
|
` | Has zeros: ${results.hasZeros ? 'YES' : 'No'}` +
|
||||||
|
` | ${results.passed ? 'PASSED ✓' : 'FAILED ✗'}`
|
||||||
|
)
|
||||||
|
console.log('='.repeat(80))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Main ---
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const scriptDir = import.meta.dirname
|
||||||
|
const configPath = resolve(scriptDir, 'benchmark-config.json')
|
||||||
|
const resultsDir = resolve(scriptDir, 'benchmark-results')
|
||||||
|
|
||||||
|
// Load config
|
||||||
|
const config: BenchmarkConfig = JSON.parse(readFileSync(configPath, 'utf-8'))
|
||||||
|
console.log(`Loaded ${config.questions.length} benchmark questions.`)
|
||||||
|
|
||||||
|
// Determine iteration number
|
||||||
|
const iteration = getNextIteration(resultsDir)
|
||||||
|
console.log(`Running iteration ${iteration}...`)
|
||||||
|
|
||||||
|
// Build system prompt (same as production llm.ts)
|
||||||
|
const systemPrompt = buildSystemPrompt()
|
||||||
|
console.log(`System prompt built (${systemPrompt.length} chars).`)
|
||||||
|
|
||||||
|
// Run each question
|
||||||
|
const questionResults: QuestionResult[] = []
|
||||||
|
|
||||||
|
for (const q of config.questions) {
|
||||||
|
console.log(`\n[${q.id}] ${q.question}`)
|
||||||
|
|
||||||
|
// Get answer from LLM
|
||||||
|
console.log(' Getting answer...')
|
||||||
|
const actualAnswer = await callLLM(systemPrompt, q.question)
|
||||||
|
console.log(` Answer: ${actualAnswer.slice(0, 100)}...`)
|
||||||
|
|
||||||
|
// Score the answer
|
||||||
|
console.log(' Scoring...')
|
||||||
|
const { score, justification } = await scoreAnswer(
|
||||||
|
q.question,
|
||||||
|
q.expectedAnswer,
|
||||||
|
q.keyFacts,
|
||||||
|
actualAnswer,
|
||||||
|
)
|
||||||
|
console.log(` Score: ${score}/2 — ${justification}`)
|
||||||
|
|
||||||
|
questionResults.push({
|
||||||
|
id: q.id,
|
||||||
|
question: q.question,
|
||||||
|
expectedAnswer: q.expectedAnswer,
|
||||||
|
actualAnswer,
|
||||||
|
score,
|
||||||
|
justification,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate totals
|
||||||
|
const totalScore = questionResults.reduce((sum, r) => sum + r.score, 0)
|
||||||
|
const hasZeros = questionResults.some((r) => r.score === 0)
|
||||||
|
const passed = totalScore >= config.passThreshold && !hasZeros
|
||||||
|
|
||||||
|
const results: BenchmarkResults = {
|
||||||
|
iteration,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
model: LLM_MODEL,
|
||||||
|
totalScore,
|
||||||
|
maxPossibleScore: config.maxScore,
|
||||||
|
passThreshold: config.passThreshold,
|
||||||
|
passed,
|
||||||
|
hasZeros,
|
||||||
|
results: questionResults,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save results
|
||||||
|
mkdirSync(resultsDir, { recursive: true })
|
||||||
|
const resultsPath = resolve(resultsDir, `iteration-${iteration}.json`)
|
||||||
|
writeFileSync(resultsPath, JSON.stringify(results, null, 2))
|
||||||
|
console.log(`\nResults saved to ${resultsPath}`)
|
||||||
|
|
||||||
|
// Print summary table
|
||||||
|
printSummary(results)
|
||||||
|
|
||||||
|
// Exit with appropriate code
|
||||||
|
process.exit(passed ? 0 : 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error('Benchmark failed:', err)
|
||||||
|
process.exit(2)
|
||||||
|
})
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { writeFileSync } from 'node:fs'
|
||||||
|
import { resolve } from 'node:path'
|
||||||
|
import { env, pipeline } from '@xenova/transformers'
|
||||||
|
import { buildEmbeddingTexts } from '@/lib/search'
|
||||||
|
|
||||||
|
// Use local model files from public/models/ (same files the browser uses)
|
||||||
|
env.localModelPath = resolve(import.meta.dirname, '..', 'public', 'models')
|
||||||
|
env.allowRemoteModels = false
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const items = buildEmbeddingTexts()
|
||||||
|
console.log(`Found ${items.length} items to embed.`)
|
||||||
|
|
||||||
|
console.log('Loading all-MiniLM-L6-v2 model...')
|
||||||
|
const extractor = await pipeline('feature-extraction', 'Xenova/all-MiniLM-L6-v2')
|
||||||
|
|
||||||
|
const embeddings: Array<{ id: string; embedding: number[] }> = []
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
const output = await extractor(item.text, { pooling: 'mean', normalize: true })
|
||||||
|
const vector = Array.from(output.data as Float32Array)
|
||||||
|
embeddings.push({ id: item.id, embedding: vector })
|
||||||
|
console.log(` [${embeddings.length}/${items.length}] ${item.id} (${vector.length}d)`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const outPath = resolve(import.meta.dirname, '..', 'src', 'data', 'embeddings.json')
|
||||||
|
writeFileSync(outPath, JSON.stringify(embeddings, null, 2))
|
||||||
|
console.log(`\nWrote ${embeddings.length} embeddings to ${outPath}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error('Failed:', err)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import 'dotenv/config'
|
||||||
|
import express from 'express'
|
||||||
|
import nodemailer from 'nodemailer'
|
||||||
|
import path from 'path'
|
||||||
|
import { fileURLToPath } from 'url'
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url)
|
||||||
|
const __dirname = path.dirname(__filename)
|
||||||
|
|
||||||
|
const app = express()
|
||||||
|
app.use(express.json())
|
||||||
|
|
||||||
|
// Serve static files from Vite build (dist/ is at project root, one level up from dist-server/)
|
||||||
|
app.use(express.static(path.join(__dirname, '..', 'dist')))
|
||||||
|
|
||||||
|
// Contact API endpoint
|
||||||
|
app.post('/api/contact', async (req, res) => {
|
||||||
|
const { name, organisation, email, subject, message } = req.body
|
||||||
|
|
||||||
|
if (!name || !email || !subject || !message) {
|
||||||
|
return res.status(400).json({ success: false, message: 'All fields are required' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||||
|
if (!emailRegex.test(email)) {
|
||||||
|
return res.status(400).json({ success: false, message: 'Invalid email address' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const transporter = nodemailer.createTransport({
|
||||||
|
host: process.env.SMTP_HOST,
|
||||||
|
port: Number(process.env.SMTP_PORT),
|
||||||
|
secure: true,
|
||||||
|
auth: {
|
||||||
|
user: process.env.SMTP_USER,
|
||||||
|
pass: process.env.SMTP_PASS,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const contactEmail = process.env.CONTACT_EMAIL || 'andy@charlwood.xyz'
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Admin notification
|
||||||
|
await transporter.sendMail({
|
||||||
|
from: `"${name}" <${process.env.SMTP_USER}>`,
|
||||||
|
replyTo: email,
|
||||||
|
to: contactEmail,
|
||||||
|
subject: `Portfolio Referral: ${subject}`,
|
||||||
|
html: `
|
||||||
|
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
||||||
|
<h2 style="color: #00897B; border-bottom: 2px solid #00897B; padding-bottom: 10px;">
|
||||||
|
New Patient Referral
|
||||||
|
</h2>
|
||||||
|
<div style="background: #f5f5f5; padding: 20px; border-radius: 8px; margin: 20px 0;">
|
||||||
|
<p><strong>Referring Clinician:</strong> ${name}</p>
|
||||||
|
<p><strong>Organisation:</strong> ${organisation || 'Not specified'}</p>
|
||||||
|
<p><strong>Email:</strong> ${email}</p>
|
||||||
|
<p><strong>Subject:</strong> ${subject}</p>
|
||||||
|
</div>
|
||||||
|
<div style="padding: 20px 0;">
|
||||||
|
<h3 style="color: #333;">Clinical Details:</h3>
|
||||||
|
<p style="white-space: pre-wrap; line-height: 1.6;">${message}</p>
|
||||||
|
</div>
|
||||||
|
<hr style="border: none; border-top: 1px solid #ddd; margin: 20px 0;" />
|
||||||
|
<p style="color: #666; font-size: 12px;">
|
||||||
|
This message was sent from your portfolio contact form.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Auto-reply
|
||||||
|
await transporter.sendMail({
|
||||||
|
from: `"Andy Charlwood" <${process.env.SMTP_USER}>`,
|
||||||
|
to: email,
|
||||||
|
subject: 'Thanks for getting in touch!',
|
||||||
|
html: `
|
||||||
|
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
||||||
|
<h2 style="color: #00897B; border-bottom: 2px solid #00897B; padding-bottom: 10px;">
|
||||||
|
Thanks for your message, ${name}!
|
||||||
|
</h2>
|
||||||
|
<p style="line-height: 1.6;">
|
||||||
|
I've received your referral and will get back to you as soon as possible.
|
||||||
|
</p>
|
||||||
|
<div style="background: #f5f5f5; padding: 20px; border-radius: 8px; margin: 20px 0;">
|
||||||
|
<p><strong>Your message:</strong></p>
|
||||||
|
<p style="white-space: pre-wrap; color: #555;">${message}</p>
|
||||||
|
</div>
|
||||||
|
<p style="line-height: 1.6;">
|
||||||
|
Best regards,<br/>
|
||||||
|
<strong>Andy Charlwood</strong><br/>
|
||||||
|
Informatics Pharmacist
|
||||||
|
</p>
|
||||||
|
<hr style="border: none; border-top: 1px solid #ddd; margin: 20px 0;" />
|
||||||
|
<p style="color: #666; font-size: 12px;">
|
||||||
|
This is an automated confirmation. Please do not reply to this email.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
|
||||||
|
return res.status(200).json({ success: true, message: 'Referral sent successfully!' })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Email error:', error)
|
||||||
|
return res.status(500).json({ success: false, message: 'Failed to send referral. Please try again.' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Chat proxy endpoint — keeps API key server-side
|
||||||
|
app.post('/api/chat', async (req, res) => {
|
||||||
|
const apiKey = process.env.OPEN_ROUTER_API_KEY
|
||||||
|
if (!apiKey) {
|
||||||
|
return res.status(500).json({ error: 'LLM API key not configured' })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('https://openrouter.ai/api/v1/chat/completions', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${apiKey}`,
|
||||||
|
'HTTP-Referer': req.headers.origin || req.headers.referer || '',
|
||||||
|
'X-Title': 'Andy Charlwood Portfolio',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(req.body),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return res.status(response.status).json({ error: `LLM API error: ${response.status}` })
|
||||||
|
}
|
||||||
|
|
||||||
|
res.setHeader('Content-Type', 'text/event-stream')
|
||||||
|
res.setHeader('Cache-Control', 'no-cache')
|
||||||
|
res.setHeader('Connection', 'keep-alive')
|
||||||
|
|
||||||
|
const reader = response.body?.getReader()
|
||||||
|
if (!reader) {
|
||||||
|
return res.status(500).json({ error: 'No response body' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const pump = async () => {
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read()
|
||||||
|
if (done) break
|
||||||
|
res.write(value)
|
||||||
|
}
|
||||||
|
res.end()
|
||||||
|
}
|
||||||
|
await pump()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Chat proxy error:', error)
|
||||||
|
if (!res.headersSent) {
|
||||||
|
return res.status(500).json({ error: 'Failed to proxy chat request' })
|
||||||
|
}
|
||||||
|
res.end()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// SPA fallback
|
||||||
|
app.get('*', (_req, res) => {
|
||||||
|
res.sendFile(path.join(__dirname, '..', 'dist', 'index.html'))
|
||||||
|
})
|
||||||
|
|
||||||
|
const PORT = process.env.PORT || 3000
|
||||||
|
app.listen(PORT, () => {
|
||||||
|
console.log(`Server running on port ${PORT}`)
|
||||||
|
})
|
||||||
@@ -1,49 +1,102 @@
|
|||||||
import { useState } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import type { Phase } from './types'
|
import type { Phase } from './types'
|
||||||
import { BootSequence } from './components/BootSequence'
|
import { BootSequence } from './components/BootSequence'
|
||||||
import { ECGAnimation } from './components/ECGAnimation'
|
import { LoginScreen } from './components/LoginScreen'
|
||||||
import { FloatingNav } from './components/FloatingNav'
|
import { DashboardLayout } from './components/DashboardLayout'
|
||||||
import { Hero } from './components/Hero'
|
import { AccessibilityProvider } from './contexts/AccessibilityContext'
|
||||||
import { Skills } from './components/Skills'
|
import { DetailPanelProvider } from './contexts/DetailPanelContext'
|
||||||
import { Experience } from './components/Experience'
|
import { initModel } from './lib/embedding-model'
|
||||||
import { Education } from './components/Education'
|
|
||||||
import { Projects } from './components/Projects'
|
|
||||||
import { Contact } from './components/Contact'
|
|
||||||
import { Footer } from './components/Footer'
|
|
||||||
|
|
||||||
function App() {
|
function SkipButton({ onSkip }: { onSkip: () => void }) {
|
||||||
const [phase, setPhase] = useState<Phase>('boot')
|
const [visible, setVisible] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => setVisible(true), 1500)
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-white">
|
<button
|
||||||
{phase === 'boot' && (
|
onClick={onSkip}
|
||||||
<BootSequence onComplete={() => setPhase('ecg')} />
|
aria-label="Skip intro animation"
|
||||||
)}
|
className="fixed bottom-6 left-1/2 -translate-x-1/2 z-[100] px-4 py-1.5 text-xs tracking-widest uppercase font-mono border rounded transition-all duration-700 cursor-pointer select-none focus:outline-none focus-visible:ring-2 focus-visible:ring-white/40"
|
||||||
|
style={{
|
||||||
{phase === 'ecg' && (
|
color: '#555',
|
||||||
<ECGAnimation onComplete={() => setPhase('content')} />
|
borderColor: '#333',
|
||||||
)}
|
backgroundColor: 'rgba(255,255,255,0.03)',
|
||||||
|
opacity: visible ? 1 : 0,
|
||||||
{phase === 'content' && (
|
pointerEvents: visible ? 'auto' : 'none',
|
||||||
<>
|
}}
|
||||||
<FloatingNav />
|
onMouseEnter={(e) => {
|
||||||
<main className="max-w-[1000px] mx-auto px-5 xs:px-6 md:px-8">
|
e.currentTarget.style.color = '#888'
|
||||||
<Hero />
|
e.currentTarget.style.borderColor = '#555'
|
||||||
|
e.currentTarget.style.backgroundColor = 'rgba(255,255,255,0.06)'
|
||||||
<Skills />
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.color = '#555'
|
||||||
|
e.currentTarget.style.borderColor = '#333'
|
||||||
|
e.currentTarget.style.backgroundColor = 'rgba(255,255,255,0.03)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Skip
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
<Experience />
|
function App() {
|
||||||
|
const [phase, setPhase] = useState<Phase>(() => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
const visitedAt = sessionStorage.getItem('portfolio-visited')
|
||||||
|
if (visitedAt && Date.now() - Number(visitedAt) < 60 * 60 * 1000) {
|
||||||
|
return 'pmr'
|
||||||
|
}
|
||||||
|
sessionStorage.removeItem('portfolio-visited')
|
||||||
|
}
|
||||||
|
return 'boot'
|
||||||
|
})
|
||||||
|
|
||||||
<Education />
|
useEffect(() => {
|
||||||
|
if (phase === 'login' || phase === 'pmr') {
|
||||||
|
initModel()
|
||||||
|
}
|
||||||
|
if (phase === 'pmr') {
|
||||||
|
sessionStorage.setItem('portfolio-visited', String(Date.now()))
|
||||||
|
}
|
||||||
|
}, [phase])
|
||||||
|
|
||||||
<Projects />
|
const skipToDashboard = () => setPhase('pmr')
|
||||||
|
|
||||||
<Contact />
|
return (
|
||||||
</main>
|
<AccessibilityProvider>
|
||||||
<Footer />
|
<div className="min-h-screen bg-black">
|
||||||
</>
|
{/* Screen reader announcement for PMR phase */}
|
||||||
)}
|
{phase === 'pmr' && (
|
||||||
</div>
|
<div className="sr-only" role="status" aria-live="polite" aria-atomic="true">
|
||||||
|
Patient Record for Charlwood, Andrew. Summary view.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{phase === 'boot' && (
|
||||||
|
<BootSequence
|
||||||
|
onComplete={() => setPhase('login')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(phase === 'login' || phase === 'pmr') && (
|
||||||
|
<DetailPanelProvider>
|
||||||
|
<DashboardLayout />
|
||||||
|
</DetailPanelProvider>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{phase === 'login' && (
|
||||||
|
<LoginScreen onComplete={() => setPhase('pmr')} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(phase === 'boot' || phase === 'login') && (
|
||||||
|
<SkipButton onSkip={skipToDashboard} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</AccessibilityProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,95 +1,571 @@
|
|||||||
|
import { useEffect, useLayoutEffect, useState, useRef } from 'react'
|
||||||
import { motion, AnimatePresence } from 'framer-motion'
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
import { useEffect, useState } from 'react'
|
|
||||||
|
// =============================================================================
|
||||||
|
// Types
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
type BootLineType = 'header' | 'status' | 'separator' | 'field' | 'module' | 'ready'
|
||||||
|
|
||||||
|
type BootLineStyle = 'bright' | 'dim' | 'cyan'
|
||||||
|
|
||||||
interface BootLine {
|
interface BootLine {
|
||||||
html: string
|
type: BootLineType
|
||||||
delay: number
|
text?: string
|
||||||
|
label?: string
|
||||||
|
value?: string
|
||||||
|
style?: BootLineStyle
|
||||||
}
|
}
|
||||||
|
|
||||||
const bootLines: BootLine[] = [
|
interface BootConfig {
|
||||||
{ html: '<span class="text-[#00ff41] font-bold">CLINICAL TERMINAL v3.2.1</span>', delay: 0 },
|
header: string
|
||||||
{ html: '<span class="text-[#3a6b45]">Initialising pharmacist profile...</span>', delay: 220 },
|
lines: BootLine[]
|
||||||
{ html: '<span class="text-[#3a6b45]">---</span>', delay: 220 },
|
timing: {
|
||||||
{ html: '<span class="text-[#00e5ff]">SYSTEM </span><span class="text-[#00ff41]">NHS Norfolk & Waveney ICB</span>', delay: 220 },
|
lineDelay: number
|
||||||
{ html: '<span class="text-[#00e5ff]">USER </span><span class="text-[#00ff41]">Andy Charlwood</span>', delay: 220 },
|
cursorBlinkInterval: number
|
||||||
{ html: '<span class="text-[#00e5ff]">ROLE </span><span class="text-[#00ff41]">Deputy Head of Population Health & Data Analysis</span>', delay: 220 },
|
holdAfterComplete: number
|
||||||
{ html: '<span class="text-[#00e5ff]">LOCATION </span><span class="text-[#00ff41]">Norwich, UK</span>', delay: 220 },
|
loadingDuration: number
|
||||||
{ html: '<span class="text-[#3a6b45]">---</span>', delay: 220 },
|
fadeOutDuration: number
|
||||||
{ html: '<span class="text-[#3a6b45]">Loading modules...</span>', delay: 220 },
|
cursorShrinkDuration: number
|
||||||
{ html: '<span class="text-[#00ff41] font-bold">[OK]</span> <span class="text-[#3a6b45]">pharmacist_core.sys</span>', delay: 220 },
|
}
|
||||||
{ html: '<span class="text-[#00ff41] font-bold">[OK]</span> <span class="text-[#3a6b45]">population_health.mod</span>', delay: 220 },
|
colors: {
|
||||||
{ html: '<span class="text-[#00ff41] font-bold">[OK]</span> <span class="text-[#3a6b45]">data_analytics.eng</span>', delay: 220 },
|
bright: string
|
||||||
{ html: '<span class="text-[#3a6b45]">---</span>', delay: 220 },
|
dim: string
|
||||||
{ html: '<span class="text-[#00ff41] font-bold">> READY — Rendering CV..<span class="ecg-seed-dot" id="ecg-seed-dot">.</span></span>', delay: 220 },
|
cyan: string
|
||||||
]
|
}
|
||||||
|
}
|
||||||
|
|
||||||
interface BootSequenceProps {
|
interface BootSequenceProps {
|
||||||
onComplete: () => void
|
onComplete: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BootSequence({ onComplete }: BootSequenceProps) {
|
interface TypedSegment {
|
||||||
const [isVisible, setIsVisible] = useState(true)
|
text: string
|
||||||
const [lineDelays, setLineDelays] = useState<number[]>([])
|
color: string
|
||||||
|
bold?: boolean
|
||||||
useEffect(() => {
|
isSeedDot?: boolean
|
||||||
const delays: number[] = []
|
}
|
||||||
let totalDelay = 0
|
|
||||||
bootLines.forEach((line) => {
|
interface TypedLine {
|
||||||
delays.push(totalDelay)
|
segments: TypedSegment[]
|
||||||
totalDelay += line.delay
|
totalChars: number
|
||||||
})
|
pauseAfter: number // ms to pause after this line completes
|
||||||
setLineDelays(delays)
|
speed: number // ms per character (0 = instant)
|
||||||
|
}
|
||||||
const totalBootTime = totalDelay
|
|
||||||
const fadeStartTime = totalBootTime + 400
|
// =============================================================================
|
||||||
|
// Configuration
|
||||||
const fadeTimer = setTimeout(() => {
|
// =============================================================================
|
||||||
setIsVisible(false)
|
|
||||||
}, fadeStartTime)
|
// Global speed multiplier for typing animation.
|
||||||
|
// 1.0 = default (~3.3s typing). Lower = faster, higher = slower.
|
||||||
const completeTimer = setTimeout(() => {
|
const TYPING_SPEED = 1.0
|
||||||
onComplete()
|
|
||||||
}, fadeStartTime + 800)
|
const COLORS = {
|
||||||
|
bright: '#00ff41',
|
||||||
return () => {
|
dim: '#3a6b45',
|
||||||
clearTimeout(fadeTimer)
|
cyan: '#00e5ff',
|
||||||
clearTimeout(completeTimer)
|
}
|
||||||
|
|
||||||
|
const BOOT_CONFIG: BootConfig = {
|
||||||
|
header: 'CV Management Information System v1.0.0',
|
||||||
|
lines: [
|
||||||
|
{ type: 'status', text: 'Initialising pharmacist profile...', style: 'dim' },
|
||||||
|
{ type: 'separator', text: '---', style: 'dim' },
|
||||||
|
{ type: 'field', label: 'SYSTEM', value: 'NHS Norfolk & Waveney ICB', style: 'cyan' },
|
||||||
|
{ type: 'field', label: 'USER', value: 'Andy Charlwood', style: 'bright' },
|
||||||
|
{ type: 'field', label: 'ROLE', value: 'Deputy Head of Population Health & Data Analysis', style: 'bright' },
|
||||||
|
{ type: 'field', label: 'LOCATION', value: 'Norwich, UK', style: 'bright' },
|
||||||
|
{ type: 'separator', text: '---', style: 'dim' },
|
||||||
|
{ type: 'status', text: 'Loading modules...', style: 'dim' },
|
||||||
|
{ type: 'module', text: 'pharmacist_core.sys', style: 'dim' },
|
||||||
|
{ type: 'module', text: 'population_health.mod', style: 'dim' },
|
||||||
|
{ type: 'module', text: 'data_analytics.eng', style: 'dim' },
|
||||||
|
{ type: 'separator', text: '---', style: 'dim' },
|
||||||
|
{ type: 'ready', text: 'READY \u2014 Launching CV..', style: 'bright' },
|
||||||
|
],
|
||||||
|
timing: {
|
||||||
|
lineDelay: 220,
|
||||||
|
cursorBlinkInterval: 300,
|
||||||
|
holdAfterComplete: 1000,
|
||||||
|
loadingDuration: 2000,
|
||||||
|
fadeOutDuration: 500,
|
||||||
|
cursorShrinkDuration: 400,
|
||||||
|
},
|
||||||
|
colors: COLORS,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply speed multiplier — instant lines (speed=0) stay instant
|
||||||
|
function s(ms: number): number {
|
||||||
|
return Math.round(ms * TYPING_SPEED)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build typed lines from BOOT_CONFIG
|
||||||
|
function buildTypedLines(): TypedLine[] {
|
||||||
|
const lines: TypedLine[] = []
|
||||||
|
|
||||||
|
// Header
|
||||||
|
const headerText = BOOT_CONFIG.header
|
||||||
|
lines.push({
|
||||||
|
segments: [{ text: headerText, color: COLORS.bright, bold: true }],
|
||||||
|
totalChars: headerText.length,
|
||||||
|
pauseAfter: s(40),
|
||||||
|
speed: s(18),
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const line of BOOT_CONFIG.lines) {
|
||||||
|
switch (line.type) {
|
||||||
|
case 'status': {
|
||||||
|
const text = line.text || ''
|
||||||
|
lines.push({
|
||||||
|
segments: [{ text, color: COLORS.dim }],
|
||||||
|
totalChars: text.length,
|
||||||
|
pauseAfter: s(40),
|
||||||
|
speed: s(14),
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'separator': {
|
||||||
|
const text = line.text || '---'
|
||||||
|
lines.push({
|
||||||
|
segments: [{ text, color: COLORS.dim }],
|
||||||
|
totalChars: text.length,
|
||||||
|
pauseAfter: s(50),
|
||||||
|
speed: 0, // instant
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'field': {
|
||||||
|
const label = (line.label || '').padEnd(9)
|
||||||
|
const value = line.value || ''
|
||||||
|
const valueColor = line.style === 'cyan' ? COLORS.cyan : COLORS.bright
|
||||||
|
lines.push({
|
||||||
|
segments: [
|
||||||
|
{ text: label, color: COLORS.cyan },
|
||||||
|
{ text: value, color: valueColor },
|
||||||
|
],
|
||||||
|
totalChars: label.length + value.length,
|
||||||
|
pauseAfter: s(30),
|
||||||
|
speed: s(10),
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'module': {
|
||||||
|
const prefix = '[OK] '
|
||||||
|
const name = line.text || ''
|
||||||
|
lines.push({
|
||||||
|
segments: [
|
||||||
|
{ text: '[OK]', color: COLORS.bright, bold: true },
|
||||||
|
{ text: ' ', color: COLORS.dim },
|
||||||
|
{ text: name, color: COLORS.dim },
|
||||||
|
],
|
||||||
|
totalChars: prefix.length + name.length,
|
||||||
|
pauseAfter: s(50),
|
||||||
|
speed: 0, // instant — stdout output
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'ready': {
|
||||||
|
const prefix = '> '
|
||||||
|
const body = line.text || ''
|
||||||
|
const seedDot = '.'
|
||||||
|
lines.push({
|
||||||
|
segments: [
|
||||||
|
{ text: prefix + body, color: COLORS.bright, bold: true },
|
||||||
|
{ text: seedDot, color: COLORS.bright, bold: true, isSeedDot: true },
|
||||||
|
],
|
||||||
|
totalChars: prefix.length + body.length + seedDot.length,
|
||||||
|
pauseAfter: 0,
|
||||||
|
speed: s(16),
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [onComplete])
|
}
|
||||||
|
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
|
||||||
|
const TYPED_LINES = buildTypedLines()
|
||||||
|
const TOTAL_CHARS = TYPED_LINES.reduce((sum, l) => sum + l.totalChars, 0)
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// ASCII Loading Screen Component
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
function LoadingBar({ active }: { active: boolean }) {
|
||||||
|
const [progress, setProgress] = useState(0)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!active) return
|
||||||
|
const start = performance.now()
|
||||||
|
let raf: number
|
||||||
|
|
||||||
|
const tick = (now: number) => {
|
||||||
|
const elapsed = now - start
|
||||||
|
const pct = Math.min(elapsed / BOOT_CONFIG.timing.loadingDuration, 1)
|
||||||
|
setProgress(1 - Math.pow(1 - pct, 2.5))
|
||||||
|
if (pct < 1) raf = requestAnimationFrame(tick)
|
||||||
|
}
|
||||||
|
|
||||||
|
raf = requestAnimationFrame(tick)
|
||||||
|
return () => cancelAnimationFrame(raf)
|
||||||
|
}, [active])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 'calc(100vw - 48px)',
|
||||||
|
position: 'relative',
|
||||||
|
overflow: 'hidden',
|
||||||
|
height: '1.2em',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: 14,
|
||||||
|
letterSpacing: '0.02em',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
color: `${COLORS.bright}30`,
|
||||||
|
overflow: 'hidden',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{'\u2591'.repeat(500)}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
bottom: 0,
|
||||||
|
width: `${progress * 100}%`,
|
||||||
|
color: COLORS.bright,
|
||||||
|
overflow: 'hidden',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
textShadow: `0 0 4px ${COLORS.bright}30`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{'\u2588'.repeat(500)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Main Component
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export function BootSequence({ onComplete }: BootSequenceProps) {
|
||||||
|
const [typedCount, setTypedCount] = useState(0)
|
||||||
|
const [phase, setPhase] = useState<'typing' | 'holding' | 'loading' | 'fading' | 'done'>('typing')
|
||||||
|
const [isVisible, setIsVisible] = useState(true)
|
||||||
|
const cursorAnchorRef = useRef<HTMLSpanElement>(null)
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
const [cursorPos, setCursorPos] = useState<{ left: number; top: number } | null>(null)
|
||||||
|
|
||||||
|
const reducedMotion = typeof window !== 'undefined'
|
||||||
|
? window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||||
|
: false
|
||||||
|
|
||||||
|
// Typing engine — runs as a self-scheduling setTimeout chain
|
||||||
|
useEffect(() => {
|
||||||
|
if (reducedMotion || phase !== 'typing') return
|
||||||
|
|
||||||
|
// All characters typed
|
||||||
|
if (typedCount >= TOTAL_CHARS) {
|
||||||
|
setPhase('holding')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find which line the cursor is on and position within it
|
||||||
|
let lineStart = 0
|
||||||
|
let lineIdx = 0
|
||||||
|
for (let i = 0; i < TYPED_LINES.length; i++) {
|
||||||
|
if (lineStart + TYPED_LINES[i].totalChars > typedCount) {
|
||||||
|
lineIdx = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
lineStart += TYPED_LINES[i].totalChars
|
||||||
|
}
|
||||||
|
|
||||||
|
const line = TYPED_LINES[lineIdx]
|
||||||
|
const posInLine = typedCount - lineStart
|
||||||
|
|
||||||
|
if (posInLine === 0 && line.speed === 0) {
|
||||||
|
// Instant line: show all chars at once after a brief pause
|
||||||
|
timeoutRef.current = setTimeout(() => {
|
||||||
|
setTypedCount(lineStart + line.totalChars)
|
||||||
|
}, line.pauseAfter || 10)
|
||||||
|
} else if (posInLine === 0 && lineIdx > 0) {
|
||||||
|
// Start of a new typed line — apply previous line's pauseAfter
|
||||||
|
timeoutRef.current = setTimeout(() => {
|
||||||
|
setTypedCount(prev => prev + 1)
|
||||||
|
}, TYPED_LINES[lineIdx - 1].pauseAfter)
|
||||||
|
} else {
|
||||||
|
// Type one character at the line's speed
|
||||||
|
timeoutRef.current = setTimeout(() => {
|
||||||
|
setTypedCount(prev => prev + 1)
|
||||||
|
}, line.speed)
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (timeoutRef.current) clearTimeout(timeoutRef.current)
|
||||||
|
}
|
||||||
|
}, [typedCount, phase, reducedMotion])
|
||||||
|
|
||||||
|
// Hold phase → loading
|
||||||
|
useEffect(() => {
|
||||||
|
if (phase !== 'holding') return
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setPhase('loading')
|
||||||
|
}, BOOT_CONFIG.timing.holdAfterComplete)
|
||||||
|
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}, [phase])
|
||||||
|
|
||||||
|
// Loading phase → fading (after progress bar completes)
|
||||||
|
useEffect(() => {
|
||||||
|
if (phase !== 'loading') return
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setPhase('fading')
|
||||||
|
}, BOOT_CONFIG.timing.loadingDuration + 100)
|
||||||
|
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}, [phase])
|
||||||
|
|
||||||
|
// Fade phase: notify parent immediately so login can mount alongside fade
|
||||||
|
useEffect(() => {
|
||||||
|
if (phase !== 'fading') return
|
||||||
|
|
||||||
|
onComplete()
|
||||||
|
|
||||||
|
const hideTimer = setTimeout(() => {
|
||||||
|
setIsVisible(false)
|
||||||
|
setPhase('done')
|
||||||
|
}, BOOT_CONFIG.timing.fadeOutDuration)
|
||||||
|
|
||||||
|
return () => clearTimeout(hideTimer)
|
||||||
|
}, [phase, onComplete])
|
||||||
|
|
||||||
|
// Reduced motion: skip animation
|
||||||
|
useEffect(() => {
|
||||||
|
if (!reducedMotion) return
|
||||||
|
const timer = setTimeout(onComplete, 500)
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}, [reducedMotion, onComplete])
|
||||||
|
|
||||||
|
// Track cursor anchor position relative to the content container
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!cursorAnchorRef.current || !containerRef.current || phase === 'done') return
|
||||||
|
const anchor = cursorAnchorRef.current.getBoundingClientRect()
|
||||||
|
const container = containerRef.current.getBoundingClientRect()
|
||||||
|
setCursorPos({
|
||||||
|
left: anchor.left - container.left,
|
||||||
|
top: anchor.top - container.top,
|
||||||
|
})
|
||||||
|
}, [typedCount, phase])
|
||||||
|
|
||||||
|
// Render the typed lines up to typedCount
|
||||||
|
const renderLines = () => {
|
||||||
|
let remaining = typedCount
|
||||||
|
const renderedLines: React.ReactNode[] = []
|
||||||
|
let cursorPlaced = false
|
||||||
|
|
||||||
|
for (let lineIdx = 0; lineIdx < TYPED_LINES.length; lineIdx++) {
|
||||||
|
const line = TYPED_LINES[lineIdx]
|
||||||
|
|
||||||
|
// During typing, render this line if we've started typing into it (or it's the first line with cursor)
|
||||||
|
if (phase === 'typing' && remaining <= 0 && lineIdx > 0) break
|
||||||
|
|
||||||
|
const charsForLine = Math.min(Math.max(0, remaining), line.totalChars)
|
||||||
|
remaining -= charsForLine
|
||||||
|
|
||||||
|
// During typing: cursor inline on the line being typed
|
||||||
|
// During holding/loading: cursor handled after the loop (on a new line)
|
||||||
|
const isCursorLine = phase === 'typing'
|
||||||
|
? !cursorPlaced && (charsForLine < line.totalChars || remaining <= 0)
|
||||||
|
: false
|
||||||
|
|
||||||
|
// Render segments
|
||||||
|
let charBudget = phase === 'typing' ? charsForLine : line.totalChars
|
||||||
|
const spans: React.ReactNode[] = []
|
||||||
|
|
||||||
|
for (let segIdx = 0; segIdx < line.segments.length; segIdx++) {
|
||||||
|
const seg = line.segments[segIdx]
|
||||||
|
if (charBudget <= 0 && phase === 'typing') break
|
||||||
|
|
||||||
|
const visibleChars = phase === 'typing'
|
||||||
|
? Math.min(charBudget, seg.text.length)
|
||||||
|
: seg.text.length
|
||||||
|
const visibleText = seg.text.slice(0, visibleChars)
|
||||||
|
charBudget -= visibleChars
|
||||||
|
|
||||||
|
if (seg.isSeedDot && visibleChars > 0) {
|
||||||
|
spans.push(
|
||||||
|
<span
|
||||||
|
key={segIdx}
|
||||||
|
className={phase === 'holding' ? 'boot-seed-dot animate-seed-pulse' : 'boot-seed-dot'}
|
||||||
|
style={{ color: seg.color, fontWeight: seg.bold ? 700 : 400 }}
|
||||||
|
>
|
||||||
|
{visibleText}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
} else if (visibleChars > 0) {
|
||||||
|
spans.push(
|
||||||
|
<span
|
||||||
|
key={segIdx}
|
||||||
|
style={{ color: seg.color, fontWeight: seg.bold ? 700 : 400 }}
|
||||||
|
>
|
||||||
|
{visibleText}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invisible placeholder to mark cursor position (actual cursor rendered outside fading wrapper)
|
||||||
|
if (isCursorLine && phase !== 'done') {
|
||||||
|
cursorPlaced = true
|
||||||
|
spans.push(
|
||||||
|
<span
|
||||||
|
key="cursor-anchor"
|
||||||
|
ref={cursorAnchorRef}
|
||||||
|
className="inline-block align-middle"
|
||||||
|
style={{ width: 8, height: 16, marginLeft: 1 }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
renderedLines.push(
|
||||||
|
<div key={lineIdx} className="font-mono text-sm leading-relaxed">
|
||||||
|
{spans}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// After typing completes: cursor on new line, or loading bar replacing it
|
||||||
|
if (phase === 'holding') {
|
||||||
|
renderedLines.push(
|
||||||
|
<div key="cursor-line" className="font-mono text-sm leading-relaxed">
|
||||||
|
<span
|
||||||
|
ref={cursorAnchorRef}
|
||||||
|
className="inline-block align-middle"
|
||||||
|
style={{ width: 8, height: 16 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
} else if (phase === 'loading' || phase === 'fading') {
|
||||||
|
renderedLines.push(
|
||||||
|
<div key="bar-line" style={{ marginTop: 4 }}>
|
||||||
|
<LoadingBar active={phase === 'loading'} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return renderedLines
|
||||||
|
}
|
||||||
|
|
||||||
|
const isFadingOut = phase === 'fading' || phase === 'done'
|
||||||
|
|
||||||
|
// Reduced motion: instant render
|
||||||
|
if (reducedMotion) {
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex flex-col justify-center bg-black px-5 py-8 sm:p-10 font-mono text-sm overflow-hidden">
|
||||||
|
<div className="flex flex-col gap-1 max-w-[640px] transform -translate-y-1/2">
|
||||||
|
{(() => {
|
||||||
|
// Render all lines fully
|
||||||
|
const lines: React.ReactNode[] = []
|
||||||
|
for (let lineIdx = 0; lineIdx < TYPED_LINES.length; lineIdx++) {
|
||||||
|
const line = TYPED_LINES[lineIdx]
|
||||||
|
const spans: React.ReactNode[] = []
|
||||||
|
for (let segIdx = 0; segIdx < line.segments.length; segIdx++) {
|
||||||
|
const seg = line.segments[segIdx]
|
||||||
|
spans.push(
|
||||||
|
<span
|
||||||
|
key={segIdx}
|
||||||
|
className={seg.isSeedDot ? 'boot-seed-dot' : undefined}
|
||||||
|
style={{ color: seg.color, fontWeight: seg.bold ? 700 : 400 }}
|
||||||
|
>
|
||||||
|
{seg.text}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
lines.push(
|
||||||
|
<div key={lineIdx} className="font-mono text-sm leading-relaxed">
|
||||||
|
{spans}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return lines
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{isVisible && (
|
{isVisible && (
|
||||||
<motion.div
|
<motion.div
|
||||||
className="fixed inset-0 z-50 flex flex-col justify-center bg-black p-10 font-mono text-sm overflow-hidden"
|
className="fixed inset-0 z-50 flex flex-col justify-center bg-black px-5 py-8 sm:p-10 font-mono text-sm overflow-hidden"
|
||||||
initial={{ opacity: 1 }}
|
initial={{ opacity: 1 }}
|
||||||
exit={{ opacity: 0 }}
|
exit={{ opacity: 0 }}
|
||||||
transition={{ duration: 0.8, ease: 'easeOut' }}
|
transition={{ duration: BOOT_CONFIG.timing.fadeOutDuration / 1000, ease: 'easeOut' }}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-1 max-w-[640px]">
|
{/* CRT Scanlines */}
|
||||||
{bootLines.map((line, index) => (
|
<motion.div
|
||||||
<motion.div
|
className="absolute inset-0 pointer-events-none"
|
||||||
key={index}
|
animate={{ opacity: isFadingOut ? 0 : 1 }}
|
||||||
className="whitespace-nowrap leading-relaxed"
|
transition={{ duration: BOOT_CONFIG.timing.fadeOutDuration / 1000, ease: 'easeOut' }}
|
||||||
initial={{ opacity: 0, y: 8 }}
|
style={{
|
||||||
animate={{ opacity: 1, y: 0 }}
|
background: `repeating-linear-gradient(
|
||||||
transition={{
|
0deg,
|
||||||
delay: lineDelays[index] / 1000,
|
rgba(0, 0, 0, 0.15) 0px,
|
||||||
duration: 0.4,
|
transparent 1px,
|
||||||
ease: 'easeOut',
|
transparent 2px,
|
||||||
}}
|
rgba(0, 0, 0, 0.15) 3px
|
||||||
dangerouslySetInnerHTML={{ __html: line.html }}
|
)`,
|
||||||
/>
|
}}
|
||||||
))}
|
/>
|
||||||
|
|
||||||
|
{/* Content container — text always visible, bar appears below during loading */}
|
||||||
|
<div ref={containerRef} className="flex flex-col gap-1 transform -translate-y-1/2 relative z-10">
|
||||||
<motion.div
|
<motion.div
|
||||||
className="inline-block w-2 h-4 bg-[#00ff41] ml-1 animate-blink"
|
animate={{
|
||||||
initial={{ opacity: 0 }}
|
opacity: isFadingOut ? 0 : 1,
|
||||||
animate={{ opacity: 1 }}
|
y: isFadingOut ? -20 : 0,
|
||||||
transition={{ delay: lineDelays[lineDelays.length - 1] / 1000 }}
|
}}
|
||||||
/>
|
transition={{
|
||||||
|
duration: BOOT_CONFIG.timing.fadeOutDuration / 1000,
|
||||||
|
ease: 'easeIn',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{renderLines()}
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Cursor — blinks during typing/holding, hidden when bar takes over */}
|
||||||
|
{cursorPos && phase !== 'loading' && !isFadingOut && (
|
||||||
|
<span
|
||||||
|
className="absolute animate-blink"
|
||||||
|
style={{
|
||||||
|
left: cursorPos.left,
|
||||||
|
top: cursorPos.top,
|
||||||
|
width: 8,
|
||||||
|
height: 16,
|
||||||
|
backgroundColor: COLORS.bright,
|
||||||
|
animationDuration: `${BOOT_CONFIG.timing.cursorBlinkInterval}ms`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { DOT_COLORS } from '@/lib/theme-colors'
|
||||||
|
|
||||||
|
interface CardProps {
|
||||||
|
children: React.ReactNode
|
||||||
|
full?: boolean // spans both grid columns
|
||||||
|
className?: string
|
||||||
|
tileId?: string // data-tile-id for command palette scroll targeting
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Card({ children, full, className, tileId }: CardProps) {
|
||||||
|
const [isHovered, setIsHovered] = React.useState(false)
|
||||||
|
|
||||||
|
const baseStyles: React.CSSProperties = {
|
||||||
|
background: 'var(--surface)',
|
||||||
|
border: isHovered
|
||||||
|
? '1px solid var(--border)'
|
||||||
|
: '1px solid var(--border-light)',
|
||||||
|
borderRadius: 'var(--radius)',
|
||||||
|
padding: '24px',
|
||||||
|
boxShadow: isHovered ? 'var(--shadow-md)' : 'var(--shadow-sm)',
|
||||||
|
transition: 'box-shadow 0.2s, border-color 0.2s',
|
||||||
|
gridColumn: full ? '1 / -1' : undefined,
|
||||||
|
minWidth: 0,
|
||||||
|
overflow: 'hidden',
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article
|
||||||
|
style={baseStyles}
|
||||||
|
className={['card-base', className].filter(Boolean).join(' ')}
|
||||||
|
data-tile-id={tileId}
|
||||||
|
onMouseEnter={() => setIsHovered(true)}
|
||||||
|
onMouseLeave={() => setIsHovered(false)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</article>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CardHeaderProps {
|
||||||
|
dotColor: 'teal' | 'amber' | 'green' | 'alert' | 'purple'
|
||||||
|
title: string
|
||||||
|
rightText?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CardHeader({ dotColor, title, rightText }: CardHeaderProps) {
|
||||||
|
const headerStyles: React.CSSProperties = {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
marginBottom: '18px',
|
||||||
|
}
|
||||||
|
|
||||||
|
const dotStyles: React.CSSProperties = {
|
||||||
|
width: '9px',
|
||||||
|
height: '9px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
backgroundColor: DOT_COLORS[dotColor],
|
||||||
|
flexShrink: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
const titleStyles: React.CSSProperties = {
|
||||||
|
fontSize: '13px',
|
||||||
|
fontWeight: 600,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.06em',
|
||||||
|
color: 'var(--text-secondary)',
|
||||||
|
}
|
||||||
|
|
||||||
|
const rightTextStyles: React.CSSProperties = {
|
||||||
|
fontSize: '11px',
|
||||||
|
fontWeight: 400,
|
||||||
|
textTransform: 'none',
|
||||||
|
letterSpacing: 'normal',
|
||||||
|
color: 'var(--text-tertiary)',
|
||||||
|
fontFamily: 'var(--font-geist-mono)',
|
||||||
|
marginLeft: 'auto',
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={headerStyles}>
|
||||||
|
<div style={dotStyles} aria-hidden="true" />
|
||||||
|
<span style={titleStyles}>{title}</span>
|
||||||
|
{rightText && <span style={rightTextStyles}>{rightText}</span>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,813 @@
|
|||||||
|
import { useState, useRef, useEffect, useCallback, useMemo, type KeyboardEvent } from 'react'
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
|
import { MessageCircle, X, Send, Loader2 } from 'lucide-react'
|
||||||
|
import ReactMarkdown from 'react-markdown'
|
||||||
|
import {
|
||||||
|
sendChatMessage,
|
||||||
|
isLLMAvailable,
|
||||||
|
parseItemIds,
|
||||||
|
stripItemsSuffix,
|
||||||
|
LLM_DISPLAY_NAME,
|
||||||
|
type ChatMessage,
|
||||||
|
} from '@/lib/llm'
|
||||||
|
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
|
||||||
|
|
||||||
|
const SUGGESTED_QUESTIONS = [
|
||||||
|
"What's his NHS experience?",
|
||||||
|
'Tell me about his data skills',
|
||||||
|
'What projects has he built?',
|
||||||
|
]
|
||||||
|
|
||||||
|
const buttonVariants = {
|
||||||
|
hidden: prefersReducedMotion
|
||||||
|
? { opacity: 1, y: 0 }
|
||||||
|
: { opacity: 0, y: 8 },
|
||||||
|
visible: {
|
||||||
|
opacity: 1,
|
||||||
|
y: 0,
|
||||||
|
transition: motionSafeTransition(0.3, 'easeOut', 1),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const panelVariants = {
|
||||||
|
hidden: prefersReducedMotion
|
||||||
|
? { opacity: 1, scale: 1 }
|
||||||
|
: { opacity: 0, scale: 0.95 },
|
||||||
|
visible: {
|
||||||
|
opacity: 1,
|
||||||
|
scale: 1,
|
||||||
|
transition: motionSafeTransition(0.2),
|
||||||
|
},
|
||||||
|
exit: prefersReducedMotion
|
||||||
|
? { opacity: 1, scale: 1 }
|
||||||
|
: { opacity: 0, scale: 0.95, transition: { duration: 0.15, ease: 'easeIn' } },
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChatWidgetProps {
|
||||||
|
onAction?: (action: PaletteAction) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChatWidget({ onAction }: ChatWidgetProps) {
|
||||||
|
const isMobileNav = useIsMobileNav()
|
||||||
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
|
const [messages, setMessages] = useState<ChatMessage[]>([])
|
||||||
|
const [inputValue, setInputValue] = useState('')
|
||||||
|
const [isStreaming, setIsStreaming] = useState(false)
|
||||||
|
const [expandedItems, setExpandedItems] = useState<Set<number>>(new Set())
|
||||||
|
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||||
|
const inputRef = useRef<HTMLTextAreaElement>(null)
|
||||||
|
|
||||||
|
const llmAvailable = isLLMAvailable()
|
||||||
|
|
||||||
|
// Nudge bubble: show once after 12s if user hasn't opened chat yet
|
||||||
|
const [showNudge, setShowNudge] = useState(false)
|
||||||
|
const hasInteracted = useRef(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
if (!hasInteracted.current) setShowNudge(true)
|
||||||
|
}, 5_000)
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showNudge) return
|
||||||
|
const dismiss = () => {
|
||||||
|
hasInteracted.current = true
|
||||||
|
setShowNudge(false)
|
||||||
|
}
|
||||||
|
window.addEventListener('click', dismiss, { once: true })
|
||||||
|
return () => window.removeEventListener('click', dismiss)
|
||||||
|
}, [showNudge])
|
||||||
|
|
||||||
|
// Build palette map for looking up items by ID
|
||||||
|
const paletteMap = useMemo(() => {
|
||||||
|
const items = buildPaletteData()
|
||||||
|
const map = new Map<string, PaletteItem>()
|
||||||
|
for (const item of items) map.set(item.id, item)
|
||||||
|
return map
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Auto-scroll to latest message
|
||||||
|
useEffect(() => {
|
||||||
|
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||||
|
}, [messages])
|
||||||
|
|
||||||
|
// Focus input when panel opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
setTimeout(() => inputRef.current?.focus(), 200)
|
||||||
|
}
|
||||||
|
}, [isOpen])
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(async (overrideText?: string) => {
|
||||||
|
const trimmed = (overrideText ?? inputValue).trim()
|
||||||
|
if (!trimmed || isStreaming) return
|
||||||
|
|
||||||
|
const userMessage: ChatMessage = { role: 'user', content: trimmed }
|
||||||
|
const updatedMessages = [...messages, userMessage]
|
||||||
|
|
||||||
|
// Cap history to last MAX_HISTORY messages, strip internal metadata
|
||||||
|
const historyForApi = updatedMessages.slice(-MAX_HISTORY).map((msg) => ({
|
||||||
|
...msg,
|
||||||
|
content: msg.content.replace(/\n?<!--ITEMS:[^>]*-->/, '').trim(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
setMessages(updatedMessages)
|
||||||
|
setInputValue('')
|
||||||
|
setIsStreaming(true)
|
||||||
|
|
||||||
|
// Add empty assistant message that will be streamed into
|
||||||
|
const assistantMessage: ChatMessage = { role: 'assistant', content: '' }
|
||||||
|
setMessages((prev) => [...prev, assistantMessage])
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stream = sendChatMessage(historyForApi)
|
||||||
|
let accumulated = ''
|
||||||
|
|
||||||
|
for await (const chunk of stream) {
|
||||||
|
accumulated += chunk
|
||||||
|
// Update the last (assistant) message with accumulated text
|
||||||
|
setMessages((prev) => {
|
||||||
|
const updated = [...prev]
|
||||||
|
updated[updated.length - 1] = { role: 'assistant', content: accumulated }
|
||||||
|
return updated
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Final cleanup: strip [ITEMS: ...] suffix from display text (keep raw for parsing)
|
||||||
|
// We store the clean display text but parse items from the raw accumulated text
|
||||||
|
const cleanText = stripItemsSuffix(accumulated)
|
||||||
|
const itemIds = parseItemIds(accumulated)
|
||||||
|
const finalContent = itemIds.length > 0
|
||||||
|
? `${cleanText}\n<!--ITEMS:${itemIds.join(',')}-->`
|
||||||
|
: cleanText
|
||||||
|
|
||||||
|
setMessages((prev) => {
|
||||||
|
const updated = [...prev]
|
||||||
|
updated[updated.length - 1] = { role: 'assistant', content: finalContent }
|
||||||
|
return updated
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
setMessages((prev) => {
|
||||||
|
const updated = [...prev]
|
||||||
|
updated[updated.length - 1] = {
|
||||||
|
role: 'assistant',
|
||||||
|
content: "Sorry, I couldn't process that. Please try again.",
|
||||||
|
}
|
||||||
|
return updated
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
setIsStreaming(false)
|
||||||
|
}
|
||||||
|
}, [inputValue, isStreaming, messages])
|
||||||
|
|
||||||
|
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault()
|
||||||
|
handleSubmit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract display text from message content (strip hidden item metadata)
|
||||||
|
const getDisplayText = (content: string) => {
|
||||||
|
return content.replace(/\n?<!--ITEMS:[^>]*-->/, '').trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract item IDs from the <!--ITEMS:...--> HTML comment in message content
|
||||||
|
const getMessageItemIds = (content: string): string[] => {
|
||||||
|
const match = content.match(/<!--ITEMS:([^>]*)-->/)
|
||||||
|
if (!match) return []
|
||||||
|
return match[1].split(',').map((id) => id.trim()).filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve item IDs to PaletteItems
|
||||||
|
const getMessageItems = (content: string): PaletteItem[] => {
|
||||||
|
return getMessageItemIds(content)
|
||||||
|
.map((id) => paletteMap.get(id))
|
||||||
|
.filter((item): item is PaletteItem => item !== undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle clicking an item card — route through onAction
|
||||||
|
const handleItemClick = useCallback((item: PaletteItem) => {
|
||||||
|
if (onAction) {
|
||||||
|
onAction(item.action)
|
||||||
|
} else {
|
||||||
|
if (item.action.type === 'link') {
|
||||||
|
window.open(item.action.url, '_blank', 'noopener,noreferrer')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [onAction])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Chat panel */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{isOpen && (
|
||||||
|
<motion.div
|
||||||
|
key="chat-panel"
|
||||||
|
initial="hidden"
|
||||||
|
animate="visible"
|
||||||
|
exit="exit"
|
||||||
|
variants={panelVariants}
|
||||||
|
role="dialog"
|
||||||
|
aria-label="Chat with AI about Andy"
|
||||||
|
data-chat-panel
|
||||||
|
className="fixed z-[90] font-ui
|
||||||
|
inset-0 rounded-none max-md:z-[101]
|
||||||
|
md:inset-auto md:bottom-[88px] md:right-6 md:rounded-xl lg:bottom-[100px] xl:bottom-[112px]"
|
||||||
|
style={{
|
||||||
|
background: 'var(--surface)',
|
||||||
|
border: '1px solid var(--border-light)',
|
||||||
|
boxShadow: 'var(--shadow-lg)',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
overflow: 'hidden',
|
||||||
|
transformOrigin: 'bottom right',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<style>{`
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
[data-chat-panel] { width: clamp(380px, 30vw, 500px); height: calc(66vh); }
|
||||||
|
}
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
[data-chat-panel] {
|
||||||
|
height: 100dvh;
|
||||||
|
max-height: 100dvh;
|
||||||
|
padding-top: env(safe-area-inset-top, 0px);
|
||||||
|
padding-bottom: env(safe-area-inset-bottom, 0px);
|
||||||
|
padding-left: env(safe-area-inset-left, 0px);
|
||||||
|
padding-right: env(safe-area-inset-right, 0px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
minHeight: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
padding: '14px 16px',
|
||||||
|
borderBottom: '1px solid var(--border-light)',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'baseline', gap: '8px' }}>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Ask about Andy
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="font-geist"
|
||||||
|
style={{
|
||||||
|
fontSize: '11px',
|
||||||
|
color: 'var(--text-tertiary)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{LLM_DISPLAY_NAME}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsOpen(false)}
|
||||||
|
aria-label="Close chat"
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
width: '28px',
|
||||||
|
height: '28px',
|
||||||
|
borderRadius: '6px',
|
||||||
|
border: 'none',
|
||||||
|
background: 'transparent',
|
||||||
|
color: 'var(--text-secondary)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'background-color 150ms ease-out',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = 'var(--accent-light)'
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = 'transparent'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X size={16} strokeWidth={2} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Messages area */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
overflowY: 'auto',
|
||||||
|
padding: '16px',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '12px',
|
||||||
|
}}
|
||||||
|
className="pmr-scrollbar"
|
||||||
|
>
|
||||||
|
{!llmAvailable && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
textAlign: 'center',
|
||||||
|
padding: '32px 16px',
|
||||||
|
color: 'var(--text-tertiary)',
|
||||||
|
fontSize: '13px',
|
||||||
|
lineHeight: 1.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Chat is currently unavailable.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{llmAvailable && messages.length === 0 && (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||||
|
{/* Welcome bubble — styled as assistant message */}
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-start' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
maxWidth: '85%',
|
||||||
|
padding: '10px 14px',
|
||||||
|
borderRadius: '12px 12px 12px 4px',
|
||||||
|
fontSize: '13px',
|
||||||
|
lineHeight: 1.5,
|
||||||
|
background: 'var(--bg-dashboard)',
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
border: '1px solid var(--border-light)',
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Hey! I'm here to help you learn more about Andy. What would you like to know?
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Suggested question chips */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: '8px',
|
||||||
|
paddingLeft: '4px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{SUGGESTED_QUESTIONS.map((question) => (
|
||||||
|
<button
|
||||||
|
key={question}
|
||||||
|
onClick={() => handleSubmit(question)}
|
||||||
|
style={{
|
||||||
|
padding: '6px 14px',
|
||||||
|
borderRadius: '9999px',
|
||||||
|
border: '1px solid var(--accent-border)',
|
||||||
|
background: 'transparent',
|
||||||
|
color: 'var(--text-secondary)',
|
||||||
|
fontSize: '12.5px',
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'background-color 150ms ease-out, color 150ms ease-out',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = 'var(--accent-light)'
|
||||||
|
e.currentTarget.style.color = 'var(--accent)'
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = 'transparent'
|
||||||
|
e.currentTarget.style.color = 'var(--text-secondary)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{question}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{messages.map((msg, i) => {
|
||||||
|
const referencedItems = msg.role === 'assistant' ? getMessageItems(msg.content) : []
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: msg.role === 'user' ? 'flex-end' : 'flex-start',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
maxWidth: '85%',
|
||||||
|
borderRadius: msg.role === 'user'
|
||||||
|
? '12px 12px 4px 12px'
|
||||||
|
: '12px 12px 12px 4px',
|
||||||
|
fontSize: '13px',
|
||||||
|
lineHeight: 1.5,
|
||||||
|
background: msg.role === 'user'
|
||||||
|
? 'var(--accent-light)'
|
||||||
|
: 'var(--bg-dashboard)',
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
border: msg.role === 'user'
|
||||||
|
? '1px solid var(--accent-border)'
|
||||||
|
: '1px solid var(--border-light)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ padding: '10px 14px', whiteSpace: msg.role === 'user' ? 'pre-wrap' : undefined }}>
|
||||||
|
{msg.role === 'assistant' ? (
|
||||||
|
<div className="chat-markdown">
|
||||||
|
<ReactMarkdown>{getDisplayText(msg.content)}</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
getDisplayText(msg.content)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{referencedItems.length > 0 && (() => {
|
||||||
|
const isExpanded = expandedItems.has(i)
|
||||||
|
const visibleItems = isExpanded ? referencedItems : referencedItems.slice(0, 3)
|
||||||
|
const hasMore = referencedItems.length > 3
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
borderTop: '1px solid var(--border-light)',
|
||||||
|
padding: '6px 8px',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '2px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{visibleItems.map((item) => {
|
||||||
|
const IconComponent = iconByType[item.iconType]
|
||||||
|
const colorStyle = iconColorStyles[item.iconVariant]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
onClick={() => handleItemClick(item)}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
padding: '6px 8px',
|
||||||
|
borderRadius: '6px',
|
||||||
|
border: 'none',
|
||||||
|
background: 'transparent',
|
||||||
|
cursor: 'pointer',
|
||||||
|
width: '100%',
|
||||||
|
textAlign: 'left',
|
||||||
|
transition: 'background-color 100ms ease-out',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = 'var(--accent-light)'
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = 'transparent'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '22px',
|
||||||
|
height: '22px',
|
||||||
|
borderRadius: '5px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
flexShrink: 0,
|
||||||
|
background: colorStyle.background,
|
||||||
|
color: colorStyle.color,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{IconComponent && <IconComponent size={12} />}
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontWeight: 500,
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.title}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '11px',
|
||||||
|
color: 'var(--text-tertiary)',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
marginTop: '-1px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.subtitle}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{hasMore && !isExpanded && (
|
||||||
|
<button
|
||||||
|
onClick={() => setExpandedItems((prev) => new Set(prev).add(i))}
|
||||||
|
style={{
|
||||||
|
padding: '5px 8px',
|
||||||
|
borderRadius: '6px',
|
||||||
|
border: 'none',
|
||||||
|
background: 'transparent',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '11.5px',
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
color: 'var(--accent)',
|
||||||
|
textAlign: 'left',
|
||||||
|
transition: 'background-color 100ms ease-out',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = 'var(--accent-light)'
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = 'transparent'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
See {referencedItems.length - 3} more related items
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Typing indicator */}
|
||||||
|
{isStreaming && messages.length > 0 && messages[messages.length - 1].content === '' && (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-start' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '10px 14px',
|
||||||
|
borderRadius: '12px 12px 12px 4px',
|
||||||
|
background: 'var(--bg-dashboard)',
|
||||||
|
border: '1px solid var(--border-light)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '6px',
|
||||||
|
color: 'var(--text-tertiary)',
|
||||||
|
fontSize: '13px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Loader2
|
||||||
|
size={14}
|
||||||
|
strokeWidth={2}
|
||||||
|
style={{
|
||||||
|
animation: 'spin 1s linear infinite',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span>Thinking...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div ref={messagesEndRef} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Input area */}
|
||||||
|
{llmAvailable && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '12px 16px',
|
||||||
|
borderTop: '1px solid var(--border-light)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'flex-end',
|
||||||
|
gap: '8px',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<textarea
|
||||||
|
ref={inputRef}
|
||||||
|
value={inputValue}
|
||||||
|
onChange={(e) => setInputValue(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder="Ask me anything..."
|
||||||
|
rows={1}
|
||||||
|
disabled={isStreaming}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
resize: 'none',
|
||||||
|
border: '1px solid var(--border-light)',
|
||||||
|
borderRadius: '8px',
|
||||||
|
padding: '10px 12px',
|
||||||
|
fontSize: '13px',
|
||||||
|
lineHeight: 1.5,
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
background: 'var(--surface)',
|
||||||
|
outline: 'none',
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
maxHeight: '80px',
|
||||||
|
overflowY: 'auto',
|
||||||
|
transition: 'border-color 150ms ease-out',
|
||||||
|
opacity: isStreaming ? 0.6 : 1,
|
||||||
|
}}
|
||||||
|
onFocus={(e) => {
|
||||||
|
e.currentTarget.style.borderColor = 'var(--accent)'
|
||||||
|
}}
|
||||||
|
onBlur={(e) => {
|
||||||
|
e.currentTarget.style.borderColor = 'var(--border-light)'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => handleSubmit()}
|
||||||
|
disabled={!inputValue.trim() || isStreaming}
|
||||||
|
aria-label="Send message"
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
width: '36px',
|
||||||
|
height: '36px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
border: 'none',
|
||||||
|
background: inputValue.trim() && !isStreaming ? 'var(--accent)' : 'var(--border-light)',
|
||||||
|
color: inputValue.trim() && !isStreaming ? '#FFFFFF' : 'var(--text-tertiary)',
|
||||||
|
cursor: inputValue.trim() && !isStreaming ? 'pointer' : 'default',
|
||||||
|
flexShrink: 0,
|
||||||
|
transition: 'background-color 150ms ease-out, color 150ms ease-out',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Send size={16} strokeWidth={2} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* Floating chat button — hidden on mobile when panel is open */}
|
||||||
|
<motion.button
|
||||||
|
initial="hidden"
|
||||||
|
animate="visible"
|
||||||
|
variants={buttonVariants}
|
||||||
|
onClick={() => {
|
||||||
|
hasInteracted.current = true
|
||||||
|
setShowNudge(false)
|
||||||
|
setIsOpen((prev) => !prev)
|
||||||
|
}}
|
||||||
|
aria-label={isOpen ? 'Close chat' : 'Open chat'}
|
||||||
|
className={`fixed z-[101] cursor-pointer flex items-center justify-center bottom-4 right-4 h-12 w-12 md:bottom-6 md:right-6 md:h-14 md:w-14 lg:h-16 lg:w-16 xl:h-[4.5rem] xl:w-[4.5rem]${isOpen ? ' max-md:!hidden' : ''}`}
|
||||||
|
style={{
|
||||||
|
bottom: isMobileNav ? 'calc(56px + env(safe-area-inset-bottom) + 16px)' : undefined,
|
||||||
|
borderRadius: '50%',
|
||||||
|
border: 'none',
|
||||||
|
background: 'var(--accent)',
|
||||||
|
opacity: 0.85,
|
||||||
|
color: '#FFFFFF',
|
||||||
|
boxShadow: 'var(--shadow-md)',
|
||||||
|
animation: prefersReducedMotion ? 'none' : 'chat-pulse 3s ease-in-out infinite',
|
||||||
|
transition: 'box-shadow 150ms ease-out, transform 150ms ease-out, opacity 150ms ease-out',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.boxShadow = 'var(--shadow-lg)'
|
||||||
|
e.currentTarget.style.transform = 'scale(1.05)'
|
||||||
|
e.currentTarget.style.opacity = '1'
|
||||||
|
e.currentTarget.style.animation = 'none'
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.boxShadow = 'var(--shadow-md)'
|
||||||
|
e.currentTarget.style.transform = 'scale(1)'
|
||||||
|
e.currentTarget.style.opacity = '0.85'
|
||||||
|
e.currentTarget.style.animation = prefersReducedMotion ? 'none' : 'chat-pulse 3s ease-in-out infinite'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isOpen ? (
|
||||||
|
<>
|
||||||
|
<X size={22} strokeWidth={2} className="lg:hidden" />
|
||||||
|
<X size={26} strokeWidth={2} className="hidden lg:block xl:hidden" />
|
||||||
|
<X size={30} strokeWidth={2} className="hidden xl:block" />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<MessageCircle size={22} strokeWidth={2} className="lg:hidden" />
|
||||||
|
<MessageCircle size={26} strokeWidth={2} className="hidden lg:block xl:hidden" />
|
||||||
|
<MessageCircle size={30} strokeWidth={2} className="hidden xl:block" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</motion.button>
|
||||||
|
|
||||||
|
{/* Nudge bubble */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{showNudge && !isOpen && (
|
||||||
|
<motion.div
|
||||||
|
initial={prefersReducedMotion ? { opacity: 1 } : { opacity: 0, y: 6 }}
|
||||||
|
animate={{ opacity: 1, y: 0, transition: motionSafeTransition(0.25, 'easeOut') }}
|
||||||
|
exit={{ opacity: 0, transition: { duration: 0 } }}
|
||||||
|
className="fixed z-[101] right-4 md:right-6 pointer-events-none"
|
||||||
|
style={{
|
||||||
|
/* Position above button: button-bottom + button-height + gap */
|
||||||
|
bottom: isMobileNav
|
||||||
|
? 'calc(56px + env(safe-area-inset-bottom) + 72px)'
|
||||||
|
: undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Mobile: above 48px button at bottom-4 */}
|
||||||
|
<div
|
||||||
|
className="md:hidden px-3 py-2 rounded-xl text-xs font-medium max-w-[200px]"
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
bottom: isMobileNav
|
||||||
|
? 'calc(56px + env(safe-area-inset-bottom) + 16px + 48px + 10px)'
|
||||||
|
: 'calc(16px + 48px + 10px)',
|
||||||
|
right: '16px',
|
||||||
|
background: 'var(--surface)',
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
boxShadow: 'var(--shadow-md)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Hey! I can help you learn more about Andy.
|
||||||
|
</div>
|
||||||
|
{/* md: above 56px button at bottom-6 */}
|
||||||
|
<div
|
||||||
|
className="hidden md:block lg:hidden px-3.5 py-2.5 rounded-xl text-sm font-medium max-w-[240px]"
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
bottom: 'calc(24px + 56px + 10px)',
|
||||||
|
right: '24px',
|
||||||
|
background: 'var(--surface)',
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
boxShadow: 'var(--shadow-md)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Hey! I can help you learn more about Andy.
|
||||||
|
</div>
|
||||||
|
{/* lg: above 64px button */}
|
||||||
|
<div
|
||||||
|
className="hidden lg:block xl:hidden px-4 py-3 rounded-xl text-base font-medium max-w-[280px]"
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
bottom: 'calc(24px + 64px + 12px)',
|
||||||
|
right: '24px',
|
||||||
|
background: 'var(--surface)',
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
boxShadow: 'var(--shadow-md)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Hey! I can help you learn more about Andy.
|
||||||
|
</div>
|
||||||
|
{/* xl: above 72px button */}
|
||||||
|
<div
|
||||||
|
className="hidden xl:block px-5 py-3 rounded-2xl text-base font-medium max-w-[300px]"
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
bottom: 'calc(24px + 72px + 14px)',
|
||||||
|
right: '24px',
|
||||||
|
background: 'var(--surface)',
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
boxShadow: 'var(--shadow-md)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Hey! I can help you learn more about Andy.
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* Spinner keyframes */}
|
||||||
|
<style>{`
|
||||||
|
@keyframes spin {
|
||||||
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
@keyframes chat-pulse {
|
||||||
|
0%, 100% { transform: scale(1); opacity: 0.85; }
|
||||||
|
50% { transform: scale(1.06); opacity: 0.85; }
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,455 @@
|
|||||||
|
import { useState, useEffect, useRef, useMemo, useCallback } from 'react'
|
||||||
|
import { Search } from 'lucide-react'
|
||||||
|
import {
|
||||||
|
buildPaletteData,
|
||||||
|
buildSearchIndex,
|
||||||
|
groupBySection,
|
||||||
|
} from '@/lib/search'
|
||||||
|
import type { PaletteItem, PaletteAction } from '@/lib/search'
|
||||||
|
import { iconByType, iconColorStyles } from '@/lib/palette-icons'
|
||||||
|
import { isModelReady, embedQuery } from '@/lib/embedding-model'
|
||||||
|
import { semanticSearch, loadEmbeddings } from '@/lib/semantic-search'
|
||||||
|
import { prefersReducedMotion } from '@/lib/utils'
|
||||||
|
|
||||||
|
interface CommandPaletteProps {
|
||||||
|
isOpen: boolean
|
||||||
|
onClose: () => void
|
||||||
|
onAction?: (action: PaletteAction) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CommandPalette({ isOpen, onClose, onAction }: CommandPaletteProps) {
|
||||||
|
const [query, setQuery] = useState('')
|
||||||
|
const [selectedIndex, setSelectedIndex] = useState(-1)
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const resultsRef = useRef<HTMLDivElement>(null)
|
||||||
|
const overlayRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
// Build data and search index once
|
||||||
|
const paletteData = useMemo(() => buildPaletteData(), [])
|
||||||
|
const searchIndex = useMemo(() => buildSearchIndex(paletteData), [paletteData])
|
||||||
|
|
||||||
|
// Preload embeddings and build lookup map
|
||||||
|
const embeddings = useMemo(() => loadEmbeddings(), [])
|
||||||
|
const paletteMap = useMemo(() => {
|
||||||
|
const map = new Map<string, PaletteItem>()
|
||||||
|
for (const item of paletteData) map.set(item.id, item)
|
||||||
|
return map
|
||||||
|
}, [paletteData])
|
||||||
|
|
||||||
|
// Semantic search results (async, debounced)
|
||||||
|
const [semanticResults, setSemanticResults] = useState<PaletteItem[] | null>(null)
|
||||||
|
const debounceRef = useRef<ReturnType<typeof setTimeout>>()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const trimmed = query.trim()
|
||||||
|
|
||||||
|
// Clear semantic results when query is empty
|
||||||
|
if (!trimmed) {
|
||||||
|
setSemanticResults(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only use semantic search when model is ready
|
||||||
|
if (!isModelReady()) {
|
||||||
|
setSemanticResults(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debounce ~200ms
|
||||||
|
clearTimeout(debounceRef.current)
|
||||||
|
debounceRef.current = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const queryVec = await embedQuery(trimmed)
|
||||||
|
const results = semanticSearch(queryVec, embeddings)
|
||||||
|
const items = results
|
||||||
|
.map(r => paletteMap.get(r.id))
|
||||||
|
.filter((item): item is PaletteItem => item !== undefined)
|
||||||
|
setSemanticResults(items)
|
||||||
|
} catch {
|
||||||
|
// Fall back to Fuse.js on any error
|
||||||
|
setSemanticResults(null)
|
||||||
|
}
|
||||||
|
}, 200)
|
||||||
|
|
||||||
|
return () => clearTimeout(debounceRef.current)
|
||||||
|
}, [query, embeddings, paletteMap])
|
||||||
|
|
||||||
|
// Compute visible items: semantic search when available, Fuse.js fallback
|
||||||
|
const visibleItems = useMemo(() => {
|
||||||
|
if (!query.trim()) {
|
||||||
|
return paletteData
|
||||||
|
}
|
||||||
|
if (semanticResults !== null) {
|
||||||
|
return semanticResults
|
||||||
|
}
|
||||||
|
return searchIndex.search(query).map(result => result.item)
|
||||||
|
}, [query, paletteData, searchIndex, semanticResults])
|
||||||
|
|
||||||
|
// Group visible items by section
|
||||||
|
const groupedResults = useMemo(() => groupBySection(visibleItems), [visibleItems])
|
||||||
|
|
||||||
|
// Flat list for keyboard navigation
|
||||||
|
const flatItems = useMemo(() => {
|
||||||
|
const flat: PaletteItem[] = []
|
||||||
|
for (const group of groupedResults) {
|
||||||
|
for (const item of group.items) {
|
||||||
|
flat.push(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return flat
|
||||||
|
}, [groupedResults])
|
||||||
|
|
||||||
|
// Reset state when opening/closing
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
setQuery('')
|
||||||
|
setSelectedIndex(-1)
|
||||||
|
setSemanticResults(null)
|
||||||
|
// Focus input on next frame
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
inputRef.current?.focus()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [isOpen])
|
||||||
|
|
||||||
|
// Reset selection when query changes
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedIndex(-1)
|
||||||
|
}, [query])
|
||||||
|
|
||||||
|
// Global Ctrl+K listener
|
||||||
|
useEffect(() => {
|
||||||
|
function handleGlobalKeyDown(e: KeyboardEvent) {
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!isOpen) {
|
||||||
|
// Parent controls isOpen, so we need onAction or an onOpen callback
|
||||||
|
// For now, the parent will handle Ctrl+K via its own listener
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('keydown', handleGlobalKeyDown)
|
||||||
|
return () => document.removeEventListener('keydown', handleGlobalKeyDown)
|
||||||
|
}, [isOpen])
|
||||||
|
|
||||||
|
// Execute action for a palette item
|
||||||
|
const executeAction = useCallback((item: PaletteItem) => {
|
||||||
|
onClose()
|
||||||
|
if (onAction) {
|
||||||
|
onAction(item.action)
|
||||||
|
} else {
|
||||||
|
// Fallback: handle link and download actions directly
|
||||||
|
const { action } = item
|
||||||
|
if (action.type === 'link') {
|
||||||
|
window.open(action.url, '_blank', 'noopener,noreferrer')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [onClose, onAction])
|
||||||
|
|
||||||
|
// Keyboard navigation within the palette
|
||||||
|
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||||
|
switch (e.key) {
|
||||||
|
case 'ArrowDown': {
|
||||||
|
e.preventDefault()
|
||||||
|
setSelectedIndex(prev => {
|
||||||
|
const next = prev + 1
|
||||||
|
return next >= flatItems.length ? 0 : next
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'ArrowUp': {
|
||||||
|
e.preventDefault()
|
||||||
|
setSelectedIndex(prev => {
|
||||||
|
const next = prev - 1
|
||||||
|
return next < 0 ? flatItems.length - 1 : next
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'Enter': {
|
||||||
|
e.preventDefault()
|
||||||
|
if (selectedIndex >= 0 && selectedIndex < flatItems.length) {
|
||||||
|
executeAction(flatItems[selectedIndex])
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'Escape': {
|
||||||
|
e.preventDefault()
|
||||||
|
onClose()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [flatItems, selectedIndex, executeAction, onClose])
|
||||||
|
|
||||||
|
// Auto-scroll selected item into view
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedIndex < 0 || !resultsRef.current) return
|
||||||
|
const selectedEl = resultsRef.current.querySelector(`[data-palette-index="${selectedIndex}"]`)
|
||||||
|
if (selectedEl) {
|
||||||
|
selectedEl.scrollIntoView({ block: 'nearest' })
|
||||||
|
}
|
||||||
|
}, [selectedIndex])
|
||||||
|
|
||||||
|
// Click on overlay (outside modal) to close
|
||||||
|
const handleOverlayClick = useCallback((e: React.MouseEvent) => {
|
||||||
|
if (e.target === overlayRef.current) {
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
}, [onClose])
|
||||||
|
|
||||||
|
if (!isOpen) return null
|
||||||
|
|
||||||
|
// Track flat index across groups
|
||||||
|
let flatIndex = 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={overlayRef}
|
||||||
|
onClick={handleOverlayClick}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="Command palette"
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
inset: 0,
|
||||||
|
background: 'rgba(26,43,42,0.45)',
|
||||||
|
zIndex: 1000,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: '8px',
|
||||||
|
paddingTop: 'max(8px, 10vh)',
|
||||||
|
backdropFilter: 'blur(4px)',
|
||||||
|
WebkitBackdropFilter: 'blur(4px)',
|
||||||
|
animation: prefersReducedMotion ? 'none' : 'palette-overlay-in 0.2s ease-out forwards',
|
||||||
|
}}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
>
|
||||||
|
{/* Palette modal */}
|
||||||
|
<div
|
||||||
|
className="w-full max-w-[calc(100vw-16px)] md:max-w-[calc(100vw-32px)] md:w-[580px]"
|
||||||
|
style={{
|
||||||
|
maxHeight: 'calc(100vh - 24vh)',
|
||||||
|
background: 'var(--surface)',
|
||||||
|
borderRadius: '12px',
|
||||||
|
boxShadow: '0 20px 60px rgba(26,43,42,0.2), 0 0 0 1px rgba(26,43,42,0.08)',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
overflow: 'hidden',
|
||||||
|
animation: prefersReducedMotion ? 'none' : 'palette-modal-in 0.2s cubic-bezier(0.4,0,0.2,1) forwards',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Search input row */}
|
||||||
|
<div
|
||||||
|
className="px-3 py-3 md:px-[18px] md:py-[14px]"
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '10px',
|
||||||
|
borderBottom: '1px solid var(--border-light)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Search
|
||||||
|
size={18}
|
||||||
|
style={{ color: 'var(--accent)', flexShrink: 0 }}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
placeholder="Search records, experience, skills..."
|
||||||
|
autoComplete="off"
|
||||||
|
className="font-ui"
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
border: 'none',
|
||||||
|
outline: 'none',
|
||||||
|
background: 'transparent',
|
||||||
|
fontSize: '15px',
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
}}
|
||||||
|
aria-label="Search"
|
||||||
|
aria-activedescendant={
|
||||||
|
selectedIndex >= 0 ? `palette-item-${flatItems[selectedIndex]?.id}` : undefined
|
||||||
|
}
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded="true"
|
||||||
|
aria-controls="palette-results"
|
||||||
|
aria-autocomplete="list"
|
||||||
|
/>
|
||||||
|
<kbd
|
||||||
|
className="font-geist"
|
||||||
|
style={{
|
||||||
|
fontSize: '10px',
|
||||||
|
color: 'var(--text-tertiary)',
|
||||||
|
background: 'var(--bg-dashboard)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
padding: '2px 7px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
flexShrink: 0,
|
||||||
|
lineHeight: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
ESC
|
||||||
|
</kbd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Results area */}
|
||||||
|
<div
|
||||||
|
id="palette-results"
|
||||||
|
ref={resultsRef}
|
||||||
|
role="listbox"
|
||||||
|
aria-label="Search results"
|
||||||
|
className="pmr-scrollbar p-2 md:p-[8px]"
|
||||||
|
style={{
|
||||||
|
overflowY: 'auto',
|
||||||
|
flex: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{flatItems.length === 0 ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
textAlign: 'center',
|
||||||
|
padding: '32px 16px',
|
||||||
|
color: 'var(--text-tertiary)',
|
||||||
|
fontSize: '13px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
No results found for “{query}”
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
groupedResults.map((group) => {
|
||||||
|
const sectionItems = group.items.map((item) => {
|
||||||
|
const currentIndex = flatIndex
|
||||||
|
flatIndex++
|
||||||
|
const isSelected = currentIndex === selectedIndex
|
||||||
|
const IconComponent = iconByType[item.iconType]
|
||||||
|
const colorStyle = iconColorStyles[item.iconVariant]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
id={`palette-item-${item.id}`}
|
||||||
|
role="option"
|
||||||
|
aria-selected={isSelected}
|
||||||
|
data-palette-index={currentIndex}
|
||||||
|
onClick={() => executeAction(item)}
|
||||||
|
onMouseEnter={() => setSelectedIndex(currentIndex)}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '10px',
|
||||||
|
padding: '9px 10px',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'background 0.1s',
|
||||||
|
fontSize: '13px',
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
background: isSelected ? 'var(--accent-light)' : 'transparent',
|
||||||
|
outline: isSelected ? '1.5px solid var(--accent-border)' : 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Icon container */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '28px',
|
||||||
|
height: '28px',
|
||||||
|
borderRadius: '6px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
flexShrink: 0,
|
||||||
|
background: colorStyle.background,
|
||||||
|
color: colorStyle.color,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{IconComponent && <IconComponent size={14} />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Text */}
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ fontWeight: 500 }}>{item.title}</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '11px',
|
||||||
|
color: 'var(--text-tertiary)',
|
||||||
|
marginTop: '1px',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.subtitle}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={group.section}>
|
||||||
|
{/* Section label */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '10px',
|
||||||
|
fontWeight: 600,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.08em',
|
||||||
|
color: 'var(--text-tertiary)',
|
||||||
|
padding: '8px 10px 5px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{group.section}
|
||||||
|
</div>
|
||||||
|
{sectionItems}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer with keyboard hints */}
|
||||||
|
<div
|
||||||
|
className="hidden md:flex px-3 py-2 md:px-[18px] md:py-[10px]"
|
||||||
|
style={{
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '12px',
|
||||||
|
borderTop: '1px solid var(--border-light)',
|
||||||
|
fontSize: '11px',
|
||||||
|
color: 'var(--text-tertiary)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
<Kbd>\u2191</Kbd> <Kbd>\u2193</Kbd> Navigate
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<Kbd>Enter</Kbd> Select
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<Kbd>Esc</Kbd> Close
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Small kbd element for the footer
|
||||||
|
function Kbd({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<kbd
|
||||||
|
className="font-geist"
|
||||||
|
style={{
|
||||||
|
fontSize: '10px',
|
||||||
|
background: 'var(--bg-dashboard)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
padding: '1px 5px',
|
||||||
|
borderRadius: '3px',
|
||||||
|
color: 'var(--text-secondary)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</kbd>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
import { motion } from 'framer-motion'
|
|
||||||
import { Phone, Mail, Linkedin, MapPin } from 'lucide-react'
|
|
||||||
import { useScrollReveal } from '@/hooks/useScrollReveal'
|
|
||||||
import type { ContactItem } from '@/types'
|
|
||||||
|
|
||||||
const contactData: ContactItem[] = [
|
|
||||||
{
|
|
||||||
icon: 'phone',
|
|
||||||
value: '07795553088',
|
|
||||||
label: 'Phone',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: 'mail',
|
|
||||||
value: 'andy@charlwood.xyz',
|
|
||||||
label: 'Email',
|
|
||||||
href: 'mailto:andy@charlwood.xyz',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: 'linkedin',
|
|
||||||
value: 'linkedin.com/in/andrewcharlwood',
|
|
||||||
label: 'LinkedIn',
|
|
||||||
href: 'https://linkedin.com/in/andrewcharlwood',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: 'mapPin',
|
|
||||||
value: 'Norwich, UK',
|
|
||||||
label: 'Location',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
const iconMap = {
|
|
||||||
phone: Phone,
|
|
||||||
mail: Mail,
|
|
||||||
linkedin: Linkedin,
|
|
||||||
mapPin: MapPin,
|
|
||||||
}
|
|
||||||
|
|
||||||
const ContactItemCard = ({
|
|
||||||
item,
|
|
||||||
delay,
|
|
||||||
isVisible,
|
|
||||||
}: {
|
|
||||||
item: ContactItem
|
|
||||||
delay: number
|
|
||||||
isVisible: boolean
|
|
||||||
}) => {
|
|
||||||
const Icon = iconMap[item.icon]
|
|
||||||
|
|
||||||
return (
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 24 }}
|
|
||||||
animate={isVisible ? { opacity: 1, y: 0 } : { opacity: 0, y: 24 }}
|
|
||||||
transition={{ duration: 0.5, delay, ease: 'easeOut' }}
|
|
||||||
className="text-center"
|
|
||||||
>
|
|
||||||
<div className="w-10 h-10 rounded-full bg-[rgba(0,137,123,0.08)] flex items-center justify-center mx-auto mb-2 text-teal">
|
|
||||||
<Icon size={18} />
|
|
||||||
</div>
|
|
||||||
<div className="font-secondary text-[13px] text-heading break-words">
|
|
||||||
{item.href ? (
|
|
||||||
<a
|
|
||||||
href={item.href}
|
|
||||||
target={item.href.startsWith('http') ? '_blank' : undefined}
|
|
||||||
rel={item.href.startsWith('http') ? 'noopener noreferrer' : undefined}
|
|
||||||
className="text-teal hover:text-[#00796B] transition-colors"
|
|
||||||
>
|
|
||||||
{item.value}
|
|
||||||
</a>
|
|
||||||
) : (
|
|
||||||
item.value
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="font-secondary text-[10px] uppercase tracking-wider text-muted mt-0.5">
|
|
||||||
{item.label}
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Contact() {
|
|
||||||
const [sectionRef, isVisible] = useScrollReveal<HTMLElement>({
|
|
||||||
threshold: 0.1,
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section id="contact" ref={sectionRef} className="py-12 xs:py-16 md:py-20">
|
|
||||||
<motion.h2
|
|
||||||
initial={{ opacity: 0, y: 12 }}
|
|
||||||
animate={isVisible ? { opacity: 1, y: 0 } : { opacity: 0, y: 12 }}
|
|
||||||
transition={{ duration: 0.5 }}
|
|
||||||
className="font-primary text-2xl font-bold text-heading text-center mb-8"
|
|
||||||
>
|
|
||||||
Contact
|
|
||||||
</motion.h2>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
|
||||||
{contactData.map((item, index) => (
|
|
||||||
<ContactItemCard
|
|
||||||
key={item.label}
|
|
||||||
item={item}
|
|
||||||
delay={0.1 + index * 0.1}
|
|
||||||
isVisible={isVisible}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
import { useEffect, useState, useMemo } from 'react'
|
||||||
|
import { motion, useReducedMotion } from 'framer-motion'
|
||||||
|
|
||||||
|
interface CvmisLogoProps {
|
||||||
|
size?: number
|
||||||
|
cssHeight?: string
|
||||||
|
animated?: boolean
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Animation timing constants ──────────────────────────────────────
|
||||||
|
// Rise phase: all pills rise together from below
|
||||||
|
const RISE_DURATION_MS = 1250 // duration of the upward rise (ms)
|
||||||
|
const RISE_DURATION_S = RISE_DURATION_MS / 1000
|
||||||
|
const RISE_OPACITY_DURATION_S = 0.25 // opacity fade-in during rise (s)
|
||||||
|
const RISE_EASING: [number, number, number, number] = [0.33, 1, 0.68, 1]
|
||||||
|
const RISE_START_Y = 350 // initial Y offset (viewBox units)
|
||||||
|
|
||||||
|
// Fan phase: left and right pills fan outward
|
||||||
|
const FAN_DELAY_AFTER_RISE_MS = RISE_DURATION_MS - 100 // delay before fan begins (ms from mount)
|
||||||
|
const FAN_DURATION_S = 2 // duration of fan-out (s)
|
||||||
|
const FAN_EASING = 'cubic-bezier(0.34, 1.56, 0.64, 1)'
|
||||||
|
const FAN_ROTATION_DEG = 55 // rotation angle for fanned pills (±degrees)
|
||||||
|
const FAN_HORIZONTAL_PX = -10 // horizontal offset for fanned pills (±px)
|
||||||
|
const FAN_RIGHT_STAGGER_S = 0 // stagger delay for right pill (s)
|
||||||
|
|
||||||
|
// Total animation = rise delay + fan duration
|
||||||
|
const TOTAL_ANIMATION_MS = FAN_DELAY_AFTER_RISE_MS + FAN_DURATION_S * 1000
|
||||||
|
|
||||||
|
// Overlap blend: multiply blend on fanning capsules (used by US-005)
|
||||||
|
const OVERLAY_BLEND_START_PROGRESS = 0.2 // fan progress at which blend fades in
|
||||||
|
const OVERLAP_BLEND_MAX_OPACITY = 0.3 // max blend opacity (20%)
|
||||||
|
const OVERLAP_BLEND_TRANSITION_DURATION_S = FAN_DURATION_S * (1 - OVERLAY_BLEND_START_PROGRESS)
|
||||||
|
|
||||||
|
// Pivot point: bottom-center of the pill stack (in viewBox coords)
|
||||||
|
const PX = 300
|
||||||
|
const PY = 275
|
||||||
|
|
||||||
|
// Build a CSS transform that rotates around (PX, PY) then offsets by dx
|
||||||
|
function fanTransform(rotation: number, dx: number): string {
|
||||||
|
return [
|
||||||
|
`translate(${dx}px, 0px)`,
|
||||||
|
`translate(${PX}px, ${PY}px)`,
|
||||||
|
`rotate(${rotation}deg)`,
|
||||||
|
`translate(${-PX}px, ${-PY}px)`,
|
||||||
|
].join(' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
const IDENTITY_TRANSFORM = fanTransform(0, 0)
|
||||||
|
|
||||||
|
export function CvmisLogo({ size, cssHeight, animated = false, className }: CvmisLogoProps) {
|
||||||
|
const prefersReducedMotion = useReducedMotion()
|
||||||
|
const [phase, setPhase] = useState<'rising' | 'fanning' | 'done'>(
|
||||||
|
animated && !prefersReducedMotion ? 'rising' : 'done'
|
||||||
|
)
|
||||||
|
const [blendActive, setBlendActive] = useState(!animated || !!prefersReducedMotion)
|
||||||
|
|
||||||
|
// Blend starts at OVERLAY_BLEND_START_PROGRESS through the fan animation
|
||||||
|
const blendStartMs = useMemo(
|
||||||
|
() => FAN_DELAY_AFTER_RISE_MS + FAN_DURATION_S * 1000 * OVERLAY_BLEND_START_PROGRESS,
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!animated || prefersReducedMotion) return
|
||||||
|
|
||||||
|
const fanTimer = setTimeout(() => setPhase('fanning'), FAN_DELAY_AFTER_RISE_MS)
|
||||||
|
const doneTimer = setTimeout(() => setPhase('done'), TOTAL_ANIMATION_MS)
|
||||||
|
const blendTimer = setTimeout(() => setBlendActive(true), blendStartMs)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(fanTimer)
|
||||||
|
clearTimeout(doneTimer)
|
||||||
|
clearTimeout(blendTimer)
|
||||||
|
}
|
||||||
|
}, [animated, prefersReducedMotion, blendStartMs])
|
||||||
|
|
||||||
|
const skip = !animated || prefersReducedMotion
|
||||||
|
const isFanned = phase === 'fanning' || phase === 'done'
|
||||||
|
const fanTarget = isFanned || skip
|
||||||
|
|
||||||
|
const leftTransform = fanTarget ? fanTransform(-FAN_ROTATION_DEG, -FAN_HORIZONTAL_PX) : IDENTITY_TRANSFORM
|
||||||
|
const rightTransform = fanTarget ? fanTransform(FAN_ROTATION_DEG, FAN_HORIZONTAL_PX) : IDENTITY_TRANSFORM
|
||||||
|
const fanTransition = skip ? 'none' : `transform ${FAN_DURATION_S}s ${FAN_EASING}`
|
||||||
|
const fanTransitionDelayed = skip ? 'none' : `transform ${FAN_DURATION_S}s ${FAN_EASING} ${FAN_RIGHT_STAGGER_S}s`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 600 300"
|
||||||
|
height={cssHeight ? undefined : size}
|
||||||
|
className={className}
|
||||||
|
role="img"
|
||||||
|
aria-label="CVMIS logo"
|
||||||
|
style={{
|
||||||
|
overflow: 'visible',
|
||||||
|
...(cssHeight ? { height: cssHeight, width: 'auto' } : {}),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="center-pill-clip">
|
||||||
|
<rect x="250" y="50" width="100" height="225" rx="50" />
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
{/* Rise group — all pills rise together from below */}
|
||||||
|
<motion.g
|
||||||
|
initial={skip ? false : { y: RISE_START_Y, opacity: 0 }}
|
||||||
|
animate={{ y: 0, opacity: 1 }}
|
||||||
|
transition={{
|
||||||
|
y: { duration: RISE_DURATION_S, ease: RISE_EASING },
|
||||||
|
opacity: { duration: RISE_OPACITY_DURATION_S },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Rx pill — teal, fans left (bottom layer) */}
|
||||||
|
<g style={{ transform: leftTransform, transition: fanTransition }}>
|
||||||
|
<g transform="translate(250, 50)">
|
||||||
|
<rect width="100" height="225" rx="50" fill="#0E7A7D" />
|
||||||
|
<g transform="translate(21, 50) scale(0.6)">
|
||||||
|
<path
|
||||||
|
d="M25 70 V0 H55 C80 0 80 35 55 35 H25 M55 35 L85 70 M53 67 L87 38"
|
||||||
|
stroke="white"
|
||||||
|
strokeWidth="10"
|
||||||
|
strokeLinecap="butt"
|
||||||
|
strokeLinejoin="miter"
|
||||||
|
fill="none"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
{/* Data pill — green, fans right (middle layer) */}
|
||||||
|
<g style={{ transform: rightTransform, transition: fanTransitionDelayed }}>
|
||||||
|
<g transform="translate(250, 50)">
|
||||||
|
<rect width="100" height="225" rx="50" fill="#109E6C" />
|
||||||
|
<g transform="translate(22.5, 50) scale(0.5)">
|
||||||
|
<rect x="0" y="60" width="20" height="40" fill="white" />
|
||||||
|
<rect x="30" y="40" width="20" height="60" fill="white" />
|
||||||
|
<rect x="60" y="20" width="20" height="80" fill="white" />
|
||||||
|
<rect x="90" y="0" width="20" height="100" fill="white" />
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
{/* Code pill — amber, center (top layer, no fan) */}
|
||||||
|
<g transform="translate(250, 50)">
|
||||||
|
<rect width="100" height="225" rx="50" fill="#E38B16" />
|
||||||
|
<g transform="translate(25, 50) scale(0.6)">
|
||||||
|
<path
|
||||||
|
d="M10 0 L50 30 L10 60"
|
||||||
|
stroke="white"
|
||||||
|
strokeWidth="10"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
fill="none"
|
||||||
|
/>
|
||||||
|
<line
|
||||||
|
x1="55"
|
||||||
|
y1="65"
|
||||||
|
x2="85"
|
||||||
|
y2="65"
|
||||||
|
stroke="white"
|
||||||
|
strokeWidth="10"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
{/* Blend overlays — multiply-blend copies of fanning pills, clipped to center pill overlap */}
|
||||||
|
<g clipPath="url(#center-pill-clip)">
|
||||||
|
<g
|
||||||
|
style={{
|
||||||
|
transform: leftTransform,
|
||||||
|
transition: skip ? 'none' : `${fanTransition}, opacity ${OVERLAP_BLEND_TRANSITION_DURATION_S}s ease-out`,
|
||||||
|
mixBlendMode: 'multiply',
|
||||||
|
opacity: blendActive ? OVERLAP_BLEND_MAX_OPACITY : 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<g transform="translate(250, 50)">
|
||||||
|
<rect width="100" height="225" rx="50" fill="#0E7A7D" />
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<g clipPath="url(#center-pill-clip)">
|
||||||
|
<g
|
||||||
|
style={{
|
||||||
|
transform: rightTransform,
|
||||||
|
transition: skip ? 'none' : `${fanTransitionDelayed}, opacity ${OVERLAP_BLEND_TRANSITION_DURATION_S}s ease-out`,
|
||||||
|
mixBlendMode: 'multiply',
|
||||||
|
opacity: blendActive ? OVERLAP_BLEND_MAX_OPACITY : 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<g transform="translate(250, 50)">
|
||||||
|
<rect width="100" height="225" rx="50" fill="#109E6C" />
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</motion.g>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,389 @@
|
|||||||
|
import { useState, useEffect, useCallback, useRef, useMemo, lazy, Suspense } from 'react'
|
||||||
|
import { motion } from 'framer-motion'
|
||||||
|
import Sidebar from './Sidebar'
|
||||||
|
import { MobileBottomNav } from './MobileBottomNav'
|
||||||
|
import { PatientSummaryTile } from './tiles/PatientSummaryTile'
|
||||||
|
import { ParentSection } from './ParentSection'
|
||||||
|
import { TimelineInterventionsSubsection } from './TimelineInterventionsSubsection'
|
||||||
|
import { LastConsultationCard } from './LastConsultationCard'
|
||||||
|
import { MobileOverviewHeader } from './MobileOverviewHeader'
|
||||||
|
|
||||||
|
const CommandPalette = lazy(() => import('./CommandPalette').then(m => ({ default: m.CommandPalette })))
|
||||||
|
const DetailPanel = lazy(() => import('./DetailPanel').then(m => ({ default: m.DetailPanel })))
|
||||||
|
const CareerConstellation = lazy(() => import('./constellation/CareerConstellation'))
|
||||||
|
const RepeatMedicationsSubsection = lazy(() => import('./RepeatMedicationsSubsection').then(m => ({ default: m.RepeatMedicationsSubsection })))
|
||||||
|
const ChatWidget = lazy(() => import('./ChatWidget').then(m => ({ default: m.ChatWidget })))
|
||||||
|
import { useActiveSection } from '@/hooks/useActiveSection'
|
||||||
|
import { useIsMobileNav } from '@/hooks/useIsMobileNav'
|
||||||
|
import { useIsTabletOrBelow } from '@/hooks/useIsTabletOrBelow'
|
||||||
|
import { useDetailPanel } from '@/contexts/DetailPanelContext'
|
||||||
|
import { timelineConsultations, timelineEntities } from '@/data/timeline'
|
||||||
|
import { skills } from '@/data/skills'
|
||||||
|
import { constellationNodes } from '@/data/constellation'
|
||||||
|
import type { PaletteAction } from '@/lib/search'
|
||||||
|
import { prefersReducedMotion, motionSafeTransition } from '@/lib/utils'
|
||||||
|
|
||||||
|
const sidebarVariants = {
|
||||||
|
hidden: prefersReducedMotion ? { x: 0, opacity: 1 } : { x: -272, opacity: 0 },
|
||||||
|
visible: {
|
||||||
|
x: 0,
|
||||||
|
opacity: 1,
|
||||||
|
transition: motionSafeTransition(0.25, 'easeOut', 0.05),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentVariants = {
|
||||||
|
hidden: prefersReducedMotion ? { opacity: 1 } : { opacity: 0 },
|
||||||
|
visible: {
|
||||||
|
opacity: 1,
|
||||||
|
transition: motionSafeTransition(0.3, 'easeOut', 0.15),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DashboardLayout() {
|
||||||
|
const [commandPaletteOpen, setCommandPaletteOpen] = useState(false)
|
||||||
|
const [highlightedNodeId, setHighlightedNodeId] = useState<string | null>(null)
|
||||||
|
const [highlightedRoleId, setHighlightedRoleId] = useState<string | null>(null)
|
||||||
|
const [chronologyHeight, setChronologyHeight] = useState<number | null>(null)
|
||||||
|
const [constellationReady, setConstellationReady] = useState(false)
|
||||||
|
const isMobileNav = useIsMobileNav()
|
||||||
|
const isTabletOrBelow = useIsTabletOrBelow()
|
||||||
|
const chronologyRef = useRef<HTMLDivElement>(null)
|
||||||
|
const patientSummaryRef = useRef<HTMLDivElement>(null)
|
||||||
|
const constellationWrapperRef = useRef<HTMLDivElement>(null)
|
||||||
|
const activeSection = useActiveSection()
|
||||||
|
const { openPanel } = useDetailPanel()
|
||||||
|
const careerConsultationsById = useMemo(
|
||||||
|
() => new Map(timelineConsultations.map((consultation) => [consultation.id, consultation])),
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
// Global focus mode: tracks which entity (skill or role) is being hovered across all components
|
||||||
|
const [globalFocusId, setGlobalFocusId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Build lookup maps for resolving relationships between skills and roles
|
||||||
|
const nodeTypeById = useMemo(
|
||||||
|
() => new Map(constellationNodes.map(n => [n.id, n.type])),
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
const skillToRoles = useMemo(() => {
|
||||||
|
const map = new Map<string, Set<string>>()
|
||||||
|
for (const entity of timelineEntities) {
|
||||||
|
for (const skillId of entity.skills) {
|
||||||
|
if (!map.has(skillId)) map.set(skillId, new Set())
|
||||||
|
map.get(skillId)!.add(entity.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
}, [])
|
||||||
|
const roleToSkills = useMemo(
|
||||||
|
() => new Map(timelineEntities.map(e => [e.id, new Set(e.skills)])),
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
// Derive the set of all IDs related to the focused entity
|
||||||
|
const focusRelatedIds = useMemo(() => {
|
||||||
|
if (!globalFocusId) return null
|
||||||
|
const related = new Set<string>()
|
||||||
|
related.add(globalFocusId)
|
||||||
|
const nodeType = nodeTypeById.get(globalFocusId)
|
||||||
|
if (nodeType === 'skill') {
|
||||||
|
// Skill focused: related roles are those containing this skill
|
||||||
|
const roles = skillToRoles.get(globalFocusId)
|
||||||
|
if (roles) roles.forEach(r => related.add(r))
|
||||||
|
} else {
|
||||||
|
// Role/education focused: related skills are that entity's skills
|
||||||
|
const entitySkills = roleToSkills.get(globalFocusId)
|
||||||
|
if (entitySkills) entitySkills.forEach(s => related.add(s))
|
||||||
|
}
|
||||||
|
return related
|
||||||
|
}, [globalFocusId, nodeTypeById, skillToRoles, roleToSkills])
|
||||||
|
|
||||||
|
// Signal constellation animation readiness:
|
||||||
|
// Desktop (>=768): patient summary scrolls out of view OR constellation enters viewport
|
||||||
|
// Mobile (<768): constellation scrolls into view
|
||||||
|
useEffect(() => {
|
||||||
|
const isMobile = window.innerWidth < 768
|
||||||
|
const observers: IntersectionObserver[] = []
|
||||||
|
|
||||||
|
// Always observe the constellation entering the viewport
|
||||||
|
const constellationEl = constellationWrapperRef.current
|
||||||
|
if (constellationEl) {
|
||||||
|
const chartObserver = new IntersectionObserver(
|
||||||
|
([entry]) => {
|
||||||
|
if (entry.isIntersecting) setConstellationReady(true)
|
||||||
|
},
|
||||||
|
{ threshold: 0.5 },
|
||||||
|
)
|
||||||
|
chartObserver.observe(constellationEl)
|
||||||
|
observers.push(chartObserver)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Desktop: also trigger when patient summary scrolls out of view
|
||||||
|
if (!isMobile) {
|
||||||
|
const summaryEl = patientSummaryRef.current
|
||||||
|
if (summaryEl) {
|
||||||
|
const summaryObserver = new IntersectionObserver(
|
||||||
|
([entry]) => {
|
||||||
|
if (!entry.isIntersecting) setConstellationReady(true)
|
||||||
|
},
|
||||||
|
{ threshold: 0 },
|
||||||
|
)
|
||||||
|
summaryObserver.observe(summaryEl)
|
||||||
|
observers.push(summaryObserver)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => observers.forEach((o) => o.disconnect())
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Measure the chronology stream height so the constellation graph can match it
|
||||||
|
useEffect(() => {
|
||||||
|
const el = chronologyRef.current
|
||||||
|
if (!el) return
|
||||||
|
|
||||||
|
const observer = new ResizeObserver((entries) => {
|
||||||
|
for (const entry of entries) {
|
||||||
|
setChronologyHeight(entry.contentRect.height)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
observer.observe(el)
|
||||||
|
return () => observer.disconnect()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handlePaletteClose = useCallback(() => {
|
||||||
|
setCommandPaletteOpen(false)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleSearchClick = useCallback(() => {
|
||||||
|
setCommandPaletteOpen(true)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const scrollToSection = useCallback((tileId: string) => {
|
||||||
|
const tileEl = document.querySelector(`[data-tile-id="${tileId}"]`)
|
||||||
|
if (tileEl) {
|
||||||
|
tileEl.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Constellation graph handlers
|
||||||
|
const handleRoleClick = useCallback(
|
||||||
|
(roleId: string) => {
|
||||||
|
const consultation = careerConsultationsById.get(roleId)
|
||||||
|
if (consultation) {
|
||||||
|
openPanel({ type: 'career-role', consultation })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[careerConsultationsById, openPanel],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleSkillClick = useCallback(
|
||||||
|
(skillId: string) => {
|
||||||
|
const skill = skills.find((s) => s.id === skillId)
|
||||||
|
if (skill) {
|
||||||
|
openPanel({ type: 'skill', skill })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[openPanel],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleNodeHighlight = useCallback((id: string | null) => {
|
||||||
|
if (isTabletOrBelow) return
|
||||||
|
setHighlightedNodeId(id)
|
||||||
|
setGlobalFocusId(id)
|
||||||
|
}, [isTabletOrBelow])
|
||||||
|
|
||||||
|
const handleNodeHover = useCallback((id: string | null) => {
|
||||||
|
if (isTabletOrBelow) return
|
||||||
|
const nodeType = id ? nodeTypeById.get(id) : null
|
||||||
|
setHighlightedRoleId(nodeType !== 'skill' ? id : null)
|
||||||
|
setGlobalFocusId(id)
|
||||||
|
}, [isTabletOrBelow, nodeTypeById])
|
||||||
|
|
||||||
|
// Global Ctrl+K listener to open command palette
|
||||||
|
useEffect(() => {
|
||||||
|
function handleKeyDown(e: KeyboardEvent) {
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
|
||||||
|
e.preventDefault()
|
||||||
|
setCommandPaletteOpen(prev => !prev)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('keydown', handleKeyDown)
|
||||||
|
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Handle palette actions (scroll to tile, expand item, open link, download)
|
||||||
|
const handlePaletteAction = useCallback((action: PaletteAction) => {
|
||||||
|
switch (action.type) {
|
||||||
|
case 'scroll': {
|
||||||
|
scrollToSection(action.tileId)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'expand': {
|
||||||
|
const tileEl = document.querySelector(`[data-tile-id="${action.tileId}"]`)
|
||||||
|
if (tileEl) {
|
||||||
|
tileEl.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||||
|
// Dispatch a custom event that the tile can listen for to expand the item
|
||||||
|
const expandEvent = new CustomEvent('palette-expand', {
|
||||||
|
detail: { tileId: action.tileId, itemId: action.itemId },
|
||||||
|
})
|
||||||
|
document.dispatchEvent(expandEvent)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'link': {
|
||||||
|
window.open(action.url, '_blank', 'noopener,noreferrer')
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'download': {
|
||||||
|
// For now, open the CV file or trigger a download
|
||||||
|
// This can be wired to an actual PDF when available
|
||||||
|
window.open('/Andrew_Charlwood_CV.pdf', '_blank')
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'panel': {
|
||||||
|
openPanel(action.panelContent)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [openPanel, scrollToSection])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="font-ui"
|
||||||
|
style={{ background: 'var(--bg-dashboard)', height: '100vh', overflow: 'hidden' }}
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href="#main-content"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '-48px',
|
||||||
|
left: 0,
|
||||||
|
background: 'var(--accent)',
|
||||||
|
color: '#FFFFFF',
|
||||||
|
padding: '8px 16px',
|
||||||
|
textDecoration: 'none',
|
||||||
|
zIndex: 120,
|
||||||
|
borderRadius: '0 0 4px 0',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
onFocus={(e) => {
|
||||||
|
e.currentTarget.style.top = '0'
|
||||||
|
}}
|
||||||
|
onBlur={(e) => {
|
||||||
|
e.currentTarget.style.top = '-48px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Skip to main content
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
height: '100%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{!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"
|
||||||
|
initial="hidden"
|
||||||
|
animate="visible"
|
||||||
|
variants={contentVariants}
|
||||||
|
aria-label="Dashboard content"
|
||||||
|
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,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isMobileNav && <MobileOverviewHeader onSearchClick={handleSearchClick} />}
|
||||||
|
<div className="dashboard-grid">
|
||||||
|
{/* PatientSummaryTile — full width (includes Latest Results subsection) */}
|
||||||
|
<div ref={patientSummaryRef}>
|
||||||
|
<PatientSummaryTile />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Patient Pathway — parent section with constellation graph + subsections */}
|
||||||
|
<ParentSection title="Patient Pathway" tileId="patient-pathway">
|
||||||
|
<div className="pathway-columns">
|
||||||
|
<div ref={chronologyRef} className="chronology-stream" data-tile-id="section-experience">
|
||||||
|
|
||||||
|
|
||||||
|
<div className="chronology-item">
|
||||||
|
<LastConsultationCard highlightedRoleId={highlightedRoleId} focusRelatedIds={focusRelatedIds} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="chronology-item">
|
||||||
|
<TimelineInterventionsSubsection onNodeHighlight={handleNodeHighlight} highlightedRoleId={highlightedRoleId} focusRelatedIds={focusRelatedIds} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div ref={constellationWrapperRef} className="pathway-graph-sticky">
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<CareerConstellation
|
||||||
|
onRoleClick={handleRoleClick}
|
||||||
|
onSkillClick={handleSkillClick}
|
||||||
|
onNodeHover={handleNodeHover}
|
||||||
|
highlightedNodeId={highlightedNodeId}
|
||||||
|
containerHeight={chronologyHeight}
|
||||||
|
animationReady={constellationReady}
|
||||||
|
globalFocusActive={globalFocusId !== null}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div data-tile-id="section-skills" style={{ marginTop: '22px' }}>
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<RepeatMedicationsSubsection onNodeHighlight={handleNodeHighlight} focusRelatedIds={focusRelatedIds} />
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
</ParentSection>
|
||||||
|
</div>
|
||||||
|
</motion.main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Command palette overlay */}
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<CommandPalette
|
||||||
|
isOpen={commandPaletteOpen}
|
||||||
|
onClose={handlePaletteClose}
|
||||||
|
onAction={handlePaletteAction}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
|
||||||
|
{/* Detail panel */}
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<DetailPanel />
|
||||||
|
</Suspense>
|
||||||
|
|
||||||
|
{/* Floating chat widget */}
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<ChatWidget onAction={handlePaletteAction} />
|
||||||
|
</Suspense>
|
||||||
|
|
||||||
|
{/* Mobile bottom navigation */}
|
||||||
|
<MobileBottomNav
|
||||||
|
activeSection={activeSection}
|
||||||
|
onNavigate={scrollToSection}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
import { X } from 'lucide-react'
|
||||||
|
import { useDetailPanel } from '@/contexts/DetailPanelContext'
|
||||||
|
import { useFocusTrap } from '@/hooks/useFocusTrap'
|
||||||
|
import { DetailPanelContent } from '@/types/pmr'
|
||||||
|
import type { CardHeaderProps } from './Card'
|
||||||
|
import { KPIDetail } from './detail/KPIDetail'
|
||||||
|
import { ConsultationDetail } from './detail/ConsultationDetail'
|
||||||
|
import { SkillDetail } from './detail/SkillDetail'
|
||||||
|
import { SkillsAllDetail } from './detail/SkillsAllDetail'
|
||||||
|
import { EducationDetail } from './detail/EducationDetail'
|
||||||
|
import { ProjectDetail } from './detail/ProjectDetail'
|
||||||
|
import { DOT_COLORS } from '@/lib/theme-colors'
|
||||||
|
|
||||||
|
// Width mapping from content type
|
||||||
|
const widthMap: Record<DetailPanelContent['type'], 'narrow' | 'wide'> = {
|
||||||
|
kpi: 'narrow',
|
||||||
|
skill: 'narrow',
|
||||||
|
'skills-all': 'narrow',
|
||||||
|
consultation: 'wide',
|
||||||
|
project: 'wide',
|
||||||
|
education: 'narrow',
|
||||||
|
'career-role': 'wide',
|
||||||
|
}
|
||||||
|
|
||||||
|
// Title mapping from content data
|
||||||
|
function getPanelTitle(content: DetailPanelContent): string {
|
||||||
|
switch (content.type) {
|
||||||
|
case 'kpi':
|
||||||
|
return content.kpi.label
|
||||||
|
case 'skill':
|
||||||
|
return content.skill.name
|
||||||
|
case 'skills-all':
|
||||||
|
return 'All Medications'
|
||||||
|
case 'consultation':
|
||||||
|
return content.consultation.role
|
||||||
|
case 'project':
|
||||||
|
return content.investigation.name
|
||||||
|
case 'education':
|
||||||
|
return content.document.title
|
||||||
|
case 'career-role':
|
||||||
|
return content.consultation.role
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dot color mapping from content type
|
||||||
|
function getDotColor(content: DetailPanelContent): CardHeaderProps['dotColor'] {
|
||||||
|
switch (content.type) {
|
||||||
|
case 'kpi':
|
||||||
|
return 'teal'
|
||||||
|
case 'skill':
|
||||||
|
case 'skills-all':
|
||||||
|
return 'amber'
|
||||||
|
case 'consultation':
|
||||||
|
case 'career-role':
|
||||||
|
return 'teal'
|
||||||
|
case 'project':
|
||||||
|
return 'amber'
|
||||||
|
case 'education':
|
||||||
|
return 'purple'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DetailPanel() {
|
||||||
|
const { content, closePanel, isOpen, isClosing } = useDetailPanel()
|
||||||
|
const panelRef = useRef<HTMLDivElement>(null)
|
||||||
|
const titleId = 'detail-panel-title'
|
||||||
|
|
||||||
|
// Focus trap when open
|
||||||
|
useFocusTrap(panelRef, isOpen)
|
||||||
|
|
||||||
|
// Close on Escape key
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return
|
||||||
|
|
||||||
|
const handleEscape = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
closePanel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('keydown', handleEscape)
|
||||||
|
return () => document.removeEventListener('keydown', handleEscape)
|
||||||
|
}, [isOpen, closePanel])
|
||||||
|
|
||||||
|
if ((!isOpen && !isClosing) || !content) return null
|
||||||
|
|
||||||
|
const width = widthMap[content.type]
|
||||||
|
const title = getPanelTitle(content)
|
||||||
|
const dotColor = getDotColor(content)
|
||||||
|
const dotColorValue = DOT_COLORS[dotColor]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
inset: 0,
|
||||||
|
backgroundColor: 'var(--backdrop-bg)',
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Panel */}
|
||||||
|
<div
|
||||||
|
ref={panelRef}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby={titleId}
|
||||||
|
className="detail-panel"
|
||||||
|
data-width={width}
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
backgroundColor: 'var(--surface)',
|
||||||
|
boxShadow: 'var(--shadow-lg)',
|
||||||
|
zIndex: 1001,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
animation: isClosing ? 'panel-slide-out 250ms ease-in forwards' : 'panel-slide-in 250ms ease-out',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div
|
||||||
|
data-panel-header=""
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
padding: '20px 24px',
|
||||||
|
borderBottom: '1px solid var(--border-light)',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '8px',
|
||||||
|
height: '8px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
backgroundColor: dotColorValue,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<h2
|
||||||
|
id={titleId}
|
||||||
|
style={{
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
fontFamily: 'var(--font-ui)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={closePanel}
|
||||||
|
aria-label="Close panel"
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
width: '44px',
|
||||||
|
height: '44px',
|
||||||
|
border: 'none',
|
||||||
|
background: 'transparent',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
color: 'var(--text-secondary)',
|
||||||
|
transition: 'background-color 150ms, color 150ms',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = 'var(--accent-light)'
|
||||||
|
e.currentTarget.style.color = 'var(--accent)'
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = 'transparent'
|
||||||
|
e.currentTarget.style.color = 'var(--text-secondary)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body (scrollable) */}
|
||||||
|
<div
|
||||||
|
data-panel-body=""
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
overflowY: 'auto',
|
||||||
|
padding: '24px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Render content based on type */}
|
||||||
|
{content.type === 'kpi' && <KPIDetail kpi={content.kpi} />}
|
||||||
|
{(content.type === 'consultation' || content.type === 'career-role') && (
|
||||||
|
<ConsultationDetail consultation={content.consultation} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{content.type === 'skill' && <SkillDetail skill={content.skill} />}
|
||||||
|
{content.type === 'skills-all' && <SkillsAllDetail category={content.category} />}
|
||||||
|
{content.type === 'education' && <EducationDetail document={content.document} />}
|
||||||
|
{content.type === 'project' && <ProjectDetail investigation={content.investigation} />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,345 +0,0 @@
|
|||||||
import { useEffect, useRef, useCallback } from 'react'
|
|
||||||
import { motion, AnimatePresence } from 'framer-motion'
|
|
||||||
|
|
||||||
interface ECGAnimationProps {
|
|
||||||
onComplete: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Point {
|
|
||||||
x: number
|
|
||||||
y: number
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Beat {
|
|
||||||
startTime: number
|
|
||||||
widthPx: number
|
|
||||||
amplitude: number
|
|
||||||
startWX: number
|
|
||||||
}
|
|
||||||
|
|
||||||
interface LetterLayout {
|
|
||||||
char: string
|
|
||||||
startX: number
|
|
||||||
endX: number
|
|
||||||
centerX: number
|
|
||||||
}
|
|
||||||
|
|
||||||
const ECG_LETTERS: Record<string, Point[]> = {
|
|
||||||
A: [{x:0,y:0},{x:0.48,y:1},{x:0.53,y:0.42},{x:0.6,y:0.42},{x:1,y:0}],
|
|
||||||
N: [{x:0,y:0},{x:0.12,y:1},{x:0.72,y:0},{x:0.88,y:1},{x:1,y:0}],
|
|
||||||
D: [{x:0,y:0},{x:0.1,y:1},{x:0.5,y:1},{x:0.85,y:0.55},{x:1,y:0}],
|
|
||||||
R: [{x:0,y:0},{x:0.1,y:1},{x:0.35,y:1},{x:0.5,y:0.6},{x:0.55,y:0.45},{x:1,y:0}],
|
|
||||||
E: [{x:0,y:0},{x:0.1,y:1},{x:0.4,y:1},{x:0.45,y:0.5},{x:0.65,y:0.5},{x:0.7,y:0},{x:1,y:0}],
|
|
||||||
W: [{x:0,y:0},{x:0.05,y:1},{x:0.27,y:0},{x:0.5,y:0.65},{x:0.73,y:0},{x:0.95,y:1},{x:1,y:0}],
|
|
||||||
C: [{x:0,y:0},{x:0.08,y:0.6},{x:0.18,y:1},{x:0.6,y:1},{x:0.8,y:0.5},{x:0.95,y:0.1},{x:1,y:0}],
|
|
||||||
H: [{x:0,y:0},{x:0.1,y:1},{x:0.18,y:0.5},{x:0.82,y:0.5},{x:0.9,y:1},{x:1,y:0}],
|
|
||||||
L: [{x:0,y:0},{x:0.12,y:1},{x:0.3,y:1},{x:0.38,y:0},{x:1,y:0}],
|
|
||||||
O: [{x:0,y:0},{x:0.2,y:0.85},{x:0.35,y:1},{x:0.65,y:1},{x:0.8,y:0.85},{x:1,y:0}],
|
|
||||||
}
|
|
||||||
|
|
||||||
const ECG_TEXT = 'ANDREW CHARLWOOD'
|
|
||||||
|
|
||||||
function generateHeartbeatPoints(amplitude: number): Point[] {
|
|
||||||
const points: Point[] = []
|
|
||||||
const steps = 200
|
|
||||||
for (let i = 0; i <= steps; i++) {
|
|
||||||
const t = i / steps
|
|
||||||
let y = 0
|
|
||||||
if (t >= 0.05 && t < 0.2) { y = 0.12 * Math.sin(((t - 0.05) / 0.15) * Math.PI) }
|
|
||||||
else if (t >= 0.25 && t < 0.32) { y = -0.1 * Math.sin(((t - 0.25) / 0.07) * Math.PI) }
|
|
||||||
else if (t >= 0.32 && t < 0.42) { y = 1.0 * Math.sin(((t - 0.32) / 0.1) * Math.PI) }
|
|
||||||
else if (t >= 0.42 && t < 0.5) { y = -0.25 * Math.sin(((t - 0.42) / 0.08) * Math.PI) }
|
|
||||||
else if (t >= 0.55 && t < 0.75) { y = 0.2 * Math.sin(((t - 0.55) / 0.2) * Math.PI) }
|
|
||||||
points.push({ x: t, y: y * amplitude })
|
|
||||||
}
|
|
||||||
return points
|
|
||||||
}
|
|
||||||
|
|
||||||
function interpolateLetterY(points: Point[], t: number): number {
|
|
||||||
if (t <= points[0].x) return points[0].y
|
|
||||||
if (t >= points[points.length - 1].x) return points[points.length - 1].y
|
|
||||||
for (let i = 0; i < points.length - 1; i++) {
|
|
||||||
if (t >= points[i].x && t <= points[i + 1].x) {
|
|
||||||
const seg = (t - points[i].x) / (points[i + 1].x - points[i].x)
|
|
||||||
return points[i].y + (points[i + 1].y - points[i].y) * seg
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
function ecgGetTextWidth(lw: number, lg: number, sw: number): number {
|
|
||||||
const chars = ECG_TEXT.replace(/ /g, '').length
|
|
||||||
const spaces = ECG_TEXT.split(' ').length - 1
|
|
||||||
return chars * (lw + lg) - lg + spaces * sw
|
|
||||||
}
|
|
||||||
|
|
||||||
function ecgLayoutText(offsetX: number, lw: number, lg: number, sw: number): LetterLayout[] {
|
|
||||||
const layout: LetterLayout[] = []
|
|
||||||
let cursor = offsetX
|
|
||||||
for (let i = 0; i < ECG_TEXT.length; i++) {
|
|
||||||
const ch = ECG_TEXT[i]
|
|
||||||
if (ch === ' ') { cursor += sw; continue }
|
|
||||||
layout.push({ char: ch, startX: cursor, endX: cursor + lw, centerX: cursor + lw / 2 })
|
|
||||||
cursor += lw + lg
|
|
||||||
}
|
|
||||||
return layout
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ECGAnimation({ onComplete }: ECGAnimationProps) {
|
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
|
||||||
const animationRef = useRef<number | null>(null)
|
|
||||||
const startTsRef = useRef<number | null>(null)
|
|
||||||
const bgTransitionedRef = useRef(false)
|
|
||||||
const completedRef = useRef(false)
|
|
||||||
|
|
||||||
const finishAnimation = useCallback(() => {
|
|
||||||
if (completedRef.current) return
|
|
||||||
completedRef.current = true
|
|
||||||
if (animationRef.current) {
|
|
||||||
cancelAnimationFrame(animationRef.current)
|
|
||||||
}
|
|
||||||
onComplete()
|
|
||||||
}, [onComplete])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const canvas = canvasRef.current
|
|
||||||
const container = containerRef.current
|
|
||||||
if (!canvas || !container) return
|
|
||||||
|
|
||||||
const ctx = canvas.getContext('2d')
|
|
||||||
if (!ctx) return
|
|
||||||
|
|
||||||
const vw = window.innerWidth
|
|
||||||
const vh = window.innerHeight
|
|
||||||
const dpr = window.devicePixelRatio || 1
|
|
||||||
|
|
||||||
canvas.width = vw * dpr
|
|
||||||
canvas.height = vh * dpr
|
|
||||||
ctx.scale(dpr, dpr)
|
|
||||||
|
|
||||||
const scale = Math.min(1.2, Math.max(0.35, vw / 1400))
|
|
||||||
const LETTER_W = 72 * scale
|
|
||||||
const LETTER_G = 10 * scale
|
|
||||||
const SPACE_W = 30 * scale
|
|
||||||
const TRACE_SPEED = 450 * scale
|
|
||||||
const FLAT_GAP = 0.4
|
|
||||||
const HOLD_TIME = 0.75
|
|
||||||
const EXIT_TIME = 0.8
|
|
||||||
const baselineY = vh * 0.5
|
|
||||||
const ecgMaxDefl = vh * 0.25
|
|
||||||
const textMaxDefl = vh * 0.08
|
|
||||||
const lineColor = '#00ff41'
|
|
||||||
|
|
||||||
const beats: Beat[] = [
|
|
||||||
{ startTime: 0.5, widthPx: 60 * scale, amplitude: 0.3, startWX: 0 },
|
|
||||||
{ startTime: 1.2, widthPx: 90 * scale, amplitude: 0.55, startWX: 0 },
|
|
||||||
{ startTime: 2.0, widthPx: 120 * scale, amplitude: 0.85, startWX: 0 },
|
|
||||||
{ startTime: 2.8, widthPx: 140 * scale, amplitude: 1.0, startWX: 0 },
|
|
||||||
]
|
|
||||||
beats.forEach((b) => { b.startWX = b.startTime * TRACE_SPEED })
|
|
||||||
|
|
||||||
const lastBeat = beats[beats.length - 1]
|
|
||||||
const lastBeatEndWX = lastBeat.startWX + lastBeat.widthPx
|
|
||||||
const textStartWX = lastBeatEndWX + FLAT_GAP * TRACE_SPEED
|
|
||||||
const totalTextW = ecgGetTextWidth(LETTER_W, LETTER_G, SPACE_W)
|
|
||||||
const textEndWX = textStartWX + totalTextW
|
|
||||||
const textLayout = ecgLayoutText(textStartWX, LETTER_W, LETTER_G, SPACE_W)
|
|
||||||
const fontSize = Math.round(textMaxDefl / 0.715)
|
|
||||||
|
|
||||||
const headScreenRatio = 0.75
|
|
||||||
const finalHeadSX = (vw - totalTextW) / 2 + totalTextW
|
|
||||||
const textEndTime = textEndWX / TRACE_SPEED
|
|
||||||
const holdEndTime = textEndTime + HOLD_TIME
|
|
||||||
const exitEndTime = holdEndTime + EXIT_TIME
|
|
||||||
|
|
||||||
const getYAtX = (wx: number): number => {
|
|
||||||
for (let i = 0; i < beats.length; i++) {
|
|
||||||
const b = beats[i]
|
|
||||||
if (wx >= b.startWX && wx <= b.startWX + b.widthPx) {
|
|
||||||
const prog = (wx - b.startWX) / b.widthPx
|
|
||||||
const pts = generateHeartbeatPoints(b.amplitude)
|
|
||||||
const idx = Math.min(Math.floor(prog * (pts.length - 1)), pts.length - 1)
|
|
||||||
return baselineY - pts[idx].y * ecgMaxDefl
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (let j = 0; j < textLayout.length; j++) {
|
|
||||||
const item = textLayout[j]
|
|
||||||
if (wx >= item.startX && wx <= item.endX) {
|
|
||||||
const t = (wx - item.startX) / (item.endX - item.startX)
|
|
||||||
const ld = ECG_LETTERS[item.char]
|
|
||||||
if (ld) return baselineY - interpolateLetterY(ld, t) * textMaxDefl
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return baselineY
|
|
||||||
}
|
|
||||||
|
|
||||||
const animate = (timestamp: number) => {
|
|
||||||
if (!startTsRef.current) startTsRef.current = timestamp
|
|
||||||
const elapsed = (timestamp - startTsRef.current) / 1000
|
|
||||||
|
|
||||||
if (elapsed >= exitEndTime) {
|
|
||||||
finishAnimation()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.clearRect(0, 0, vw, vh)
|
|
||||||
|
|
||||||
let headWX = elapsed * TRACE_SPEED
|
|
||||||
const isExitPhase = elapsed >= holdEndTime
|
|
||||||
|
|
||||||
if (isExitPhase) {
|
|
||||||
headWX = textEndWX + (elapsed - holdEndTime) * TRACE_SPEED * 1.5
|
|
||||||
}
|
|
||||||
|
|
||||||
let headSX: number
|
|
||||||
let viewOff: number
|
|
||||||
const headSXEcg = headScreenRatio * vw
|
|
||||||
|
|
||||||
if (headWX <= textStartWX) {
|
|
||||||
viewOff = Math.max(0, headWX - headSXEcg)
|
|
||||||
headSX = headWX - viewOff
|
|
||||||
} else if (headWX >= textEndWX || isExitPhase) {
|
|
||||||
viewOff = textEndWX - finalHeadSX
|
|
||||||
headSX = headWX - viewOff
|
|
||||||
} else {
|
|
||||||
const p = (headWX - textStartWX) / (textEndWX - textStartWX)
|
|
||||||
headSX = headSXEcg + p * (finalHeadSX - headSXEcg)
|
|
||||||
viewOff = headWX - headSX
|
|
||||||
}
|
|
||||||
|
|
||||||
const fadeAlpha = isExitPhase ? Math.max(0, 1 - (elapsed - holdEndTime) / EXIT_TIME) : 1
|
|
||||||
|
|
||||||
if (!bgTransitionedRef.current && elapsed >= textEndTime - 0.3) {
|
|
||||||
bgTransitionedRef.current = true
|
|
||||||
container.style.transition = 'background 1200ms ease-out'
|
|
||||||
container.style.background = '#FFFFFF'
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.save()
|
|
||||||
ctx.globalAlpha = fadeAlpha
|
|
||||||
|
|
||||||
const traceStart = Math.max(0, Math.floor(viewOff))
|
|
||||||
const traceEnd = Math.min(Math.ceil(isExitPhase ? textEndWX : headWX), Math.ceil(viewOff + vw))
|
|
||||||
|
|
||||||
if (traceEnd > traceStart) {
|
|
||||||
ctx.beginPath()
|
|
||||||
ctx.strokeStyle = 'rgba(0, 255, 65, 0.25)'
|
|
||||||
ctx.lineWidth = 6
|
|
||||||
ctx.lineJoin = 'round'
|
|
||||||
ctx.lineCap = 'round'
|
|
||||||
ctx.shadowColor = lineColor
|
|
||||||
ctx.shadowBlur = 14
|
|
||||||
for (let wx = traceStart; wx <= traceEnd; wx++) {
|
|
||||||
const sx = wx - viewOff
|
|
||||||
const sy = getYAtX(wx)
|
|
||||||
if (wx === traceStart) ctx.moveTo(sx, sy)
|
|
||||||
else ctx.lineTo(sx, sy)
|
|
||||||
}
|
|
||||||
ctx.stroke()
|
|
||||||
|
|
||||||
ctx.beginPath()
|
|
||||||
ctx.strokeStyle = lineColor
|
|
||||||
ctx.lineWidth = 2
|
|
||||||
ctx.shadowBlur = 4
|
|
||||||
for (let wx = traceStart; wx <= traceEnd; wx++) {
|
|
||||||
const sx = wx - viewOff
|
|
||||||
const sy = getYAtX(wx)
|
|
||||||
if (wx === traceStart) ctx.moveTo(sx, sy)
|
|
||||||
else ctx.lineTo(sx, sy)
|
|
||||||
}
|
|
||||||
ctx.stroke()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isExitPhase) {
|
|
||||||
const exitStartSX = textEndWX - viewOff
|
|
||||||
const exitEndSX = headWX - viewOff
|
|
||||||
ctx.beginPath()
|
|
||||||
ctx.strokeStyle = lineColor
|
|
||||||
ctx.lineWidth = 2
|
|
||||||
ctx.shadowBlur = 8
|
|
||||||
ctx.moveTo(exitStartSX, baselineY)
|
|
||||||
ctx.lineTo(exitEndSX, baselineY)
|
|
||||||
ctx.stroke()
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.shadowColor = lineColor
|
|
||||||
ctx.shadowBlur = 8
|
|
||||||
ctx.font = `bold ${fontSize}px Arial, Helvetica, sans-serif`
|
|
||||||
ctx.textAlign = 'center'
|
|
||||||
ctx.textBaseline = 'alphabetic'
|
|
||||||
ctx.lineWidth = 1.5 * scale
|
|
||||||
ctx.strokeStyle = lineColor
|
|
||||||
|
|
||||||
for (let k = 0; k < textLayout.length; k++) {
|
|
||||||
const item = textLayout[k]
|
|
||||||
const letterProgress = (headWX - item.startX) / (item.endX - item.startX)
|
|
||||||
if (letterProgress > 0.3) {
|
|
||||||
const alpha = Math.min(1, (letterProgress - 0.3) * 1.43)
|
|
||||||
ctx.globalAlpha = fadeAlpha * alpha
|
|
||||||
const lsx = item.centerX - viewOff
|
|
||||||
ctx.strokeText(item.char, lsx, baselineY)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.globalAlpha = fadeAlpha
|
|
||||||
ctx.shadowBlur = 0
|
|
||||||
if (headSX >= -20 && headSX <= vw + 20) {
|
|
||||||
const headY = isExitPhase ? baselineY : getYAtX(headWX)
|
|
||||||
const grad = ctx.createRadialGradient(headSX, headY, 0, headSX, headY, 20 * scale)
|
|
||||||
grad.addColorStop(0, 'rgba(255,255,255,0.8)')
|
|
||||||
grad.addColorStop(0.3, 'rgba(0,255,65,0.6)')
|
|
||||||
grad.addColorStop(1, 'rgba(0,255,65,0)')
|
|
||||||
ctx.fillStyle = grad
|
|
||||||
ctx.beginPath()
|
|
||||||
ctx.arc(headSX, headY, 20 * scale, 0, Math.PI * 2)
|
|
||||||
ctx.fill()
|
|
||||||
|
|
||||||
ctx.fillStyle = lineColor
|
|
||||||
ctx.beginPath()
|
|
||||||
ctx.arc(headSX, headY, 3, 0, Math.PI * 2)
|
|
||||||
ctx.fill()
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.restore()
|
|
||||||
|
|
||||||
ctx.fillStyle = 'rgba(0, 0, 0, 0.05)'
|
|
||||||
for (let sly = 0; sly < vh; sly += 4) {
|
|
||||||
ctx.fillRect(0, sly + 2, vw, 2)
|
|
||||||
}
|
|
||||||
|
|
||||||
const vig = ctx.createRadialGradient(vw / 2, vh / 2, vh * 0.3, vw / 2, vh / 2, vh * 0.85)
|
|
||||||
vig.addColorStop(0, 'rgba(0,0,0,0)')
|
|
||||||
vig.addColorStop(1, 'rgba(0,0,0,0.4)')
|
|
||||||
ctx.fillStyle = vig
|
|
||||||
ctx.fillRect(0, 0, vw, vh)
|
|
||||||
|
|
||||||
animationRef.current = requestAnimationFrame(animate)
|
|
||||||
}
|
|
||||||
|
|
||||||
animationRef.current = requestAnimationFrame(animate)
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (animationRef.current) {
|
|
||||||
cancelAnimationFrame(animationRef.current)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [finishAnimation])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AnimatePresence>
|
|
||||||
<motion.div
|
|
||||||
ref={containerRef}
|
|
||||||
className="fixed inset-0 z-50 bg-black"
|
|
||||||
initial={{ opacity: 1 }}
|
|
||||||
exit={{ opacity: 0 }}
|
|
||||||
transition={{ duration: 0.5, ease: 'easeOut' }}
|
|
||||||
>
|
|
||||||
<canvas
|
|
||||||
ref={canvasRef}
|
|
||||||
className="w-full h-full"
|
|
||||||
/>
|
|
||||||
</motion.div>
|
|
||||||
</AnimatePresence>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
import { motion } from 'framer-motion'
|
|
||||||
import { useScrollReveal } from '@/hooks/useScrollReveal'
|
|
||||||
import type { Education as EducationType } from '@/types'
|
|
||||||
|
|
||||||
const educationData: EducationType[] = [
|
|
||||||
{
|
|
||||||
degree: 'MPharm (Hons) Pharmacy',
|
|
||||||
institution: 'University of East Anglia',
|
|
||||||
period: '2011 — 2015',
|
|
||||||
detail: 'Upper Second-Class Honours (2:1)',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
degree: 'Mary Seacole Leadership Programme',
|
|
||||||
institution: 'NHS Leadership Academy',
|
|
||||||
period: '2018',
|
|
||||||
detail: 'National healthcare leadership development programme.',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
const EducationCard = ({
|
|
||||||
education,
|
|
||||||
delay,
|
|
||||||
isVisible,
|
|
||||||
}: {
|
|
||||||
education: EducationType
|
|
||||||
delay: number
|
|
||||||
isVisible: boolean
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 24 }}
|
|
||||||
animate={isVisible ? { opacity: 1, y: 0 } : { opacity: 0, y: 24 }}
|
|
||||||
transition={{ duration: 0.5, delay, ease: 'easeOut' }}
|
|
||||||
className="relative bg-white rounded-2xl p-6 shadow-sm overflow-hidden transition-shadow hover:shadow-md hover:-translate-y-0.5"
|
|
||||||
>
|
|
||||||
<div className="absolute top-0 left-0 right-0 h-[3px] bg-gradient-to-r from-teal to-coral" />
|
|
||||||
<h3 className="font-primary text-[17px] font-semibold text-heading leading-tight">
|
|
||||||
{education.degree}
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm text-teal mt-0.5">{education.institution}</p>
|
|
||||||
<p className="text-[13px] text-muted mt-0.5">{education.period}</p>
|
|
||||||
<p className="text-sm text-text mt-1.5 leading-relaxed">
|
|
||||||
{education.detail}
|
|
||||||
</p>
|
|
||||||
</motion.div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Education() {
|
|
||||||
const [sectionRef, isVisible] = useScrollReveal<HTMLElement>({
|
|
||||||
threshold: 0.1,
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section id="education" ref={sectionRef} className="py-12 xs:py-16 md:py-20">
|
|
||||||
<motion.h2
|
|
||||||
initial={{ opacity: 0, y: 12 }}
|
|
||||||
animate={isVisible ? { opacity: 1, y: 0 } : { opacity: 0, y: 12 }}
|
|
||||||
transition={{ duration: 0.5 }}
|
|
||||||
className="font-primary text-2xl font-bold text-heading text-center mb-8"
|
|
||||||
>
|
|
||||||
Education
|
|
||||||
</motion.h2>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-5">
|
|
||||||
{educationData.map((education, index) => (
|
|
||||||
<EducationCard
|
|
||||||
key={education.degree}
|
|
||||||
education={education}
|
|
||||||
delay={0.1 + index * 0.1}
|
|
||||||
isVisible={isVisible}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<motion.p
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={isVisible ? { opacity: 1 } : { opacity: 0 }}
|
|
||||||
transition={{ duration: 0.5, delay: 0.4 }}
|
|
||||||
className="text-[13px] text-muted text-center mt-5"
|
|
||||||
>
|
|
||||||
A-Levels: Mathematics (A*), Chemistry (B), Politics (C)
|
|
||||||
</motion.p>
|
|
||||||
</section>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import React, { useCallback } from 'react'
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
|
import { ChevronRight } from 'lucide-react'
|
||||||
|
import { hexToRgba, motionSafeTransition } from '@/lib/utils'
|
||||||
|
|
||||||
|
interface ExpandableCardShellProps {
|
||||||
|
isExpanded: boolean
|
||||||
|
isHighlighted: boolean
|
||||||
|
isDimmedByFocus?: boolean
|
||||||
|
accentColor: string
|
||||||
|
onToggle: () => void
|
||||||
|
ariaLabel: string
|
||||||
|
headerPadding?: string
|
||||||
|
className?: string
|
||||||
|
dataTileId?: string
|
||||||
|
onMouseEnter?: () => void
|
||||||
|
onMouseLeave?: () => void
|
||||||
|
renderHeader: () => React.ReactNode
|
||||||
|
renderBody: () => React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExpandableCardShell({
|
||||||
|
isExpanded,
|
||||||
|
isHighlighted,
|
||||||
|
isDimmedByFocus = false,
|
||||||
|
accentColor,
|
||||||
|
onToggle,
|
||||||
|
ariaLabel,
|
||||||
|
headerPadding = '12px 14px',
|
||||||
|
className,
|
||||||
|
dataTileId,
|
||||||
|
onMouseEnter,
|
||||||
|
onMouseLeave,
|
||||||
|
renderHeader,
|
||||||
|
renderBody,
|
||||||
|
}: ExpandableCardShellProps) {
|
||||||
|
const handleKeyDown = useCallback(
|
||||||
|
(e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault()
|
||||||
|
onToggle()
|
||||||
|
}
|
||||||
|
if (e.key === 'Escape' && isExpanded) {
|
||||||
|
e.preventDefault()
|
||||||
|
onToggle()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onToggle, isExpanded],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-tile-id={dataTileId}
|
||||||
|
className={className}
|
||||||
|
onMouseEnter={onMouseEnter}
|
||||||
|
onMouseLeave={onMouseLeave}
|
||||||
|
style={{
|
||||||
|
opacity: isDimmedByFocus ? 0.25 : 1,
|
||||||
|
transition: 'opacity 150ms ease-out',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: isHighlighted ? hexToRgba(accentColor, 0.03) : 'var(--bg-dashboard)',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
border: `1px solid ${isExpanded || isHighlighted ? hexToRgba(accentColor, 0.2) : 'var(--border-light)'}`,
|
||||||
|
transition: 'border-color 0.15s, box-shadow 0.15s, background-color 0.15s',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={onToggle}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
aria-expanded={isExpanded}
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: '10px',
|
||||||
|
padding: headerPadding,
|
||||||
|
cursor: 'pointer',
|
||||||
|
minHeight: '44px',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (!isExpanded) {
|
||||||
|
e.currentTarget.parentElement!.style.borderColor = hexToRgba(accentColor, 0.2)
|
||||||
|
e.currentTarget.parentElement!.style.boxShadow = 'var(--shadow-md)'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
if (!isExpanded) {
|
||||||
|
e.currentTarget.parentElement!.style.borderColor = 'var(--border-light)'
|
||||||
|
e.currentTarget.parentElement!.style.boxShadow = 'none'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
width: '9px',
|
||||||
|
height: '9px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: accentColor,
|
||||||
|
flexShrink: 0,
|
||||||
|
marginTop: '4px',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
{renderHeader()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ChevronRight
|
||||||
|
size={14}
|
||||||
|
style={{
|
||||||
|
color: 'var(--text-tertiary)',
|
||||||
|
flexShrink: 0,
|
||||||
|
marginTop: '2px',
|
||||||
|
transform: isExpanded ? 'rotate(90deg)' : 'none',
|
||||||
|
transition: 'transform 0.15s ease-out',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{isExpanded && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ height: 0 }}
|
||||||
|
animate={{ height: 'auto' }}
|
||||||
|
exit={{ height: 0 }}
|
||||||
|
transition={motionSafeTransition(0.2)}
|
||||||
|
style={{ overflow: 'hidden' }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '0 12px 12px 30px',
|
||||||
|
borderTop: '1px solid var(--border-light)',
|
||||||
|
paddingTop: '12px',
|
||||||
|
borderLeft: `2px solid ${accentColor}`,
|
||||||
|
marginLeft: '12px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{renderBody()}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,164 +0,0 @@
|
|||||||
import { motion } from 'framer-motion'
|
|
||||||
import { useScrollReveal } from '@/hooks/useScrollReveal'
|
|
||||||
import type { Experience as ExperienceType } from '@/types'
|
|
||||||
|
|
||||||
const experiences: ExperienceType[] = [
|
|
||||||
{
|
|
||||||
role: 'Interim Head of Population Health & Data Analysis',
|
|
||||||
org: 'NHS Norfolk & Waveney ICB',
|
|
||||||
date: 'May 2025 — Nov 2025',
|
|
||||||
bullets: [
|
|
||||||
'Led team through organisational transition, maintaining delivery of £14.6M efficiency programme',
|
|
||||||
'Directed strategic priorities for population health analytics across Norfolk & Waveney (population ~1M)',
|
|
||||||
'Managed stakeholder relationships with system leaders, provider trusts, and primary care networks',
|
|
||||||
],
|
|
||||||
isCurrent: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
role: 'Deputy Head of Population Health & Data Analysis',
|
|
||||||
org: 'NHS Norfolk & Waveney ICB',
|
|
||||||
date: 'Jul 2024 — Present',
|
|
||||||
bullets: [
|
|
||||||
'Deputised for Head of department across all operational and strategic functions',
|
|
||||||
'Oversaw £220M medicines budget and led programme of cost improvement initiatives',
|
|
||||||
'Developed Python-based switching algorithm processing 14,000 patients, delivering £2.6M savings',
|
|
||||||
'Built Blueteq automation system reducing processing time by 70%, saving 200+ hours annually',
|
|
||||||
'Created PharMetrics dashboard platform for real-time medicines expenditure tracking',
|
|
||||||
],
|
|
||||||
isCurrent: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
role: 'High-Cost Drugs & Interface Pharmacist',
|
|
||||||
org: 'NHS Norfolk & Waveney ICB',
|
|
||||||
date: 'May 2022 — Jul 2024',
|
|
||||||
bullets: [
|
|
||||||
'Managed high-cost drugs budget across acute and community settings',
|
|
||||||
'Led NICE Technology Appraisal implementation and horizon scanning',
|
|
||||||
'Developed health economic models for biosimilar switching programmes',
|
|
||||||
'Built data pipelines for automated reporting of medicines expenditure',
|
|
||||||
],
|
|
||||||
isCurrent: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
role: 'Pharmacy Manager',
|
|
||||||
org: 'Tesco Pharmacy',
|
|
||||||
date: 'Nov 2017 — May 2022',
|
|
||||||
bullets: [
|
|
||||||
'Managed community pharmacy delivering 3,000+ items monthly',
|
|
||||||
'Pioneered asthma screening service generating £1M+ national revenue',
|
|
||||||
'Led team of 6 through COVID-19 pandemic service delivery',
|
|
||||||
'Completed Mary Seacole NHS Leadership Programme (2018)',
|
|
||||||
],
|
|
||||||
isCurrent: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
role: 'Duty Pharmacy Manager',
|
|
||||||
org: 'Tesco Pharmacy',
|
|
||||||
date: 'Aug 2016 — Nov 2017',
|
|
||||||
bullets: [
|
|
||||||
'Supported pharmacy manager in daily operations and clinical services',
|
|
||||||
'Delivered Medicines Use Reviews and New Medicine Service consultations',
|
|
||||||
'Maintained controlled drug compliance and clinical governance standards',
|
|
||||||
],
|
|
||||||
isCurrent: false,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
const ECGDecoration = () => (
|
|
||||||
<svg
|
|
||||||
className="shrink-0 w-[120px] xs:w-[200px] h-[30px]"
|
|
||||||
viewBox="0 0 200 30"
|
|
||||||
fill="none"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M 0 15 L 40 15 L 50 15 C 53 15 55 12 58 12 C 61 12 63 15 66 15 L 76 15 L 80 20 L 86 2 L 92 22 L 96 15 L 106 15 C 109 15 111 11 114 11 C 117 11 120 15 123 15 L 200 15"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="1.5"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
className="text-teal opacity-30"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
|
|
||||||
const TimelineEntry = ({
|
|
||||||
experience,
|
|
||||||
index,
|
|
||||||
isVisible,
|
|
||||||
}: {
|
|
||||||
experience: ExperienceType
|
|
||||||
index: number
|
|
||||||
isVisible: boolean
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<motion.div
|
|
||||||
className="relative pl-0 md:pl-[calc(20%+32px)] mb-8 last:mb-0"
|
|
||||||
initial={{ opacity: 0, y: 24 }}
|
|
||||||
animate={isVisible ? { opacity: 1, y: 0 } : { opacity: 0, y: 24 }}
|
|
||||||
transition={{ duration: 0.5, delay: index * 0.1 }}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={`absolute left-[20%] top-2 -translate-x-1/2 w-2.5 h-2.5 rounded-full border-2 border-teal bg-white z-10 hidden md:block ${
|
|
||||||
experience.isCurrent ? 'bg-teal' : ''
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
<motion.div
|
|
||||||
className="bg-white rounded-2xl p-4 xs:p-6 shadow-sm border-l-[3px] border-transparent hover:shadow-md hover:scale-[1.01] hover:border-l-teal/30 transition-all duration-300"
|
|
||||||
whileHover={{ scale: 1.01 }}
|
|
||||||
transition={{ duration: 0.2 }}
|
|
||||||
>
|
|
||||||
<h3 className="font-primary text-[17px] font-semibold text-heading leading-tight">
|
|
||||||
{experience.role}
|
|
||||||
</h3>
|
|
||||||
<p className="font-primary text-sm text-teal mt-0.5">{experience.org}</p>
|
|
||||||
<span className="inline-block px-2.5 py-0.5 mt-1.5 mb-3 bg-teal/8 rounded-full font-secondary text-xs text-teal font-medium">
|
|
||||||
{experience.date}
|
|
||||||
</span>
|
|
||||||
<ul className="space-y-1">
|
|
||||||
{experience.bullets.map((bullet, i) => (
|
|
||||||
<li
|
|
||||||
key={i}
|
|
||||||
className="relative pl-4 text-sm text-text leading-relaxed before:content-[''] before:absolute before:left-0 before:top-[10px] before:w-[5px] before:h-[5px] before:rounded-full before:bg-teal"
|
|
||||||
>
|
|
||||||
{bullet}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</motion.div>
|
|
||||||
</motion.div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Experience() {
|
|
||||||
const [sectionRef, isVisible] = useScrollReveal<HTMLDivElement>({ threshold: 0.1 })
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
id="experience"
|
|
||||||
ref={sectionRef}
|
|
||||||
className="py-12 xs:py-16 md:py-20 opacity-0 translate-y-6 transition-all duration-600 ease-out data-[visible=true]:opacity-100 data-[visible=true]:translate-y-0"
|
|
||||||
data-visible={isVisible}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-center gap-4 mb-8">
|
|
||||||
<h2 className="font-primary text-2xl font-bold text-heading">Experience</h2>
|
|
||||||
<ECGDecoration />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="relative">
|
|
||||||
<div className="absolute left-[20%] top-0 bottom-0 w-0.5 bg-teal/20 hidden md:block" />
|
|
||||||
|
|
||||||
<div className="space-y-0">
|
|
||||||
{experiences.map((exp, i) => (
|
|
||||||
<TimelineEntry
|
|
||||||
key={exp.role}
|
|
||||||
experience={exp}
|
|
||||||
index={i}
|
|
||||||
isVisible={isVisible}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
import { useCallback } from 'react'
|
|
||||||
import { motion } from 'framer-motion'
|
|
||||||
import { useActiveSection } from '@/hooks/useActiveSection'
|
|
||||||
|
|
||||||
interface NavLink {
|
|
||||||
id: string
|
|
||||||
label: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const navLinks: NavLink[] = [
|
|
||||||
{ id: 'about', label: 'About' },
|
|
||||||
{ id: 'skills', label: 'Skills' },
|
|
||||||
{ id: 'experience', label: 'Experience' },
|
|
||||||
{ id: 'education', label: 'Education' },
|
|
||||||
{ id: 'projects', label: 'Projects' },
|
|
||||||
{ id: 'contact', label: 'Contact' },
|
|
||||||
]
|
|
||||||
|
|
||||||
export function FloatingNav() {
|
|
||||||
const activeSection = useActiveSection()
|
|
||||||
|
|
||||||
const scrollToSection = useCallback((sectionId: string) => {
|
|
||||||
const element = document.getElementById(sectionId)
|
|
||||||
if (element) {
|
|
||||||
element.scrollIntoView({ behavior: 'smooth' })
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<motion.nav
|
|
||||||
className="fixed top-4 left-1/2 -translate-x-1/2 z-[100] max-w-[600px] w-[calc(100%-32px)] md:w-auto bg-white rounded-full py-2 px-4 md:px-6 shadow-md flex items-center gap-1 border border-border overflow-x-auto scrollbar-hide"
|
|
||||||
initial={{ opacity: 0, y: -20 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ duration: 0.5, ease: 'easeOut' }}
|
|
||||||
>
|
|
||||||
{navLinks.map((link) => {
|
|
||||||
const isActive = activeSection === link.id
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={link.id}
|
|
||||||
onClick={() => scrollToSection(link.id)}
|
|
||||||
className={`
|
|
||||||
relative font-secondary text-[11px] xs:text-[13px] font-medium py-1.5 px-2.5 xs:px-3.5 rounded-full
|
|
||||||
transition-colors duration-300 ease-out whitespace-nowrap
|
|
||||||
${isActive
|
|
||||||
? 'text-teal font-semibold'
|
|
||||||
: 'text-muted hover:text-teal hover:bg-teal-light'
|
|
||||||
}
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
{link.label}
|
|
||||||
{isActive && (
|
|
||||||
<motion.span
|
|
||||||
className="absolute bottom-0 left-1/2 -translate-x-1/2 w-1 h-1 rounded-full bg-teal"
|
|
||||||
layoutId="navIndicator"
|
|
||||||
initial={{ opacity: 0, scale: 0 }}
|
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
|
||||||
exit={{ opacity: 0, scale: 0 }}
|
|
||||||
transition={{ duration: 0.2, ease: 'easeOut' }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</motion.nav>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
import { motion } from 'framer-motion'
|
|
||||||
|
|
||||||
const Footer: React.FC = () => {
|
|
||||||
return (
|
|
||||||
<motion.footer
|
|
||||||
initial={{ opacity: 0, y: 16 }}
|
|
||||||
whileInView={{ opacity: 1, y: 0 }}
|
|
||||||
viewport={{ once: true, margin: '-50px' }}
|
|
||||||
transition={{ duration: 0.5, ease: 'easeOut' }}
|
|
||||||
className="text-center pt-8 xs:pt-12 pb-6 xs:pb-8 border-t border-slate-200"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
className="block mx-auto mb-3"
|
|
||||||
width="120"
|
|
||||||
height="20"
|
|
||||||
viewBox="0 0 120 20"
|
|
||||||
fill="none"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M 0 10 L 35 10 L 40 10 C 42 10 43 7 45 7 C 47 7 48 10 50 10 L 54 10 L 56 13 L 60 2 L 64 15 L 66 10 L 70 10 C 72 10 73 7 75 7 C 77 7 78 10 80 10 L 120 10"
|
|
||||||
stroke="#00897B"
|
|
||||||
strokeWidth="1.5"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
opacity="0.3"
|
|
||||||
fill="none"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<p className="font-secondary text-xs text-muted">
|
|
||||||
Andy Charlwood — MPharm, GPhC Registered Pharmacist
|
|
||||||
</p>
|
|
||||||
</motion.footer>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export { Footer }
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
import { motion } from 'framer-motion'
|
|
||||||
|
|
||||||
interface VitalCardProps {
|
|
||||||
value: string
|
|
||||||
label: string
|
|
||||||
valueSize?: 'default' | 'small' | 'medium'
|
|
||||||
delay?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
function VitalCard({ value, label, valueSize = 'default', delay = 0 }: VitalCardProps) {
|
|
||||||
const sizeClasses = {
|
|
||||||
default: 'text-[28px]',
|
|
||||||
small: 'text-base',
|
|
||||||
medium: 'text-lg'
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 16 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ duration: 0.5, delay, ease: 'easeOut' }}
|
|
||||||
className="bg-card-bg rounded-2xl px-6 py-5 shadow-sm border-t-[3px] border-teal min-w-[160px] text-center transition-all duration-300 hover:shadow-md hover:-translate-y-0.5"
|
|
||||||
>
|
|
||||||
<div className={`font-primary font-bold text-heading leading-tight ${sizeClasses[valueSize]}`}>
|
|
||||||
{value}
|
|
||||||
</div>
|
|
||||||
<div className="font-secondary text-[11px] uppercase tracking-wide text-muted mt-1">
|
|
||||||
{label}
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Hero() {
|
|
||||||
return (
|
|
||||||
<section
|
|
||||||
id="about"
|
|
||||||
className="min-h-screen flex flex-col justify-center items-center text-center py-12 xs:py-16 md:py-20"
|
|
||||||
>
|
|
||||||
<motion.h1
|
|
||||||
initial={{ opacity: 0, y: 24 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ duration: 0.6, ease: 'easeOut' }}
|
|
||||||
className="font-primary font-bold text-heading leading-tight"
|
|
||||||
style={{ fontSize: 'clamp(28px, 5vw, 52px)' }}
|
|
||||||
>
|
|
||||||
Andy Charlwood
|
|
||||||
</motion.h1>
|
|
||||||
|
|
||||||
<motion.p
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
transition={{ duration: 0.5, delay: 0.15 }}
|
|
||||||
className="text-muted text-base mt-2"
|
|
||||||
>
|
|
||||||
Deputy Head of Population Health & Data Analysis
|
|
||||||
</motion.p>
|
|
||||||
|
|
||||||
<motion.span
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
transition={{ duration: 0.5, delay: 0.2 }}
|
|
||||||
className="inline-block mt-1 px-4 py-1 border border-teal rounded-full text-xs text-teal font-medium"
|
|
||||||
>
|
|
||||||
Norwich, UK
|
|
||||||
</motion.span>
|
|
||||||
|
|
||||||
<motion.p
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
transition={{ duration: 0.5, delay: 0.3 }}
|
|
||||||
className="mt-6 max-w-[560px] text-text text-[15px] leading-[1.8]"
|
|
||||||
>
|
|
||||||
GPhC Registered Pharmacist specialising in medicines optimisation, population health analytics, and NHS efficiency programmes. Bridging clinical pharmacy with data science to drive meaningful improvements in patient outcomes.
|
|
||||||
</motion.p>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 xs:grid-cols-2 md:flex gap-4 mt-10 justify-center md:flex-wrap">
|
|
||||||
<VitalCard value="10+" label="Years Experience" delay={0.4} />
|
|
||||||
<VitalCard value="Python/SQL/BI" label="Analytics Stack" valueSize="small" delay={0.5} />
|
|
||||||
<VitalCard value="Pop. Health" label="Focus Area" valueSize="medium" delay={0.6} />
|
|
||||||
<VitalCard value="NHS N&W" label="System" valueSize="medium" delay={0.7} />
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { ChevronRight } from 'lucide-react'
|
||||||
|
import { CardHeader } from './Card'
|
||||||
|
import { useDetailPanel } from '@/contexts/DetailPanelContext'
|
||||||
|
import { timelineConsultations } from '@/data/timeline'
|
||||||
|
import { hexToRgba } from '@/lib/utils'
|
||||||
|
import { DEFAULT_ORG_COLOR } from '@/lib/theme-colors'
|
||||||
|
|
||||||
|
interface LastConsultationCardProps {
|
||||||
|
highlightedRoleId?: string | null
|
||||||
|
focusRelatedIds?: Set<string> | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LastConsultationCard({ highlightedRoleId, focusRelatedIds }: LastConsultationCardProps) {
|
||||||
|
const { openPanel } = useDetailPanel()
|
||||||
|
const consultation = timelineConsultations.find(c => c.isCurrent) ?? timelineConsultations[0]
|
||||||
|
if (!consultation) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const isHighlighted = highlightedRoleId === consultation.id
|
||||||
|
const isDimmed = focusRelatedIds != null && !focusRelatedIds.has(consultation.id)
|
||||||
|
|
||||||
|
const handleOpenPanel = () => {
|
||||||
|
openPanel({ type: 'consultation', consultation })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault()
|
||||||
|
handleOpenPanel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = (dateStr: string): string => {
|
||||||
|
const date = new Date(dateStr)
|
||||||
|
return date.toLocaleDateString('en-GB', { month: 'long', year: 'numeric' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const getEmploymentType = (): string => {
|
||||||
|
if (consultation.organization.includes('ICB')) {
|
||||||
|
return 'Permanent · Full-time'
|
||||||
|
}
|
||||||
|
return 'Permanent'
|
||||||
|
}
|
||||||
|
|
||||||
|
const getBand = (): string => {
|
||||||
|
return consultation.band ?? '—'
|
||||||
|
}
|
||||||
|
|
||||||
|
const fieldLabelStyle: React.CSSProperties = {
|
||||||
|
fontSize: '12px',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.06em',
|
||||||
|
color: 'var(--text-tertiary)',
|
||||||
|
marginBottom: '3px',
|
||||||
|
}
|
||||||
|
|
||||||
|
const fieldValueStyle: React.CSSProperties = {
|
||||||
|
fontSize: '13px',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: '24px',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: isHighlighted ? hexToRgba(consultation.orgColor ?? DEFAULT_ORG_COLOR, 0.2) : 'transparent',
|
||||||
|
background: isHighlighted ? hexToRgba(consultation.orgColor ?? DEFAULT_ORG_COLOR, 0.03) : 'transparent',
|
||||||
|
transition: 'border-color 150ms ease-out, background-color 150ms ease-out, opacity 150ms ease-out',
|
||||||
|
padding: '8px',
|
||||||
|
margin: '-8px',
|
||||||
|
opacity: isDimmed ? 0.25 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CardHeader dotColor="green" title="LATEST CONSULTATION" rightText="Current role" />
|
||||||
|
|
||||||
|
<div
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={handleOpenPanel}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: '20px',
|
||||||
|
marginBottom: '10px',
|
||||||
|
paddingBottom: '14px',
|
||||||
|
borderBottom: '1px solid var(--border-light)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
padding: '8px',
|
||||||
|
margin: '-8px -8px 14px -8px',
|
||||||
|
transition: 'background-color 150ms ease-out',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = hexToRgba(consultation.orgColor ?? DEFAULT_ORG_COLOR, 0.04)
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = 'transparent'
|
||||||
|
}}
|
||||||
|
aria-label={`View full details for ${consultation.role}`}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div style={fieldLabelStyle}>Date</div>
|
||||||
|
<div style={fieldValueStyle}>{formatDate(consultation.date)}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={fieldLabelStyle}>Organisation</div>
|
||||||
|
<div style={fieldValueStyle}>{consultation.organization}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={fieldLabelStyle}>Type</div>
|
||||||
|
<div style={fieldValueStyle}>{getEmploymentType()}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={fieldLabelStyle}>Band</div>
|
||||||
|
<div style={fieldValueStyle}>{getBand()}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '15px',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: consultation.orgColor ?? 'var(--accent)',
|
||||||
|
marginBottom: '12px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{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={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '6px',
|
||||||
|
fontSize: '13px',
|
||||||
|
fontWeight: 500,
|
||||||
|
color: consultation.orgColor ?? 'var(--accent)',
|
||||||
|
background: 'transparent',
|
||||||
|
border: 'none',
|
||||||
|
padding: '6px 0',
|
||||||
|
minHeight: '44px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'opacity 150ms ease-out',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.opacity = '0.7'
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.opacity = '1'
|
||||||
|
}}
|
||||||
|
aria-label="View full consultation record"
|
||||||
|
>
|
||||||
|
<span>View full record</span>
|
||||||
|
<ChevronRight size={15} strokeWidth={2.5} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,415 @@
|
|||||||
|
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||||
|
import { motion } from 'framer-motion'
|
||||||
|
import { CvmisLogo } from './CvmisLogo'
|
||||||
|
import { useAccessibility } from '../contexts/AccessibilityContext'
|
||||||
|
|
||||||
|
// ── Login screen timing & visual constants ──────────────────────────
|
||||||
|
const BACKDROP_BLUR_PX = 10
|
||||||
|
|
||||||
|
interface LoginScreenProps {
|
||||||
|
onComplete: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LoginScreen({ onComplete }: LoginScreenProps) {
|
||||||
|
const [username, setUsername] = useState('')
|
||||||
|
const [passwordDots, setPasswordDots] = useState(0)
|
||||||
|
const [showCursor, setShowCursor] = useState(true)
|
||||||
|
const [activeField, setActiveField] = useState<'username' | 'password' | 'done' | null>('username')
|
||||||
|
const [buttonPressed, setButtonPressed] = useState(false)
|
||||||
|
const [isExiting, setIsExiting] = useState(false)
|
||||||
|
const [typingComplete, setTypingComplete] = useState(false)
|
||||||
|
const [buttonHovered, setButtonHovered] = useState(false)
|
||||||
|
const [connectionState, setConnectionState] = useState<'connecting' | 'connected'>('connecting')
|
||||||
|
const [dotCount, setDotCount] = useState(0)
|
||||||
|
const { requestFocusAfterLogin } = useAccessibility()
|
||||||
|
|
||||||
|
const fullUsername = 'a.recruiter'
|
||||||
|
const passwordLength = 8
|
||||||
|
|
||||||
|
const prefersReducedMotion = typeof window !== 'undefined'
|
||||||
|
? window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||||
|
: false
|
||||||
|
|
||||||
|
// Refs for interval/timeout cleanup
|
||||||
|
const usernameIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||||
|
const passwordIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||||
|
const cursorIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||||
|
const timeoutRefs = useRef<ReturnType<typeof setTimeout>[]>([])
|
||||||
|
const dotIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||||
|
const loginButtonRef = useRef<HTMLButtonElement>(null)
|
||||||
|
|
||||||
|
const addTimeout = useCallback((fn: () => void, delay: number) => {
|
||||||
|
const id = setTimeout(fn, delay)
|
||||||
|
timeoutRefs.current.push(id)
|
||||||
|
return id
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const canLogin = typingComplete && connectionState === 'connected'
|
||||||
|
|
||||||
|
const handleLogin = useCallback(() => {
|
||||||
|
if (!canLogin || isExiting) return
|
||||||
|
setButtonPressed(true)
|
||||||
|
addTimeout(() => {
|
||||||
|
setIsExiting(true)
|
||||||
|
addTimeout(() => {
|
||||||
|
requestFocusAfterLogin()
|
||||||
|
onComplete()
|
||||||
|
}, prefersReducedMotion ? 0 : 400)
|
||||||
|
}, 100)
|
||||||
|
}, [canLogin, isExiting, onComplete, requestFocusAfterLogin, prefersReducedMotion, addTimeout])
|
||||||
|
|
||||||
|
const startLoginSequence = useCallback(() => {
|
||||||
|
if (prefersReducedMotion) {
|
||||||
|
setUsername(fullUsername)
|
||||||
|
setPasswordDots(passwordLength)
|
||||||
|
setActiveField('done')
|
||||||
|
setTypingComplete(true)
|
||||||
|
// Button is immediately available for user to click
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Username typing: 80ms per character
|
||||||
|
let usernameIndex = 0
|
||||||
|
usernameIntervalRef.current = setInterval(() => {
|
||||||
|
if (usernameIndex <= fullUsername.length) {
|
||||||
|
setUsername(fullUsername.slice(0, usernameIndex))
|
||||||
|
usernameIndex++
|
||||||
|
} else {
|
||||||
|
if (usernameIntervalRef.current) {
|
||||||
|
clearInterval(usernameIntervalRef.current)
|
||||||
|
}
|
||||||
|
setActiveField('password')
|
||||||
|
|
||||||
|
// Password dots: 60ms per dot, after 300ms pause
|
||||||
|
addTimeout(() => {
|
||||||
|
let dotCount = 0
|
||||||
|
passwordIntervalRef.current = setInterval(() => {
|
||||||
|
if (dotCount <= passwordLength) {
|
||||||
|
setPasswordDots(dotCount)
|
||||||
|
dotCount++
|
||||||
|
} else {
|
||||||
|
if (passwordIntervalRef.current) {
|
||||||
|
clearInterval(passwordIntervalRef.current)
|
||||||
|
}
|
||||||
|
setActiveField('done')
|
||||||
|
setTypingComplete(true)
|
||||||
|
// Button becomes interactive — user clicks to proceed
|
||||||
|
}
|
||||||
|
}, 40)
|
||||||
|
}, 200)
|
||||||
|
}
|
||||||
|
}, 55)
|
||||||
|
}, [prefersReducedMotion, addTimeout])
|
||||||
|
|
||||||
|
// Focus the login button when login becomes available for keyboard accessibility
|
||||||
|
useEffect(() => {
|
||||||
|
if (canLogin && loginButtonRef.current) {
|
||||||
|
loginButtonRef.current.focus()
|
||||||
|
}
|
||||||
|
}, [canLogin])
|
||||||
|
|
||||||
|
// Connection transitions to green 500ms after typing completes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!typingComplete) return
|
||||||
|
const timeout = addTimeout(() => {
|
||||||
|
setConnectionState('connected')
|
||||||
|
}, prefersReducedMotion ? 0 : 500)
|
||||||
|
return () => clearTimeout(timeout)
|
||||||
|
}, [typingComplete, addTimeout, prefersReducedMotion])
|
||||||
|
|
||||||
|
// Animated trailing dots while connecting
|
||||||
|
useEffect(() => {
|
||||||
|
if (connectionState === 'connected' || prefersReducedMotion) {
|
||||||
|
if (dotIntervalRef.current) clearInterval(dotIntervalRef.current)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dotIntervalRef.current = setInterval(() => {
|
||||||
|
setDotCount(prev => (prev + 1) % 4)
|
||||||
|
}, 500)
|
||||||
|
return () => {
|
||||||
|
if (dotIntervalRef.current) clearInterval(dotIntervalRef.current)
|
||||||
|
}
|
||||||
|
}, [connectionState, prefersReducedMotion])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Cursor blink: 530ms interval
|
||||||
|
cursorIntervalRef.current = setInterval(() => {
|
||||||
|
setShowCursor(prev => !prev)
|
||||||
|
}, 530)
|
||||||
|
|
||||||
|
// Delay start to allow card entrance + logo animation to complete
|
||||||
|
// Reduced motion: logo shows instantly, so use original 400ms delay
|
||||||
|
// Full motion: 400ms card entrance + 1000ms logo animation + 100ms pause = 1500ms
|
||||||
|
const startTimeout = addTimeout(() => {
|
||||||
|
startLoginSequence()
|
||||||
|
}, prefersReducedMotion ? 400 : 600)
|
||||||
|
|
||||||
|
// Capture ref value for cleanup
|
||||||
|
const pendingTimeouts = timeoutRefs.current
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (cursorIntervalRef.current) clearInterval(cursorIntervalRef.current)
|
||||||
|
if (usernameIntervalRef.current) clearInterval(usernameIntervalRef.current)
|
||||||
|
if (passwordIntervalRef.current) clearInterval(passwordIntervalRef.current)
|
||||||
|
if (dotIntervalRef.current) clearInterval(dotIntervalRef.current)
|
||||||
|
clearTimeout(startTimeout)
|
||||||
|
pendingTimeouts.forEach(id => clearTimeout(id))
|
||||||
|
}
|
||||||
|
}, [startLoginSequence, addTimeout, prefersReducedMotion])
|
||||||
|
|
||||||
|
const buttonBg = buttonPressed
|
||||||
|
? 'var(--accent-pressed, #085858)'
|
||||||
|
: buttonHovered && canLogin
|
||||||
|
? 'var(--accent-hover, #0A8080)'
|
||||||
|
: 'var(--accent, #0D6E6E)'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
className="fixed inset-0 flex items-center justify-center"
|
||||||
|
style={{
|
||||||
|
zIndex: 110,
|
||||||
|
}}
|
||||||
|
initial={{
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 1)',
|
||||||
|
backdropFilter: 'blur(0px)',
|
||||||
|
WebkitBackdropFilter: 'blur(0px)',
|
||||||
|
}}
|
||||||
|
animate={isExiting ? {
|
||||||
|
backgroundColor: 'rgba(240, 245, 244, 0)',
|
||||||
|
backdropFilter: 'blur(0px)',
|
||||||
|
WebkitBackdropFilter: 'blur(0px)',
|
||||||
|
} : {
|
||||||
|
backgroundColor: 'rgba(240, 245, 244, 0.7)',
|
||||||
|
backdropFilter: `blur(${BACKDROP_BLUR_PX}px)`,
|
||||||
|
WebkitBackdropFilter: `blur(${BACKDROP_BLUR_PX}px)`,
|
||||||
|
}}
|
||||||
|
transition={isExiting ? { duration: 0.6, ease: 'easeOut' } : { duration: 0.6, ease: 'easeOut' }}
|
||||||
|
role="dialog"
|
||||||
|
aria-label="Clinical system login"
|
||||||
|
aria-modal="true"
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
style={{
|
||||||
|
width: 'clamp(320px, 28vw, 480px)',
|
||||||
|
maxWidth: 'calc(100vw - 32px)',
|
||||||
|
padding: 'clamp(24px, 2.5vw, 40px)',
|
||||||
|
borderRadius: 'var(--radius-card, 8px)',
|
||||||
|
border: '1px solid var(--border-light, #E4EDEB)',
|
||||||
|
boxShadow: 'var(--shadow-lg, 0 8px 32px rgba(26,43,42,0.12))',
|
||||||
|
backgroundColor: 'var(--surface, #FFFFFF)',
|
||||||
|
}}
|
||||||
|
initial={{ opacity: 0, scale: 0.98 }}
|
||||||
|
animate={isExiting ? { scale: 1.03, opacity: 0 } : { scale: 1, opacity: 1 }}
|
||||||
|
transition={isExiting ? { duration: 0.4, ease: 'easeOut' } : { duration: 0.2, ease: 'easeOut' }}
|
||||||
|
>
|
||||||
|
<>
|
||||||
|
{/* Branding Header */}
|
||||||
|
<div
|
||||||
|
className="flex flex-col items-center"
|
||||||
|
style={{ marginBottom: '28px' }}
|
||||||
|
>
|
||||||
|
<div style={{ marginBottom: '12px', overflow: 'hidden' }}>
|
||||||
|
<CvmisLogo
|
||||||
|
cssHeight="clamp(160px, 18vw, 280px)"
|
||||||
|
animated={true}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontFamily: "var(--font-ui)",
|
||||||
|
fontSize: 'clamp(16px, 1.4vw, 20px)',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'var(--text-secondary, #5B7A78)',
|
||||||
|
letterSpacing: '0.01em',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
CVMIS
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontFamily: "var(--font-ui)",
|
||||||
|
fontSize: 'clamp(12px, 1vw, 14px)',
|
||||||
|
fontWeight: 400,
|
||||||
|
color: 'var(--text-tertiary, #8DA8A5)',
|
||||||
|
marginTop: '3px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
CV Management Information System
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Login Form */}
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
|
||||||
|
{/* Username Field */}
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
style={{
|
||||||
|
display: 'block',
|
||||||
|
fontFamily: "var(--font-ui)",
|
||||||
|
fontSize: 'clamp(12px, 1vw, 14px)',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'var(--text-secondary, #5B7A78)',
|
||||||
|
marginBottom: '6px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Username
|
||||||
|
</label>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '9px 11px',
|
||||||
|
fontFamily: 'var(--font-geist-mono)',
|
||||||
|
fontSize: 'clamp(13px, 1.2vw, 15px)',
|
||||||
|
backgroundColor: activeField === 'username' ? 'var(--surface, #FFFFFF)' : 'var(--bg-dashboard, #F0F5F4)',
|
||||||
|
border: activeField === 'username' ? '1px solid var(--accent, #0D6E6E)' : '1px solid var(--border-light, #E4EDEB)',
|
||||||
|
borderRadius: 'var(--radius-sm, 6px)',
|
||||||
|
color: 'var(--text-primary, #1A2B2A)',
|
||||||
|
minHeight: '38px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
transition: 'background-color 150ms ease-out, border-color 150ms ease-out',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>{username}</span>
|
||||||
|
{activeField === 'username' && (
|
||||||
|
<span
|
||||||
|
style={{ opacity: showCursor ? 1 : 0, color: 'var(--accent, #0D6E6E)' }}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
|
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Password Field */}
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
style={{
|
||||||
|
display: 'block',
|
||||||
|
fontFamily: "var(--font-ui)",
|
||||||
|
fontSize: 'clamp(12px, 1vw, 14px)',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'var(--text-secondary, #5B7A78)',
|
||||||
|
marginBottom: '6px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '9px 11px',
|
||||||
|
fontFamily: 'var(--font-geist-mono)',
|
||||||
|
fontSize: 'clamp(13px, 1.2vw, 15px)',
|
||||||
|
backgroundColor: activeField === 'password' ? 'var(--surface, #FFFFFF)' : 'var(--bg-dashboard, #F0F5F4)',
|
||||||
|
border: activeField === 'password' ? '1px solid var(--accent, #0D6E6E)' : '1px solid var(--border-light, #E4EDEB)',
|
||||||
|
borderRadius: 'var(--radius-sm, 6px)',
|
||||||
|
color: 'var(--text-primary, #1A2B2A)',
|
||||||
|
letterSpacing: '0.15em',
|
||||||
|
minHeight: '38px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
transition: 'background-color 150ms ease-out, border-color 150ms ease-out',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>{'\u2022'.repeat(passwordDots)}</span>
|
||||||
|
{activeField === 'password' && (
|
||||||
|
<span
|
||||||
|
style={{ opacity: showCursor ? 1 : 0, color: 'var(--accent, #0D6E6E)' }}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
|
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Log In Button — user clicks to proceed */}
|
||||||
|
<button
|
||||||
|
ref={loginButtonRef}
|
||||||
|
onClick={handleLogin}
|
||||||
|
disabled={!canLogin}
|
||||||
|
onMouseEnter={() => setButtonHovered(true)}
|
||||||
|
onMouseLeave={() => setButtonHovered(false)}
|
||||||
|
className={`focus-visible:ring-2 focus-visible:ring-accent/40 focus-visible:ring-offset-2 focus:outline-none${canLogin && !buttonPressed ? ' login-pulse-active' : ''}`}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '10px 16px',
|
||||||
|
fontFamily: "var(--font-ui)",
|
||||||
|
fontSize: 'clamp(14px, 1.2vw, 16px)',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: '#FFFFFF',
|
||||||
|
backgroundColor: buttonBg,
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: 'var(--radius-sm, 6px)',
|
||||||
|
cursor: canLogin ? 'pointer' : 'default',
|
||||||
|
opacity: canLogin ? 1 : 0.6,
|
||||||
|
transition: 'background-color 150ms, opacity 300ms',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Log In
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Connection Status Indicator */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
marginTop: '4px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
width: '10px',
|
||||||
|
height: '10px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
backgroundColor: connectionState === 'connected' ? 'var(--success, #059669)' : 'var(--alert, #DC2626)',
|
||||||
|
boxShadow: connectionState === 'connected'
|
||||||
|
? '0 0 6px 1px rgba(5,150,105,0.4)'
|
||||||
|
: '0 0 6px 1px rgba(220,38,38,0.4)',
|
||||||
|
transition: prefersReducedMotion ? 'none' : 'background-color 300ms ease, box-shadow 300ms ease',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontFamily: 'var(--font-geist-mono)',
|
||||||
|
fontSize: '12px',
|
||||||
|
color: connectionState === 'connected' ? 'var(--success, #059669)' : 'var(--alert, #DC2626)',
|
||||||
|
transition: prefersReducedMotion ? 'none' : 'color 300ms ease',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{connectionState === 'connected'
|
||||||
|
? 'Secure connection established, awaiting login'
|
||||||
|
: `Awaiting secure connection${'.'.repeat(dotCount)}`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: '22px',
|
||||||
|
paddingTop: '18px',
|
||||||
|
borderTop: '1px solid var(--border-light, #E4EDEB)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
fontFamily: "var(--font-ui)",
|
||||||
|
fontSize: '11px',
|
||||||
|
color: 'var(--text-tertiary, #8DA8A5)',
|
||||||
|
textAlign: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Secure clinical system login
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { ClipboardList, UserRound, Workflow, Wrench } from 'lucide-react'
|
||||||
|
import { useIsMobileNav } from '@/hooks/useIsMobileNav'
|
||||||
|
|
||||||
|
interface MobileBottomNavProps {
|
||||||
|
activeSection: string
|
||||||
|
onNavigate: (tileId: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ id: 'overview', label: 'Overview', tileId: 'mobile-overview', Icon: UserRound },
|
||||||
|
{ id: 'summary', label: 'Summary', tileId: 'patient-summary', Icon: ClipboardList },
|
||||||
|
{ id: 'experience', label: 'Experience', tileId: 'section-experience', Icon: Workflow },
|
||||||
|
{ id: 'skills', label: 'Skills', tileId: 'section-skills', Icon: Wrench },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function MobileBottomNav({ activeSection, onNavigate }: MobileBottomNavProps) {
|
||||||
|
const isMobileNav = useIsMobileNav()
|
||||||
|
|
||||||
|
if (!isMobileNav) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav
|
||||||
|
aria-label="Mobile navigation"
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
height: '56px',
|
||||||
|
background: 'var(--sidebar-bg)',
|
||||||
|
borderTop: '1px solid var(--border)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-around',
|
||||||
|
zIndex: 100,
|
||||||
|
paddingBottom: 'env(safe-area-inset-bottom)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{navItems.map((item) => {
|
||||||
|
const isActive = activeSection === item.id
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onNavigate(item.tileId)}
|
||||||
|
aria-current={isActive ? 'page' : undefined}
|
||||||
|
aria-label={item.label}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: '2px',
|
||||||
|
width: '44px',
|
||||||
|
height: '44px',
|
||||||
|
background: 'transparent',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
color: isActive ? 'var(--accent)' : 'var(--text-tertiary)',
|
||||||
|
transition: 'color 150ms',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<item.Icon size={20} strokeWidth={isActive ? 2.4 : 2} />
|
||||||
|
<span style={{ fontSize: '10px', fontWeight: isActive ? 600 : 400 }}>{item.label}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import type { CSSProperties } from 'react'
|
||||||
|
import { Download, Github, Linkedin, Search, Send } from 'lucide-react'
|
||||||
|
import { CvmisLogo } from './CvmisLogo'
|
||||||
|
import { PhoneCaptcha } from './PhoneCaptcha'
|
||||||
|
import { ReferralFormModal } from './ReferralFormModal'
|
||||||
|
import { patient } from '@/data/patient'
|
||||||
|
import { tags } from '@/data/tags'
|
||||||
|
import { getSidebarCopy } from '@/lib/profile-content'
|
||||||
|
import type { Tag } from '@/types/pmr'
|
||||||
|
|
||||||
|
interface MobileOverviewHeaderProps {
|
||||||
|
onSearchClick: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function TagPill({ tag }: { tag: Tag }) {
|
||||||
|
const styles: Record<Tag['colorVariant'], CSSProperties> = {
|
||||||
|
teal: {
|
||||||
|
background: 'var(--accent-light)',
|
||||||
|
color: 'var(--accent)',
|
||||||
|
border: '1px solid var(--accent-border)',
|
||||||
|
},
|
||||||
|
amber: {
|
||||||
|
background: 'var(--amber-light)',
|
||||||
|
color: 'var(--amber)',
|
||||||
|
border: '1px solid var(--amber-border)',
|
||||||
|
},
|
||||||
|
green: {
|
||||||
|
background: 'var(--success-light)',
|
||||||
|
color: 'var(--success)',
|
||||||
|
border: '1px solid var(--success-border)',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: 500,
|
||||||
|
padding: '4px 10px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
lineHeight: 1.3,
|
||||||
|
...styles[tag.colorVariant],
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tag.label}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MobileOverviewHeader({ onSearchClick }: MobileOverviewHeaderProps) {
|
||||||
|
const sidebarCopy = getSidebarCopy()
|
||||||
|
const [showReferralForm, setShowReferralForm] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-tile-id="mobile-overview"
|
||||||
|
style={{
|
||||||
|
padding: '16px',
|
||||||
|
background: 'var(--sidebar-bg)',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
marginBottom: '16px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Logo + Search row */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'flex-end', gap: '6px', marginBottom: '12px' }}>
|
||||||
|
<CvmisLogo cssHeight="50px" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onSearchClick}
|
||||||
|
className="sidebar-control"
|
||||||
|
aria-label={sidebarCopy.searchAriaLabel}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
minHeight: '44px',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
background: 'var(--surface)',
|
||||||
|
color: 'var(--text-secondary)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
padding: '0 10px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Search size={16} style={{ color: 'var(--text-tertiary)', flexShrink: 0 }} />
|
||||||
|
<span style={{ flex: 1, textAlign: 'left', fontSize: '13px' }}>{sidebarCopy.searchLabel}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Patient info */}
|
||||||
|
<section style={{ borderBottom: '2px solid var(--accent)', paddingBottom: '12px', marginBottom: '12px' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '8px' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '44px',
|
||||||
|
height: '44px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: 'linear-gradient(135deg, var(--accent), #0A8080)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
color: '#FFFFFF',
|
||||||
|
fontSize: '16px',
|
||||||
|
fontWeight: 700,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
AC
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: '15px', fontWeight: 700, color: 'var(--text-primary)' }}>
|
||||||
|
CHARLWOOD, Andrew
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '12px', fontFamily: 'var(--font-geist-mono)', 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 ? 'var(--font-geist-mono)' : undefined, fontSize: mono ? '12px' : undefined, letterSpacing: mono ? '0.12em' : undefined }}>
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontSize: '13px', padding: '2px 0' }}>
|
||||||
|
<span style={{ color: 'var(--text-tertiary)' }}>{sidebarCopy.phoneLabel}</span>
|
||||||
|
<PhoneCaptcha phone={patient.phone} />
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontSize: '13px', padding: '2px 0' }}>
|
||||||
|
<span style={{ color: 'var(--text-tertiary)' }}>{sidebarCopy.emailLabel}</span>
|
||||||
|
<a
|
||||||
|
href={`mailto:${patient.email}`}
|
||||||
|
style={{ color: 'var(--accent)', fontWeight: 500, textDecoration: 'none', textAlign: 'right' }}
|
||||||
|
>
|
||||||
|
{patient.email}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Tags */}
|
||||||
|
<section style={{ marginBottom: '12px' }}>
|
||||||
|
<div style={{ fontSize: '11px', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.08em', color: 'var(--text-tertiary)', marginBottom: '8px' }}>
|
||||||
|
{sidebarCopy.tagsTitle}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '5px' }}>
|
||||||
|
{tags.map((tag) => (
|
||||||
|
<TagPill key={tag.label} tag={tag} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Action buttons */}
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
|
||||||
|
{/* Download CV — full width */}
|
||||||
|
<a
|
||||||
|
href="/Andrew_Charlwood_CV.pdf"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
aria-label="Download CV"
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
minHeight: '40px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
border: '1px solid var(--accent-border)',
|
||||||
|
background: 'var(--surface)',
|
||||||
|
color: 'var(--accent)',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '13px',
|
||||||
|
fontWeight: 600,
|
||||||
|
letterSpacing: '0.03em',
|
||||||
|
textDecoration: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Download size={14} />
|
||||||
|
Download CV
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{/* Three icon buttons row */}
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '6px' }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowReferralForm(true)}
|
||||||
|
aria-label="Contact patient"
|
||||||
|
style={{
|
||||||
|
minHeight: '40px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
border: '1px solid var(--accent-border)',
|
||||||
|
background: 'var(--surface)',
|
||||||
|
color: 'var(--accent)',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Send size={16} />
|
||||||
|
</button>
|
||||||
|
<a
|
||||||
|
href="https://www.linkedin.com/in/andrewcharlwood/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
aria-label="LinkedIn profile"
|
||||||
|
style={{
|
||||||
|
minHeight: '40px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
border: '1px solid var(--border-light)',
|
||||||
|
background: 'var(--surface)',
|
||||||
|
color: 'var(--text-secondary)',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
textDecoration: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Linkedin size={16} />
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="https://github.com/andrewcharlwood"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
aria-label="GitHub profile"
|
||||||
|
style={{
|
||||||
|
minHeight: '40px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
border: '1px solid var(--border-light)',
|
||||||
|
background: 'var(--surface)',
|
||||||
|
color: 'var(--text-secondary)',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
textDecoration: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Github size={16} />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ReferralFormModal isOpen={showReferralForm} onClose={() => setShowReferralForm(false)} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { Card } from './Card'
|
||||||
|
|
||||||
|
interface ParentSectionProps {
|
||||||
|
title: string
|
||||||
|
children: React.ReactNode
|
||||||
|
className?: string
|
||||||
|
tileId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ParentSection({ title, children, className, tileId }: ParentSectionProps) {
|
||||||
|
return (
|
||||||
|
<Card full className={className} tileId={tileId}>
|
||||||
|
<h2
|
||||||
|
className="text-[1.375rem] sm:text-[1.6rem] md:text-[1.8rem] lg:text-[2.4rem]"
|
||||||
|
style={{
|
||||||
|
fontWeight: 600,
|
||||||
|
letterSpacing: '-0.02em',
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
lineHeight: 1.1,
|
||||||
|
margin: 0,
|
||||||
|
paddingBottom: '1.5rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
{children}
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import { useState, useCallback, useRef, useEffect } from 'react'
|
||||||
|
|
||||||
|
interface PhoneCaptchaProps {
|
||||||
|
phone: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateChallenge() {
|
||||||
|
const a = Math.floor(Math.random() * 10) + 2
|
||||||
|
const b = Math.floor(Math.random() * 8) + 1
|
||||||
|
return { question: `${a} + ${b}`, answer: a + b }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PhoneCaptcha({ phone }: PhoneCaptchaProps) {
|
||||||
|
const [state, setState] = useState<'masked' | 'challenge' | 'revealed'>('masked')
|
||||||
|
const [challenge, setChallenge] = useState(generateChallenge)
|
||||||
|
const [input, setInput] = useState('')
|
||||||
|
const [error, setError] = useState(false)
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
const maskedPhone = phone.slice(0, 2) + '\u2022\u2022\u2022 \u2022\u2022\u2022\u2022\u2022\u2022'
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (state === 'challenge') {
|
||||||
|
requestAnimationFrame(() => inputRef.current?.focus())
|
||||||
|
}
|
||||||
|
}, [state])
|
||||||
|
|
||||||
|
const handleRevealClick = useCallback(() => {
|
||||||
|
setChallenge(generateChallenge())
|
||||||
|
setInput('')
|
||||||
|
setError(false)
|
||||||
|
setState('challenge')
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(() => {
|
||||||
|
const parsed = parseInt(input.trim(), 10)
|
||||||
|
if (parsed === challenge.answer) {
|
||||||
|
setState('revealed')
|
||||||
|
} else {
|
||||||
|
setError(true)
|
||||||
|
setTimeout(() => {
|
||||||
|
setError(false)
|
||||||
|
setChallenge(generateChallenge())
|
||||||
|
setInput('')
|
||||||
|
}, 600)
|
||||||
|
}
|
||||||
|
}, [input, challenge.answer])
|
||||||
|
|
||||||
|
const handleDismiss = useCallback(() => {
|
||||||
|
setState('masked')
|
||||||
|
setInput('')
|
||||||
|
setError(false)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (state === 'revealed') {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={`tel:${phone}`}
|
||||||
|
style={{
|
||||||
|
color: 'var(--accent)',
|
||||||
|
fontWeight: 500,
|
||||||
|
textDecoration: 'none',
|
||||||
|
textAlign: 'right',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => (e.currentTarget.style.textDecoration = 'underline')}
|
||||||
|
onMouseLeave={(e) => (e.currentTarget.style.textDecoration = 'none')}
|
||||||
|
>
|
||||||
|
{phone.replace(/(\d{5})(\d{6})/, '$1 $2')}
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state === 'challenge') {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: '4px' }}>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: '11px',
|
||||||
|
color: error ? 'var(--alert, #e53935)' : 'var(--text-tertiary)',
|
||||||
|
fontFamily: 'var(--font-geist-mono)',
|
||||||
|
transition: 'color 150ms',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{error ? 'Try again' : `${challenge.question} = ?`}
|
||||||
|
</span>
|
||||||
|
<div style={{ display: 'flex', gap: '4px', alignItems: 'center' }}>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
autoComplete="off"
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => { setInput(e.target.value); setError(false) }}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') handleSubmit()
|
||||||
|
if (e.key === 'Escape') handleDismiss()
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
width: '36px',
|
||||||
|
padding: '3px 4px',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontFamily: 'var(--font-geist-mono)',
|
||||||
|
border: `1px solid ${error ? 'var(--alert, #e53935)' : 'var(--border)'}`,
|
||||||
|
borderRadius: '4px',
|
||||||
|
background: 'var(--surface)',
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
textAlign: 'center',
|
||||||
|
outline: 'none',
|
||||||
|
transition: 'border-color 150ms',
|
||||||
|
}}
|
||||||
|
aria-label={`Solve: ${challenge.question}`}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSubmit}
|
||||||
|
style={{
|
||||||
|
padding: '3px 8px',
|
||||||
|
fontSize: '11px',
|
||||||
|
fontWeight: 600,
|
||||||
|
border: '1px solid var(--accent-border)',
|
||||||
|
borderRadius: '4px',
|
||||||
|
background: 'var(--accent-light)',
|
||||||
|
color: 'var(--accent)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
lineHeight: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
OK
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleRevealClick}
|
||||||
|
style={{
|
||||||
|
color: 'var(--text-secondary)',
|
||||||
|
fontWeight: 500,
|
||||||
|
textAlign: 'right',
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
padding: 0,
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: 'inherit',
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
}}
|
||||||
|
aria-label="Reveal phone number"
|
||||||
|
title="Click to verify and reveal"
|
||||||
|
>
|
||||||
|
{maskedPhone}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
import { motion } from 'framer-motion'
|
|
||||||
import { ExternalLink } from 'lucide-react'
|
|
||||||
import { useScrollReveal } from '@/hooks/useScrollReveal'
|
|
||||||
import type { Project as ProjectType } from '@/types'
|
|
||||||
|
|
||||||
const projectsData: ProjectType[] = [
|
|
||||||
{
|
|
||||||
title: 'PharMetrics',
|
|
||||||
description:
|
|
||||||
'Real-time medicines expenditure dashboard providing actionable analytics for NHS decision-makers.',
|
|
||||||
link: 'https://medicines.charlwood.xyz/',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Patient Pathway Analysis',
|
|
||||||
description:
|
|
||||||
'Data-driven analysis of patient pathways to identify optimisation opportunities and improve clinical outcomes.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Blueteq Generator',
|
|
||||||
description:
|
|
||||||
'Automation tool reducing high-cost drug approval processing time by 70%, saving 200+ hours annually.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'NMS Video',
|
|
||||||
description:
|
|
||||||
'Educational video resource supporting New Medicine Service consultations, improving patient engagement.',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
const ProjectCard = ({
|
|
||||||
project,
|
|
||||||
delay,
|
|
||||||
isVisible,
|
|
||||||
}: {
|
|
||||||
project: ProjectType
|
|
||||||
delay: number
|
|
||||||
isVisible: boolean
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 24 }}
|
|
||||||
animate={isVisible ? { opacity: 1, y: 0 } : { opacity: 0, y: 24 }}
|
|
||||||
transition={{ duration: 0.5, delay, ease: 'easeOut' }}
|
|
||||||
className="group relative bg-white rounded-2xl p-6 shadow-sm overflow-hidden transition-all hover:shadow-md hover:-translate-y-0.5"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="absolute inset-0 rounded-2xl p-[2px] opacity-0 group-hover:opacity-100 transition-opacity duration-300 pointer-events-none"
|
|
||||||
style={{
|
|
||||||
background: 'linear-gradient(135deg, #00897B, #FF6B6B)',
|
|
||||||
WebkitMask:
|
|
||||||
'linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0)',
|
|
||||||
WebkitMaskComposite: 'xor',
|
|
||||||
maskComposite: 'exclude',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<h3 className="font-primary text-base font-semibold text-heading leading-tight">
|
|
||||||
{project.title}
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm text-text leading-relaxed mt-2">
|
|
||||||
{project.description}
|
|
||||||
</p>
|
|
||||||
{project.link && (
|
|
||||||
<a
|
|
||||||
href={project.link}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="inline-flex items-center gap-1.5 mt-3 px-4 py-1.5 bg-teal text-white rounded-full text-xs font-medium font-secondary transition-all hover:bg-[#00796B] hover:-translate-y-px"
|
|
||||||
>
|
|
||||||
Visit Project
|
|
||||||
<ExternalLink size={12} />
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</motion.div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Projects() {
|
|
||||||
const [sectionRef, isVisible] = useScrollReveal<HTMLElement>({
|
|
||||||
threshold: 0.1,
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section id="projects" ref={sectionRef} className="py-12 xs:py-16 md:py-20">
|
|
||||||
<motion.h2
|
|
||||||
initial={{ opacity: 0, y: 12 }}
|
|
||||||
animate={isVisible ? { opacity: 1, y: 0 } : { opacity: 0, y: 12 }}
|
|
||||||
transition={{ duration: 0.5 }}
|
|
||||||
className="font-primary text-2xl font-bold text-heading text-center mb-8"
|
|
||||||
>
|
|
||||||
Projects
|
|
||||||
</motion.h2>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
|
|
||||||
{projectsData.map((project, index) => (
|
|
||||||
<ProjectCard
|
|
||||||
key={project.title}
|
|
||||||
project={project}
|
|
||||||
delay={0.1 + index * 0.1}
|
|
||||||
isVisible={isVisible}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,433 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
|
import { X, Send } from 'lucide-react'
|
||||||
|
|
||||||
|
interface ReferralFormModalProps {
|
||||||
|
isOpen: boolean
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FormData {
|
||||||
|
referringClinician: string
|
||||||
|
organisationFrom: string
|
||||||
|
presentingComplaint: string
|
||||||
|
clinicalDetails: string
|
||||||
|
contactEmail: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const INITIAL_FORM: FormData = {
|
||||||
|
referringClinician: '',
|
||||||
|
organisationFrom: '',
|
||||||
|
presentingComplaint: '',
|
||||||
|
clinicalDetails: '',
|
||||||
|
contactEmail: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
type SubmitStatus = 'idle' | 'submitting' | 'success' | 'error'
|
||||||
|
|
||||||
|
export function ReferralFormModal({ isOpen, onClose }: ReferralFormModalProps) {
|
||||||
|
const [form, setForm] = useState<FormData>(INITIAL_FORM)
|
||||||
|
const [status, setStatus] = useState<SubmitStatus>('idle')
|
||||||
|
const [errorMessage, setErrorMessage] = useState('')
|
||||||
|
|
||||||
|
const updateField = (field: keyof FormData, value: string) => {
|
||||||
|
setForm(prev => ({ ...prev, [field]: value }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setStatus('submitting')
|
||||||
|
setErrorMessage('')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/contact', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: form.referringClinician,
|
||||||
|
organisation: form.organisationFrom,
|
||||||
|
subject: form.presentingComplaint,
|
||||||
|
message: form.clinicalDetails,
|
||||||
|
email: form.contactEmail,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
const data = await res.json()
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(data.message || 'Failed to send referral')
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus('success')
|
||||||
|
setTimeout(() => {
|
||||||
|
setForm(INITIAL_FORM)
|
||||||
|
setStatus('idle')
|
||||||
|
onClose()
|
||||||
|
}, 2000)
|
||||||
|
} catch (err) {
|
||||||
|
setStatus('error')
|
||||||
|
setErrorMessage(err instanceof Error ? err.message : 'Failed to send referral. Please try again.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const labelStyle: React.CSSProperties = {
|
||||||
|
display: 'block',
|
||||||
|
fontFamily: 'var(--font-geist-mono)',
|
||||||
|
fontSize: '11px',
|
||||||
|
fontWeight: 500,
|
||||||
|
color: 'var(--text-tertiary, #8DA8A5)',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.06em',
|
||||||
|
marginBottom: '6px',
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputStyle: React.CSSProperties = {
|
||||||
|
width: '100%',
|
||||||
|
padding: '10px 12px',
|
||||||
|
minHeight: '44px',
|
||||||
|
fontFamily: 'var(--font-ui)',
|
||||||
|
fontSize: '14px',
|
||||||
|
color: 'var(--text-primary, #1A2B2A)',
|
||||||
|
backgroundColor: 'var(--surface, #FFFFFF)',
|
||||||
|
border: '1px solid var(--border, #D1DDD9)',
|
||||||
|
borderRadius: 'var(--radius-sm, 6px)',
|
||||||
|
outline: 'none',
|
||||||
|
transition: 'border-color 150ms ease',
|
||||||
|
}
|
||||||
|
|
||||||
|
const readOnlyStyle: React.CSSProperties = {
|
||||||
|
...inputStyle,
|
||||||
|
backgroundColor: 'var(--bg-dashboard, #F0F5F4)',
|
||||||
|
fontStyle: 'italic',
|
||||||
|
color: 'var(--text-secondary, #5B7A78)',
|
||||||
|
cursor: 'default',
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{isOpen && (
|
||||||
|
<motion.div
|
||||||
|
key="referral-overlay"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
inset: 0,
|
||||||
|
backgroundColor: 'rgba(26, 43, 42, 0.5)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
zIndex: 1100,
|
||||||
|
padding: '16px',
|
||||||
|
}}
|
||||||
|
onClick={(e) => {
|
||||||
|
if (e.target === e.currentTarget) onClose()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
key="referral-modal"
|
||||||
|
initial={{ opacity: 0, y: 24 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: 24 }}
|
||||||
|
transition={{ duration: 0.25, ease: 'easeOut' }}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: 'min(540px, calc(100vw - 32px))',
|
||||||
|
maxHeight: 'calc(100vh - 32px)',
|
||||||
|
overflowY: 'auto',
|
||||||
|
backgroundColor: 'var(--surface, #FFFFFF)',
|
||||||
|
borderRadius: 'var(--radius-card, 8px)',
|
||||||
|
boxShadow: 'var(--shadow-lg, 0 8px 32px rgba(26,43,42,0.12))',
|
||||||
|
border: '1px solid var(--border-light, #E4EDEB)',
|
||||||
|
}}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="referral-form-title"
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
padding: '14px 16px',
|
||||||
|
borderBottom: '2px solid var(--accent, #0D6E6E)',
|
||||||
|
backgroundColor: 'var(--bg-dashboard, #F0F5F4)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '8px',
|
||||||
|
height: '8px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
backgroundColor: 'var(--accent, #0D6E6E)',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<h2
|
||||||
|
id="referral-form-title"
|
||||||
|
style={{
|
||||||
|
fontFamily: 'var(--font-geist-mono)',
|
||||||
|
fontSize: '13px',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'var(--accent, #0D6E6E)',
|
||||||
|
letterSpacing: '0.08em',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
margin: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Patient Referral Form
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="Close referral form"
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
width: '44px',
|
||||||
|
height: '44px',
|
||||||
|
border: 'none',
|
||||||
|
background: 'transparent',
|
||||||
|
borderRadius: 'var(--radius-sm, 6px)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
color: 'var(--text-secondary, #5B7A78)',
|
||||||
|
transition: 'background-color 150ms, color 150ms',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = 'var(--accent-light, #E0F2F1)'
|
||||||
|
e.currentTarget.style.color = 'var(--accent, #0D6E6E)'
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = 'transparent'
|
||||||
|
e.currentTarget.style.color = 'var(--text-secondary, #5B7A78)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Form body */}
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
style={{ padding: '16px', display: 'flex', flexDirection: 'column', gap: '18px' }}
|
||||||
|
>
|
||||||
|
{/* Referring Clinician */}
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle} htmlFor="referringClinician">
|
||||||
|
Referring Clinician
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="referringClinician"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={form.referringClinician}
|
||||||
|
onChange={(e) => updateField('referringClinician', e.target.value)}
|
||||||
|
placeholder="Your name"
|
||||||
|
style={inputStyle}
|
||||||
|
onFocus={(e) => { e.currentTarget.style.borderColor = 'var(--accent, #0D6E6E)' }}
|
||||||
|
onBlur={(e) => { e.currentTarget.style.borderColor = 'var(--border, #D1DDD9)' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Organisation Referred From */}
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle} htmlFor="organisationFrom">
|
||||||
|
Organisation Referred From
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="organisationFrom"
|
||||||
|
type="text"
|
||||||
|
value={form.organisationFrom}
|
||||||
|
onChange={(e) => updateField('organisationFrom', e.target.value)}
|
||||||
|
placeholder="Your organisation"
|
||||||
|
style={inputStyle}
|
||||||
|
onFocus={(e) => { e.currentTarget.style.borderColor = 'var(--accent, #0D6E6E)' }}
|
||||||
|
onBlur={(e) => { e.currentTarget.style.borderColor = 'var(--border, #D1DDD9)' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Organisation Referred To (read-only) */}
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle} htmlFor="organisationTo">
|
||||||
|
Organisation Referred To
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="organisationTo"
|
||||||
|
type="text"
|
||||||
|
readOnly
|
||||||
|
value="CV Managment Information System"
|
||||||
|
style={readOnlyStyle}
|
||||||
|
tabIndex={-1}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Receiving Clinician (read-only) */}
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle} htmlFor="receivingClinician">
|
||||||
|
Receiving Clinician
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="receivingClinician"
|
||||||
|
type="text"
|
||||||
|
readOnly
|
||||||
|
value="Mr A. Charlwood"
|
||||||
|
style={readOnlyStyle}
|
||||||
|
tabIndex={-1}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Presenting Complaint */}
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle} htmlFor="presentingComplaint">
|
||||||
|
Presenting Complaint
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="presentingComplaint"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={form.presentingComplaint}
|
||||||
|
onChange={(e) => updateField('presentingComplaint', e.target.value)}
|
||||||
|
placeholder="Subject / reason for referral"
|
||||||
|
style={inputStyle}
|
||||||
|
onFocus={(e) => { e.currentTarget.style.borderColor = 'var(--accent, #0D6E6E)' }}
|
||||||
|
onBlur={(e) => { e.currentTarget.style.borderColor = 'var(--border, #D1DDD9)' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Clinical Details */}
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle} htmlFor="clinicalDetails">
|
||||||
|
Clinical Details
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="clinicalDetails"
|
||||||
|
required
|
||||||
|
value={form.clinicalDetails}
|
||||||
|
onChange={(e) => updateField('clinicalDetails', e.target.value)}
|
||||||
|
placeholder="Your message..."
|
||||||
|
rows={5}
|
||||||
|
style={{
|
||||||
|
...inputStyle,
|
||||||
|
resize: 'vertical',
|
||||||
|
minHeight: '100px',
|
||||||
|
}}
|
||||||
|
onFocus={(e) => { e.currentTarget.style.borderColor = 'var(--accent, #0D6E6E)' }}
|
||||||
|
onBlur={(e) => { e.currentTarget.style.borderColor = 'var(--border, #D1DDD9)' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Contact Email */}
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle} htmlFor="contactEmail">
|
||||||
|
Contact Email
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="contactEmail"
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
value={form.contactEmail}
|
||||||
|
onChange={(e) => updateField('contactEmail', e.target.value)}
|
||||||
|
placeholder="your.email@example.com"
|
||||||
|
style={inputStyle}
|
||||||
|
onFocus={(e) => { e.currentTarget.style.borderColor = 'var(--accent, #0D6E6E)' }}
|
||||||
|
onBlur={(e) => { e.currentTarget.style.borderColor = 'var(--border, #D1DDD9)' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Success message */}
|
||||||
|
{status === 'success' && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '12px 16px',
|
||||||
|
backgroundColor: 'rgba(5, 150, 105, 0.08)',
|
||||||
|
border: '1px solid rgba(5, 150, 105, 0.2)',
|
||||||
|
borderRadius: 'var(--radius-sm, 6px)',
|
||||||
|
fontFamily: 'var(--font-ui)',
|
||||||
|
fontSize: '13px',
|
||||||
|
color: 'var(--success, #059669)',
|
||||||
|
textAlign: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Referral sent successfully!
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error message */}
|
||||||
|
{status === 'error' && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '12px 16px',
|
||||||
|
backgroundColor: 'rgba(220, 38, 38, 0.08)',
|
||||||
|
border: '1px solid rgba(220, 38, 38, 0.2)',
|
||||||
|
borderRadius: 'var(--radius-sm, 6px)',
|
||||||
|
fontFamily: 'var(--font-ui)',
|
||||||
|
fontSize: '13px',
|
||||||
|
color: 'var(--alert, #DC2626)',
|
||||||
|
textAlign: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{errorMessage}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Submit button */}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={status === 'submitting' || status === 'success'}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '12px 16px',
|
||||||
|
minHeight: '44px',
|
||||||
|
fontFamily: 'var(--font-ui)',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: '#FFFFFF',
|
||||||
|
backgroundColor: status === 'submitting' || status === 'success'
|
||||||
|
? 'var(--accent-hover, #0A8080)'
|
||||||
|
: 'var(--accent, #0D6E6E)',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: 'var(--radius-sm, 6px)',
|
||||||
|
cursor: status === 'submitting' || status === 'success' ? 'default' : 'pointer',
|
||||||
|
opacity: status === 'submitting' ? 0.8 : 1,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
transition: 'background-color 150ms, opacity 150ms',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (status === 'idle' || status === 'error') {
|
||||||
|
e.currentTarget.style.backgroundColor = 'var(--accent-hover, #0A8080)'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
if (status === 'idle' || status === 'error') {
|
||||||
|
e.currentTarget.style.backgroundColor = 'var(--accent, #0D6E6E)'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{status === 'submitting' ? (
|
||||||
|
'Sending referral...'
|
||||||
|
) : status === 'success' ? (
|
||||||
|
'Referral sent!'
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Send size={16} />
|
||||||
|
Submit Referral
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import type { LucideIcon } from 'lucide-react'
|
||||||
|
import {
|
||||||
|
BarChart3, Code2, Database, LayoutDashboard, Bot, FileCode2,
|
||||||
|
Pill, Users, FileCheck, Stethoscope,
|
||||||
|
TrendingUp, Route, BookOpen, Store,
|
||||||
|
Presentation, Calculator, Banknote, Handshake, RefreshCw,
|
||||||
|
GitBranch, Workflow, UserPlus, ChevronRight,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { CardHeader } from './Card'
|
||||||
|
import { skills } from '@/data/skills'
|
||||||
|
import { useDetailPanel } from '@/contexts/DetailPanelContext'
|
||||||
|
import { getSkillsUICopy } from '@/lib/profile-content'
|
||||||
|
import type { SkillMedication } from '@/types/pmr'
|
||||||
|
|
||||||
|
const iconMap: Record<string, LucideIcon> = {
|
||||||
|
BarChart3, Code2, Database, LayoutDashboard, Bot, FileCode2,
|
||||||
|
Pill, Users, FileCheck, Stethoscope,
|
||||||
|
TrendingUp, Route, BookOpen, Store,
|
||||||
|
Presentation, Calculator, Banknote, Handshake, RefreshCw,
|
||||||
|
GitBranch, Workflow, UserPlus,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
interface SkillRowProps {
|
||||||
|
skill: SkillMedication
|
||||||
|
yearsSuffix: string
|
||||||
|
onClick: () => void
|
||||||
|
onHighlight?: (id: string | null) => void
|
||||||
|
isDimmedByFocus?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function SkillRow({ skill, yearsSuffix, onClick, onHighlight, isDimmedByFocus = false }: SkillRowProps) {
|
||||||
|
const IconComponent = iconMap[skill.icon]
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault()
|
||||||
|
onClick()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={onClick}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
aria-label={`${skill.name}: ${skill.frequency}, ${skill.yearsOfExperience} years experience. Click for details.`}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '10px',
|
||||||
|
padding: '10px 12px',
|
||||||
|
minHeight: '44px',
|
||||||
|
background: 'var(--bg-dashboard)',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
border: '1px solid var(--border-light)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'border-color 0.15s, box-shadow 0.15s, opacity 150ms ease-out',
|
||||||
|
opacity: isDimmedByFocus ? 0.25 : 1,
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.borderColor = 'var(--accent-border)'
|
||||||
|
e.currentTarget.style.boxShadow = 'var(--shadow-md)'
|
||||||
|
onHighlight?.(skill.id)
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.borderColor = 'var(--border-light)'
|
||||||
|
e.currentTarget.style.boxShadow = 'none'
|
||||||
|
onHighlight?.(null)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '30px',
|
||||||
|
height: '30px',
|
||||||
|
borderRadius: '6px',
|
||||||
|
background: 'var(--accent-light)',
|
||||||
|
color: 'var(--accent)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{IconComponent && <IconComponent size={15} />}
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
lineHeight: 1.3,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{skill.name}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '12px',
|
||||||
|
color: 'var(--text-tertiary)',
|
||||||
|
fontFamily: 'var(--font-geist-mono)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{skill.frequency} · {skill.yearsOfExperience} {yearsSuffix}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '11px',
|
||||||
|
fontWeight: 500,
|
||||||
|
padding: '3px 8px',
|
||||||
|
borderRadius: '20px',
|
||||||
|
background: 'var(--success-light)',
|
||||||
|
color: 'var(--success)',
|
||||||
|
border: '1px solid var(--success-border)',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{skill.status}
|
||||||
|
</div>
|
||||||
|
<ChevronRight
|
||||||
|
size={14}
|
||||||
|
style={{ color: 'var(--text-tertiary)', flexShrink: 0 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CategorySectionProps {
|
||||||
|
label: string
|
||||||
|
skills: SkillMedication[]
|
||||||
|
itemCountSuffix: string
|
||||||
|
yearsSuffix: string
|
||||||
|
onSkillClick: (skill: SkillMedication) => void
|
||||||
|
isFirst: boolean
|
||||||
|
onNodeHighlight?: (id: string | null) => void
|
||||||
|
focusRelatedIds?: Set<string> | null
|
||||||
|
}
|
||||||
|
|
||||||
|
function CategorySection({
|
||||||
|
label,
|
||||||
|
skills: categorySkills,
|
||||||
|
itemCountSuffix,
|
||||||
|
yearsSuffix,
|
||||||
|
onSkillClick,
|
||||||
|
isFirst,
|
||||||
|
onNodeHighlight,
|
||||||
|
focusRelatedIds,
|
||||||
|
}: CategorySectionProps) {
|
||||||
|
return (
|
||||||
|
<div style={{ marginTop: isFirst ? 0 : '16px' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
marginBottom: '10px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: '11px',
|
||||||
|
fontWeight: 600,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.06em',
|
||||||
|
color: 'var(--text-tertiary)',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
height: '1px',
|
||||||
|
background: 'var(--border-light)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: '11px',
|
||||||
|
color: 'var(--text-tertiary)',
|
||||||
|
fontFamily: 'var(--font-geist-mono)',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{categorySkills.length} {itemCountSuffix}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||||
|
{categorySkills.map((skill) => (
|
||||||
|
<SkillRow
|
||||||
|
key={skill.id}
|
||||||
|
skill={skill}
|
||||||
|
yearsSuffix={yearsSuffix}
|
||||||
|
onClick={() => onSkillClick(skill)}
|
||||||
|
onHighlight={onNodeHighlight}
|
||||||
|
isDimmedByFocus={focusRelatedIds != null && !focusRelatedIds.has(skill.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RepeatMedicationsSubsectionProps {
|
||||||
|
onNodeHighlight?: (id: string | null) => void
|
||||||
|
focusRelatedIds?: Set<string> | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const frequencyRank = (freq: string): number => {
|
||||||
|
if (freq.includes('daily')) return freq.startsWith('4') ? 0 : freq.startsWith('3') ? 1 : freq.startsWith('1') ? 3 : 2
|
||||||
|
if (freq === 'Daily') return 4
|
||||||
|
if (freq.includes('weekly')) return freq.startsWith('2') ? 5 : freq.startsWith('1') ? 6 : 7
|
||||||
|
if (freq === 'Weekly') return 7
|
||||||
|
if (freq === 'Bi-monthly') return 8
|
||||||
|
return 9 // As needed
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RepeatMedicationsSubsection({ onNodeHighlight, focusRelatedIds }: RepeatMedicationsSubsectionProps) {
|
||||||
|
const { openPanel } = useDetailPanel()
|
||||||
|
const skillsCopy = getSkillsUICopy()
|
||||||
|
|
||||||
|
const groupedSkills = skillsCopy.categories.map(({ id, label }) => ({
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
skills: skills
|
||||||
|
.filter((s) => s.category === id)
|
||||||
|
.sort((a, b) => frequencyRank(a.frequency) - frequencyRank(b.frequency)),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const handleSkillClick = (skill: SkillMedication) => {
|
||||||
|
openPanel({ type: 'skill', skill })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<CardHeader
|
||||||
|
dotColor="amber"
|
||||||
|
title={skillsCopy.sectionTitle}
|
||||||
|
rightText={skillsCopy.rightText}
|
||||||
|
/>
|
||||||
|
<div className="medications-grid">
|
||||||
|
{groupedSkills.map((group) => (
|
||||||
|
<CategorySection
|
||||||
|
key={group.id}
|
||||||
|
label={group.label}
|
||||||
|
skills={group.skills}
|
||||||
|
itemCountSuffix={skillsCopy.itemCountSuffix}
|
||||||
|
yearsSuffix={skillsCopy.yearsSuffix}
|
||||||
|
onSkillClick={handleSkillClick}
|
||||||
|
isFirst
|
||||||
|
onNodeHighlight={onNodeHighlight}
|
||||||
|
focusRelatedIds={focusRelatedIds}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,612 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import type { CSSProperties, ReactNode } from 'react'
|
||||||
|
import {
|
||||||
|
Download,
|
||||||
|
Github,
|
||||||
|
Linkedin,
|
||||||
|
type LucideIcon,
|
||||||
|
Menu,
|
||||||
|
Search,
|
||||||
|
Send,
|
||||||
|
UserRound,
|
||||||
|
Workflow,
|
||||||
|
Wrench,
|
||||||
|
X,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { useIsMobileNav } from '@/hooks/useIsMobileNav'
|
||||||
|
import { CvmisLogo } from './CvmisLogo'
|
||||||
|
import { PhoneCaptcha } from './PhoneCaptcha'
|
||||||
|
import { ReferralFormModal } from './ReferralFormModal'
|
||||||
|
import { patient } from '@/data/patient'
|
||||||
|
import { tags } from '@/data/tags'
|
||||||
|
import { getSidebarCopy } from '@/lib/profile-content'
|
||||||
|
import type { Tag } from '@/types/pmr'
|
||||||
|
|
||||||
|
interface SidebarProps {
|
||||||
|
activeSection: string
|
||||||
|
onNavigate: (tileId: string) => void
|
||||||
|
onSearchClick: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NavSection {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
tileId: string
|
||||||
|
Icon: LucideIcon
|
||||||
|
}
|
||||||
|
|
||||||
|
const navSections: NavSection[] = [
|
||||||
|
{ id: 'overview', label: 'Overview / Highlights', tileId: 'patient-summary', Icon: UserRound },
|
||||||
|
{ id: 'experience', label: 'Experience', tileId: 'section-experience', Icon: Workflow },
|
||||||
|
{ id: 'skills', label: 'Skills', tileId: 'section-skills', Icon: Wrench },
|
||||||
|
]
|
||||||
|
|
||||||
|
interface SectionTitleProps {
|
||||||
|
children: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
function SectionTitle({ children }: SectionTitleProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '6px',
|
||||||
|
fontSize: '11px',
|
||||||
|
fontWeight: 600,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.08em',
|
||||||
|
color: 'var(--text-tertiary)',
|
||||||
|
marginBottom: '10px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>{children}</span>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
height: '1px',
|
||||||
|
background: 'var(--border-light)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TagPillProps {
|
||||||
|
tag: Tag
|
||||||
|
}
|
||||||
|
|
||||||
|
function TagPill({ tag }: TagPillProps) {
|
||||||
|
const styles: Record<Tag['colorVariant'], CSSProperties> = {
|
||||||
|
teal: {
|
||||||
|
background: 'var(--accent-light)',
|
||||||
|
color: 'var(--accent)',
|
||||||
|
border: '1px solid var(--accent-border)',
|
||||||
|
},
|
||||||
|
amber: {
|
||||||
|
background: 'var(--amber-light)',
|
||||||
|
color: 'var(--amber)',
|
||||||
|
border: '1px solid var(--amber-border)',
|
||||||
|
},
|
||||||
|
green: {
|
||||||
|
background: 'var(--success-light)',
|
||||||
|
color: 'var(--success)',
|
||||||
|
border: '1px solid var(--success-border)',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: 500,
|
||||||
|
padding: '4px 10px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
lineHeight: 1.3,
|
||||||
|
...styles[tag.colorVariant],
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tag.label}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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)
|
||||||
|
const [showReferralForm, setShowReferralForm] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const mediaQuery = window.matchMedia('(min-width: 1024px)')
|
||||||
|
const updateDesktopState = (event: MediaQueryListEvent | MediaQueryList) => {
|
||||||
|
const desktopMode = event.matches
|
||||||
|
setIsDesktop(desktopMode)
|
||||||
|
if (desktopMode) {
|
||||||
|
setIsMobileExpanded(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateDesktopState(mediaQuery)
|
||||||
|
|
||||||
|
const listener = (event: MediaQueryListEvent) => updateDesktopState(event)
|
||||||
|
mediaQuery.addEventListener('change', listener)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
mediaQuery.removeEventListener('change', listener)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const isExpanded = isDesktop || isMobileExpanded
|
||||||
|
|
||||||
|
const handleNavActivate = (tileId: string) => {
|
||||||
|
onNavigate(tileId)
|
||||||
|
if (!isDesktop) {
|
||||||
|
setIsMobileExpanded(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isMobileNav) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{!isDesktop && isMobileExpanded && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Close sidebar navigation"
|
||||||
|
onClick={() => setIsMobileExpanded(false)}
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
inset: 0,
|
||||||
|
background: 'rgba(26,43,42,0.28)',
|
||||||
|
border: 'none',
|
||||||
|
zIndex: 108,
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<aside
|
||||||
|
id="sidebar-panel"
|
||||||
|
aria-label="Sidebar"
|
||||||
|
style={{
|
||||||
|
position: isDesktop ? 'relative' : 'fixed',
|
||||||
|
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)',
|
||||||
|
borderRight: '1px solid var(--border)',
|
||||||
|
overflowY: isExpanded ? 'auto' : 'hidden',
|
||||||
|
overflowX: 'hidden',
|
||||||
|
padding: isExpanded ? '6px 16px' : '12px 8px',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '10px',
|
||||||
|
transition: 'width 180ms ease-out, min-width 180ms ease-out, padding 180ms ease-out',
|
||||||
|
zIndex: isDesktop ? 'auto' : 110,
|
||||||
|
}}
|
||||||
|
className={isExpanded ? 'pmr-scrollbar' : undefined}
|
||||||
|
>
|
||||||
|
{!isDesktop && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={isExpanded ? 'Collapse sidebar navigation' : 'Expand sidebar navigation'}
|
||||||
|
aria-expanded={isExpanded}
|
||||||
|
aria-controls="sidebar-panel"
|
||||||
|
onClick={() => setIsMobileExpanded((prev) => !prev)}
|
||||||
|
className="sidebar-control"
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
minHeight: '44px',
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: isExpanded ? 'space-between' : 'center',
|
||||||
|
gap: '8px',
|
||||||
|
border: '1px solid var(--border-light)',
|
||||||
|
background: 'var(--surface)',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
padding: isExpanded ? '0 12px' : '0',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isExpanded && <span style={{ fontSize: '12px', fontWeight: 600 }}>{sidebarCopy.menuLabel}</span>}
|
||||||
|
{isExpanded ? <X size={17} strokeWidth={2.4} /> : <Menu size={18} strokeWidth={2.4} />}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isExpanded && (
|
||||||
|
<section style={{ borderBottom: '2px solid var(--accent)', paddingBottom: '16px' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'flex-end',
|
||||||
|
justifyContent: 'flex-start',
|
||||||
|
gap: '6px',
|
||||||
|
marginBottom: '4px',
|
||||||
|
width: '100%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CvmisLogo cssHeight="50px" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onSearchClick}
|
||||||
|
className="sidebar-control"
|
||||||
|
aria-label={sidebarCopy.searchAriaLabel}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
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 }} aria-hidden="true" />
|
||||||
|
<span style={{ flex: 1, textAlign: 'left', fontSize: '13px', padding: '5px 0' }}>
|
||||||
|
{sidebarCopy.searchLabel}
|
||||||
|
</span>
|
||||||
|
<kbd
|
||||||
|
style={{
|
||||||
|
fontSize: '11px',
|
||||||
|
color: 'var(--text-tertiary)',
|
||||||
|
background: 'var(--bg-dashboard)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
padding: '2px 6px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
lineHeight: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{sidebarCopy.searchShortcut}
|
||||||
|
</kbd>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<SectionTitle>{sidebarCopy.sectionTitle}</SectionTitle>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '60px',
|
||||||
|
height: '60px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: 'linear-gradient(135deg, var(--accent), #0A8080)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
color: '#FFFFFF',
|
||||||
|
fontSize: '20px',
|
||||||
|
fontWeight: 700,
|
||||||
|
boxShadow: '0 2px 8px rgba(13,110,110,0.25)',
|
||||||
|
marginBottom: '12px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
AC
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '17px',
|
||||||
|
fontWeight: 700,
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
letterSpacing: '-0.01em',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
CHARLWOOD, Andrew
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '13px',
|
||||||
|
fontFamily: 'var(--font-geist-mono)',
|
||||||
|
fontWeight: 400,
|
||||||
|
color: 'var(--text-secondary)',
|
||||||
|
marginTop: '2px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{sidebarCopy.roleTitle}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: '1fr',
|
||||||
|
gap: '8px',
|
||||||
|
marginTop: '12px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
fontSize: '13px',
|
||||||
|
padding: '4px 0',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ color: 'var(--text-tertiary)', fontWeight: 400 }}>{sidebarCopy.gphcLabel}</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
fontWeight: 500,
|
||||||
|
fontFamily: 'var(--font-geist-mono)',
|
||||||
|
fontSize: '12px',
|
||||||
|
letterSpacing: '0.12em',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{patient.nhsNumber.replace(/\s/g, '')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
fontSize: '13px',
|
||||||
|
padding: '4px 0',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ color: 'var(--text-tertiary)', fontWeight: 400 }}>{sidebarCopy.educationLabel}</span>
|
||||||
|
<span style={{ color: 'var(--text-primary)', fontWeight: 500, textAlign: 'right' }}>
|
||||||
|
{patient.qualification}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
fontSize: '13px',
|
||||||
|
padding: '4px 0',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ color: 'var(--text-tertiary)', fontWeight: 400 }}>{sidebarCopy.locationLabel}</span>
|
||||||
|
<span style={{ color: 'var(--text-primary)', fontWeight: 500, textAlign: 'right' }}>
|
||||||
|
{patient.address}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
fontSize: '13px',
|
||||||
|
padding: '4px 0',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ color: 'var(--text-tertiary)', fontWeight: 400 }}>{sidebarCopy.phoneLabel}</span>
|
||||||
|
<PhoneCaptcha phone={patient.phone} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
fontSize: '13px',
|
||||||
|
padding: '4px 0',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ color: 'var(--text-tertiary)', fontWeight: 400 }}>{sidebarCopy.emailLabel}</span>
|
||||||
|
<a
|
||||||
|
href={`mailto:${patient.email}`}
|
||||||
|
style={{
|
||||||
|
color: 'var(--accent)',
|
||||||
|
fontWeight: 500,
|
||||||
|
textDecoration: 'none',
|
||||||
|
textAlign: 'right',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => (e.currentTarget.style.textDecoration = 'underline')}
|
||||||
|
onMouseLeave={(e) => (e.currentTarget.style.textDecoration = 'none')}
|
||||||
|
>
|
||||||
|
{patient.email}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
fontSize: '13px',
|
||||||
|
padding: '4px 0',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ color: 'var(--text-tertiary)', fontWeight: 400 }}>{sidebarCopy.registeredLabel}</span>
|
||||||
|
<span style={{ color: 'var(--text-primary)', fontWeight: 500, textAlign: 'right' }}>
|
||||||
|
{patient.registrationYear}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href="/Andrew_Charlwood_CV.pdf"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
minHeight: '40px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
border: '1px solid var(--accent-border)',
|
||||||
|
background: 'var(--surface)',
|
||||||
|
color: 'var(--accent)',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '13px',
|
||||||
|
fontWeight: 600,
|
||||||
|
//fontFamily: 'var(--font-geist-mono)',
|
||||||
|
letterSpacing: '0.03em',
|
||||||
|
transition: 'border-color 150ms, color 150ms',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => (e.currentTarget.style.textDecoration = 'underline')}
|
||||||
|
onMouseLeave={(e) => (e.currentTarget.style.textDecoration = 'none')}
|
||||||
|
>
|
||||||
|
|
||||||
|
<Download size={14} />
|
||||||
|
Download CV
|
||||||
|
</a>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<section>
|
||||||
|
{isExpanded && <SectionTitle>{sidebarCopy.navigationTitle}</SectionTitle>}
|
||||||
|
<nav aria-label="Sidebar navigation" style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
|
||||||
|
{navSections.map((section) => {
|
||||||
|
const isActive = activeSection === section.id
|
||||||
|
const Icon = section.Icon
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={section.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleNavActivate(section.tileId)}
|
||||||
|
aria-current={isActive ? 'page' : undefined}
|
||||||
|
aria-label={!isExpanded ? section.label : undefined}
|
||||||
|
className="sidebar-control"
|
||||||
|
style={{
|
||||||
|
minHeight: '44px',
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: isActive ? 'var(--accent-border)' : 'transparent',
|
||||||
|
background: isActive ? 'var(--accent-light)' : 'transparent',
|
||||||
|
color: isActive ? 'var(--accent)' : 'var(--text-secondary)',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
width: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: isExpanded ? 'flex-start' : 'center',
|
||||||
|
gap: '10px',
|
||||||
|
padding: isExpanded ? '0 10px' : '0',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'background-color 150ms ease-out, color 150ms ease-out, border-color 150ms ease-out',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon size={17} strokeWidth={2.2} />
|
||||||
|
{isExpanded && (
|
||||||
|
<span style={{ fontSize: '14px', fontWeight: 600 }}>
|
||||||
|
{section.label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{isExpanded && (
|
||||||
|
<>
|
||||||
|
<section style={{ paddingTop: '4px' }}>
|
||||||
|
<SectionTitle>Contact</SectionTitle>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowReferralForm(true)}
|
||||||
|
className="sidebar-control"
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
minHeight: '40px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
border: '1px solid var(--accent-border)',
|
||||||
|
background: 'var(--surface)',
|
||||||
|
color: 'var(--accent)',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '13px',
|
||||||
|
fontWeight: 500,
|
||||||
|
//fontFamily: 'var(--font-geist-mono)',
|
||||||
|
letterSpacing: '0.03em',
|
||||||
|
transition: 'border-color 150ms, color 150ms',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Send size={14} />
|
||||||
|
Contact patient
|
||||||
|
</button>
|
||||||
|
<div style={{ display: 'flex', gap: '6px' }}>
|
||||||
|
<a
|
||||||
|
href="https://www.linkedin.com/in/andrewcharlwood/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="sidebar-control"
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
minHeight: '36px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: '6px',
|
||||||
|
border: '1px solid var(--border-light)',
|
||||||
|
background: 'var(--surface)',
|
||||||
|
color: 'var(--text-secondary)',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: 500,
|
||||||
|
textDecoration: 'none',
|
||||||
|
transition: 'border-color 150ms, color 150ms',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Linkedin size={14} />
|
||||||
|
LinkedIn
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="https://github.com/andrewcharlwood"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="sidebar-control"
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
minHeight: '36px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: '6px',
|
||||||
|
border: '1px solid var(--border-light)',
|
||||||
|
background: 'var(--surface)',
|
||||||
|
color: 'var(--text-secondary)',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: 500,
|
||||||
|
textDecoration: 'none',
|
||||||
|
transition: 'border-color 150ms, color 150ms',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Github size={14} />
|
||||||
|
GitHub
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section style={{ paddingTop: '8px' }}>
|
||||||
|
<SectionTitle>{sidebarCopy.tagsTitle}</SectionTitle>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '5px' }}>
|
||||||
|
{tags.map((tag) => (
|
||||||
|
<TagPill key={tag.label} tag={tag} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
<ReferralFormModal isOpen={showReferralForm} onClose={() => setShowReferralForm(false)} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,193 +0,0 @@
|
|||||||
import { useRef, useState, useEffect } from 'react'
|
|
||||||
import { motion } from 'framer-motion'
|
|
||||||
import type { Skill } from '../types'
|
|
||||||
import { calculateSkillOffset } from '../lib/utils'
|
|
||||||
|
|
||||||
const GAUGE_RADIUS = 34
|
|
||||||
const GAUGE_CIRCUMFERENCE = 2 * Math.PI * GAUGE_RADIUS
|
|
||||||
|
|
||||||
interface SkillGaugeProps {
|
|
||||||
skill: Skill
|
|
||||||
delay: number
|
|
||||||
isVisible: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
function SkillGauge({ skill, delay, isVisible }: SkillGaugeProps) {
|
|
||||||
const [animated, setAnimated] = useState(false)
|
|
||||||
const strokeColor = skill.color === 'coral' ? '#FF6B6B' : '#00897B'
|
|
||||||
const hoverBg = skill.color === 'coral' ? 'hover:bg-coral-light' : 'hover:bg-teal-light'
|
|
||||||
|
|
||||||
const targetOffset = calculateSkillOffset(skill.level, GAUGE_RADIUS)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isVisible && !animated) {
|
|
||||||
const timer = setTimeout(() => setAnimated(true), delay)
|
|
||||||
return () => clearTimeout(timer)
|
|
||||||
}
|
|
||||||
}, [isVisible, animated, delay])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 24 }}
|
|
||||||
animate={isVisible ? { opacity: 1, y: 0 } : { opacity: 0, y: 24 }}
|
|
||||||
transition={{ duration: 0.5, delay: delay / 1000, ease: 'easeOut' }}
|
|
||||||
className={`flex flex-col items-center p-3 xs:p-4 rounded-2xl transition-colors duration-300 ${hoverBg}`}
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
className="skill-gauge block w-16 h-16 xs:w-20 xs:h-20"
|
|
||||||
viewBox="0 0 80 80"
|
|
||||||
>
|
|
||||||
<circle
|
|
||||||
cx="40"
|
|
||||||
cy="40"
|
|
||||||
r={GAUGE_RADIUS}
|
|
||||||
fill="none"
|
|
||||||
stroke="#E2E8F0"
|
|
||||||
strokeWidth="5"
|
|
||||||
/>
|
|
||||||
<circle
|
|
||||||
cx="40"
|
|
||||||
cy="40"
|
|
||||||
r={GAUGE_RADIUS}
|
|
||||||
fill="none"
|
|
||||||
stroke={strokeColor}
|
|
||||||
strokeWidth="5"
|
|
||||||
strokeLinecap="round"
|
|
||||||
transform="rotate(-90, 40, 40)"
|
|
||||||
style={{
|
|
||||||
strokeDasharray: GAUGE_CIRCUMFERENCE,
|
|
||||||
strokeDashoffset: animated ? targetOffset : GAUGE_CIRCUMFERENCE,
|
|
||||||
transition: animated ? 'stroke-dashoffset 1.2s ease-out' : 'none'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<text
|
|
||||||
x="40"
|
|
||||||
y="40"
|
|
||||||
textAnchor="middle"
|
|
||||||
dominantBaseline="central"
|
|
||||||
fontSize="14"
|
|
||||||
fontWeight="600"
|
|
||||||
fill="#0F172A"
|
|
||||||
fontFamily="'Inter Tight', system-ui, sans-serif"
|
|
||||||
>
|
|
||||||
{skill.level}%
|
|
||||||
</text>
|
|
||||||
</svg>
|
|
||||||
<span className="font-primary text-xs font-semibold text-heading mt-2 text-center leading-tight">
|
|
||||||
{skill.name}
|
|
||||||
</span>
|
|
||||||
<span className="font-secondary text-[10px] text-muted uppercase tracking-wide mt-0.5">
|
|
||||||
{skill.category}
|
|
||||||
</span>
|
|
||||||
</motion.div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SkillCategoryProps {
|
|
||||||
label: string
|
|
||||||
skills: Skill[]
|
|
||||||
isVisible: boolean
|
|
||||||
baseDelay: number
|
|
||||||
}
|
|
||||||
|
|
||||||
function SkillCategory({ label, skills, isVisible, baseDelay }: SkillCategoryProps) {
|
|
||||||
return (
|
|
||||||
<div className="mb-10 last:mb-0">
|
|
||||||
<h3 className="font-secondary text-xs font-semibold uppercase tracking-widest text-muted mb-5 pl-1">
|
|
||||||
{label}
|
|
||||||
</h3>
|
|
||||||
<div className="grid grid-cols-2 xs:grid-cols-3 md:grid-cols-[repeat(auto-fit,minmax(140px,1fr))] gap-4 xs:gap-6">
|
|
||||||
{skills.map((skill, index) => (
|
|
||||||
<SkillGauge
|
|
||||||
key={skill.name}
|
|
||||||
skill={skill}
|
|
||||||
delay={baseDelay + index * 100}
|
|
||||||
isVisible={isVisible}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const skillsData: Skill[] = [
|
|
||||||
{ name: 'Python', level: 90, category: 'Technical', color: 'teal' },
|
|
||||||
{ name: 'SQL', level: 88, category: 'Technical', color: 'teal' },
|
|
||||||
{ name: 'Power BI', level: 92, category: 'Technical', color: 'teal' },
|
|
||||||
{ name: 'JS / TS', level: 70, category: 'Technical', color: 'teal' },
|
|
||||||
{ name: 'Data Analysis', level: 95, category: 'Technical', color: 'teal' },
|
|
||||||
{ name: 'Dashboard Dev', level: 88, category: 'Technical', color: 'teal' },
|
|
||||||
{ name: 'Algorithm Design', level: 82, category: 'Technical', color: 'teal' },
|
|
||||||
{ name: 'Data Pipelines', level: 80, category: 'Technical', color: 'teal' },
|
|
||||||
|
|
||||||
{ name: 'Medicines Optimisation', level: 95, category: 'Clinical', color: 'coral' },
|
|
||||||
{ name: 'Pop. Health Analytics', level: 90, category: 'Clinical', color: 'coral' },
|
|
||||||
{ name: 'NICE TA', level: 85, category: 'Clinical', color: 'coral' },
|
|
||||||
{ name: 'Health Economics', level: 80, category: 'Clinical', color: 'coral' },
|
|
||||||
{ name: 'Clinical Pathways', level: 82, category: 'Clinical', color: 'coral' },
|
|
||||||
{ name: 'CD Assurance', level: 88, category: 'Clinical', color: 'coral' },
|
|
||||||
|
|
||||||
{ name: 'Budget Mgmt', level: 90, category: 'Strategic', color: 'teal' },
|
|
||||||
{ name: 'Stakeholder Engagement', level: 88, category: 'Strategic', color: 'teal' },
|
|
||||||
{ name: 'Pharma Negotiation', level: 85, category: 'Strategic', color: 'teal' },
|
|
||||||
{ name: 'Team Development', level: 82, category: 'Strategic', color: 'teal' },
|
|
||||||
]
|
|
||||||
|
|
||||||
export function Skills() {
|
|
||||||
const sectionRef = useRef<HTMLElement>(null)
|
|
||||||
const [isVisible, setIsVisible] = useState(false)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const element = sectionRef.current
|
|
||||||
if (!element) return
|
|
||||||
|
|
||||||
const observer = new IntersectionObserver(
|
|
||||||
([entry]) => {
|
|
||||||
if (entry.isIntersecting) {
|
|
||||||
setIsVisible(true)
|
|
||||||
observer.unobserve(element)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ threshold: 0.15, rootMargin: '0px' }
|
|
||||||
)
|
|
||||||
|
|
||||||
observer.observe(element)
|
|
||||||
return () => observer.disconnect()
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const technicalSkills = skillsData.filter(s => s.category === 'Technical')
|
|
||||||
const clinicalSkills = skillsData.filter(s => s.category === 'Clinical')
|
|
||||||
const strategicSkills = skillsData.filter(s => s.category === 'Strategic')
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section id="skills" ref={sectionRef} className="py-12 xs:py-16 md:py-20">
|
|
||||||
<motion.h2
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={isVisible ? { opacity: 1, y: 0 } : { opacity: 0, y: 20 }}
|
|
||||||
transition={{ duration: 0.6, ease: 'easeOut' }}
|
|
||||||
className="font-primary text-2xl font-bold text-heading text-center mb-8"
|
|
||||||
>
|
|
||||||
Skills & Expertise
|
|
||||||
</motion.h2>
|
|
||||||
|
|
||||||
<SkillCategory
|
|
||||||
label="Technical"
|
|
||||||
skills={technicalSkills}
|
|
||||||
isVisible={isVisible}
|
|
||||||
baseDelay={200}
|
|
||||||
/>
|
|
||||||
<SkillCategory
|
|
||||||
label="Clinical"
|
|
||||||
skills={clinicalSkills}
|
|
||||||
isVisible={isVisible}
|
|
||||||
baseDelay={200 + technicalSkills.length * 100 + 100}
|
|
||||||
/>
|
|
||||||
<SkillCategory
|
|
||||||
label="Strategic"
|
|
||||||
skills={strategicSkills}
|
|
||||||
isVisible={isVisible}
|
|
||||||
baseDelay={200 + technicalSkills.length * 100 + clinicalSkills.length * 100 + 200}
|
|
||||||
/>
|
|
||||||
</section>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,437 @@
|
|||||||
|
import { useMemo, useState, useCallback } from 'react'
|
||||||
|
import { ChevronRight, ChevronDown, History } from 'lucide-react'
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
|
import { ExpandableCardShell } from './ExpandableCardShell'
|
||||||
|
import { useDetailPanel } from '@/contexts/DetailPanelContext'
|
||||||
|
import { timelineEntities, timelineConsultations } from '@/data/timeline'
|
||||||
|
import { documents } from '@/data/documents'
|
||||||
|
import { getExperienceEducationUICopy } from '@/lib/profile-content'
|
||||||
|
import type { TimelineEntity } from '@/types/pmr'
|
||||||
|
|
||||||
|
const timelineToDocumentId: Record<string, string> = {
|
||||||
|
'nhs-mary-seacole-2018': 'doc-mary-seacole',
|
||||||
|
'uea-mpharm-2011': 'doc-mpharm',
|
||||||
|
'highworth-alevels-2009': 'doc-alevels',
|
||||||
|
}
|
||||||
|
import { hexToRgba, motionSafeTransition } from '@/lib/utils'
|
||||||
|
|
||||||
|
const VISIBLE_COUNT = 5
|
||||||
|
|
||||||
|
interface TimelineInterventionItemProps {
|
||||||
|
entity: TimelineEntity
|
||||||
|
isExpanded: boolean
|
||||||
|
isHighlightedFromGraph: boolean
|
||||||
|
isDimmedByFocus: boolean
|
||||||
|
isEducationAnchor: boolean
|
||||||
|
onToggle: () => void
|
||||||
|
onViewFull: () => void
|
||||||
|
onHighlight?: (id: string | null) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function TimelineInterventionItem({
|
||||||
|
entity,
|
||||||
|
isExpanded,
|
||||||
|
isHighlightedFromGraph,
|
||||||
|
isDimmedByFocus,
|
||||||
|
isEducationAnchor,
|
||||||
|
onToggle,
|
||||||
|
onViewFull,
|
||||||
|
onHighlight,
|
||||||
|
}: TimelineInterventionItemProps) {
|
||||||
|
const experienceEducationCopy = getExperienceEducationUICopy()
|
||||||
|
const isEducation = entity.kind === 'education'
|
||||||
|
const interventionLabel = isEducation ? experienceEducationCopy.educationLabel : experienceEducationCopy.employmentLabel
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ExpandableCardShell
|
||||||
|
isExpanded={isExpanded}
|
||||||
|
isHighlighted={isHighlightedFromGraph}
|
||||||
|
isDimmedByFocus={isDimmedByFocus}
|
||||||
|
accentColor={entity.orgColor}
|
||||||
|
onToggle={onToggle}
|
||||||
|
ariaLabel={`${entity.title} at ${entity.organization}, ${entity.dateRange.display}. Click to ${isExpanded ? 'collapse' : 'expand'} details.`}
|
||||||
|
headerPadding="8px 8px"
|
||||||
|
className={isEducation ? 'timeline-intervention-item timeline-intervention-item--education' : 'timeline-intervention-item'}
|
||||||
|
dataTileId={isEducationAnchor ? 'section-education' : undefined}
|
||||||
|
onMouseEnter={() => onHighlight?.(entity.id)}
|
||||||
|
onMouseLeave={() => onHighlight?.(null)}
|
||||||
|
renderHeader={() => (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: '8px' }}>
|
||||||
|
<div style={{ minWidth: 0 }}>
|
||||||
|
<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>
|
||||||
|
{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',
|
||||||
|
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>
|
||||||
|
{(entity.band || entity.employmentBasis) && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexShrink: 0,
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '5px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{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',
|
||||||
|
padding: 0,
|
||||||
|
margin: '0 0 10px 0',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '5px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{entity.details.map((detail, 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: entity.orgColor,
|
||||||
|
opacity: 0.5,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{detail}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{!!entity.codedEntries?.length && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: '6px',
|
||||||
|
marginBottom: '10px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{entity.codedEntries.map((entry) => (
|
||||||
|
<span
|
||||||
|
key={entry.code}
|
||||||
|
style={{
|
||||||
|
fontSize: '11px',
|
||||||
|
fontFamily: 'var(--font-geist-mono)',
|
||||||
|
padding: '3px 8px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
background: hexToRgba(entity.orgColor, 0.08),
|
||||||
|
color: entity.orgColor,
|
||||||
|
border: `1px solid ${hexToRgba(entity.orgColor, 0.2)}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{entry.code}: {entry.description}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onViewFull()
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '4px',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: 500,
|
||||||
|
color: entity.orgColor,
|
||||||
|
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'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{experienceEducationCopy.viewFullRecordLabel}
|
||||||
|
<ChevronRight size={12} />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TimelineInterventionsSubsectionProps {
|
||||||
|
onNodeHighlight?: (id: string | null) => void
|
||||||
|
highlightedRoleId?: string | null
|
||||||
|
focusRelatedIds?: Set<string> | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TimelineInterventionsSubsection({ onNodeHighlight, highlightedRoleId, focusRelatedIds }: TimelineInterventionsSubsectionProps) {
|
||||||
|
const [expandedId, setExpandedId] = useState<string | null>(null)
|
||||||
|
const [historicalOpen, setHistoricalOpen] = useState(false)
|
||||||
|
const { openPanel } = useDetailPanel()
|
||||||
|
|
||||||
|
const visibleEntities = useMemo(() => timelineEntities.slice(0, VISIBLE_COUNT), [])
|
||||||
|
const historicalEntities = useMemo(() => timelineEntities.slice(VISIBLE_COUNT), [])
|
||||||
|
|
||||||
|
const consultationsById = useMemo(
|
||||||
|
() => new Map(timelineConsultations.map((consultation) => [consultation.id, consultation])),
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
const firstEducationId = useMemo(
|
||||||
|
() => timelineEntities.find((entity) => entity.kind === 'education')?.id ?? null,
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleToggle = useCallback((id: string) => {
|
||||||
|
setExpandedId((prev) => (prev === id ? null : id))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleViewFull = useCallback((entity: TimelineEntity) => {
|
||||||
|
if (entity.kind === 'education') {
|
||||||
|
const docId = timelineToDocumentId[entity.id]
|
||||||
|
const doc = docId ? documents.find((d) => d.id === docId) : undefined
|
||||||
|
if (doc) openPanel({ type: 'education', document: doc })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const consultation = consultationsById.get(entity.id)
|
||||||
|
if (!consultation) return
|
||||||
|
openPanel({ type: 'career-role', consultation })
|
||||||
|
}, [consultationsById, openPanel])
|
||||||
|
|
||||||
|
const historicalHasAnyFocusRelevance = focusRelatedIds !== null && focusRelatedIds !== undefined &&
|
||||||
|
historicalEntities.some((e) => focusRelatedIds.has(e.id))
|
||||||
|
const historicalDimmed = focusRelatedIds !== null && focusRelatedIds !== undefined && !historicalHasAnyFocusRelevance
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
|
||||||
|
{visibleEntities.map((entity) => (
|
||||||
|
<TimelineInterventionItem
|
||||||
|
key={entity.id}
|
||||||
|
entity={entity}
|
||||||
|
isExpanded={expandedId === entity.id}
|
||||||
|
isHighlightedFromGraph={highlightedRoleId === entity.id}
|
||||||
|
isDimmedByFocus={focusRelatedIds !== null && focusRelatedIds !== undefined && !focusRelatedIds.has(entity.id)}
|
||||||
|
isEducationAnchor={entity.id === firstEducationId}
|
||||||
|
onToggle={() => handleToggle(entity.id)}
|
||||||
|
onViewFull={() => handleViewFull(entity)}
|
||||||
|
onHighlight={onNodeHighlight}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{historicalEntities.length > 0 && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
opacity: historicalDimmed ? 0.25 : 1,
|
||||||
|
transition: 'opacity 150ms ease-out',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={() => setHistoricalOpen((prev) => !prev)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault()
|
||||||
|
setHistoricalOpen((prev) => !prev)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
aria-expanded={historicalOpen}
|
||||||
|
aria-label={`${historicalOpen ? 'Hide' : 'Show'} ${historicalEntities.length} historical entries`}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
padding: '6px 10px',
|
||||||
|
background: 'var(--bg-dashboard)',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
border: '1px solid var(--border-light)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'border-color 0.15s, box-shadow 0.15s',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.borderColor = 'rgba(0, 137, 123, 0.2)'
|
||||||
|
e.currentTarget.style.boxShadow = 'var(--shadow-md)'
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.borderColor = 'var(--border-light)'
|
||||||
|
e.currentTarget.style.boxShadow = 'none'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<History size={13} style={{ color: 'var(--text-tertiary)', flexShrink: 0 }} />
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: '12px',
|
||||||
|
color: 'var(--text-secondary)',
|
||||||
|
fontFamily: 'var(--font-geist-mono)',
|
||||||
|
flex: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{historicalOpen ? 'Hide' : 'View'} historical entries ({historicalEntities.length})
|
||||||
|
</span>
|
||||||
|
<ChevronDown
|
||||||
|
size={13}
|
||||||
|
style={{
|
||||||
|
color: 'var(--text-tertiary)',
|
||||||
|
flexShrink: 0,
|
||||||
|
transform: historicalOpen ? 'rotate(180deg)' : 'none',
|
||||||
|
transition: 'transform 0.15s ease-out',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{historicalOpen && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ height: 0, opacity: 0 }}
|
||||||
|
animate={{ height: 'auto', opacity: 1 }}
|
||||||
|
exit={{ height: 0, opacity: 0 }}
|
||||||
|
transition={motionSafeTransition(0.25)}
|
||||||
|
style={{ overflow: 'hidden' }}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px', paddingTop: '10px' }}>
|
||||||
|
{historicalEntities.map((entity) => (
|
||||||
|
<TimelineInterventionItem
|
||||||
|
key={entity.id}
|
||||||
|
entity={entity}
|
||||||
|
isExpanded={expandedId === entity.id}
|
||||||
|
isHighlightedFromGraph={highlightedRoleId === entity.id}
|
||||||
|
isDimmedByFocus={focusRelatedIds !== null && focusRelatedIds !== undefined && !focusRelatedIds.has(entity.id)}
|
||||||
|
isEducationAnchor={entity.id === firstEducationId}
|
||||||
|
onToggle={() => handleToggle(entity.id)}
|
||||||
|
onViewFull={() => handleViewFull(entity)}
|
||||||
|
onHighlight={onNodeHighlight}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import type { ConstellationNode } from '@/types/pmr'
|
||||||
|
import { ROLE_WIDTH, ROLE_HEIGHT, MOBILE_ROLE_WIDTH } from './constants'
|
||||||
|
|
||||||
|
interface AccessibleNodeOverlayProps {
|
||||||
|
nodes: ConstellationNode[]
|
||||||
|
nodeButtonPositions: Record<string, { x: number; y: number }>
|
||||||
|
dimensions: { width: number; height: number; scaleFactor: number }
|
||||||
|
onFocus: (nodeId: string) => void
|
||||||
|
onBlur: () => void
|
||||||
|
onClick: (nodeId: string, nodeType: 'role' | 'skill' | 'education') => void
|
||||||
|
onKeyDown: (e: React.KeyboardEvent, nodeId: string, nodeType: 'role' | 'skill' | 'education') => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AccessibleNodeOverlay: React.FC<AccessibleNodeOverlayProps> = ({
|
||||||
|
nodes,
|
||||||
|
nodeButtonPositions,
|
||||||
|
dimensions,
|
||||||
|
onFocus,
|
||||||
|
onBlur,
|
||||||
|
onClick,
|
||||||
|
onKeyDown,
|
||||||
|
}) => {
|
||||||
|
const domainOrder: Record<string, number> = { technical: 0, clinical: 1, leadership: 2 }
|
||||||
|
const isEntity = (t: string) => t === 'role' || t === 'education'
|
||||||
|
const sorted = [...nodes].sort((a, b) => {
|
||||||
|
if (isEntity(a.type) && !isEntity(b.type)) return -1
|
||||||
|
if (!isEntity(a.type) && isEntity(b.type)) return 1
|
||||||
|
if (isEntity(a.type) && isEntity(b.type)) {
|
||||||
|
return (b.startYear ?? 0) - (a.startYear ?? 0)
|
||||||
|
}
|
||||||
|
const da = domainOrder[a.domain ?? 'technical'] ?? 0
|
||||||
|
const db = domainOrder[b.domain ?? 'technical'] ?? 0
|
||||||
|
if (da !== db) return da - db
|
||||||
|
return (a.label ?? '').localeCompare(b.label ?? '')
|
||||||
|
})
|
||||||
|
|
||||||
|
const isMobileBtn = typeof window !== 'undefined' && window.innerWidth < 640
|
||||||
|
const btnSf = isMobileBtn ? 1 : dimensions.scaleFactor
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="group"
|
||||||
|
aria-label="Career nodes - use Tab to navigate and Enter to open details"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{sorted.map(node => {
|
||||||
|
const yearRange = node.endYear
|
||||||
|
? `${node.startYear}-${node.endYear}`
|
||||||
|
: `${node.startYear}-present`
|
||||||
|
|
||||||
|
const position = nodeButtonPositions[node.id] ?? { x: dimensions.width * 0.5, y: dimensions.height * 0.5 }
|
||||||
|
const isEntityBtn = isEntity(node.type)
|
||||||
|
const buttonWidth = isEntityBtn ? (isMobileBtn ? MOBILE_ROLE_WIDTH : Math.round(ROLE_WIDTH * btnSf)) : Math.round(34 * btnSf)
|
||||||
|
const buttonHeight = isEntityBtn ? Math.round(ROLE_HEIGHT * btnSf) : Math.round(34 * btnSf)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={node.id}
|
||||||
|
type="button"
|
||||||
|
aria-label={
|
||||||
|
isEntityBtn
|
||||||
|
? `${node.label} at ${node.organization}, ${yearRange}. Press Enter to view details.`
|
||||||
|
: `${node.label} skill node. Press Enter to view details.`
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
width: buttonWidth,
|
||||||
|
height: buttonHeight,
|
||||||
|
top: `${position.y}px`,
|
||||||
|
left: `${position.x}px`,
|
||||||
|
transform: 'translate(-50%, -50%)',
|
||||||
|
background: 'transparent',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'default',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
padding: 0,
|
||||||
|
opacity: 0,
|
||||||
|
}}
|
||||||
|
onFocus={() => onFocus(node.id)}
|
||||||
|
onBlur={onBlur}
|
||||||
|
onClick={() => onClick(node.id, node.type)}
|
||||||
|
onKeyDown={e => onKeyDown(e, node.id, node.type)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,422 @@
|
|||||||
|
import React, { useRef, useEffect, useState, useCallback, useMemo } from 'react'
|
||||||
|
import * as d3 from 'd3'
|
||||||
|
import { constellationNodes } from '@/data/constellation'
|
||||||
|
import { timelineEntities } from '@/data/timeline'
|
||||||
|
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,
|
||||||
|
SKILL_RADIUS_DEFAULT, SKILL_RADIUS_ACTIVE,
|
||||||
|
MOBILE_SKILL_RADIUS_DEFAULT, MOBILE_SKILL_RADIUS_ACTIVE,
|
||||||
|
supportsCoarsePointer, prefersReducedMotion,
|
||||||
|
} from './constants'
|
||||||
|
|
||||||
|
interface CareerConstellationProps {
|
||||||
|
onRoleClick: (id: string) => void
|
||||||
|
onSkillClick: (id: string) => void
|
||||||
|
onNodeHover?: (id: string | null) => void
|
||||||
|
highlightedNodeId?: string | null
|
||||||
|
containerHeight?: number | null
|
||||||
|
animationReady?: boolean
|
||||||
|
globalFocusActive?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodeById = new Map(constellationNodes.map(node => [node.id, node]))
|
||||||
|
const careerEntityById = new Map(timelineEntities.map(entity => [entity.id, entity]))
|
||||||
|
|
||||||
|
const CareerConstellation: React.FC<CareerConstellationProps> = ({
|
||||||
|
onRoleClick,
|
||||||
|
onSkillClick,
|
||||||
|
onNodeHover,
|
||||||
|
highlightedNodeId,
|
||||||
|
containerHeight,
|
||||||
|
animationReady = false,
|
||||||
|
globalFocusActive = false,
|
||||||
|
}) => {
|
||||||
|
const svgRef = useRef<SVGSVGElement>(null)
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
const callbacksRef = useRef({ onRoleClick, onSkillClick, onNodeHover })
|
||||||
|
const highlightedNodeIdRef = useRef<string | null>(highlightedNodeId ?? null)
|
||||||
|
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 }
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
highlightedNodeIdRef.current = highlightedNodeId ?? null
|
||||||
|
}, [highlightedNodeId])
|
||||||
|
|
||||||
|
// Track chart visibility for play/pause button
|
||||||
|
useEffect(() => {
|
||||||
|
const container = containerRef.current
|
||||||
|
if (!container) return
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
([entry]) => setChartInView(entry.isIntersecting),
|
||||||
|
{ threshold: 0.1 },
|
||||||
|
)
|
||||||
|
observer.observe(container)
|
||||||
|
return () => observer.disconnect()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const container = containerRef.current
|
||||||
|
if (!container) return
|
||||||
|
|
||||||
|
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
const X_CHANGE_THRESHOLD = 0.3
|
||||||
|
|
||||||
|
const updateDimensions = (force = false) => {
|
||||||
|
const width = container.clientWidth
|
||||||
|
const viewportWidth = window.innerWidth
|
||||||
|
const height = isFullscreen ? window.innerHeight : getHeight(viewportWidth, containerHeight)
|
||||||
|
const scaleFactor = viewportWidth >= 1024
|
||||||
|
? Math.max(1, Math.min(1.6, viewportWidth / 1440))
|
||||||
|
: 1
|
||||||
|
setDimensions(prev => {
|
||||||
|
if (!force) {
|
||||||
|
const widthDelta = Math.abs(prev.width - width) / prev.width
|
||||||
|
const heightRatio = Math.max(height / prev.height, prev.height / height)
|
||||||
|
if (widthDelta < X_CHANGE_THRESHOLD && heightRatio < 2) {
|
||||||
|
return prev
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { width, height, scaleFactor }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Force update on fullscreen/orientation change so animation always restarts
|
||||||
|
requestAnimationFrame(() => updateDimensions(true))
|
||||||
|
|
||||||
|
const onOrientationChange = () => {
|
||||||
|
requestAnimationFrame(() => updateDimensions(true))
|
||||||
|
}
|
||||||
|
window.addEventListener('orientationchange', onOrientationChange)
|
||||||
|
|
||||||
|
const observer = new ResizeObserver(() => {
|
||||||
|
if (debounceTimer) clearTimeout(debounceTimer)
|
||||||
|
debounceTimer = setTimeout(() => updateDimensions(), 2000)
|
||||||
|
})
|
||||||
|
observer.observe(container)
|
||||||
|
return () => {
|
||||||
|
observer.disconnect()
|
||||||
|
window.removeEventListener('orientationchange', onOrientationChange)
|
||||||
|
if (debounceTimer) clearTimeout(debounceTimer)
|
||||||
|
}
|
||||||
|
}, [containerHeight, isFullscreen])
|
||||||
|
|
||||||
|
const toggleFullscreen = useCallback(() => {
|
||||||
|
const entering = !isFullscreen
|
||||||
|
setIsFullscreen(entering)
|
||||||
|
|
||||||
|
if (entering) {
|
||||||
|
// On portrait touch devices, request native fullscreen + lock landscape
|
||||||
|
const isPortrait = window.matchMedia('(orientation: portrait)').matches
|
||||||
|
const isTouch = window.matchMedia('(pointer: coarse)').matches
|
||||||
|
if (isPortrait && isTouch && containerRef.current?.requestFullscreen) {
|
||||||
|
const so = screen.orientation as ScreenOrientation & { lock?: (o: string) => Promise<void> }
|
||||||
|
containerRef.current.requestFullscreen()
|
||||||
|
.then(() => so.lock?.('landscape'))
|
||||||
|
.catch(() => {})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const so = screen.orientation as ScreenOrientation & { unlock?: () => void }
|
||||||
|
try { so.unlock?.() } catch { /* not supported */ }
|
||||||
|
if (document.fullscreenElement) {
|
||||||
|
document.exitFullscreen().catch(() => {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [isFullscreen])
|
||||||
|
|
||||||
|
// 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])
|
||||||
|
|
||||||
|
// Sync state when native fullscreen is exited via browser controls
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = () => {
|
||||||
|
if (!document.fullscreenElement && isFullscreen) {
|
||||||
|
setIsFullscreen(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('fullscreenchange', handler)
|
||||||
|
return () => document.removeEventListener('fullscreenchange', handler)
|
||||||
|
}, [isFullscreen])
|
||||||
|
|
||||||
|
// Focus trap when fullscreen
|
||||||
|
useFocusTrap(containerRef, isFullscreen)
|
||||||
|
|
||||||
|
const isMobile = typeof window !== 'undefined' && window.innerWidth < 640
|
||||||
|
const sf = isMobile ? 1 : dimensions.scaleFactor
|
||||||
|
const srDefault = isMobile ? MOBILE_SKILL_RADIUS_DEFAULT : Math.round(SKILL_RADIUS_DEFAULT * sf)
|
||||||
|
const srActive = isMobile ? MOBILE_SKILL_RADIUS_ACTIVE : Math.round(SKILL_RADIUS_ACTIVE * sf)
|
||||||
|
|
||||||
|
// pinnedNodeIdRef is declared later but only accessed at call-time (not during render), so empty dep arrays are correct
|
||||||
|
/* eslint-disable react-hooks/exhaustive-deps */
|
||||||
|
const resolveGraphFallback = useCallback(
|
||||||
|
() => highlightedNodeIdRef.current ?? pinnedNodeIdRef.current,
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
const resolveRoleFallback = useCallback(() => {
|
||||||
|
const hId = highlightedNodeIdRef.current
|
||||||
|
const hType = hId ? nodeById.get(hId)?.type : null
|
||||||
|
if (hId && hType && hType !== 'skill') return hId
|
||||||
|
const pId = pinnedNodeIdRef.current
|
||||||
|
const pType = pId ? nodeById.get(pId)?.type : null
|
||||||
|
if (pId && pType && pType !== 'skill') return pId
|
||||||
|
return null
|
||||||
|
}, [])
|
||||||
|
/* eslint-enable react-hooks/exhaustive-deps */
|
||||||
|
|
||||||
|
// Shared refs for hooks
|
||||||
|
const highlightGraphRef = useRef<((activeNodeId: string | null) => void) | null>(null)
|
||||||
|
const nodesRef = useRef<import('./types').SimNode[]>([])
|
||||||
|
const nodeSelectionRef = useRef<d3.Selection<SVGGElement, import('./types').SimNode, SVGGElement, unknown> | null>(null)
|
||||||
|
const linkSelectionRef = useRef<d3.Selection<SVGPathElement, import('./types').SimLink, SVGGElement, unknown> | null>(null)
|
||||||
|
const connectedMapRef = useRef<Map<string, Set<string>>>(new Map())
|
||||||
|
const skillRestRadiiRef = useRef<Map<string, number>>(new Map())
|
||||||
|
const visibleNodeIdsRef = useRef<Set<string>>(new Set())
|
||||||
|
|
||||||
|
const { applyGraphHighlight } = useConstellationHighlight({
|
||||||
|
nodeSelectionRef,
|
||||||
|
linkSelectionRef,
|
||||||
|
connectedMap: connectedMapRef.current,
|
||||||
|
srDefault,
|
||||||
|
srActive,
|
||||||
|
nodesRef,
|
||||||
|
skillRestRadii: skillRestRadiiRef.current,
|
||||||
|
visibleNodeIdsRef,
|
||||||
|
})
|
||||||
|
|
||||||
|
highlightGraphRef.current = applyGraphHighlight
|
||||||
|
|
||||||
|
const simOptionsRef = useRef({
|
||||||
|
resolveGraphFallback,
|
||||||
|
applyHighlight: applyGraphHighlight,
|
||||||
|
})
|
||||||
|
simOptionsRef.current = { resolveGraphFallback, applyHighlight: applyGraphHighlight }
|
||||||
|
|
||||||
|
const stableSimOptions = useMemo(() => ({
|
||||||
|
resolveGraphFallback: () => simOptionsRef.current.resolveGraphFallback(),
|
||||||
|
applyHighlight: (id: string | null) => simOptionsRef.current.applyHighlight(id),
|
||||||
|
}), [])
|
||||||
|
|
||||||
|
const sim = useForceSimulation(svgRef, dimensions, stableSimOptions)
|
||||||
|
|
||||||
|
// Sync simulation refs
|
||||||
|
useEffect(() => {
|
||||||
|
nodesRef.current = sim.nodesRef.current
|
||||||
|
nodeSelectionRef.current = sim.nodeSelectionRef.current
|
||||||
|
linkSelectionRef.current = sim.linkSelectionRef.current
|
||||||
|
if (sim.connectedMap.size > 0) connectedMapRef.current = sim.connectedMap
|
||||||
|
if (sim.skillRestRadii.size > 0) skillRestRadiiRef.current = sim.skillRestRadii
|
||||||
|
})
|
||||||
|
|
||||||
|
// Animation hook
|
||||||
|
const animation = useTimelineAnimation({
|
||||||
|
nodeSelectionRef,
|
||||||
|
linkSelectionRef,
|
||||||
|
simulationRef: sim.simulationRef,
|
||||||
|
yearIndicatorRef: sim.yearIndicatorRef,
|
||||||
|
connectorSelectionRef: sim.connectorSelectionRef,
|
||||||
|
timelineGroupRef: sim.timelineGroupRef,
|
||||||
|
skillRestRadiiRef,
|
||||||
|
srDefault,
|
||||||
|
dimensionsTrigger: dimensions.width + dimensions.height,
|
||||||
|
ready: animationReady,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Sync visibleNodeIdsRef from animation hook
|
||||||
|
visibleNodeIdsRef.current = animation.visibleNodeIdsRef.current
|
||||||
|
|
||||||
|
// Interaction hook
|
||||||
|
const { pinnedNodeId, setPinnedNodeId, pinnedNodeIdRef } = useConstellationInteraction({
|
||||||
|
highlightGraphRef,
|
||||||
|
nodeSelectionRef,
|
||||||
|
svgRef,
|
||||||
|
callbacksRef,
|
||||||
|
resolveGraphFallback,
|
||||||
|
resolveRoleFallback,
|
||||||
|
dimensionsTrigger: dimensions.width + dimensions.height,
|
||||||
|
})
|
||||||
|
|
||||||
|
// External highlight sync
|
||||||
|
useEffect(() => {
|
||||||
|
if (!highlightGraphRef.current) return
|
||||||
|
highlightGraphRef.current(highlightedNodeId ?? pinnedNodeId)
|
||||||
|
}, [highlightedNodeId, pinnedNodeId])
|
||||||
|
|
||||||
|
// Focus ring management
|
||||||
|
useEffect(() => {
|
||||||
|
if (!svgRef.current) return
|
||||||
|
const svg = d3.select(svgRef.current)
|
||||||
|
svg.selectAll('.focus-ring').attr('stroke', 'transparent')
|
||||||
|
if (focusedNodeId) {
|
||||||
|
svg.selectAll<SVGGElement, { id: string }>('g.node')
|
||||||
|
.filter(d => d.id === focusedNodeId)
|
||||||
|
.select('.focus-ring')
|
||||||
|
.attr('stroke', 'var(--accent)')
|
||||||
|
.attr('stroke-width', 2)
|
||||||
|
}
|
||||||
|
}, [focusedNodeId])
|
||||||
|
|
||||||
|
const handleNodeKeyDown = useCallback((e: React.KeyboardEvent, nodeId: string, nodeType: 'role' | 'skill' | 'education') => {
|
||||||
|
if (e.key !== 'Enter' && e.key !== ' ') return
|
||||||
|
e.preventDefault()
|
||||||
|
setPinnedNodeId(nodeId)
|
||||||
|
pinnedNodeIdRef.current = nodeId
|
||||||
|
highlightGraphRef.current?.(nodeId)
|
||||||
|
onNodeHover?.(nodeType !== 'skill' ? nodeId : resolveRoleFallback())
|
||||||
|
;(nodeType !== 'skill' ? onRoleClick : onSkillClick)(nodeId)
|
||||||
|
}, [onRoleClick, onSkillClick, onNodeHover, resolveRoleFallback, setPinnedNodeId, pinnedNodeIdRef])
|
||||||
|
|
||||||
|
const pinnedRoleNode = pinnedNodeId ? constellationNodes.find(n => n.id === pinnedNodeId && (n.type === 'role' || n.type === 'education')) : null
|
||||||
|
const pinnedCareerEntity = pinnedRoleNode ? careerEntityById.get(pinnedRoleNode.id) ?? null : null
|
||||||
|
const domainCounts = useMemo(() => {
|
||||||
|
const counts: Record<string, number> = {}
|
||||||
|
constellationNodes.filter(n => n.type === 'skill').forEach(n => {
|
||||||
|
const d = n.domain ?? 'technical'
|
||||||
|
counts[d] = (counts[d] ?? 0) + 1
|
||||||
|
})
|
||||||
|
return counts
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const showAccordion = supportsCoarsePointer && pinnedCareerEntity !== null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{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',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
{...(isFullscreen ? {
|
||||||
|
role: 'dialog',
|
||||||
|
'aria-modal': true,
|
||||||
|
'aria-label': 'Career constellation fullscreen view',
|
||||||
|
} : {})}
|
||||||
|
style={{
|
||||||
|
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,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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"
|
||||||
|
className={globalFocusActive || highlightedNodeId || pinnedNodeId ? 'constellation-focus-active' : ''}
|
||||||
|
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}
|
||||||
|
isCompleted={animation.isCompleted}
|
||||||
|
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())
|
||||||
|
}}
|
||||||
|
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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CareerConstellation
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { supportsCoarsePointer } from './constants'
|
||||||
|
|
||||||
|
interface ConstellationLegendProps {
|
||||||
|
isTouch: boolean
|
||||||
|
domainCounts?: Record<string, number>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ConstellationLegend: React.FC<ConstellationLegendProps> = ({ isTouch, domainCounts }) => {
|
||||||
|
const items = [
|
||||||
|
{ label: 'Technical', domain: 'technical', color: 'var(--accent)' },
|
||||||
|
{ label: 'Clinical', domain: 'clinical', color: 'var(--success)' },
|
||||||
|
{ label: 'Leadership', domain: 'leadership', color: 'var(--amber)' },
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '2px',
|
||||||
|
padding: '8px 12px',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '12px',
|
||||||
|
fontFamily: 'var(--font-ui)',
|
||||||
|
color: 'var(--text-secondary)',
|
||||||
|
opacity: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isTouch || supportsCoarsePointer ? 'Tap to explore connections' : 'Hover to explore connections'}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '12px',
|
||||||
|
fontFamily: 'var(--font-geist-mono)',
|
||||||
|
fontSize: '12px',
|
||||||
|
color: 'var(--text-tertiary)',
|
||||||
|
lineHeight: '24px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{items.map((item, i) => (
|
||||||
|
<React.Fragment key={item.label}>
|
||||||
|
{i > 0 && (
|
||||||
|
<span style={{ color: 'var(--border)', userSelect: 'none' }} aria-hidden="true">·</span>
|
||||||
|
)}
|
||||||
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '5px' }}>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
width: '6px',
|
||||||
|
height: '6px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
backgroundColor: item.color,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{item.label}{domainCounts?.[item.domain] != null ? ` (${domainCounts[item.domain]})` : ''}
|
||||||
|
</span>
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import React, { useState, useEffect } from 'react'
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
|
import type { TimelineEntity } from '@/types/pmr'
|
||||||
|
import { motionSafeTransition } from '@/lib/utils'
|
||||||
|
|
||||||
|
interface MobileAccordionProps {
|
||||||
|
pinnedCareerEntity: TimelineEntity | null
|
||||||
|
show: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MobileAccordion: React.FC<MobileAccordionProps> = ({ pinnedCareerEntity, show }) => {
|
||||||
|
const [accordionShowMore, setAccordionShowMore] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setAccordionShowMore(false)
|
||||||
|
}, [pinnedCareerEntity?.id])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{show && pinnedCareerEntity && (
|
||||||
|
<motion.div
|
||||||
|
key={pinnedCareerEntity.id}
|
||||||
|
initial={{ height: 0 }}
|
||||||
|
animate={{ height: 'auto' }}
|
||||||
|
exit={{ height: 0 }}
|
||||||
|
transition={motionSafeTransition(0.2)}
|
||||||
|
style={{ overflow: 'hidden' }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '12px 16px',
|
||||||
|
borderTop: `1px solid ${pinnedCareerEntity.orgColor ?? 'var(--border-light)'}`,
|
||||||
|
fontFamily: 'var(--font-ui)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ marginBottom: '8px' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '2px' }}>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
width: '6px',
|
||||||
|
height: '6px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
backgroundColor: pinnedCareerEntity.orgColor ?? 'var(--accent)',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span style={{ fontSize: '13px', fontWeight: 600, color: 'var(--text-primary)' }}>
|
||||||
|
{pinnedCareerEntity.title}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '11px',
|
||||||
|
color: 'var(--text-secondary)',
|
||||||
|
fontFamily: 'var(--font-geist-mono)',
|
||||||
|
paddingLeft: '14px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{pinnedCareerEntity.organization} · {pinnedCareerEntity.dateRange.display}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul style={{ margin: 0, paddingLeft: '14px', listStyle: 'none' }}>
|
||||||
|
{(accordionShowMore ? pinnedCareerEntity.details : pinnedCareerEntity.details.slice(0, 3)).map((item, i) => (
|
||||||
|
<li
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
fontSize: '12px',
|
||||||
|
color: 'var(--text-secondary)',
|
||||||
|
lineHeight: '1.5',
|
||||||
|
marginBottom: '4px',
|
||||||
|
display: 'flex',
|
||||||
|
gap: '8px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
width: '4px',
|
||||||
|
height: '4px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
backgroundColor: pinnedCareerEntity.orgColor ?? 'var(--accent)',
|
||||||
|
opacity: 0.5,
|
||||||
|
flexShrink: 0,
|
||||||
|
marginTop: '7px',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{item}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{accordionShowMore && (pinnedCareerEntity.outcomes ?? []).length > 0 && (
|
||||||
|
<ul style={{ margin: '8px 0 0', paddingLeft: '14px', listStyle: 'none' }}>
|
||||||
|
{(pinnedCareerEntity.outcomes ?? []).map((item, i) => (
|
||||||
|
<li
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
fontSize: '12px',
|
||||||
|
color: 'var(--text-tertiary)',
|
||||||
|
lineHeight: '1.5',
|
||||||
|
marginBottom: '4px',
|
||||||
|
display: 'flex',
|
||||||
|
gap: '8px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
width: '4px',
|
||||||
|
height: '4px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
backgroundColor: 'var(--text-tertiary)',
|
||||||
|
opacity: 0.4,
|
||||||
|
flexShrink: 0,
|
||||||
|
marginTop: '7px',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{item}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{pinnedCareerEntity.details.length > 3 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setAccordionShowMore(prev => !prev)}
|
||||||
|
style={{
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
padding: '4px 14px',
|
||||||
|
fontSize: '11px',
|
||||||
|
fontFamily: 'var(--font-geist-mono)',
|
||||||
|
color: pinnedCareerEntity.orgColor ?? 'var(--accent)',
|
||||||
|
fontWeight: 500,
|
||||||
|
marginTop: '4px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{accordionShowMore ? 'Show less' : 'Show more'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import React, { useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
|
interface PlayPauseButtonProps {
|
||||||
|
isPlaying: boolean
|
||||||
|
isCompleted?: boolean
|
||||||
|
onToggle: () => void
|
||||||
|
isMobile: boolean
|
||||||
|
visible?: boolean
|
||||||
|
containerRef: React.RefObject<HTMLDivElement | null>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PlayPauseButton: React.FC<PlayPauseButtonProps> = ({
|
||||||
|
isPlaying, isCompleted = false, onToggle, isMobile, visible = true, containerRef,
|
||||||
|
}) => {
|
||||||
|
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 btnRef = useRef<HTMLButtonElement>(null)
|
||||||
|
const [topPos, setTopPos] = useState(56)
|
||||||
|
const [scrolling, setScrolling] = useState(false)
|
||||||
|
const debounceRef = useRef(0)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const container = containerRef.current
|
||||||
|
if (!container) return
|
||||||
|
|
||||||
|
const scrollParent = container.closest('.dashboard-main') as HTMLElement | null
|
||||||
|
if (!scrollParent) return
|
||||||
|
|
||||||
|
const margin = isMobile ? 12 : 56
|
||||||
|
|
||||||
|
const update = () => {
|
||||||
|
const cRect = container.getBoundingClientRect()
|
||||||
|
const sRect = scrollParent.getBoundingClientRect()
|
||||||
|
const visibleTop = Math.max(sRect.top, cRect.top) + margin + 50
|
||||||
|
const visibleBottom = Math.min(sRect.bottom, cRect.bottom) - size - 12
|
||||||
|
const targetY = Math.min(visibleTop, visibleBottom)
|
||||||
|
const relativeTop = targetY - cRect.top
|
||||||
|
setTopPos(Math.max(margin, relativeTop))
|
||||||
|
|
||||||
|
setScrolling(true)
|
||||||
|
clearTimeout(debounceRef.current)
|
||||||
|
debounceRef.current = window.setTimeout(() => setScrolling(false), 500)
|
||||||
|
}
|
||||||
|
|
||||||
|
scrollParent.addEventListener('scroll', update, { passive: true })
|
||||||
|
window.addEventListener('resize', update, { passive: true })
|
||||||
|
update()
|
||||||
|
// Don't start hidden — clear the initial scroll trigger
|
||||||
|
setScrolling(false)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
scrollParent.removeEventListener('scroll', update)
|
||||||
|
window.removeEventListener('resize', update)
|
||||||
|
clearTimeout(debounceRef.current)
|
||||||
|
}
|
||||||
|
}, [containerRef, isMobile, size])
|
||||||
|
|
||||||
|
const showButton = visible && !scrolling
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
ref={btnRef}
|
||||||
|
onClick={onToggle}
|
||||||
|
aria-label={isCompleted ? 'Replay animation' : isPlaying ? 'Pause animation' : 'Play animation'}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: offset,
|
||||||
|
top: topPos,
|
||||||
|
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: showButton ? 0.85 : 0,
|
||||||
|
pointerEvents: showButton ? 'auto' : 'none',
|
||||||
|
transition: scrolling
|
||||||
|
? 'opacity 150ms ease, top 80ms linear'
|
||||||
|
: 'opacity 500ms ease, top 80ms linear',
|
||||||
|
zIndex: 5,
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { if (showButton) e.currentTarget.style.opacity = '1' }}
|
||||||
|
onMouseLeave={e => { if (showButton) e.currentTarget.style.opacity = '0.85' }}
|
||||||
|
>
|
||||||
|
{isCompleted ? (
|
||||||
|
<svg width={Math.round(14 * scale)} height={Math.round(14 * scale)} viewBox="0 0 76.398 76.398" fill="var(--text-secondary)">
|
||||||
|
<path d="M58.828,16.208l-3.686,4.735c7.944,6.182,11.908,16.191,10.345,26.123C63.121,62.112,48.954,72.432,33.908,70.06C18.863,67.69,8.547,53.522,10.912,38.477c1.146-7.289,5.063-13.694,11.028-18.037c5.207-3.79,11.433-5.613,17.776-5.252l-5.187,5.442l3.848,3.671l8.188-8.596l0.002,0.003l3.668-3.852L46.39,8.188l-0.002,0.001L37.795,0l-3.671,3.852l5.6,5.334c-7.613-0.36-15.065,1.853-21.316,6.403c-7.26,5.286-12.027,13.083-13.423,21.956c-2.879,18.313,9.676,35.558,27.989,38.442c1.763,0.277,3.514,0.411,5.245,0.411c16.254-0.001,30.591-11.85,33.195-28.4C73.317,35.911,68.494,23.73,58.828,16.208z" />
|
||||||
|
</svg>
|
||||||
|
) : isPlaying ? (
|
||||||
|
<svg width={Math.round(14 * scale)} height={Math.round(14 * scale)} viewBox="0 0 14 14" fill="var(--text-secondary)">
|
||||||
|
<rect x="2" y="1" width="4" height="12" rx="1" />
|
||||||
|
<rect x="8" y="1" width="4" height="12" rx="1" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg width={Math.round(14 * scale)} height={Math.round(14 * scale)} viewBox="0 0 14 14" fill="var(--text-secondary)">
|
||||||
|
<polygon points="3,1 13,7 3,13" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
// Sizing
|
||||||
|
export const MIN_HEIGHT = 400
|
||||||
|
export const ROLE_WIDTH = 104
|
||||||
|
export const ROLE_HEIGHT = 32
|
||||||
|
export const ROLE_RX = 16
|
||||||
|
export const SKILL_RADIUS_DEFAULT = 7
|
||||||
|
export const SKILL_RADIUS_ACTIVE = 11
|
||||||
|
export const MOBILE_ROLE_WIDTH = 80
|
||||||
|
export const MOBILE_SKILL_RADIUS_DEFAULT = 6
|
||||||
|
export const MOBILE_SKILL_RADIUS_ACTIVE = 9
|
||||||
|
export const MOBILE_LABEL_MAX_LEN = 10
|
||||||
|
|
||||||
|
// Animation / opacity
|
||||||
|
export const HIGHLIGHT_DIM_OPACITY = 0.15
|
||||||
|
export const SKILL_REST_OPACITY = 0.6
|
||||||
|
export const SKILL_ACTIVE_OPACITY = 0.9
|
||||||
|
export const LABEL_REST_OPACITY = 0.6
|
||||||
|
|
||||||
|
// Link visual params
|
||||||
|
export const LINK_BASE_WIDTH = 0.7
|
||||||
|
export const LINK_STRENGTH_WIDTH_FACTOR = 0
|
||||||
|
export const LINK_BASE_OPACITY = 0
|
||||||
|
export const LINK_STRENGTH_OPACITY_FACTOR = 0
|
||||||
|
export const LINK_REST_OPACITY = 0.3
|
||||||
|
export const LINK_REST_STRENGTH_FACTOR = 0.08
|
||||||
|
export const LINK_HIGHLIGHT_BASE_WIDTH = 1
|
||||||
|
export const LINK_HIGHLIGHT_STRENGTH_WIDTH_FACTOR = 2
|
||||||
|
export const LINK_BEZIER_VERTICAL_OFFSET = 0.15
|
||||||
|
|
||||||
|
// Role node visual params
|
||||||
|
export const ROLE_STROKE_OPACITY_DEFAULT = 1
|
||||||
|
export const ROLE_STROKE_OPACITY_ACTIVE = 1
|
||||||
|
export const ROLE_STROKE_OPACITY_CONNECTED = 0.9
|
||||||
|
export const ROLE_STROKE_WIDTH_DEFAULT = 1
|
||||||
|
export const ROLE_STROKE_WIDTH_ACTIVE = 2
|
||||||
|
export const ROLE_FILL_OPACITY_ACTIVE = 1
|
||||||
|
export const ROLE_FILL_ACTIVE = '#FFFFFF'
|
||||||
|
|
||||||
|
// Skill node visual params
|
||||||
|
export const SKILL_STROKE_WIDTH = 1
|
||||||
|
export const SKILL_STROKE_OPACITY = 0.4
|
||||||
|
export const SKILL_SIZE_ROLE_FACTOR = 0.8
|
||||||
|
export const SKILL_GLOW_STD_DEVIATION = 2.5
|
||||||
|
export const SKILL_ACTIVE_STROKE_OPACITY = 0.1
|
||||||
|
|
||||||
|
// Skill overlap offsets
|
||||||
|
export const SKILL_Y_OFFSET_STEP = 35
|
||||||
|
export const SKILL_Y_OFFSET_STEP_MOBILE = 26
|
||||||
|
export const SKILL_Y_GLOBAL_OFFSET_RATIO = -0.05
|
||||||
|
export const SKILL_Y_CENTER_BLEND = 0.55
|
||||||
|
export const SKILL_X_OVERLAP_MAX_RATIO = 1
|
||||||
|
|
||||||
|
// Timeline animation
|
||||||
|
export const ANIM_CHRONOLOGICAL_ENABLED = true
|
||||||
|
export const ANIM_ENTITY_REVEAL_MS = 2000
|
||||||
|
export const ANIM_SKILL_REVEAL_MS = 2000
|
||||||
|
export const ANIM_SKILL_STAGGER_MS = 200
|
||||||
|
export const ANIM_LINK_DRAW_MS = 600
|
||||||
|
export const ANIM_LINK_STAGGER_MS = 200
|
||||||
|
export const ANIM_REINFORCEMENT_MS = 700
|
||||||
|
export const ANIM_STEP_GAP_MS = 1000
|
||||||
|
export const ANIM_RESTART_DELAY_MS = 400
|
||||||
|
|
||||||
|
export const ANIM_SETTLE_ALPHA = 0.05
|
||||||
|
export const ANIM_MONTH_STEP_MS = 80
|
||||||
|
|
||||||
|
// Domain color map
|
||||||
|
export const DOMAIN_COLOR_MAP: Record<string, string> = {
|
||||||
|
clinical: '#059669',
|
||||||
|
technical: '#0D6E6E',
|
||||||
|
leadership: '#D97706',
|
||||||
|
}
|
||||||
|
|
||||||
|
// Entities hidden from the constellation (education + early career roles)
|
||||||
|
export const HIDDEN_ENTITY_IDS = new Set([
|
||||||
|
'pre-reg-pharmacist-2015',
|
||||||
|
'duty-pharmacy-manager-2016',
|
||||||
|
'uea-mpharm-2011',
|
||||||
|
'highworth-alevels-2009',
|
||||||
|
])
|
||||||
|
|
||||||
|
// Media queries (evaluated once at module level)
|
||||||
|
export { prefersReducedMotion } from '@/lib/utils'
|
||||||
|
export const supportsCoarsePointer = window.matchMedia('(pointer: coarse)').matches
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { constellationNodes, roleSkillMappings } from '@/data/constellation'
|
||||||
|
|
||||||
|
function buildScreenReaderDescription(): string {
|
||||||
|
const entities = constellationNodes.filter(n => n.type === 'role' || n.type === 'education')
|
||||||
|
const skills = constellationNodes.filter(n => n.type === 'skill')
|
||||||
|
|
||||||
|
const entityDescriptions = entities.map(entity => {
|
||||||
|
const mapping = roleSkillMappings.find(m => m.roleId === entity.id)
|
||||||
|
const skillNames = mapping
|
||||||
|
? mapping.skillIds
|
||||||
|
.map(sid => skills.find(s => s.id === sid)?.label)
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(', ')
|
||||||
|
: ''
|
||||||
|
const yearRange = entity.endYear
|
||||||
|
? `${entity.startYear}-${entity.endYear}`
|
||||||
|
: `${entity.startYear}-present`
|
||||||
|
return `${entity.label} at ${entity.organization} (${yearRange}): ${skillNames}`
|
||||||
|
})
|
||||||
|
|
||||||
|
return `Career constellation graph showing ${entities.length} roles and ${skills.length} skills in reverse-chronological order along a vertical timeline, with the most recent role at the top. ` +
|
||||||
|
entityDescriptions.join('. ') + '.'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const srDescription = buildScreenReaderDescription()
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import type { ConstellationNode } from '@/types/pmr'
|
||||||
|
|
||||||
|
export interface SimNode extends ConstellationNode {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
vx: number
|
||||||
|
vy: number
|
||||||
|
fx?: number | null
|
||||||
|
fy?: number | null
|
||||||
|
homeX: number
|
||||||
|
homeY: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SimLink {
|
||||||
|
source: SimNode | string
|
||||||
|
target: SimNode | string
|
||||||
|
strength: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LayoutParams {
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
scaleFactor: number
|
||||||
|
isMobile: boolean
|
||||||
|
rw: number
|
||||||
|
rh: number
|
||||||
|
rrx: number
|
||||||
|
srDefault: number
|
||||||
|
srActive: number
|
||||||
|
topPadding: number
|
||||||
|
bottomPadding: number
|
||||||
|
sidePadding: number
|
||||||
|
timelineX: number
|
||||||
|
sf: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConstellationCallbacks {
|
||||||
|
onRoleClick: (id: string) => void
|
||||||
|
onSkillClick: (id: string) => void
|
||||||
|
onNodeHover?: (id: string | null) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AnimationState = 'IDLE' | 'PLAYING' | 'PAUSED' | 'HOLDING' | 'RESETTING' | 'COMPLETED'
|
||||||
|
|
||||||
|
export interface AnimationStep {
|
||||||
|
entityId: string
|
||||||
|
startYear: number
|
||||||
|
startMonth: number // 0-indexed (0 = January)
|
||||||
|
skillIds: string[]
|
||||||
|
newSkillIds: string[]
|
||||||
|
reinforcedSkillIds: string[]
|
||||||
|
linkPairs: Array<{ source: string; target: string }>
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import type { Consultation } from '@/types/pmr'
|
||||||
|
import { detailRootStyle, sectionHeadingStyle, bulletListStyle, bodyTextStyle, paragraphStyle } from './detail-styles'
|
||||||
|
|
||||||
|
interface ConsultationDetailProps {
|
||||||
|
consultation: Consultation
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConsultationDetail({ consultation }: ConsultationDetailProps) {
|
||||||
|
return (
|
||||||
|
<div style={detailRootStyle}>
|
||||||
|
{/* Role header */}
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '20px',
|
||||||
|
fontWeight: 700,
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
lineHeight: '1.3',
|
||||||
|
marginBottom: '4px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{consultation.role}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: consultation.orgColor,
|
||||||
|
marginBottom: '8px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{consultation.organization}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '12px',
|
||||||
|
fontFamily: 'var(--font-geist)',
|
||||||
|
color: 'var(--text-tertiary)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>{consultation.duration}</span>
|
||||||
|
{consultation.isCurrent && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
padding: '2px 8px',
|
||||||
|
backgroundColor: 'var(--success-light)',
|
||||||
|
color: 'var(--success)',
|
||||||
|
fontSize: '10px',
|
||||||
|
fontWeight: 600,
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.05em',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Current
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* History (presenting complaint) */}
|
||||||
|
<div>
|
||||||
|
<h3 style={sectionHeadingStyle}>History</h3>
|
||||||
|
<p style={paragraphStyle}>
|
||||||
|
{consultation.history}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Examination (achievements) */}
|
||||||
|
{consultation.examination && consultation.examination.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h3 style={sectionHeadingStyle}>Key Achievements</h3>
|
||||||
|
<ul style={bulletListStyle}>
|
||||||
|
{consultation.examination.map((item, index) => (
|
||||||
|
<li key={index} style={bodyTextStyle}>
|
||||||
|
{item}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Plan (outcomes) */}
|
||||||
|
{consultation.plan && consultation.plan.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h3 style={sectionHeadingStyle}>Outcomes & Impact</h3>
|
||||||
|
<ul style={bulletListStyle}>
|
||||||
|
{consultation.plan.map((item, index) => (
|
||||||
|
<li key={index} style={bodyTextStyle}>
|
||||||
|
{item}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Coded entries (technical environment / tags) */}
|
||||||
|
{consultation.codedEntries && consultation.codedEntries.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h3 style={sectionHeadingStyle}>Coded Entries</h3>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: '8px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{consultation.codedEntries.map((entry, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '6px',
|
||||||
|
padding: '6px 12px',
|
||||||
|
backgroundColor: 'var(--bg-dashboard)',
|
||||||
|
border: '1px solid var(--border-light)',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: '11px',
|
||||||
|
fontFamily: 'var(--font-geist)',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'var(--accent)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{entry.code}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: '11px',
|
||||||
|
color: 'var(--text-secondary)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{entry.description}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
import { GraduationCap, Award, BookOpen, FlaskConical, type LucideIcon } from 'lucide-react'
|
||||||
|
import type { Document } from '@/types/pmr'
|
||||||
|
import { educationExtras } from '@/data/educationExtras'
|
||||||
|
import { detailRootStyle, sectionHeadingStyle, bulletListStyle, bodyTextStyle, paragraphStyle } from './detail-styles'
|
||||||
|
|
||||||
|
interface EducationDetailProps {
|
||||||
|
document: Document
|
||||||
|
}
|
||||||
|
|
||||||
|
const typeIconMap: Record<string, LucideIcon> = {
|
||||||
|
Certificate: GraduationCap,
|
||||||
|
Registration: Award,
|
||||||
|
Results: BookOpen,
|
||||||
|
Research: FlaskConical,
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EducationDetail({ document }: EducationDetailProps) {
|
||||||
|
const extra = educationExtras.find((e) => e.documentId === document.id)
|
||||||
|
const Icon = typeIconMap[document.type] || GraduationCap
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={detailRootStyle}>
|
||||||
|
{/* Header */}
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '10px',
|
||||||
|
marginBottom: '8px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '36px',
|
||||||
|
height: '36px',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
backgroundColor: 'var(--purple-light, rgba(124,58,237,0.08))',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon size={18} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '20px',
|
||||||
|
fontWeight: 700,
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
lineHeight: '1.3',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{document.title}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{document.institution && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: '#7C3AED',
|
||||||
|
marginBottom: '4px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{document.institution}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '12px',
|
||||||
|
fontFamily: 'var(--font-geist)',
|
||||||
|
color: 'var(--text-tertiary)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{document.duration && <span>{document.duration}</span>}
|
||||||
|
{document.classification && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
padding: '2px 8px',
|
||||||
|
backgroundColor: 'var(--purple-light, rgba(124,58,237,0.08))',
|
||||||
|
color: '#7C3AED',
|
||||||
|
fontSize: '10px',
|
||||||
|
fontWeight: 600,
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{document.classification}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Research project (MPharm) */}
|
||||||
|
{extra?.researchDescription && (
|
||||||
|
<div>
|
||||||
|
<h3 style={sectionHeadingStyle}>Research Project</h3>
|
||||||
|
<p style={paragraphStyle}>
|
||||||
|
{extra.researchDescription}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* OSCE score (MPharm) */}
|
||||||
|
{extra?.osceScore && (
|
||||||
|
<div>
|
||||||
|
<h3 style={sectionHeadingStyle}>OSCE Performance</h3>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
padding: '8px 14px',
|
||||||
|
backgroundColor: 'var(--success-light)',
|
||||||
|
border: '1px solid var(--success-border)',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: '20px',
|
||||||
|
fontWeight: 700,
|
||||||
|
color: 'var(--success)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{extra.osceScore}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: '12px',
|
||||||
|
color: 'var(--text-secondary)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Objective Structured Clinical Examination
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Extracurricular activities (MPharm) */}
|
||||||
|
{extra?.extracurriculars && extra.extracurriculars.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h3 style={sectionHeadingStyle}>Extracurricular Activities</h3>
|
||||||
|
<ul style={bulletListStyle}>
|
||||||
|
{extra.extracurriculars.map((activity, index) => (
|
||||||
|
<li key={index} style={bodyTextStyle}>
|
||||||
|
{activity}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Programme detail (Mary Seacole) */}
|
||||||
|
{extra?.programmeDetail && (
|
||||||
|
<div>
|
||||||
|
<h3 style={sectionHeadingStyle}>Programme Overview</h3>
|
||||||
|
<p style={paragraphStyle}>
|
||||||
|
{extra.programmeDetail}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Notes */}
|
||||||
|
{document.notes && (
|
||||||
|
<div>
|
||||||
|
<h3 style={sectionHeadingStyle}>Notes</h3>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
...paragraphStyle,
|
||||||
|
color: 'var(--text-secondary)',
|
||||||
|
fontStyle: 'italic',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{document.notes}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import type { KPI } from '@/types/pmr'
|
||||||
|
import { KPI_COLORS } from '@/lib/theme-colors'
|
||||||
|
import { detailRootStyle, sectionHeadingStyle, bulletListStyle, bodyTextStyle, paragraphStyle } from './detail-styles'
|
||||||
|
|
||||||
|
interface KPIDetailProps {
|
||||||
|
kpi: KPI
|
||||||
|
}
|
||||||
|
|
||||||
|
export function KPIDetail({ kpi }: KPIDetailProps) {
|
||||||
|
// If story exists, render rich content; otherwise fallback to explanation
|
||||||
|
if (!kpi.story) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontFamily: 'var(--font-ui)',
|
||||||
|
fontSize: '14px',
|
||||||
|
lineHeight: '1.6',
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '32px',
|
||||||
|
fontWeight: 700,
|
||||||
|
color: KPI_COLORS[kpi.colorVariant],
|
||||||
|
marginBottom: '16px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{kpi.value}
|
||||||
|
</div>
|
||||||
|
<p>{kpi.explanation}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { context, role, outcomes, period } = kpi.story
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={detailRootStyle}>
|
||||||
|
{/* Headline number */}
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '48px',
|
||||||
|
fontWeight: 700,
|
||||||
|
color: KPI_COLORS[kpi.colorVariant],
|
||||||
|
lineHeight: '1',
|
||||||
|
marginBottom: '8px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{kpi.value}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{kpi.label}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '12px',
|
||||||
|
fontFamily: 'var(--font-geist)',
|
||||||
|
color: 'var(--text-tertiary)',
|
||||||
|
marginTop: '2px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{kpi.sub}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Period badge (if present) */}
|
||||||
|
{period && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
padding: '4px 10px',
|
||||||
|
backgroundColor: 'var(--accent-light)',
|
||||||
|
color: 'var(--accent)',
|
||||||
|
fontSize: '11px',
|
||||||
|
fontWeight: 600,
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
fontFamily: 'var(--font-geist)',
|
||||||
|
alignSelf: 'flex-start',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{period}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Context paragraph */}
|
||||||
|
<div>
|
||||||
|
<h3 style={sectionHeadingStyle}>Context</h3>
|
||||||
|
<p style={paragraphStyle}>{context}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* My role paragraph */}
|
||||||
|
<div>
|
||||||
|
<h3 style={sectionHeadingStyle}>My Role</h3>
|
||||||
|
<p style={paragraphStyle}>{role}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Outcome bullets */}
|
||||||
|
<div>
|
||||||
|
<h3 style={sectionHeadingStyle}>Key Outcomes</h3>
|
||||||
|
<ul style={bulletListStyle}>
|
||||||
|
{outcomes.map((outcome, index) => (
|
||||||
|
<li key={index} style={bodyTextStyle}>
|
||||||
|
{outcome}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
import { ExternalLink } from 'lucide-react'
|
||||||
|
import type { Investigation } from '@/types/pmr'
|
||||||
|
import { PROJECT_STATUS_COLORS } from '@/lib/theme-colors'
|
||||||
|
import { detailRootStyle, sectionHeadingStyle, bulletListStyle, bodyTextStyle, paragraphStyle } from './detail-styles'
|
||||||
|
|
||||||
|
interface ProjectDetailProps {
|
||||||
|
investigation: Investigation
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusBgMap: Record<Investigation['status'], string> = {
|
||||||
|
Complete: 'rgba(5,150,105,0.08)',
|
||||||
|
Ongoing: 'rgba(217,119,6,0.08)',
|
||||||
|
Live: 'rgba(10,128,128,0.08)',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProjectDetail({ investigation }: ProjectDetailProps) {
|
||||||
|
const statusColor = PROJECT_STATUS_COLORS[investigation.status]
|
||||||
|
const statusBg = statusBgMap[investigation.status]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={detailRootStyle}>
|
||||||
|
{/* Header: name + year + status */}
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '10px',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
marginBottom: '8px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: '11px',
|
||||||
|
fontFamily: 'var(--font-geist-mono)',
|
||||||
|
color: 'var(--text-tertiary)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{investigation.requestedYear}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
padding: '2px 8px',
|
||||||
|
fontSize: '11px',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: statusColor,
|
||||||
|
backgroundColor: statusBg,
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{investigation.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '12px',
|
||||||
|
fontFamily: 'var(--font-geist-mono)',
|
||||||
|
color: 'var(--text-tertiary)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{investigation.requestingClinician}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Methodology */}
|
||||||
|
<div>
|
||||||
|
<h3 style={sectionHeadingStyle}>Methodology</h3>
|
||||||
|
<p style={{ ...paragraphStyle, whiteSpace: 'pre-line' }}>{investigation.methodology}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tech stack tags */}
|
||||||
|
<div>
|
||||||
|
<h3 style={sectionHeadingStyle}>Tech Stack</h3>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
|
||||||
|
{investigation.techStack.map((tech) => (
|
||||||
|
<span
|
||||||
|
key={tech}
|
||||||
|
style={{
|
||||||
|
padding: '3px 10px',
|
||||||
|
fontSize: '11px',
|
||||||
|
fontWeight: 500,
|
||||||
|
fontFamily: 'var(--font-geist-mono)',
|
||||||
|
color: 'var(--accent)',
|
||||||
|
backgroundColor: 'var(--accent-light)',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
border: '1px solid var(--accent-border)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tech}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</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>
|
||||||
|
<ul style={bulletListStyle}>
|
||||||
|
{investigation.results.map((result, index) => (
|
||||||
|
<li key={index} style={bodyTextStyle}>
|
||||||
|
{result}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 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={{
|
||||||
|
width: '100%',
|
||||||
|
height: 'auto',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
border: '1px solid var(--border-light)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
import type { SkillMedication } from '@/types/pmr'
|
||||||
|
import { roleSkillMappings, constellationNodes } from '@/data/constellation'
|
||||||
|
import { detailRootStyle, sectionHeadingStyle } from './detail-styles'
|
||||||
|
|
||||||
|
interface SkillDetailProps {
|
||||||
|
skill: SkillMedication
|
||||||
|
}
|
||||||
|
|
||||||
|
// Category display names
|
||||||
|
const categoryLabels: Record<SkillMedication['category'], string> = {
|
||||||
|
Technical: 'Technical',
|
||||||
|
Clinical: 'Clinical',
|
||||||
|
Strategic: 'Strategic',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SkillDetail({ skill }: SkillDetailProps) {
|
||||||
|
// Find roles that use this skill from constellation data
|
||||||
|
const usedInRoles = roleSkillMappings
|
||||||
|
.filter((mapping) => mapping.skillIds.includes(skill.id))
|
||||||
|
.map((mapping) => {
|
||||||
|
const node = constellationNodes.find((n) => n.id === mapping.roleId && n.type === 'role')
|
||||||
|
return node
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
// Sort chronologically (earliest first)
|
||||||
|
.sort((a, b) => (a!.startYear ?? 0) - (b!.startYear ?? 0))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={detailRootStyle}>
|
||||||
|
{/* Skill header */}
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '20px',
|
||||||
|
fontWeight: 700,
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
lineHeight: '1.3',
|
||||||
|
marginBottom: '8px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{skill.name}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Medication metaphor badges */}
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', alignItems: 'center' }}>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
padding: '3px 10px',
|
||||||
|
backgroundColor: 'var(--accent-light)',
|
||||||
|
color: 'var(--accent)',
|
||||||
|
fontSize: '11px',
|
||||||
|
fontWeight: 600,
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
fontFamily: 'var(--font-geist)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{skill.frequency}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
padding: '3px 10px',
|
||||||
|
backgroundColor:
|
||||||
|
skill.status === 'Active' ? 'var(--success-light)' : 'var(--bg-dashboard)',
|
||||||
|
color: skill.status === 'Active' ? 'var(--success)' : 'var(--text-tertiary)',
|
||||||
|
fontSize: '10px',
|
||||||
|
fontWeight: 600,
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.05em',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{skill.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Category label */}
|
||||||
|
<div>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: '11px',
|
||||||
|
fontWeight: 500,
|
||||||
|
color: 'var(--text-tertiary)',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.06em',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{categoryLabels[skill.category]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Years of experience */}
|
||||||
|
<div>
|
||||||
|
<h3 style={sectionHeadingStyle}>Experience</h3>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'baseline', gap: '6px' }}>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: '28px',
|
||||||
|
fontWeight: 700,
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
lineHeight: '1',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{skill.yearsOfExperience}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: '13px',
|
||||||
|
color: 'var(--text-secondary)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{skill.yearsOfExperience === 1 ? 'year' : 'years'}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: '11px',
|
||||||
|
fontFamily: 'var(--font-geist)',
|
||||||
|
color: 'var(--text-tertiary)',
|
||||||
|
marginLeft: '4px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Since {skill.startYear}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Used in roles */}
|
||||||
|
{usedInRoles.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h3 style={{ ...sectionHeadingStyle, marginBottom: '10px' }}>Used In</h3>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||||
|
{usedInRoles.map((node) => (
|
||||||
|
<div
|
||||||
|
key={node!.id}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '10px',
|
||||||
|
padding: '8px 12px',
|
||||||
|
backgroundColor: 'var(--bg-dashboard)',
|
||||||
|
border: '1px solid var(--border-light)',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '8px',
|
||||||
|
height: '8px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
backgroundColor: node!.orgColor ?? 'var(--accent)',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '12.5px',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{node!.label}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '10px',
|
||||||
|
fontFamily: 'var(--font-geist)',
|
||||||
|
color: 'var(--text-tertiary)',
|
||||||
|
marginTop: '1px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{node!.organization} · {node!.startYear}
|
||||||
|
{node!.endYear === null ? '–Present' : node!.endYear !== node!.startYear ? `–${node!.endYear}` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
import React, { useEffect, useRef } from 'react'
|
||||||
|
import type { LucideIcon } from 'lucide-react'
|
||||||
|
import {
|
||||||
|
BarChart3, Code2, Database, LayoutDashboard, Bot, FileCode2,
|
||||||
|
Pill, Users, FileCheck, Stethoscope,
|
||||||
|
TrendingUp, Route, BookOpen, Store,
|
||||||
|
Presentation, Calculator, Banknote, Handshake, RefreshCw,
|
||||||
|
GitBranch, Workflow, UserPlus, ChevronRight,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { skills } from '@/data/skills'
|
||||||
|
import { useDetailPanel } from '@/contexts/DetailPanelContext'
|
||||||
|
import { getSkillsUICopy } from '@/lib/profile-content'
|
||||||
|
import type { SkillMedication, SkillCategory } from '@/types/pmr'
|
||||||
|
|
||||||
|
const iconMap: Record<string, LucideIcon> = {
|
||||||
|
BarChart3, Code2, Database, LayoutDashboard, Bot, FileCode2,
|
||||||
|
Pill, Users, FileCheck, Stethoscope,
|
||||||
|
TrendingUp, Route, BookOpen, Store,
|
||||||
|
Presentation, Calculator, Banknote, Handshake, RefreshCw,
|
||||||
|
GitBranch, Workflow, UserPlus,
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SkillsAllDetailProps {
|
||||||
|
category?: SkillCategory
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SkillsAllDetail({ category }: SkillsAllDetailProps) {
|
||||||
|
const { openPanel } = useDetailPanel()
|
||||||
|
const categoryRefs = useRef<Record<string, HTMLDivElement | null>>({})
|
||||||
|
const skillsCopy = getSkillsUICopy()
|
||||||
|
|
||||||
|
// Scroll to highlighted category on mount
|
||||||
|
useEffect(() => {
|
||||||
|
if (category && categoryRefs.current[category]) {
|
||||||
|
categoryRefs.current[category]?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||||
|
}
|
||||||
|
}, [category])
|
||||||
|
|
||||||
|
const frequencyRank = (freq: string): number => {
|
||||||
|
if (freq.includes('daily')) return freq.startsWith('4') ? 0 : freq.startsWith('3') ? 1 : freq.startsWith('1') ? 3 : 2
|
||||||
|
if (freq === 'Daily') return 4
|
||||||
|
if (freq.includes('weekly')) return freq.startsWith('2') ? 5 : freq.startsWith('1') ? 6 : 7
|
||||||
|
if (freq === 'Weekly') return 7
|
||||||
|
if (freq === 'Bi-monthly') return 8
|
||||||
|
return 9 // As needed
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupedSkills = skillsCopy.categories.map(({ id, label }) => ({
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
skills: skills
|
||||||
|
.filter((s) => s.category === id)
|
||||||
|
.sort((a, b) => frequencyRank(a.frequency) - frequencyRank(b.frequency)),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const handleSkillClick = (skill: SkillMedication) => {
|
||||||
|
openPanel({ type: 'skill', skill })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ fontFamily: 'var(--font-ui)', display: 'flex', flexDirection: 'column', gap: '20px' }}>
|
||||||
|
{groupedSkills.map((group) => {
|
||||||
|
const isHighlighted = category === group.id
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={group.id}
|
||||||
|
ref={(el) => { categoryRefs.current[group.id] = el }}
|
||||||
|
>
|
||||||
|
{/* Category header — divider style */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
marginBottom: '10px',
|
||||||
|
paddingBottom: '6px',
|
||||||
|
borderBottom: isHighlighted ? '2px solid var(--accent)' : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: '10px',
|
||||||
|
fontWeight: 600,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.06em',
|
||||||
|
color: isHighlighted ? 'var(--accent)' : 'var(--text-tertiary)',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{group.label}
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
height: '1px',
|
||||||
|
background: 'var(--border-light)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: '10px',
|
||||||
|
color: 'var(--text-tertiary)',
|
||||||
|
fontFamily: 'var(--font-geist-mono)',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{group.skills.length} {skillsCopy.itemCountSuffix}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Skill rows */}
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
|
||||||
|
{group.skills.map((skill) => (
|
||||||
|
<SkillRow
|
||||||
|
key={skill.id}
|
||||||
|
skill={skill}
|
||||||
|
yearsSuffix={skillsCopy.yearsSuffix}
|
||||||
|
onClick={() => handleSkillClick(skill)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SkillRowProps {
|
||||||
|
skill: SkillMedication
|
||||||
|
yearsSuffix: string
|
||||||
|
onClick: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function SkillRow({ skill, yearsSuffix, onClick }: SkillRowProps) {
|
||||||
|
const IconComponent = iconMap[skill.icon]
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault()
|
||||||
|
onClick()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={onClick}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
aria-label={`${skill.name}: ${skill.frequency}, ${skill.yearsOfExperience} years experience. Click for details.`}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '10px',
|
||||||
|
padding: '8px 10px',
|
||||||
|
background: 'var(--bg-dashboard)',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
border: '1px solid var(--border-light)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'border-color 0.15s, box-shadow 0.15s',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.borderColor = 'var(--accent-border)'
|
||||||
|
e.currentTarget.style.boxShadow = 'var(--shadow-md)'
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.borderColor = 'var(--border-light)'
|
||||||
|
e.currentTarget.style.boxShadow = 'none'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Icon */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '26px',
|
||||||
|
height: '26px',
|
||||||
|
borderRadius: '6px',
|
||||||
|
background: 'var(--accent-light)',
|
||||||
|
color: 'var(--accent)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{IconComponent && <IconComponent size={13} />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Text */}
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '12.5px',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
lineHeight: 1.3,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{skill.name}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '10.5px',
|
||||||
|
color: 'var(--text-tertiary)',
|
||||||
|
fontFamily: 'var(--font-geist-mono)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{skill.frequency} · {skill.yearsOfExperience} {yearsSuffix}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Chevron */}
|
||||||
|
<ChevronRight
|
||||||
|
size={14}
|
||||||
|
style={{ color: 'var(--text-tertiary)', flexShrink: 0 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import type { CSSProperties } from 'react'
|
||||||
|
|
||||||
|
export const detailRootStyle: CSSProperties = {
|
||||||
|
fontFamily: 'var(--font-ui)',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '24px',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sectionHeadingStyle: CSSProperties = {
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'var(--text-secondary)',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.05em',
|
||||||
|
marginBottom: '8px',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const bulletListStyle: CSSProperties = {
|
||||||
|
margin: 0,
|
||||||
|
paddingLeft: '20px',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '8px',
|
||||||
|
listStyleType: 'disc',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const bodyTextStyle: CSSProperties = {
|
||||||
|
fontSize: '14px',
|
||||||
|
lineHeight: '1.6',
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const paragraphStyle: CSSProperties = {
|
||||||
|
fontSize: '14px',
|
||||||
|
lineHeight: '1.6',
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
margin: 0,
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { FileText, ChevronRight } 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, getPatientSummaryNarrative } from '@/lib/profile-content'
|
||||||
|
import { KPI_COLORS } from '@/lib/theme-colors'
|
||||||
|
import { ProjectsCarousel } from './ProjectsTile'
|
||||||
|
|
||||||
|
interface MetricCardProps {
|
||||||
|
kpi: KPI
|
||||||
|
}
|
||||||
|
|
||||||
|
function MetricCard({ kpi }: MetricCardProps) {
|
||||||
|
const { openPanel } = useDetailPanel()
|
||||||
|
const latestResultsCopy = getLatestResultsCopy()
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
openPanel({ type: 'kpi', kpi })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault()
|
||||||
|
openPanel({ type: 'kpi', kpi })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const buttonStyles: React.CSSProperties = {
|
||||||
|
width: '100%',
|
||||||
|
textAlign: 'left',
|
||||||
|
padding: '16px 16px 14px',
|
||||||
|
background: 'var(--surface)',
|
||||||
|
border: '1px solid var(--border-light)',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'border-color 160ms ease-out, box-shadow 160ms ease-out, transform 120ms ease-out',
|
||||||
|
position: 'relative',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
const valueStyles: React.CSSProperties = {
|
||||||
|
fontSize: 'clamp(22px, 6vw, 30px)',
|
||||||
|
fontWeight: 700,
|
||||||
|
letterSpacing: '-0.02em',
|
||||||
|
lineHeight: 1.2,
|
||||||
|
color: KPI_COLORS[kpi.colorVariant],
|
||||||
|
}
|
||||||
|
|
||||||
|
const labelStyles: React.CSSProperties = {
|
||||||
|
fontSize: '13px',
|
||||||
|
fontWeight: 500,
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
marginTop: '4px',
|
||||||
|
}
|
||||||
|
|
||||||
|
const subStyles: React.CSSProperties = {
|
||||||
|
fontSize: '11px',
|
||||||
|
color: 'var(--text-tertiary)',
|
||||||
|
fontFamily: 'var(--font-geist-mono)',
|
||||||
|
marginTop: '2px',
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={handleClick}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
style={buttonStyles}
|
||||||
|
className="metric-card"
|
||||||
|
aria-label={`${kpi.label}: ${kpi.value}. Click to view details.`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '12px',
|
||||||
|
right: '12px',
|
||||||
|
color: 'var(--accent)',
|
||||||
|
opacity: 0.85,
|
||||||
|
}}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<FileText size={13} />
|
||||||
|
</div>
|
||||||
|
<div style={valueStyles}>{kpi.value}</div>
|
||||||
|
<div style={labelStyles}>{kpi.label}</div>
|
||||||
|
<div style={subStyles}>{kpi.sub}</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: '8px',
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '5px',
|
||||||
|
fontSize: '11px',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'var(--accent)',
|
||||||
|
fontFamily: 'var(--font-geist-mono)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{latestResultsCopy.evidenceCta}
|
||||||
|
<ChevronRight size={12} />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PatientSummaryTile() {
|
||||||
|
const latestResultsCopy = getLatestResultsCopy()
|
||||||
|
const sectionTitle = getProfileSectionTitle()
|
||||||
|
|
||||||
|
const profileTextStyles: React.CSSProperties = {
|
||||||
|
fontSize: '15px',
|
||||||
|
lineHeight: '1.65',
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
}
|
||||||
|
|
||||||
|
const kpiGridStyles: React.CSSProperties = {
|
||||||
|
display: 'grid',
|
||||||
|
gap: '10px',
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ParentSection title={sectionTitle} tileId="patient-summary">
|
||||||
|
<div style={profileTextStyles}>{getPatientSummaryNarrative()}</div>
|
||||||
|
|
||||||
|
{/* Latest Results subsection */}
|
||||||
|
<div style={{ marginTop: '28px' }}>
|
||||||
|
<div className="latest-results-header">
|
||||||
|
<CardHeader dotColor="teal" title={latestResultsCopy.title} rightText={latestResultsCopy.rightText} />
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
fontSize: '12px',
|
||||||
|
color: 'var(--text-secondary)',
|
||||||
|
fontFamily: 'var(--font-geist-mono)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{latestResultsCopy.helperText}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="kpi-grid latest-results-grid" style={kpiGridStyles}>
|
||||||
|
{kpis.map((kpi) => (
|
||||||
|
<MetricCard key={kpi.id} kpi={kpi} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Projects carousel */}
|
||||||
|
<ProjectsCarousel />
|
||||||
|
</ParentSection>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,851 @@
|
|||||||
|
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'
|
||||||
|
import type { Investigation } from '@/types/pmr'
|
||||||
|
|
||||||
|
interface ProjectItemProps {
|
||||||
|
project: Investigation
|
||||||
|
slideWidth: string
|
||||||
|
cardMinHeight: number
|
||||||
|
onClick: () => void
|
||||||
|
index: number
|
||||||
|
total: number
|
||||||
|
cardRef?: (el: HTMLDivElement | null) => void
|
||||||
|
onArrowKey?: (direction: -1 | 1) => void
|
||||||
|
onEscape?: () => void
|
||||||
|
isInert?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProjectItem({
|
||||||
|
project,
|
||||||
|
slideWidth,
|
||||||
|
cardMinHeight,
|
||||||
|
onClick,
|
||||||
|
index,
|
||||||
|
total,
|
||||||
|
cardRef,
|
||||||
|
onArrowKey,
|
||||||
|
onEscape,
|
||||||
|
isInert,
|
||||||
|
}: ProjectItemProps) {
|
||||||
|
const livePillLabel = project.demoUrl ? 'Live Demo' : project.externalUrl ? 'Live' : null
|
||||||
|
const [isHovered, setIsHovered] = useState(false)
|
||||||
|
|
||||||
|
const handleKeyDown = useCallback(
|
||||||
|
(e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault()
|
||||||
|
onClick()
|
||||||
|
} else if (e.key === 'ArrowLeft') {
|
||||||
|
e.preventDefault()
|
||||||
|
onArrowKey?.(-1)
|
||||||
|
} else if (e.key === 'ArrowRight') {
|
||||||
|
e.preventDefault()
|
||||||
|
onArrowKey?.(1)
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
e.preventDefault()
|
||||||
|
onEscape?.()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onClick, onArrowKey, onEscape],
|
||||||
|
)
|
||||||
|
|
||||||
|
const maxVisibleResults = 4
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="group"
|
||||||
|
aria-roledescription="slide"
|
||||||
|
aria-label={`Project ${index + 1} of ${total}: ${project.name}`}
|
||||||
|
aria-hidden={isInert || undefined}
|
||||||
|
style={{
|
||||||
|
flex: `0 0 ${slideWidth}`,
|
||||||
|
minWidth: 0,
|
||||||
|
containerType: 'inline-size',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={cardRef}
|
||||||
|
role="button"
|
||||||
|
tabIndex={isInert ? -1 : 0}
|
||||||
|
onClick={onClick}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
style={{
|
||||||
|
position: 'relative',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '10px',
|
||||||
|
background: 'var(--surface)',
|
||||||
|
border: '1px solid var(--border-light)',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
padding: '12px',
|
||||||
|
minHeight: `${cardMinHeight}px`,
|
||||||
|
fontSize: '13px',
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
transition: 'border-color 0.15s, box-shadow 0.15s',
|
||||||
|
cursor: 'pointer',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
setIsHovered(true)
|
||||||
|
e.currentTarget.style.borderColor = 'var(--accent-border)'
|
||||||
|
e.currentTarget.style.boxShadow = '0 2px 8px rgba(26,43,42,0.08)'
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
setIsHovered(false)
|
||||||
|
e.currentTarget.style.borderColor = 'var(--border-light)'
|
||||||
|
e.currentTarget.style.boxShadow = 'none'
|
||||||
|
}}
|
||||||
|
onFocus={(e) => {
|
||||||
|
e.currentTarget.style.borderColor = 'var(--accent-border)'
|
||||||
|
e.currentTarget.style.boxShadow = '0 2px 8px rgba(26,43,42,0.08)'
|
||||||
|
}}
|
||||||
|
onBlur={(e) => {
|
||||||
|
e.currentTarget.style.borderColor = 'var(--border-light)'
|
||||||
|
e.currentTarget.style.boxShadow = 'none'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Results hover overlay */}
|
||||||
|
{project.results && project.results.length > 0 && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
zIndex: 1,
|
||||||
|
background: 'rgba(20, 40, 38, 0.96)',
|
||||||
|
borderRadius: 'inherit',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
padding: 'clamp(10px, 4cqi, 18px) clamp(12px, 5cqi, 20px)',
|
||||||
|
opacity: isHovered ? 1 : 0,
|
||||||
|
transition: 'opacity 0.25s ease',
|
||||||
|
pointerEvents: isHovered ? 'auto' : 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 'clamp(9px, 3.5cqi, 13px)',
|
||||||
|
fontFamily: 'var(--font-geist-mono)',
|
||||||
|
fontWeight: 600,
|
||||||
|
letterSpacing: '0.1em',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
color: 'rgba(255, 255, 255, 0.45)',
|
||||||
|
marginBottom: 'clamp(6px, 3cqi, 12px)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Intervention Outcomes
|
||||||
|
</div>
|
||||||
|
<ul
|
||||||
|
style={{
|
||||||
|
listStyle: 'none',
|
||||||
|
margin: 0,
|
||||||
|
padding: 0,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 'clamp(5px, 2.5cqi, 12px)',
|
||||||
|
flex: 1,
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{project.results.slice(0, maxVisibleResults).map((result, i) => (
|
||||||
|
<li
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: 'clamp(6px, 2.5cqi, 10px)',
|
||||||
|
fontSize: 'clamp(11px, 4.5cqi, 16px)',
|
||||||
|
lineHeight: 1.4,
|
||||||
|
color: 'rgba(255, 255, 255, 0.85)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
flexShrink: 0,
|
||||||
|
width: 'clamp(4px, 1.5cqi, 7px)',
|
||||||
|
height: 'clamp(4px, 1.5cqi, 7px)',
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: 'var(--accent-primary, #00897B)',
|
||||||
|
marginTop: 'clamp(4px, 2cqi, 7px)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span>{result}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: 'auto',
|
||||||
|
paddingTop: 'clamp(6px, 3cqi, 14px)',
|
||||||
|
fontSize: 'clamp(10px, 4cqi, 14px)',
|
||||||
|
fontFamily: 'var(--font-geist-mono)',
|
||||||
|
fontWeight: 500,
|
||||||
|
letterSpacing: '0.02em',
|
||||||
|
color: 'var(--accent-primary, #00897B)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 'clamp(3px, 1.5cqi, 6px)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Click to view more
|
||||||
|
<span style={{ fontSize: 'clamp(12px, 4.5cqi, 16px)', lineHeight: 1 }}>→</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
aspectRatio: '16 / 9',
|
||||||
|
borderRadius: '6px',
|
||||||
|
border: '1px solid var(--border-light)',
|
||||||
|
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',
|
||||||
|
color: 'var(--text-tertiary)',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{project.thumbnail ? (
|
||||||
|
<img
|
||||||
|
src={project.thumbnail}
|
||||||
|
alt={`${project.name} thumbnail`}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
objectFit: 'cover',
|
||||||
|
objectPosition: 'top',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
'Thumbnail Pending'
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
gap: '8px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ flex: 1, fontWeight: 500, display: 'flex', alignItems: 'center', gap: '6px', flexWrap: 'wrap' }}>
|
||||||
|
{project.name}
|
||||||
|
{livePillLabel && (
|
||||||
|
<span
|
||||||
|
className="live-pill"
|
||||||
|
style={{
|
||||||
|
fontSize: '9px',
|
||||||
|
fontFamily: 'var(--font-geist-mono)',
|
||||||
|
fontWeight: 600,
|
||||||
|
letterSpacing: '0.05em',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
padding: '2px 7px',
|
||||||
|
borderRadius: '9999px',
|
||||||
|
background: 'rgba(34, 197, 94, 0.12)',
|
||||||
|
color: '#16a34a',
|
||||||
|
border: '1px solid rgba(34, 197, 94, 0.3)',
|
||||||
|
animation: 'live-pill-pulse 2s ease-in-out infinite',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
lineHeight: '1.4',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{livePillLabel}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: '11px',
|
||||||
|
fontFamily: 'var(--font-geist-mono)',
|
||||||
|
color: 'var(--text-tertiary)',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{project.requestedYear}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{project.resultSummary && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '13px',
|
||||||
|
fontWeight: 400,
|
||||||
|
//fontFamily: 'var(--font-geist-mono)',
|
||||||
|
color: 'var(--accent)',
|
||||||
|
letterSpacing: '-0.01em',
|
||||||
|
lineHeight: 1.3,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{project.resultSummary}
|
||||||
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Embla slide-by-slide carousel for screens < 1024px ---
|
||||||
|
|
||||||
|
function EmblaProjectsCarousel() {
|
||||||
|
const { openPanel } = useDetailPanel()
|
||||||
|
const wrapperRef = useRef<HTMLDivElement>(null)
|
||||||
|
const [wrapperWidth, setWrapperWidth] = useState(0)
|
||||||
|
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||||
|
const [scrollSnaps, setScrollSnaps] = useState<number[]>([])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = wrapperRef.current
|
||||||
|
if (!el) return
|
||||||
|
const update = () => {
|
||||||
|
const w = el.clientWidth
|
||||||
|
if (w > 0) setWrapperWidth(w)
|
||||||
|
}
|
||||||
|
update()
|
||||||
|
const obs = new ResizeObserver(update)
|
||||||
|
obs.observe(el)
|
||||||
|
return () => obs.disconnect()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const slidesPerView = wrapperWidth < 480 ? 1 : 2
|
||||||
|
const slideWidth = slidesPerView === 1 ? '100%' : 'calc(50% - 6px)'
|
||||||
|
const cardMinHeight = wrapperWidth < 480 ? 148 : wrapperWidth < 640 ? 168 : 182
|
||||||
|
|
||||||
|
const cardRefs = useRef<Map<number, HTMLDivElement>>(new Map())
|
||||||
|
|
||||||
|
const [emblaRef, emblaApi] = useEmblaCarousel(
|
||||||
|
{ loop: true, align: 'start' },
|
||||||
|
[Autoplay({ delay: 4000, stopOnInteraction: false, stopOnMouseEnter: true })],
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!emblaApi || typeof window === 'undefined') return
|
||||||
|
const mq = window.matchMedia('(prefers-reduced-motion: reduce)')
|
||||||
|
const sync = () => {
|
||||||
|
const autoplay = emblaApi.plugins()?.autoplay
|
||||||
|
if (!autoplay) return
|
||||||
|
if (mq.matches) autoplay.stop()
|
||||||
|
else autoplay.play()
|
||||||
|
}
|
||||||
|
sync()
|
||||||
|
mq.addEventListener('change', sync)
|
||||||
|
return () => mq.removeEventListener('change', sync)
|
||||||
|
}, [emblaApi])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
emblaApi?.reInit()
|
||||||
|
}, [emblaApi, slidesPerView])
|
||||||
|
|
||||||
|
const onSelect = useCallback(() => {
|
||||||
|
if (!emblaApi) return
|
||||||
|
setSelectedIndex(emblaApi.selectedScrollSnap())
|
||||||
|
}, [emblaApi])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!emblaApi) return
|
||||||
|
const updateSnaps = () => {
|
||||||
|
setScrollSnaps(emblaApi.scrollSnapList())
|
||||||
|
setSelectedIndex(emblaApi.selectedScrollSnap())
|
||||||
|
}
|
||||||
|
updateSnaps()
|
||||||
|
emblaApi.on('select', onSelect)
|
||||||
|
emblaApi.on('reInit', updateSnaps)
|
||||||
|
return () => {
|
||||||
|
emblaApi.off('select', onSelect)
|
||||||
|
emblaApi.off('reInit', updateSnaps)
|
||||||
|
}
|
||||||
|
}, [emblaApi, onSelect])
|
||||||
|
|
||||||
|
const handleArrowKey = useCallback((currentIndex: number, direction: -1 | 1) => {
|
||||||
|
const nextIndex = (currentIndex + direction + investigations.length) % investigations.length
|
||||||
|
cardRefs.current.get(nextIndex)?.focus()
|
||||||
|
emblaApi?.scrollTo(nextIndex)
|
||||||
|
}, [emblaApi])
|
||||||
|
|
||||||
|
const handleEscape = useCallback(() => {
|
||||||
|
wrapperRef.current?.focus()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={wrapperRef}
|
||||||
|
role="region"
|
||||||
|
aria-roledescription="carousel"
|
||||||
|
aria-label="Significant Interventions"
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
<div ref={emblaRef} style={{ overflow: 'hidden' }}>
|
||||||
|
<div style={{ display: 'flex', gap: '12px' }}>
|
||||||
|
{investigations.map((project, i) => (
|
||||||
|
<ProjectItem
|
||||||
|
key={project.id}
|
||||||
|
project={project}
|
||||||
|
slideWidth={slideWidth}
|
||||||
|
cardMinHeight={cardMinHeight}
|
||||||
|
onClick={() => openPanel({ type: 'project', investigation: project })}
|
||||||
|
index={i}
|
||||||
|
total={investigations.length}
|
||||||
|
cardRef={(el) => {
|
||||||
|
if (el) cardRefs.current.set(i, el)
|
||||||
|
else cardRefs.current.delete(i)
|
||||||
|
}}
|
||||||
|
onArrowKey={(dir) => handleArrowKey(i, dir)}
|
||||||
|
onEscape={handleEscape}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{scrollSnaps.length > 1 && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: '6px',
|
||||||
|
marginTop: '12px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{scrollSnaps.map((_, index) => (
|
||||||
|
<button
|
||||||
|
key={index}
|
||||||
|
type="button"
|
||||||
|
aria-label={`Go to slide ${index + 1}`}
|
||||||
|
onClick={() => emblaApi?.scrollTo(index)}
|
||||||
|
style={{
|
||||||
|
width: index === selectedIndex ? '16px' : '6px',
|
||||||
|
height: '6px',
|
||||||
|
borderRadius: '3px',
|
||||||
|
border: 'none',
|
||||||
|
padding: 0,
|
||||||
|
cursor: 'pointer',
|
||||||
|
background:
|
||||||
|
index === selectedIndex
|
||||||
|
? 'var(--accent-primary, #00897B)'
|
||||||
|
: 'var(--border-light, #d1d5db)',
|
||||||
|
transition: 'all 0.3s ease',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Continuous scroll carousel for screens >= 1024px ---
|
||||||
|
|
||||||
|
function ContinuousScrollCarousel() {
|
||||||
|
const { openPanel } = useDetailPanel()
|
||||||
|
const viewportRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
const trackRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
const firstSetRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
const offsetRef = useRef(0)
|
||||||
|
const isPausedRef = useRef(false)
|
||||||
|
const [viewportWidth, setViewportWidth] = useState(1200)
|
||||||
|
const [prefersReducedMotion, setPrefersReducedMotion] = useState(() =>
|
||||||
|
typeof window !== 'undefined'
|
||||||
|
? window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||||
|
: false,
|
||||||
|
)
|
||||||
|
const resumeTimeoutRef = useRef<number>(0)
|
||||||
|
const resumeTimestampRef = useRef<number | null>(null)
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
const cardRefs = useRef<Map<number, HTMLDivElement>>(new Map())
|
||||||
|
|
||||||
|
const RESUME_DELAY_MS = 10000
|
||||||
|
const RAMP_DURATION_MS = 2000
|
||||||
|
|
||||||
|
const pauseCarousel = useCallback(() => {
|
||||||
|
isPausedRef.current = true
|
||||||
|
window.clearTimeout(resumeTimeoutRef.current)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const scheduleResume = useCallback(() => {
|
||||||
|
window.clearTimeout(resumeTimeoutRef.current)
|
||||||
|
resumeTimeoutRef.current = window.setTimeout(() => {
|
||||||
|
isPausedRef.current = false
|
||||||
|
resumeTimestampRef.current = performance.now()
|
||||||
|
}, RESUME_DELAY_MS)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
pauseCarousel()
|
||||||
|
|
||||||
|
// 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 })
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduleResume()
|
||||||
|
}, [viewportWidth, prefersReducedMotion, pauseCarousel, scheduleResume])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
window.clearTimeout(resumeTimeoutRef.current)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const viewportEl = viewportRef.current
|
||||||
|
if (!viewportEl || typeof window === 'undefined') return
|
||||||
|
const updateWidth = () => {
|
||||||
|
const nextWidth = viewportEl.clientWidth
|
||||||
|
if (nextWidth > 0) setViewportWidth(nextWidth)
|
||||||
|
}
|
||||||
|
updateWidth()
|
||||||
|
if (typeof ResizeObserver !== 'undefined') {
|
||||||
|
const observer = new ResizeObserver(() => updateWidth())
|
||||||
|
observer.observe(viewportEl)
|
||||||
|
return () => observer.disconnect()
|
||||||
|
}
|
||||||
|
window.addEventListener('resize', updateWidth)
|
||||||
|
return () => window.removeEventListener('resize', updateWidth)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === 'undefined') return
|
||||||
|
const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)')
|
||||||
|
const syncMotionPreference = () => setPrefersReducedMotion(mediaQuery.matches)
|
||||||
|
syncMotionPreference()
|
||||||
|
mediaQuery.addEventListener('change', syncMotionPreference)
|
||||||
|
return () => mediaQuery.removeEventListener('change', syncMotionPreference)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const trackEl = trackRef.current
|
||||||
|
const firstSetEl = firstSetRef.current
|
||||||
|
if (!trackEl || !firstSetEl || prefersReducedMotion) return
|
||||||
|
let animationFrameId = 0
|
||||||
|
let lastTime = 0
|
||||||
|
const speedPxPerSecond = 24
|
||||||
|
const tick = (timestamp: number) => {
|
||||||
|
if (!lastTime) lastTime = timestamp
|
||||||
|
const deltaSeconds = (timestamp - lastTime) / 1000
|
||||||
|
lastTime = timestamp
|
||||||
|
if (!isPausedRef.current) {
|
||||||
|
let speedMultiplier = 1
|
||||||
|
if (resumeTimestampRef.current !== null) {
|
||||||
|
const elapsed = timestamp - resumeTimestampRef.current
|
||||||
|
if (elapsed < RAMP_DURATION_MS) {
|
||||||
|
const t = elapsed / RAMP_DURATION_MS
|
||||||
|
speedMultiplier = 1 - Math.pow(1 - t, 2)
|
||||||
|
} else {
|
||||||
|
resumeTimestampRef.current = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const setWidth = firstSetEl.offsetWidth
|
||||||
|
if (setWidth > 0) {
|
||||||
|
offsetRef.current += speedPxPerSecond * speedMultiplier * deltaSeconds
|
||||||
|
if (offsetRef.current >= setWidth) offsetRef.current -= setWidth
|
||||||
|
trackEl.style.transform = `translate3d(-${offsetRef.current}px, 0, 0)`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
animationFrameId = window.requestAnimationFrame(tick)
|
||||||
|
}
|
||||||
|
animationFrameId = window.requestAnimationFrame(tick)
|
||||||
|
return () => window.cancelAnimationFrame(animationFrameId)
|
||||||
|
}, [prefersReducedMotion, viewportWidth])
|
||||||
|
|
||||||
|
const slideWidth = useMemo(() => {
|
||||||
|
const cardsPerView = 4
|
||||||
|
const gap = 12
|
||||||
|
const totalGap = (cardsPerView - 1) * gap
|
||||||
|
const computedWidth = (viewportWidth - totalGap) / cardsPerView
|
||||||
|
return `${Math.max(computedWidth, 0)}px`
|
||||||
|
}, [viewportWidth])
|
||||||
|
|
||||||
|
const cardMinHeight = useMemo(() => {
|
||||||
|
if (viewportWidth < 1440) return 196
|
||||||
|
return 214
|
||||||
|
}, [viewportWidth])
|
||||||
|
|
||||||
|
const handleArrowKey = useCallback((currentIndex: number, direction: -1 | 1) => {
|
||||||
|
const nextIndex = (currentIndex + direction + investigations.length) % investigations.length
|
||||||
|
cardRefs.current.get(nextIndex)?.focus()
|
||||||
|
jumpByCards(direction)
|
||||||
|
}, [jumpByCards])
|
||||||
|
|
||||||
|
const handleEscape = useCallback(() => {
|
||||||
|
containerRef.current?.focus()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const arrowStyle: React.CSSProperties = {
|
||||||
|
position: 'absolute',
|
||||||
|
top: '50%',
|
||||||
|
transform: 'translateY(-50%)',
|
||||||
|
width: '40px',
|
||||||
|
height: '40px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
background: 'var(--accent-light)',
|
||||||
|
border: '1px solid var(--accent-border)',
|
||||||
|
borderRadius: '50%',
|
||||||
|
cursor: 'pointer',
|
||||||
|
boxShadow: '0 2px 8px rgba(0,0,0,0.12)',
|
||||||
|
color: 'var(--accent)',
|
||||||
|
transition: 'opacity 150ms, background-color 150ms, border-color 150ms',
|
||||||
|
zIndex: 2,
|
||||||
|
opacity: 0.85,
|
||||||
|
padding: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
role="region"
|
||||||
|
aria-roledescription="carousel"
|
||||||
|
aria-label="Significant Interventions"
|
||||||
|
tabIndex={-1}
|
||||||
|
style={{ position: 'relative' }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={viewportRef}
|
||||||
|
style={{ overflow: 'hidden' }}
|
||||||
|
onMouseEnter={() => pauseCarousel()}
|
||||||
|
onMouseLeave={() => scheduleResume()}
|
||||||
|
onFocusCapture={() => pauseCarousel()}
|
||||||
|
onBlurCapture={(event) => {
|
||||||
|
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
|
||||||
|
scheduleResume()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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}
|
||||||
|
aria-hidden={setIndex === 1 || undefined}
|
||||||
|
style={{ display: 'flex', gap: '12px', paddingRight: '12px', flexShrink: 0 }}
|
||||||
|
>
|
||||||
|
{investigations.map((project, i) => (
|
||||||
|
<ProjectItem
|
||||||
|
key={`${setIndex}-${project.id}`}
|
||||||
|
project={project}
|
||||||
|
slideWidth={slideWidth}
|
||||||
|
cardMinHeight={cardMinHeight}
|
||||||
|
onClick={() => openPanel({ type: 'project', investigation: project })}
|
||||||
|
index={i}
|
||||||
|
total={investigations.length}
|
||||||
|
isInert={setIndex === 1}
|
||||||
|
cardRef={setIndex === 0 ? (el) => {
|
||||||
|
if (el) cardRefs.current.set(i, el)
|
||||||
|
else cardRefs.current.delete(i)
|
||||||
|
} : undefined}
|
||||||
|
onArrowKey={setIndex === 0 ? (dir) => handleArrowKey(i, dir) : undefined}
|
||||||
|
onEscape={setIndex === 0 ? handleEscape : undefined}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Edge fade masks */}
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', top: 0, left: 0, bottom: 0, width: '48px',
|
||||||
|
background: 'linear-gradient(to right, var(--surface), transparent)',
|
||||||
|
pointerEvents: 'none', zIndex: 1,
|
||||||
|
}} />
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', top: 0, right: 0, bottom: 0, width: '48px',
|
||||||
|
background: 'linear-gradient(to left, var(--surface), transparent)',
|
||||||
|
pointerEvents: 'none', zIndex: 1,
|
||||||
|
}} />
|
||||||
|
|
||||||
|
{/* Left arrow */}
|
||||||
|
<button
|
||||||
|
onClick={() => jumpByCards(-1)}
|
||||||
|
aria-label="Previous project"
|
||||||
|
style={{ ...arrowStyle, left: '2px' }}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.opacity = '1'
|
||||||
|
e.currentTarget.style.background = 'var(--accent)'
|
||||||
|
e.currentTarget.style.color = '#fff'
|
||||||
|
e.currentTarget.style.borderColor = 'var(--accent)'
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.opacity = '0.85'
|
||||||
|
e.currentTarget.style.background = 'var(--accent-light)'
|
||||||
|
e.currentTarget.style.color = 'var(--accent)'
|
||||||
|
e.currentTarget.style.borderColor = 'var(--accent-border)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ChevronLeft size={20} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Right arrow */}
|
||||||
|
<button
|
||||||
|
onClick={() => jumpByCards(1)}
|
||||||
|
aria-label="Next project"
|
||||||
|
style={{ ...arrowStyle, right: '2px' }}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.opacity = '1'
|
||||||
|
e.currentTarget.style.background = 'var(--accent)'
|
||||||
|
e.currentTarget.style.color = '#fff'
|
||||||
|
e.currentTarget.style.borderColor = 'var(--accent)'
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.opacity = '0.85'
|
||||||
|
e.currentTarget.style.background = 'var(--accent-light)'
|
||||||
|
e.currentTarget.style.color = 'var(--accent)'
|
||||||
|
e.currentTarget.style.borderColor = 'var(--accent-border)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ChevronRight size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Main export ---
|
||||||
|
|
||||||
|
export function ProjectsCarousel() {
|
||||||
|
const [isSmallScreen, setIsSmallScreen] = useState(() =>
|
||||||
|
typeof window !== 'undefined'
|
||||||
|
? window.matchMedia('(max-width: 1023px)').matches
|
||||||
|
: false,
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const mq = window.matchMedia('(max-width: 1023px)')
|
||||||
|
const handler = () => setIsSmallScreen(mq.matches)
|
||||||
|
setIsSmallScreen(mq.matches)
|
||||||
|
mq.addEventListener('change', handler)
|
||||||
|
return () => mq.removeEventListener('change', handler)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ marginTop: '28px' }}>
|
||||||
|
<CardHeader dotColor="amber" title="SIGNIFICANT INTERVENTIONS" />
|
||||||
|
{isSmallScreen ? <EmblaProjectsCarousel /> : <ContinuousScrollCarousel />}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { createContext, useContext, useState, useCallback, useRef, useEffect, type ReactNode } from 'react'
|
||||||
|
|
||||||
|
interface AccessibilityContextValue {
|
||||||
|
expandedItemId: string | null
|
||||||
|
setExpandedItem: (id: string | null) => void
|
||||||
|
requestFocusAfterLogin: () => void
|
||||||
|
focusAfterLoginRef: React.RefObject<HTMLButtonElement | null>
|
||||||
|
focusAfterViewChangeRef: React.RefObject<HTMLHeadingElement | null>
|
||||||
|
requestFocusAfterViewChange: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const AccessibilityContext = createContext<AccessibilityContextValue | null>(null)
|
||||||
|
|
||||||
|
export function AccessibilityProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [expandedItemId, setExpandedItemId] = useState<string | null>(null)
|
||||||
|
const focusAfterLoginRef = useRef<HTMLButtonElement | null>(null)
|
||||||
|
const focusAfterViewChangeRef = useRef<HTMLHeadingElement | null>(null)
|
||||||
|
const [shouldFocusAfterLogin, setShouldFocusAfterLogin] = useState(false)
|
||||||
|
const [shouldFocusAfterViewChange, setShouldFocusAfterViewChange] = useState(false)
|
||||||
|
|
||||||
|
const setExpandedItem = useCallback((id: string | null) => {
|
||||||
|
setExpandedItemId(id)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const requestFocusAfterLogin = useCallback(() => {
|
||||||
|
setShouldFocusAfterLogin(true)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const requestFocusAfterViewChange = useCallback(() => {
|
||||||
|
setShouldFocusAfterViewChange(true)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (shouldFocusAfterLogin && focusAfterLoginRef.current) {
|
||||||
|
focusAfterLoginRef.current.focus()
|
||||||
|
setShouldFocusAfterLogin(false)
|
||||||
|
}
|
||||||
|
}, [shouldFocusAfterLogin])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (shouldFocusAfterViewChange && focusAfterViewChangeRef.current) {
|
||||||
|
focusAfterViewChangeRef.current.focus()
|
||||||
|
setShouldFocusAfterViewChange(false)
|
||||||
|
}
|
||||||
|
}, [shouldFocusAfterViewChange])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape' && expandedItemId) {
|
||||||
|
setExpandedItemId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleKeyDown)
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||||
|
}, [expandedItemId])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AccessibilityContext.Provider
|
||||||
|
value={{
|
||||||
|
expandedItemId,
|
||||||
|
setExpandedItem,
|
||||||
|
requestFocusAfterLogin,
|
||||||
|
focusAfterLoginRef,
|
||||||
|
focusAfterViewChangeRef,
|
||||||
|
requestFocusAfterViewChange,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</AccessibilityContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components
|
||||||
|
export function useAccessibility() {
|
||||||
|
const context = useContext(AccessibilityContext)
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useAccessibility must be used within AccessibilityProvider')
|
||||||
|
}
|
||||||
|
return context
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { createContext, useContext, useState, useCallback, useRef, ReactNode } from 'react'
|
||||||
|
import { DetailPanelContent } from '@/types/pmr'
|
||||||
|
|
||||||
|
interface DetailPanelContextValue {
|
||||||
|
content: DetailPanelContent | null
|
||||||
|
openPanel: (content: DetailPanelContent) => void
|
||||||
|
closePanel: () => void
|
||||||
|
isOpen: boolean
|
||||||
|
isClosing: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const DetailPanelContext = createContext<DetailPanelContextValue | undefined>(
|
||||||
|
undefined
|
||||||
|
)
|
||||||
|
|
||||||
|
interface DetailPanelProviderProps {
|
||||||
|
children: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DetailPanelProvider({ children }: DetailPanelProviderProps) {
|
||||||
|
const [content, setContent] = useState<DetailPanelContent | null>(null)
|
||||||
|
const [isClosing, setIsClosing] = useState(false)
|
||||||
|
const closeTimerRef = useRef<number>(0)
|
||||||
|
|
||||||
|
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 = useCallback(() => {
|
||||||
|
setIsClosing(true)
|
||||||
|
closeTimerRef.current = window.setTimeout(() => {
|
||||||
|
setIsClosing(false)
|
||||||
|
setContent(null)
|
||||||
|
closeTimerRef.current = 0
|
||||||
|
}, 250) // match panel-slide-out duration
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const isOpen = content !== null
|
||||||
|
|
||||||
|
const value: DetailPanelContextValue = {
|
||||||
|
content,
|
||||||
|
openPanel,
|
||||||
|
closePanel,
|
||||||
|
isOpen,
|
||||||
|
isClosing,
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DetailPanelContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</DetailPanelContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components
|
||||||
|
export function useDetailPanel(): DetailPanelContextValue {
|
||||||
|
const context = useContext(DetailPanelContext)
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useDetailPanel must be used within DetailPanelProvider')
|
||||||
|
}
|
||||||
|
return context
|
||||||
|
}
|
||||||