diff --git a/.gitignore b/.gitignore index df1e0d8..826d7a2 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,5 @@ dist-ssr *.sw? /.env.development /.env.production + +.claude/ diff --git a/package-lock.json b/package-lock.json index 509b214..0dfa877 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,9 @@ "name": "the-human-pattern-lab", "version": "1.0.0", "dependencies": { + "@codemirror/lang-markdown": "^6.5.0", + "@codemirror/theme-one-dark": "^6.1.3", + "@uiw/react-codemirror": "^4.25.9", "dotenv": "^17.2.3", "i18next": "^25.7.2", "i18next-browser-languagedetector": "^8.2.0", @@ -99,7 +102,6 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -406,6 +408,159 @@ "keyv": "^5.5.5" } }, + "node_modules/@codemirror/autocomplete": { + "version": "6.20.1", + "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.1.tgz", + "integrity": "sha512-1cvg3Vz1dSSToCNlJfRA2WSI4ht3K+WplO0UMOgmUYPivCyy2oueZY6Lx7M9wThm7SDUBViRmuT+OG/i8+ON9A==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@codemirror/commands": { + "version": "6.10.3", + "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.3.tgz", + "integrity": "sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.6.0", + "@codemirror/view": "^6.27.0", + "@lezer/common": "^1.1.0" + } + }, + "node_modules/@codemirror/lang-css": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz", + "integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.0.2", + "@lezer/css": "^1.1.7" + } + }, + "node_modules/@codemirror/lang-html": { + "version": "6.4.11", + "resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.11.tgz", + "integrity": "sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/lang-css": "^6.0.0", + "@codemirror/lang-javascript": "^6.0.0", + "@codemirror/language": "^6.4.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0", + "@lezer/css": "^1.1.0", + "@lezer/html": "^1.3.12" + } + }, + "node_modules/@codemirror/lang-javascript": { + "version": "6.2.5", + "resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.5.tgz", + "integrity": "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.6.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0", + "@lezer/javascript": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-markdown": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@codemirror/lang-markdown/-/lang-markdown-6.5.0.tgz", + "integrity": "sha512-0K40bZ35jpHya6FriukbgaleaqzBLZfOh7HuzqbMxBXkbYMJDxfF39c23xOgxFezR+3G+tR2/Mup+Xk865OMvw==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.7.1", + "@codemirror/lang-html": "^6.0.0", + "@codemirror/language": "^6.3.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "@lezer/common": "^1.2.1", + "@lezer/markdown": "^1.0.0" + } + }, + "node_modules/@codemirror/language": { + "version": "6.12.3", + "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.3.tgz", + "integrity": "sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.23.0", + "@lezer/common": "^1.5.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0", + "style-mod": "^4.0.0" + } + }, + "node_modules/@codemirror/lint": { + "version": "6.9.5", + "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.5.tgz", + "integrity": "sha512-GElsbU9G7QT9xXhpUg1zWGmftA/7jamh+7+ydKRuT0ORpWS3wOSP0yT1FOlIZa7mIJjpVPipErsyvVqB9cfTFA==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.35.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/search": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.6.0.tgz", + "integrity": "sha512-koFuNXcDvyyotWcgOnZGmY7LZqEOXZaaxD/j6n18TCLx2/9HieZJ5H6hs1g8FiRxBD0DNfs0nXn17g872RmYdw==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.37.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/state": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.6.0.tgz", + "integrity": "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==", + "license": "MIT", + "dependencies": { + "@marijn/find-cluster-break": "^1.0.0" + } + }, + "node_modules/@codemirror/theme-one-dark": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/@codemirror/theme-one-dark/-/theme-one-dark-6.1.3.tgz", + "integrity": "sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "@lezer/highlight": "^1.0.0" + } + }, + "node_modules/@codemirror/view": { + "version": "6.41.1", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.41.1.tgz", + "integrity": "sha512-ToDnWKbBnke+ZLrP6vgTTDScGi5H37YYuZGniQaBzxMVdtCxMrslsmtnOvbPZk4RX9bvkQqnWR/WS/35tJA0qg==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.6.0", + "crelt": "^1.0.6", + "style-mod": "^4.1.0", + "w3c-keyname": "^2.2.4" + } + }, "node_modules/@csstools/css-parser-algorithms": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", @@ -422,7 +577,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -466,7 +620,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -1098,6 +1251,79 @@ "dev": true, "license": "MIT" }, + "node_modules/@lezer/common": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz", + "integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==", + "license": "MIT" + }, + "node_modules/@lezer/css": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.3.tgz", + "integrity": "sha512-RzBo8r+/6QJeow7aPHIpGVIH59xTcJXp399820gZoMo9noQDRVpJLheIBUicYwKcsbOYoBRoLZlf2720dG/4Tg==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.3.0" + } + }, + "node_modules/@lezer/highlight": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz", + "integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.3.0" + } + }, + "node_modules/@lezer/html": { + "version": "1.3.13", + "resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.13.tgz", + "integrity": "sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/javascript": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.4.tgz", + "integrity": "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.1.3", + "@lezer/lr": "^1.3.0" + } + }, + "node_modules/@lezer/lr": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz", + "integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@lezer/markdown": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/@lezer/markdown/-/markdown-1.6.3.tgz", + "integrity": "sha512-jpGm5Ps+XErS+xA4urw7ogEGkeZOahVQF21Z6oECF0sj+2liwZopd2+I8uH5I/vZsRuuze3OxBREIANLf6KKUw==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.5.0", + "@lezer/highlight": "^1.0.0" + } + }, + "node_modules/@marijn/find-cluster-break": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz", + "integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==", + "license": "MIT" + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -2025,7 +2251,6 @@ "hasInstallScript": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "node-addon-api": "^8.3.0", "node-gyp-build": "^4.8.4" @@ -2462,7 +2687,8 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -2678,7 +2904,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -2689,7 +2914,6 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "dev": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -2764,6 +2988,59 @@ "dev": true, "license": "MIT" }, + "node_modules/@uiw/codemirror-extensions-basic-setup": { + "version": "4.25.9", + "resolved": "https://registry.npmjs.org/@uiw/codemirror-extensions-basic-setup/-/codemirror-extensions-basic-setup-4.25.9.tgz", + "integrity": "sha512-QFAqr+pu6lDmNpAlecODcF49TlsrZ0bj15zPzfhiqSDl+Um3EsDLFLppixC7kFLn+rdDM2LTvVjn5CPvefpRgw==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/commands": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/search": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0" + }, + "funding": { + "url": "https://jaywcjlove.github.io/#/sponsor" + }, + "peerDependencies": { + "@codemirror/autocomplete": ">=6.0.0", + "@codemirror/commands": ">=6.0.0", + "@codemirror/language": ">=6.0.0", + "@codemirror/lint": ">=6.0.0", + "@codemirror/search": ">=6.0.0", + "@codemirror/state": ">=6.0.0", + "@codemirror/view": ">=6.0.0" + } + }, + "node_modules/@uiw/react-codemirror": { + "version": "4.25.9", + "resolved": "https://registry.npmjs.org/@uiw/react-codemirror/-/react-codemirror-4.25.9.tgz", + "integrity": "sha512-HftqCBUYShAOH0pGi1CHP8vfm5L8fQ3+0j0VI6lQD6QpK+UBu3J7nxfEN5O/BXMilMNf9ZyFJRvRcuMMOLHMng==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.6", + "@codemirror/commands": "^6.1.0", + "@codemirror/state": "^6.1.1", + "@codemirror/theme-one-dark": "^6.0.0", + "@uiw/codemirror-extensions-basic-setup": "4.25.9", + "codemirror": "^6.0.0" + }, + "funding": { + "url": "https://jaywcjlove.github.io/#/sponsor" + }, + "peerDependencies": { + "@babel/runtime": ">=7.11.0", + "@codemirror/state": ">=6.0.0", + "@codemirror/theme-one-dark": ">=6.0.0", + "@codemirror/view": ">=6.0.0", + "codemirror": ">=6.0.0", + "react": ">=17.0.0", + "react-dom": ">=17.0.0" + } + }, "node_modules/@ungap/structured-clone": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", @@ -3221,7 +3498,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -3451,6 +3727,21 @@ "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", "license": "MIT" }, + "node_modules/codemirror": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz", + "integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/commands": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/search": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -3567,6 +3858,12 @@ } } }, + "node_modules/crelt": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", + "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==", + "license": "MIT" + }, "node_modules/css-functions-list": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/css-functions-list/-/css-functions-list-3.2.3.tgz", @@ -3749,7 +4046,8 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/dom-serializer": { "version": "1.4.1", @@ -4541,7 +4839,6 @@ "integrity": "sha512-ebvqjBqzenBk2LjzNEAzoj7yhw7rW/R2/wVevMu6Mrq3MXtcI/RUz4+ozpcOcqVLEWPqLfg2v9EAU7fFXZUUJw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/node": "^20.0.0", "@types/whatwg-mimetype": "^3.0.2", @@ -4812,7 +5109,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.28.4" }, @@ -4869,7 +5165,6 @@ "resolved": "https://registry.npmjs.org/immutable/-/immutable-3.8.2.tgz", "integrity": "sha512-15gZoQ38eYjEjxkorfbcgBKBL6R7T459OuK+CpcWt7O3KF4uPCx2tD0uFETlUDIyo+1789crbMhTvQBSR5yBMg==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -5440,7 +5735,6 @@ "integrity": "sha512-FA5LmZVF1VziNc0bIdCSA1IoSVnDCqE8HJIZZv2/W8YmoAM50+tnUgJR/gQZwEeIMleuIOnRnHA/UaZRNeV4iQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@keyv/serialize": "^1.1.1" } @@ -5808,6 +6102,7 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -7169,7 +7464,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -7219,7 +7513,6 @@ "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -7257,6 +7550,7 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -7272,6 +7566,7 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -7375,7 +7670,6 @@ "resolved": "https://registry.npmjs.org/ramda/-/ramda-0.30.1.tgz", "integrity": "sha512-tEF5I22zJnuclswcZMc8bDIrwRHRzf+NqVEmqg50ShAZMP7MWeR/RGDthfM/p+BlqvF2fXAzpn8i+SJcYD3alw==", "license": "MIT", - "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/ramda" @@ -7424,7 +7718,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -7434,7 +7727,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -7506,7 +7798,8 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/react-markdown": { "version": "10.1.0", @@ -7644,8 +7937,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/redux-immutable": { "version": "4.0.0", @@ -8161,6 +8453,12 @@ "node": ">=8" } }, + "node_modules/style-mod": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz", + "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==", + "license": "MIT" + }, "node_modules/style-to-js": { "version": "1.1.21", "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", @@ -8195,7 +8493,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-syntax-patches-for-csstree": "^1.0.19", @@ -8469,8 +8766,7 @@ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/tapable": { "version": "2.3.0", @@ -8569,7 +8865,6 @@ "hasInstallScript": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "node-addon-api": "^8.0.0", "node-gyp-build": "^4.8.0" @@ -8674,7 +8969,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -8879,7 +9173,6 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -8970,7 +9263,6 @@ "integrity": "sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/expect": "4.0.16", "@vitest/mocker": "4.0.16", @@ -9052,6 +9344,12 @@ "node": ">=0.10.0" } }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT" + }, "node_modules/web-streams-polyfill": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", diff --git a/package.json b/package.json index 89bf066..feaa8c9 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,9 @@ "private": true, "description": "Main site for The Human Pattern Lab", "dependencies": { + "@codemirror/lang-markdown": "^6.5.0", + "@codemirror/theme-one-dark": "^6.1.3", + "@uiw/react-codemirror": "^4.25.9", "dotenv": "^17.2.3", "i18next": "^25.7.2", "i18next-browser-languagedetector": "^8.2.0", @@ -48,7 +51,6 @@ "dev": "vite", "build": "vite build", "preview": "vite preview", - "sync:labnotes": "tsx src/scripts/syncLabNotesFromMd.ts", "test": "vitest", "test:watch": "vitest watch", "test:coverage": "vitest run --coverage", diff --git a/scripts/fetchYouTube.js b/scripts/fetchYouTube.js index f75af26..11c85d4 100644 --- a/scripts/fetchYouTube.js +++ b/scripts/fetchYouTube.js @@ -25,6 +25,18 @@ async function fetchJson(url) { return res.json(); } +// ISO 8601 duration (e.g. "PT1H4M13S") → "H:MM:SS" or "M:SS" +function formatIsoDuration(iso) { + if (!iso) return ""; + const m = iso.match(/^PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?$/); + if (!m) return ""; + const h = Number(m[1] ?? 0); + const mm = Number(m[2] ?? 0); + const ss = Number(m[3] ?? 0); + const pad = (n) => String(n).padStart(2, "0"); + return h > 0 ? `${h}:${pad(mm)}:${pad(ss)}` : `${mm}:${pad(ss)}`; +} + async function main() { // 1) Channel info const channelUrl = @@ -60,6 +72,24 @@ async function main() { uploads = uploadsData.items || []; } + // 2b) Durations (videos?part=contentDetails in batches of 50) + const videoIds = uploads.map((i) => i.contentDetails?.videoId).filter(Boolean); + const durationById = new Map(); + for (let i = 0; i < videoIds.length; i += 50) { + const chunk = videoIds.slice(i, i + 50); + const detailsUrl = + `${YT_BASE}/videos?` + + new URLSearchParams({ + part: "contentDetails", + id: chunk.join(","), + key: API_KEY + }); + const detailsData = await fetchJson(detailsUrl); + for (const v of detailsData.items || []) { + durationById.set(v.id, v.contentDetails?.duration); + } + } + // 3) Shape data into something your site can consume const result = { fetchedAt: new Date().toISOString(), @@ -73,13 +103,19 @@ async function main() { statistics: channel.statistics, brandingSettings: channel.brandingSettings }, - uploads: uploads.map((item) => ({ - id: item.contentDetails?.videoId, - title: item.snippet?.title, - description: item.snippet?.description, - publishedAt: item.contentDetails?.videoPublishedAt, - thumbnails: item.snippet?.thumbnails - })) + uploads: uploads.map((item) => { + const videoId = item.contentDetails?.videoId; + const isoDuration = durationById.get(videoId); + return { + id: videoId, + title: item.snippet?.title, + description: item.snippet?.description, + publishedAt: item.contentDetails?.videoPublishedAt, + thumbnails: item.snippet?.thumbnails, + duration: formatIsoDuration(isoDuration), + durationIso: isoDuration ?? null + }; + }) }; const outPath = path.join("src", "data"); diff --git a/src/__tests__/DepartmentCard.test.tsx b/src/__tests__/DepartmentCard.test.tsx index d3cdbb1..f7dd3ce 100644 --- a/src/__tests__/DepartmentCard.test.tsx +++ b/src/__tests__/DepartmentCard.test.tsx @@ -1,5 +1,6 @@ import { describe, it, expect } from "vitest"; import { render, screen } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; import { DepartmentCard } from "@/components/departments/DepartmentCard"; import type { Department, MascotId } from "@/data/departments"; @@ -12,37 +13,53 @@ const makeDept = (mascot: MascotId): Department => ({ easterEgg: "Secret test egg", }); -const cases: Array<{ mascot: MascotId; expectedName: string; expectedEmoji: string }> = [ - { mascot: "founder", expectedName: "Ada", expectedEmoji: "🦊" }, - { mascot: "orbson", expectedName: "Orbson", expectedEmoji: "👁️" }, - { mascot: "carmel", expectedName: "Carmel", expectedEmoji: "😼" }, - { mascot: "mcchonk", expectedName: "Professor McChonk", expectedEmoji: "🍩" }, - { mascot: "stan", expectedName: "Stan", expectedEmoji: "🦝" }, - { mascot: "drizzle", expectedName: "Drizzle", expectedEmoji: "🌧️" }, - { mascot: "lyric", expectedName: "Lyric", expectedEmoji: "🔮" }, - { mascot: "fill-the-void", expectedName: "Fill the Void", expectedEmoji: "🌘" }, - { mascot: "nemmi", expectedName: "Nemmi", expectedEmoji: "🔥" }, +function renderCard(department: Department) { + return render( + + + + ); +} + +const cases: Array<{ mascot: MascotId; expectedName: string; expectedSlug: string }> = [ + { mascot: "founder", expectedName: "Ada", expectedSlug: "cognitive-fox-ada" }, + { mascot: "orbson", expectedName: "Orbson", expectedSlug: "orbson" }, + { mascot: "carmel", expectedName: "Carmel", expectedSlug: "carmel" }, + { mascot: "mcchonk", expectedName: "Professor McChonk", expectedSlug: "professor-mcchonk" }, + { mascot: "stan", expectedName: "Stan", expectedSlug: "stan" }, + { mascot: "drizzle", expectedName: "Drizzle", expectedSlug: "drizzle" }, + { mascot: "lyric", expectedName: "Lyric", expectedSlug: "lyric" }, + { mascot: "fill-the-void", expectedName: "Fill the Void", expectedSlug: "fill-the-void" }, + { mascot: "nemmi", expectedName: "Nemmi", expectedSlug: "nemmi" }, ]; describe("DepartmentCard", () => { - it.each(cases)("renders correct mascot identity for $mascot", ({ mascot, expectedName, expectedEmoji }) => { - render(); + it.each(cases)( + "renders correct mascot identity for $mascot", + ({ mascot, expectedName, expectedSlug }) => { + renderCard(makeDept(mascot)); - // Core content - expect(screen.getByText(`Test Dept (${mascot})`)).toBeInTheDocument(); - expect(screen.getByText("Test tagline")).toBeInTheDocument(); - expect(screen.getByText("Test description")).toBeInTheDocument(); + // Core content + expect(screen.getByText(`Test Dept (${mascot})`)).toBeInTheDocument(); + expect(screen.getByText("Test tagline")).toBeInTheDocument(); + expect(screen.getByText("Test description")).toBeInTheDocument(); - // Mascot footer - expect(screen.getByText("Mascot:")).toBeInTheDocument(); - expect(screen.getByText(expectedName)).toBeInTheDocument(); + // Mascot avatar + name (now rendered as an img + linked text) + const avatar = screen.getByAltText(expectedName) as HTMLImageElement; + expect(avatar).toBeInTheDocument(); + expect(avatar.src).toMatch(/\/assets\/labteam\//); - // Emoji icon - expect(screen.getByText(expectedEmoji)).toBeInTheDocument(); + const mascotLink = screen.getByRole("link", { name: expectedName }); + expect(mascotLink).toHaveAttribute("href", `/labteam/${expectedSlug}`); - // Easter egg presence - expect(screen.getByText("Secret test egg")).toBeInTheDocument(); - }); + // Card header links to the department detail + const cardLink = screen.getByRole("link", { name: new RegExp(`Test Dept \\(${mascot}\\)`) }); + expect(cardLink).toHaveAttribute("href", "/departments/founder"); + + // Easter egg presence + expect(screen.getByText("Secret test egg")).toBeInTheDocument(); + } + ); it("does not render easterEgg when absent", () => { const dept: Department = { @@ -53,7 +70,7 @@ describe("DepartmentCard", () => { mascot: "founder", }; - render(); + renderCard(dept); expect(screen.queryByText(/Secret/i)).not.toBeInTheDocument(); }); }); diff --git a/src/__tests__/Header.test.tsx b/src/__tests__/Header.test.tsx index 7b34e7b..9f24778 100644 --- a/src/__tests__/Header.test.tsx +++ b/src/__tests__/Header.test.tsx @@ -16,7 +16,7 @@ const labels = [ "Home", "About", "Departments", - "Lab Notes", + "Notebook", "Docs", "Videos", "Content Use", @@ -73,7 +73,7 @@ describe("TopNav (Site Header)", () => { const q = within(mobile); // now that it's open, the mobile links should be queryable - expect(q.getByRole("link", { name: "Lab Notes" })).toBeInTheDocument(); + expect(q.getByRole("link", { name: "Notebook" })).toBeInTheDocument(); await user.keyboard("{Escape}"); expect(toggle).toHaveAttribute("aria-expanded", "false"); diff --git a/src/__tests__/Hero.test.tsx b/src/__tests__/Hero.test.tsx index e4dbacf..eeda60a 100644 --- a/src/__tests__/Hero.test.tsx +++ b/src/__tests__/Hero.test.tsx @@ -37,7 +37,7 @@ describe("Hero", () => { ).toBeInTheDocument(); expect( - screen.getByRole("link", { name: /view lab notes/i }) + screen.getByRole("link", { name: /read the notebook/i }) ).toBeInTheDocument(); }); }); diff --git a/src/api/labNotesClient.ts b/src/api/labNotesClient.ts deleted file mode 100644 index 1840ab8..0000000 --- a/src/api/labNotesClient.ts +++ /dev/null @@ -1,70 +0,0 @@ -import {apiBaseUrl} from "@/api/api"; - -export type LabNoteStatus = "draft" | "published" | "archived"; -export type LabNoteType = "labnote" | "paper" | "memo"; -export type AuthorKind = "human" | "ai" | "hybrid"; - -export interface LabNoteAuthor { - kind: AuthorKind; - name?: string; - id?: string; -} - -export interface LabNote { - id: string; - slug: string; // NEW (if API provides it) - title: string; - subtitle?: string; // already present - summary?: string; // NEW (if API provides it) - - // content - contentHtml: string; - - // publishing + metadata - published: string; // "" or "YYYY-MM-DD" - status?: LabNoteStatus; // NEW (can be derived server-side) - type?: LabNoteType; // NEW - dept?: string; // NEW (human readable) - card_style?: string; - department_id: string; - locale?: "en" | "ko"; // NEW - - // flags - shadow_density: number; - safer_landing: boolean; - - // taxonomy - tags: string[]; - readingTime: number; - - // authorship - author?: LabNoteAuthor; // NEW -} - -function unwrap(payload: unknown): T { - // Envelope form: { ok: true, data: ... } - if (payload && typeof payload === "object" && (payload as any).ok === true) { - return (payload as any).data as T; - } - // Raw form: [...] or {...} - return payload as T; -} - -export async function fetchLabNotes(signal?: AbortSignal): Promise { - const res = await fetch(`${apiBaseUrl}/lab-notes`, { signal }); - - if (!res.ok) { - const text = await res.text().catch(() => ""); - throw new Error(`Failed to fetch lab notes (${res.status}): ${text}`); - } - - const payload = await res.json(); - const data = unwrap(payload); - - // Optional safety: reject non-arrays early - if (!Array.isArray(data)) { - throw new Error("Unexpected lab-notes response shape (expected array)."); - } - - return data; -} diff --git a/src/components/departments/DepartmentCard.tsx b/src/components/departments/DepartmentCard.tsx index 05fc06a..0e0e217 100644 --- a/src/components/departments/DepartmentCard.tsx +++ b/src/components/departments/DepartmentCard.tsx @@ -23,112 +23,78 @@ // src/components/departments/DepartmentCard.tsx +import { Link } from "react-router-dom"; import { Department } from "@/data/departments"; - -// NOTE: Keep in sync with MascotId (tests will fail if drift occurs) -const mascotEmoji: Record = { - founder: "🦊", - orbson: "👁️", - carmel: "😼", - mcchonk: "🍩", - stan: "🦝", - drizzle: "🌧️", - lyric: "🔮", - "fill-the-void": "🌘", - nemmi: "🔥", -}; - -const mascotName: Record = { - founder: "Ada", - orbson: "Orbson", - carmel: "Carmel", - mcchonk: "Professor McChonk", - stan: "Stan", - drizzle: "Drizzle", - lyric: "Lyric", - "fill-the-void": "Fill the Void", - nemmi: "Nemmi", -}; - +import { mascotAvatar, mascotName, mascotProfileSlug } from "@/data/mascotMeta"; type Props = { department: Department; }; export function DepartmentCard({ department }: Props) { - const emoji = mascotEmoji[department.mascot]; const name = mascotName[department.mascot]; + const avatar = mascotAvatar[department.mascot]; + const mascotSlug = mascotProfileSlug[department.mascot]; return (
- {/* Header */} -
-
- {emoji} -
+ {/* Whole header + body is a single link into the detail page */} + + {/* Header */} +
+ {name} -
-

- {department.name} -

+
+

+ {department.name} +

-

- {department.short} -

+

{department.short}

+
-
- {/* Description */} -

- {department.description} -

+ {/* Description */} +

+ {department.description} +

+ {/* Footer */} -
- - Mascot:{" "} - {name} - +
+ + Mascot:{" "} + + {name} + + {department.easterEgg && ( - - {department.easterEgg} - + + {department.easterEgg} + )}
diff --git a/src/components/ewu/EmotionalWeatherCard.tsx b/src/components/ewu/EmotionalWeatherCard.tsx index 77e5c3a..5e6d624 100644 --- a/src/components/ewu/EmotionalWeatherCard.tsx +++ b/src/components/ewu/EmotionalWeatherCard.tsx @@ -22,7 +22,7 @@ import * as React from "react"; import type { EmotionalWeatherSignal } from "@/lib/emotionalWeather"; -import { EMOTIONAL_WEATHER_STATIC } from "@/lib/emotionalWeather"; +import { EMOTIONAL_WEATHER_LATEST } from "@/lib/weatherReportLoader"; import { Thermometer, Gauge, @@ -57,7 +57,7 @@ function TrendGlyph({ trend }: { trend: EmotionalWeatherSignal["temperature"]["t } export function EmotionalWeatherCard(props: EmotionalWeatherProps) { - const { id, title = "Emotional Weather", className, density = "normal", signal = EMOTIONAL_WEATHER_STATIC } = props; + const { id, title = "Emotional Weather", className, density = "normal", signal = EMOTIONAL_WEATHER_LATEST } = props; const isCompact = density === "compact"; const headingId = id ? `${id}__heading` : "emotional-weather__heading"; const descId = id ? `${id}__desc` : "emotional-weather__desc"; diff --git a/src/components/home/FeaturedVideo.tsx b/src/components/home/FeaturedVideo.tsx index 79a428d..0d04f40 100644 --- a/src/components/home/FeaturedVideo.tsx +++ b/src/components/home/FeaturedVideo.tsx @@ -97,22 +97,24 @@ export function FeaturedVideo() { {/* VIDEO DETAILS */}
- - {featured.category} - + {featured.category && ( + + {featured.category} + + )}

{featured.title}

-

{featured.description}

+

{featured.description}

-

- Duration:{" "} - - {featured.duration} - -

+ {featured.duration && ( +

+ Duration:{" "} + {featured.duration} +

+ )} diff --git a/src/components/home/RecentLabNotesPreview.module.css b/src/components/home/RecentLabNotesPreview.module.css deleted file mode 100644 index 7a4828f..0000000 --- a/src/components/home/RecentLabNotesPreview.module.css +++ /dev/null @@ -1,199 +0,0 @@ -/* Wrapper */ -.recentLabNotes { - margin-block: 3rem; -} - -/* Header row */ -.recentLabNotesHeader { - display: flex; - justify-content: space-between; - align-items: baseline; - gap: 1rem; - margin-bottom: 1.5rem; -} - -.recentLabNotesTitle { - font-size: 1.4rem; - font-weight: 650; -} - -.recentLabNotesSubtitle { - font-size: 0.95rem; - opacity: 0.8; -} - -/* "View all notes →" */ -.recentLabNotesLink { - font-size: 0.9rem; - font-weight: 500; - text-decoration: none; - color: var(--ifm-color-primary); - display: inline-flex; - align-items: center; - gap: 0.25rem; - transition: transform 150ms ease, opacity 150ms ease; -} - -.recentLabNotesLink:hover { - transform: translateX(2px); - opacity: 0.9; -} - -/* Card grid */ -.recentLabNotesGrid { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 1.25rem; -} - -@media (max-width: 992px) { - .recentLabNotesGrid { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } -} - -@media (max-width: 700px) { - .recentLabNotesGrid { - grid-template-columns: minmax(0, 1fr); - } -} - -/* Individual card */ -.recentLabNotesCard { - position: relative; - padding: 1.4rem 1.5rem 1.2rem; - border-radius: 1.25rem; - background: radial-gradient(circle at top left, rgba(90, 230, 255, 0.08), transparent 55%), - radial-gradient(circle at bottom right, rgba(186, 104, 255, 0.08), transparent 55%), - rgba(4, 10, 25, 0.98); - border: 1px solid rgba(255, 255, 255, 0.06); - box-shadow: - 0 18px 45px rgba(0, 0, 0, 0.75), - 0 0 0 1px rgba(255, 255, 255, 0.02); - backdrop-filter: blur(12px); - transition: - transform 160ms ease-out, - box-shadow 160ms ease-out, - border-color 160ms ease-out, - background 200ms ease-out; - overflow: hidden; -} - -/* Neon edge glow */ -.recentLabNotesCard::before { - content: ""; - position: absolute; - inset: 0; - border-radius: inherit; - background: linear-gradient( - 135deg, - rgba(0, 255, 200, 0.18), - rgba(120, 130, 255, 0.08), - rgba(255, 120, 220, 0.16) - ); - opacity: 0; - pointer-events: none; - mix-blend-mode: screen; - transition: opacity 200ms ease-out; -} - -.recentLabNotesCard:hover { - transform: translateY(-4px); - border-color: rgba(0, 255, 200, 0.45); - box-shadow: - 0 22px 55px rgba(0, 0, 0, 0.85), - 0 0 25px rgba(0, 255, 200, 0.25); -} - -.recentLabNotesCard:hover::before { - opacity: 0.7; -} - -/* Top row: tag + date */ -.recentLabNotesMetaRow { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 0.9rem; -} - -/* Category pill */ -.recentLabNotesTag { - font-size: 0.7rem; - letter-spacing: 0.12em; - text-transform: uppercase; - padding: 0.25rem 0.7rem; - border-radius: 999px; - border: 1px solid rgba(0, 255, 200, 0.4); - background: radial-gradient(circle at top left, rgba(0, 255, 200, 0.24), transparent 60%); - color: rgba(210, 255, 245, 0.94); - display: inline-flex; - align-items: center; - gap: 0.35rem; - white-space: nowrap; -} - -/* You can add small icons someday if you want */ -/* .recentLabNotesTagIcon { font-size: 0.9em; } */ - -.recentLabNotesDate { - font-size: 0.75rem; - opacity: 0.7; -} - -/* Title + summary */ -.recentLabNotesCardTitle { - font-size: 1.05rem; - font-weight: 640; - margin-bottom: 0.4rem; -} - -.recentLabNotesCardExcerpt { - font-size: 0.9rem; - line-height: 1.5; - opacity: 0.85; - margin-bottom: 1rem; -} - -/* Divider */ -.recentLabNotesDivider { - height: 1px; - width: 100%; - margin-bottom: 0.75rem; - background: linear-gradient( - 90deg, - transparent, - rgba(255, 255, 255, 0.18), - transparent - ); -} - -.recentLabNotesFooter { - display: flex; - justify-content: space-between; - align-items: center; - font-size: 0.8rem; - color: rgba(255, 255, 255, 0.7); -} - -.recentLabNotesReadTime { - opacity: 0.8; -} - -/* "Read note →" */ -.recentLabNotesReadLink { - display: inline-flex; - align-items: center; - gap: 0.25rem; - text-decoration: none; - font-weight: 500; - font-size: 0.82rem; - color: var(--ifm-color-primary); - transition: transform 160ms ease, color 160ms ease, opacity 160ms ease; -} - -.recentLabNotesReadLink:hover { - transform: translateX(3px); - color: #5bffe3; /* or keep var(--ifm-color-primary) if you prefer */ - opacity: 0.95; -} diff --git a/src/components/home/RecentLabNotesPreview.tsx b/src/components/home/RecentLabNotesPreview.tsx deleted file mode 100644 index db905db..0000000 --- a/src/components/home/RecentLabNotesPreview.tsx +++ /dev/null @@ -1,119 +0,0 @@ -// src/components/home/RecentLabNotesPreview.tsx -import React, { useEffect, useState } from "react"; -import { Link } from "react-router-dom"; -import type { LabNote } from "@/lib/labNotes"; -import { getNotesIndex } from "@/lib/notesIndex"; -import { RecentLabNotesSkeleton } from "@/components/home/RecentLabNotesSkeleton"; - -export function RecentLabNotesPreview() { - const [recent, setRecent] = useState([]); - const [error, setError] = useState(null); - const [loading, setLoading] = useState(true); - - useEffect(() => { - const controller = new AbortController(); - let alive = true; - - (async () => { - setLoading(true); - setError(null); - - try { - const all = await getNotesIndex("en", controller.signal); - if (!alive) return; - setRecent(all.slice(0, 3)); - } catch (e: any) { - if (!alive) return; - - // Abort shouldn't be treated as an error, but it MUST end loading. - if (e?.name === "AbortError") { - setLoading(false); - return; - } - - setError(e?.message ?? "Failed to load notes."); - setRecent([]); - } finally { - if (alive) setLoading(false); - } - })(); - - return () => { - alive = false; - controller.abort(); - }; - }, []); - - return ( - -
- {loading ? ( - - ) : ( -
- {recent.map((note) => { - const category = note.tags?.[0]; // currently [] in your data - const date = note.published ? new Date(note.published) : null; - const formatted = date - ? date.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "2-digit" }) - : ""; - - const excerpt = (note.summary || note.subtitle || "").trim(); - - return ( -
- {/* subtle corner glow */} -
- -
-
- {category ? ( - - {category} - - ) : ( - - Lab Note - - )} - - {formatted && {formatted}} -
- -

- {note.title} -

- - {excerpt && ( -

- {excerpt} -

- )} -
- -
- {note.readingTime ?? "—"} min read - - - Read note - -
-
- ); - })} - - - {error ? ( -

Preview failed: {error}

- ) : null} -
- )} -
- ); -} diff --git a/src/components/home/RecentLabNotesSkeleton.tsx b/src/components/home/RecentLabNotesSkeleton.tsx deleted file mode 100644 index 0abf410..0000000 --- a/src/components/home/RecentLabNotesSkeleton.tsx +++ /dev/null @@ -1,46 +0,0 @@ -export function RecentLabNotesSkeleton({ count = 3 }: { count?: number }) { - return ( -
- {Array.from({ length: count }).map((_, i) => ( -