Refine portfolio experience and integrate coffee map
@@ -7,6 +7,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@base-ui/react": "^1.4.1",
|
"@base-ui/react": "^1.4.1",
|
||||||
"@icons-pack/react-simple-icons": "^13.13.0",
|
"@icons-pack/react-simple-icons": "^13.13.0",
|
||||||
|
"@kitlangton/justice": "^0.3.0",
|
||||||
"@lucide/lab": "^0.1.2",
|
"@lucide/lab": "^0.1.2",
|
||||||
"@mdx-js/loader": "^3.1.1",
|
"@mdx-js/loader": "^3.1.1",
|
||||||
"@mdx-js/react": "^3.1.1",
|
"@mdx-js/react": "^3.1.1",
|
||||||
@@ -20,7 +21,10 @@
|
|||||||
"@types/pdfjs-dist": "2.10.378",
|
"@types/pdfjs-dist": "2.10.378",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"cn": "^0.4.0",
|
||||||
|
"leaflet": "^1.9.4",
|
||||||
"lucide-react": "^1.11.0",
|
"lucide-react": "^1.11.0",
|
||||||
|
"motion": "^13.4.1",
|
||||||
"next": "^16.2.6",
|
"next": "^16.2.6",
|
||||||
"pdfjs-dist": "4.10.38",
|
"pdfjs-dist": "4.10.38",
|
||||||
"radix-ui": "^1.4.3",
|
"radix-ui": "^1.4.3",
|
||||||
@@ -36,7 +40,9 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
|
"@types/leaflet": "^1.9.22",
|
||||||
"@types/node": "^22.19.7",
|
"@types/node": "^22.19.7",
|
||||||
|
"@types/react-dom": "^19.3.0",
|
||||||
"eslint": "9.39.2",
|
"eslint": "9.39.2",
|
||||||
"eslint-config-next": "16.1.3",
|
"eslint-config-next": "16.1.3",
|
||||||
"prettier": "^3.8.0",
|
"prettier": "^3.8.0",
|
||||||
@@ -231,6 +237,8 @@
|
|||||||
|
|
||||||
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||||
|
|
||||||
|
"@kitlangton/justice": ["@kitlangton/justice@0.3.0", "", {}, "sha512-C6jHgoiqhlkWyYa6S30MydKxFoHvxnivTNgL59DEFgpVuMzv5gsxJWKiWep3kryfKpyI/dNVNtMyqZn8y9/Y1A=="],
|
||||||
|
|
||||||
"@lucide/lab": ["@lucide/lab@0.1.2", "", {}, "sha512-VprF2BJa7ZuTGOhUd5cf8tHJXyL63wdxcGieAiVVoR9hO0YmPsnZO0AGqDiX2/br+/MC6n8BoJcmPilltOXIJA=="],
|
"@lucide/lab": ["@lucide/lab@0.1.2", "", {}, "sha512-VprF2BJa7ZuTGOhUd5cf8tHJXyL63wdxcGieAiVVoR9hO0YmPsnZO0AGqDiX2/br+/MC6n8BoJcmPilltOXIJA=="],
|
||||||
|
|
||||||
"@mdx-js/loader": ["@mdx-js/loader@3.1.1", "", { "dependencies": { "@mdx-js/mdx": "^3.0.0", "source-map": "^0.7.0" }, "peerDependencies": { "webpack": ">=5" }, "optionalPeers": ["webpack"] }, "sha512-0TTacJyZ9mDmY+VefuthVshaNIyCGZHJG2fMnGaDttCt8HmjUF7SizlHJpaCDoGnN635nK1wpzfpx/Xx5S4WnQ=="],
|
"@mdx-js/loader": ["@mdx-js/loader@3.1.1", "", { "dependencies": { "@mdx-js/mdx": "^3.0.0", "source-map": "^0.7.0" }, "peerDependencies": { "webpack": ">=5" }, "optionalPeers": ["webpack"] }, "sha512-0TTacJyZ9mDmY+VefuthVshaNIyCGZHJG2fMnGaDttCt8HmjUF7SizlHJpaCDoGnN635nK1wpzfpx/Xx5S4WnQ=="],
|
||||||
@@ -457,12 +465,16 @@
|
|||||||
|
|
||||||
"@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="],
|
"@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="],
|
||||||
|
|
||||||
|
"@types/geojson": ["@types/geojson@7946.0.16", "", {}, "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg=="],
|
||||||
|
|
||||||
"@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="],
|
"@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="],
|
||||||
|
|
||||||
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
|
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
|
||||||
|
|
||||||
"@types/json5": ["@types/json5@0.0.29", "", {}, "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ=="],
|
"@types/json5": ["@types/json5@0.0.29", "", {}, "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ=="],
|
||||||
|
|
||||||
|
"@types/leaflet": ["@types/leaflet@1.9.22", "", { "dependencies": { "@types/geojson": "*" } }, "sha512-h3lhECYEKDasG7LFHu+GiHqAvsgLuQvlJvVZzJDGONo3sEL+wUOqSFLnwkZlK0qVxnxbuGFW8iBlJNYs5wgndA=="],
|
||||||
|
|
||||||
"@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="],
|
"@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="],
|
||||||
|
|
||||||
"@types/mdx": ["@types/mdx@2.0.13", "", {}, "sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw=="],
|
"@types/mdx": ["@types/mdx@2.0.13", "", {}, "sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw=="],
|
||||||
@@ -475,6 +487,8 @@
|
|||||||
|
|
||||||
"@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="],
|
"@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="],
|
||||||
|
|
||||||
|
"@types/react-dom": ["@types/react-dom@19.3.0", "", { "peerDependencies": { "@types/react": "^19.3.0" } }, "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q=="],
|
||||||
|
|
||||||
"@types/set-cookie-parser": ["@types/set-cookie-parser@2.4.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-GGmQVGpQWUe5qglJozEjZV/5dyxbOOZ0LHe/lqyWssB88Y4svNfst0uqBVscdDeIKl5Jy5+aPSvy7mI9tYRguw=="],
|
"@types/set-cookie-parser": ["@types/set-cookie-parser@2.4.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-GGmQVGpQWUe5qglJozEjZV/5dyxbOOZ0LHe/lqyWssB88Y4svNfst0uqBVscdDeIKl5Jy5+aPSvy7mI9tYRguw=="],
|
||||||
|
|
||||||
"@types/statuses": ["@types/statuses@2.0.6", "", {}, "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA=="],
|
"@types/statuses": ["@types/statuses@2.0.6", "", {}, "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA=="],
|
||||||
@@ -661,6 +675,8 @@
|
|||||||
|
|
||||||
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
||||||
|
|
||||||
|
"cn": ["cn@0.4.0", "", { "bin": { "cn": "bin/cn.mjs" } }, "sha512-qOAeUhPwPzBxx6jaeQdo646XIKliKHc2j1ZF7Ngb1o0pJZKH5gq8FkA0aVdUdPFUDXNXyUCXpCtoJISo6dhizg=="],
|
||||||
|
|
||||||
"code-block-writer": ["code-block-writer@13.0.3", "", {}, "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg=="],
|
"code-block-writer": ["code-block-writer@13.0.3", "", {}, "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg=="],
|
||||||
|
|
||||||
"collapse-white-space": ["collapse-white-space@2.1.0", "", {}, "sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw=="],
|
"collapse-white-space": ["collapse-white-space@2.1.0", "", {}, "sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw=="],
|
||||||
@@ -895,6 +911,8 @@
|
|||||||
|
|
||||||
"forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="],
|
"forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="],
|
||||||
|
|
||||||
|
"framer-motion": ["framer-motion@13.4.1", "", { "dependencies": { "motion-dom": "^13.4.1", "motion-utils": "^13.3.0", "tslib": "^2.4.0" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["react", "react-dom"] }, "sha512-6DsbU4hsNsokXkwSp3ezdMWMiv5yWzCcdjFgakkeUC6FVIK01Py8ToSzsXvBw8cYA5hff/oXFvZFU7ltbVyUYA=="],
|
||||||
|
|
||||||
"fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="],
|
"fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="],
|
||||||
|
|
||||||
"fs-extra": ["fs-extra@11.3.4", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA=="],
|
"fs-extra": ["fs-extra@11.3.4", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA=="],
|
||||||
@@ -1125,6 +1143,8 @@
|
|||||||
|
|
||||||
"language-tags": ["language-tags@1.0.9", "", { "dependencies": { "language-subtag-registry": "^0.3.20" } }, "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA=="],
|
"language-tags": ["language-tags@1.0.9", "", { "dependencies": { "language-subtag-registry": "^0.3.20" } }, "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA=="],
|
||||||
|
|
||||||
|
"leaflet": ["leaflet@1.9.4", "", {}, "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA=="],
|
||||||
|
|
||||||
"levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="],
|
"levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="],
|
||||||
|
|
||||||
"lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="],
|
"lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="],
|
||||||
@@ -1245,6 +1265,12 @@
|
|||||||
|
|
||||||
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
|
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
|
||||||
|
|
||||||
|
"motion": ["motion@13.4.1", "", { "dependencies": { "framer-motion": "^13.4.1", "tslib": "^2.4.0" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["react", "react-dom"] }, "sha512-9YjJ918QN6/4VSigEoqJysNPh33uTruTLbrBdGDI7pM6WTWU4kTPKzmEqJ4bXzLGYyDyZ5ubWX2tv2uTuFQugg=="],
|
||||||
|
|
||||||
|
"motion-dom": ["motion-dom@13.4.1", "", { "dependencies": { "motion-utils": "^13.3.0" } }, "sha512-qw6ILBFHrIPQd3k9Dd1fNlCLHTNzvMXW2zpeTlxn4wZXX02m0Dnsf8ycyvcjtAZtBiBnL4SfbyDIXWg+b1x/Ig=="],
|
||||||
|
|
||||||
|
"motion-utils": ["motion-utils@13.3.0", "", {}, "sha512-sgSschQp7EseHInIlR7hBbMuvet3RA0bs28KPZAXJcGKGdxHGvh1ogpYDilY3bOMtl73EqPNmp75sAKHYPU5sg=="],
|
||||||
|
|
||||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||||
|
|
||||||
"msw": ["msw@2.13.6", "", { "dependencies": { "@inquirer/confirm": "^6.0.11", "@mswjs/interceptors": "^0.41.3", "@open-draft/deferred-promise": "^3.0.0", "@types/statuses": "^2.0.6", "cookie": "^1.1.1", "graphql": "^16.13.2", "headers-polyfill": "^5.0.1", "is-node-process": "^1.2.0", "outvariant": "^1.4.3", "path-to-regexp": "^6.3.0", "picocolors": "^1.1.1", "rettime": "^0.11.7", "statuses": "^2.0.2", "strict-event-emitter": "^0.5.1", "tough-cookie": "^6.0.1", "type-fest": "^5.5.0", "until-async": "^3.0.2", "yargs": "^17.7.2" }, "peerDependencies": { "typescript": ">= 4.8.x" }, "optionalPeers": ["typescript"], "bin": { "msw": "cli/index.js" } }, "sha512-GAJbQy8Ra/Ydjt0Hb2MGT2qhzd83J3+QZMHdH85uW7r/XkKc846+Ma2PLif5hGvTm5Yqa+wkcstpim0WeLZU9g=="],
|
"msw": ["msw@2.13.6", "", { "dependencies": { "@inquirer/confirm": "^6.0.11", "@mswjs/interceptors": "^0.41.3", "@open-draft/deferred-promise": "^3.0.0", "@types/statuses": "^2.0.6", "cookie": "^1.1.1", "graphql": "^16.13.2", "headers-polyfill": "^5.0.1", "is-node-process": "^1.2.0", "outvariant": "^1.4.3", "path-to-regexp": "^6.3.0", "picocolors": "^1.1.1", "rettime": "^0.11.7", "statuses": "^2.0.2", "strict-event-emitter": "^0.5.1", "tough-cookie": "^6.0.1", "type-fest": "^5.5.0", "until-async": "^3.0.2", "yargs": "^17.7.2" }, "peerDependencies": { "typescript": ">= 4.8.x" }, "optionalPeers": ["typescript"], "bin": { "msw": "cli/index.js" } }, "sha512-GAJbQy8Ra/Ydjt0Hb2MGT2qhzd83J3+QZMHdH85uW7r/XkKc846+Ma2PLif5hGvTm5Yqa+wkcstpim0WeLZU9g=="],
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@base-ui/react": "^1.4.1",
|
"@base-ui/react": "^1.4.1",
|
||||||
"@icons-pack/react-simple-icons": "^13.13.0",
|
"@icons-pack/react-simple-icons": "^13.13.0",
|
||||||
|
"@kitlangton/justice": "^0.3.0",
|
||||||
"@lucide/lab": "^0.1.2",
|
"@lucide/lab": "^0.1.2",
|
||||||
"@mdx-js/loader": "^3.1.1",
|
"@mdx-js/loader": "^3.1.1",
|
||||||
"@mdx-js/react": "^3.1.1",
|
"@mdx-js/react": "^3.1.1",
|
||||||
@@ -30,7 +31,10 @@
|
|||||||
"@types/pdfjs-dist": "2.10.378",
|
"@types/pdfjs-dist": "2.10.378",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"cn": "^0.4.0",
|
||||||
|
"leaflet": "^1.9.4",
|
||||||
"lucide-react": "^1.11.0",
|
"lucide-react": "^1.11.0",
|
||||||
|
"motion": "^13.4.1",
|
||||||
"next": "^16.2.6",
|
"next": "^16.2.6",
|
||||||
"pdfjs-dist": "4.10.38",
|
"pdfjs-dist": "4.10.38",
|
||||||
"radix-ui": "^1.4.3",
|
"radix-ui": "^1.4.3",
|
||||||
@@ -46,7 +50,9 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
|
"@types/leaflet": "^1.9.22",
|
||||||
"@types/node": "^22.19.7",
|
"@types/node": "^22.19.7",
|
||||||
|
"@types/react-dom": "^19.3.0",
|
||||||
"eslint": "9.39.2",
|
"eslint": "9.39.2",
|
||||||
"eslint-config-next": "16.1.3",
|
"eslint-config-next": "16.1.3",
|
||||||
"prettier": "^3.8.0",
|
"prettier": "^3.8.0",
|
||||||
|
|||||||
|
After Width: | Height: | Size: 148 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 95 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 33 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><circle cx="16" cy="16" r="12" fill="#3949c6"/></svg>
|
||||||
|
After Width: | Height: | Size: 114 B |
@@ -0,0 +1,42 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<svg width="100%" height="100%" viewBox="0 0 2970 436" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||||
|
<g transform="matrix(1,0,0,1,-15.2844,-243.314)">
|
||||||
|
<g transform="matrix(1,0,0,1,-42.8493,2.19437)">
|
||||||
|
<g transform="matrix(1.05907,0,0,1.05907,-1187.92,22.2126)">
|
||||||
|
<g transform="matrix(460.901,0,0,460.901,1157.01,576.339)">
|
||||||
|
<path d="M0.262,0.09L0.262,-0.802L0.341,-0.802L0.341,0.09L0.262,0.09ZM0.307,0.012C0.255,0.012 0.21,0.002 0.171,-0.018C0.133,-0.038 0.103,-0.066 0.081,-0.103C0.059,-0.14 0.046,-0.184 0.042,-0.236L0.164,-0.243C0.169,-0.21 0.177,-0.183 0.19,-0.162C0.202,-0.14 0.219,-0.123 0.239,-0.112C0.259,-0.101 0.283,-0.096 0.311,-0.096C0.34,-0.096 0.364,-0.099 0.383,-0.106C0.402,-0.113 0.416,-0.123 0.425,-0.136C0.435,-0.149 0.44,-0.165 0.44,-0.184C0.44,-0.204 0.435,-0.221 0.426,-0.236C0.417,-0.25 0.4,-0.262 0.375,-0.274C0.349,-0.285 0.313,-0.297 0.265,-0.308C0.219,-0.32 0.181,-0.334 0.15,-0.352C0.12,-0.369 0.097,-0.391 0.082,-0.417C0.067,-0.443 0.059,-0.474 0.059,-0.51C0.059,-0.551 0.068,-0.587 0.087,-0.617C0.106,-0.647 0.133,-0.671 0.169,-0.687C0.205,-0.704 0.248,-0.712 0.299,-0.712C0.349,-0.712 0.392,-0.703 0.427,-0.685C0.463,-0.667 0.491,-0.641 0.511,-0.607C0.531,-0.573 0.544,-0.533 0.548,-0.486L0.426,-0.48C0.422,-0.506 0.416,-0.528 0.406,-0.547C0.396,-0.565 0.382,-0.58 0.364,-0.59C0.346,-0.599 0.323,-0.604 0.295,-0.604C0.257,-0.604 0.227,-0.597 0.207,-0.581C0.186,-0.565 0.175,-0.543 0.175,-0.516C0.175,-0.496 0.18,-0.48 0.188,-0.468C0.197,-0.455 0.212,-0.444 0.235,-0.435C0.257,-0.425 0.289,-0.415 0.33,-0.404C0.387,-0.389 0.432,-0.372 0.465,-0.353C0.498,-0.333 0.522,-0.31 0.536,-0.284C0.55,-0.257 0.558,-0.225 0.558,-0.187C0.558,-0.146 0.547,-0.111 0.527,-0.081C0.507,-0.051 0.478,-0.028 0.44,-0.012C0.403,0.004 0.359,0.012 0.307,0.012Z" style="fill:rgb(101,101,101);fill-rule:nonzero;"/>
|
||||||
|
</g>
|
||||||
|
<g transform="matrix(460.901,0,0,460.901,1515.12,576.339)">
|
||||||
|
<path d="M0.35,0.012C0.312,0.012 0.28,0.003 0.252,-0.014C0.225,-0.032 0.204,-0.055 0.189,-0.083L0.186,-0L0.074,-0L0.074,-0.71L0.192,-0.71L0.192,-0.459C0.206,-0.483 0.226,-0.504 0.254,-0.521C0.281,-0.538 0.313,-0.546 0.35,-0.546C0.394,-0.546 0.433,-0.535 0.466,-0.513C0.498,-0.49 0.523,-0.458 0.541,-0.417C0.559,-0.375 0.568,-0.325 0.568,-0.267C0.568,-0.209 0.559,-0.159 0.541,-0.117C0.523,-0.076 0.498,-0.044 0.466,-0.021C0.433,0.001 0.394,0.012 0.35,0.012ZM0.322,-0.094C0.362,-0.094 0.392,-0.11 0.413,-0.14C0.434,-0.17 0.445,-0.212 0.445,-0.267C0.445,-0.322 0.434,-0.364 0.413,-0.395C0.392,-0.425 0.362,-0.44 0.324,-0.44C0.297,-0.44 0.274,-0.433 0.254,-0.42C0.234,-0.406 0.219,-0.387 0.208,-0.361C0.198,-0.335 0.192,-0.304 0.192,-0.267C0.192,-0.231 0.198,-0.2 0.209,-0.174C0.219,-0.148 0.234,-0.129 0.254,-0.115C0.273,-0.101 0.296,-0.094 0.322,-0.094Z" style="fill-rule:nonzero;"/>
|
||||||
|
</g>
|
||||||
|
<g transform="matrix(460.901,0,0,460.901,1791.66,576.339)">
|
||||||
|
<path d="M0.305,0.012C0.255,0.012 0.212,0.001 0.174,-0.022C0.136,-0.045 0.107,-0.077 0.086,-0.119C0.065,-0.161 0.055,-0.21 0.055,-0.267C0.055,-0.323 0.065,-0.371 0.086,-0.413C0.107,-0.455 0.136,-0.487 0.173,-0.511C0.21,-0.534 0.253,-0.546 0.303,-0.546C0.351,-0.546 0.394,-0.535 0.431,-0.512C0.468,-0.489 0.496,-0.457 0.517,-0.415C0.538,-0.373 0.549,-0.323 0.549,-0.265L0.549,-0.234L0.177,-0.234C0.181,-0.188 0.194,-0.154 0.217,-0.13C0.24,-0.106 0.27,-0.094 0.307,-0.094C0.336,-0.094 0.359,-0.101 0.378,-0.115C0.397,-0.128 0.41,-0.146 0.418,-0.168L0.539,-0.159C0.522,-0.106 0.494,-0.064 0.454,-0.034C0.414,-0.003 0.364,0.012 0.305,0.012ZM0.178,-0.32L0.421,-0.32C0.418,-0.361 0.405,-0.391 0.384,-0.41C0.362,-0.43 0.335,-0.44 0.302,-0.44C0.268,-0.44 0.241,-0.429 0.219,-0.409C0.198,-0.389 0.184,-0.359 0.178,-0.32Z" style="fill-rule:nonzero;"/>
|
||||||
|
</g>
|
||||||
|
<g transform="matrix(460.901,0,0,460.901,2068.19,576.339)">
|
||||||
|
<path d="M0.305,0.012C0.255,0.012 0.212,0.001 0.174,-0.022C0.136,-0.045 0.107,-0.077 0.086,-0.119C0.065,-0.161 0.055,-0.21 0.055,-0.267C0.055,-0.323 0.065,-0.371 0.086,-0.413C0.107,-0.455 0.136,-0.487 0.173,-0.511C0.21,-0.534 0.253,-0.546 0.303,-0.546C0.351,-0.546 0.394,-0.535 0.431,-0.512C0.468,-0.489 0.496,-0.457 0.517,-0.415C0.538,-0.373 0.549,-0.323 0.549,-0.265L0.549,-0.234L0.177,-0.234C0.181,-0.188 0.194,-0.154 0.217,-0.13C0.24,-0.106 0.27,-0.094 0.307,-0.094C0.336,-0.094 0.359,-0.101 0.378,-0.115C0.397,-0.128 0.41,-0.146 0.418,-0.168L0.539,-0.159C0.522,-0.106 0.494,-0.064 0.454,-0.034C0.414,-0.003 0.364,0.012 0.305,0.012ZM0.178,-0.32L0.421,-0.32C0.418,-0.361 0.405,-0.391 0.384,-0.41C0.362,-0.43 0.335,-0.44 0.302,-0.44C0.268,-0.44 0.241,-0.429 0.219,-0.409C0.198,-0.389 0.184,-0.359 0.178,-0.32Z" style="fill-rule:nonzero;"/>
|
||||||
|
</g>
|
||||||
|
<g transform="matrix(460.901,0,0,460.901,2344.73,576.339)">
|
||||||
|
<path d="M0.076,-0L0.076,-0.534L0.184,-0.534L0.188,-0.391L0.176,-0.398C0.182,-0.432 0.194,-0.46 0.21,-0.482C0.227,-0.504 0.248,-0.52 0.272,-0.53C0.296,-0.541 0.323,-0.546 0.351,-0.546C0.391,-0.546 0.423,-0.537 0.448,-0.52C0.473,-0.502 0.492,-0.478 0.505,-0.448C0.518,-0.418 0.524,-0.384 0.524,-0.345L0.524,-0L0.406,-0L0.406,-0.317C0.406,-0.36 0.398,-0.392 0.382,-0.413C0.367,-0.434 0.343,-0.445 0.311,-0.445C0.29,-0.445 0.27,-0.44 0.253,-0.43C0.235,-0.42 0.221,-0.405 0.21,-0.385C0.2,-0.366 0.194,-0.342 0.194,-0.313L0.194,-0L0.076,-0Z" style="fill-rule:nonzero;"/>
|
||||||
|
</g>
|
||||||
|
<g transform="matrix(460.901,0,0,460.901,2621.27,576.339)">
|
||||||
|
<path d="M0.227,-0L0.04,-0.534L0.167,-0.534L0.3,-0.128L0.433,-0.534L0.56,-0.534L0.373,-0L0.227,-0Z" style="fill:rgb(101,101,101);fill-rule:nonzero;"/>
|
||||||
|
</g>
|
||||||
|
<g transform="matrix(460.901,0,0,460.901,2897.8,576.339)">
|
||||||
|
<path d="M0.3,0.012C0.25,0.012 0.206,0.001 0.169,-0.022C0.131,-0.045 0.102,-0.077 0.081,-0.119C0.06,-0.161 0.05,-0.21 0.05,-0.267C0.05,-0.324 0.06,-0.373 0.081,-0.415C0.102,-0.457 0.131,-0.489 0.169,-0.512C0.206,-0.535 0.25,-0.546 0.3,-0.546C0.35,-0.546 0.394,-0.535 0.431,-0.512C0.469,-0.489 0.498,-0.457 0.519,-0.415C0.54,-0.373 0.55,-0.324 0.55,-0.267C0.55,-0.21 0.54,-0.161 0.519,-0.119C0.498,-0.077 0.469,-0.045 0.431,-0.022C0.394,0.001 0.35,0.012 0.3,0.012ZM0.3,-0.094C0.34,-0.094 0.372,-0.11 0.394,-0.14C0.416,-0.17 0.427,-0.212 0.427,-0.267C0.427,-0.322 0.416,-0.364 0.394,-0.395C0.372,-0.425 0.34,-0.44 0.3,-0.44C0.26,-0.44 0.228,-0.425 0.206,-0.395C0.184,-0.364 0.173,-0.322 0.173,-0.267C0.173,-0.212 0.184,-0.17 0.206,-0.14C0.228,-0.11 0.26,-0.094 0.3,-0.094Z" style="fill:rgb(101,101,101);fill-rule:nonzero;"/>
|
||||||
|
</g>
|
||||||
|
<g transform="matrix(460.901,0,0,460.901,3174.34,576.339)">
|
||||||
|
<path d="M0.276,-0L0.276,-0.534L0.394,-0.534L0.394,-0L0.276,-0ZM0.072,-0L0.072,-0.096L0.568,-0.096L0.568,-0L0.072,-0ZM0.082,-0.438L0.082,-0.534L0.377,-0.534L0.377,-0.438L0.082,-0.438ZM0.271,-0.605L0.271,-0.717L0.391,-0.717L0.391,-0.605L0.271,-0.605Z" style="fill:rgb(101,101,101);fill-rule:nonzero;"/>
|
||||||
|
</g>
|
||||||
|
<g transform="matrix(460.901,0,0,460.901,3450.88,576.339)">
|
||||||
|
<path d="M0.311,0.012C0.26,0.012 0.216,0 0.178,-0.023C0.14,-0.046 0.11,-0.079 0.089,-0.121C0.068,-0.162 0.057,-0.211 0.057,-0.267C0.057,-0.323 0.068,-0.371 0.089,-0.413C0.11,-0.455 0.14,-0.487 0.178,-0.511C0.216,-0.534 0.26,-0.546 0.311,-0.546C0.352,-0.546 0.389,-0.538 0.422,-0.522C0.456,-0.506 0.483,-0.483 0.505,-0.454C0.526,-0.424 0.54,-0.389 0.546,-0.348L0.427,-0.341C0.42,-0.373 0.406,-0.397 0.386,-0.414C0.366,-0.431 0.341,-0.44 0.312,-0.44C0.271,-0.44 0.239,-0.424 0.215,-0.394C0.192,-0.363 0.18,-0.321 0.18,-0.267C0.18,-0.213 0.192,-0.171 0.215,-0.141C0.239,-0.11 0.271,-0.094 0.312,-0.094C0.341,-0.094 0.367,-0.103 0.388,-0.121C0.409,-0.139 0.423,-0.165 0.43,-0.2L0.549,-0.193C0.543,-0.152 0.528,-0.116 0.507,-0.085C0.485,-0.055 0.457,-0.031 0.423,-0.014C0.39,0.003 0.352,0.012 0.311,0.012Z" style="fill:rgb(101,101,101);fill-rule:nonzero;"/>
|
||||||
|
</g>
|
||||||
|
<g transform="matrix(460.901,0,0,460.901,3727.41,576.339)">
|
||||||
|
<path d="M0.305,0.012C0.255,0.012 0.212,0.001 0.174,-0.022C0.136,-0.045 0.107,-0.077 0.086,-0.119C0.065,-0.161 0.055,-0.21 0.055,-0.267C0.055,-0.323 0.065,-0.371 0.086,-0.413C0.107,-0.455 0.136,-0.487 0.173,-0.511C0.21,-0.534 0.253,-0.546 0.303,-0.546C0.351,-0.546 0.394,-0.535 0.431,-0.512C0.468,-0.489 0.496,-0.457 0.517,-0.415C0.538,-0.373 0.549,-0.323 0.549,-0.265L0.549,-0.234L0.177,-0.234C0.181,-0.188 0.194,-0.154 0.217,-0.13C0.24,-0.106 0.27,-0.094 0.307,-0.094C0.336,-0.094 0.359,-0.101 0.378,-0.115C0.397,-0.128 0.41,-0.146 0.418,-0.168L0.539,-0.159C0.522,-0.106 0.494,-0.064 0.454,-0.034C0.414,-0.003 0.364,0.012 0.305,0.012ZM0.178,-0.32L0.421,-0.32C0.418,-0.361 0.405,-0.391 0.384,-0.41C0.362,-0.43 0.335,-0.44 0.302,-0.44C0.268,-0.44 0.241,-0.429 0.219,-0.409C0.198,-0.389 0.184,-0.359 0.178,-0.32Z" style="fill:rgb(101,101,101);fill-rule:nonzero;"/>
|
||||||
|
</g>
|
||||||
|
<g transform="matrix(460.901,0,0,460.901,4003.95,576.339)">
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 9.3 KiB |
@@ -1,94 +1,57 @@
|
|||||||
"use client";
|
import { ArrowUpRight } from "lucide-react";
|
||||||
|
import { PageHeading } from "~/components/PageHeading";
|
||||||
import { ArrowUpRight, Newspaper } from "lucide-react";
|
import { ContourLines } from "~/components/ContourLines";
|
||||||
|
|
||||||
import Link from "next/link";
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "~/components/ui/card";
|
|
||||||
import { CardSkeleton } from "~/components/ui/skeletons";
|
|
||||||
import { useState, useEffect } from "react";
|
|
||||||
import { articles } from "~/lib/data";
|
import { articles } from "~/lib/data";
|
||||||
|
|
||||||
export default function ArticlesPage() {
|
export const metadata = { title: "In the media" };
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
|
|
||||||
useEffect(() => {
|
export default function ArticlesPage() {
|
||||||
const fetchArticles = async () => {
|
const [lead, ...more] = articles;
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
||||||
setLoading(false);
|
|
||||||
};
|
|
||||||
void fetchArticles();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="media-page">
|
||||||
<section className="animate-fade-in-up prose prose-zinc max-w-none dark:prose-invert">
|
<PageHeading
|
||||||
<div className="flex items-start gap-3">
|
eyebrow="Selected coverage / 03"
|
||||||
<Newspaper className="h-8 w-8 text-primary" />
|
title="In the media"
|
||||||
<div>
|
description="A few outside perspectives on the work and the people behind it."
|
||||||
<h1 className="mb-2 text-2xl font-bold">In the Media</h1>
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p className="mt-2 text-lg text-muted-foreground">
|
|
||||||
I have been lucky enough to have a few wonderful articles written
|
|
||||||
about me and my work. Go check them out!
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<div className="animate-fade-in-up-delay-2 space-y-6">
|
{lead && (
|
||||||
{loading ? (
|
<article className="media-feature">
|
||||||
<>
|
<ContourLines />
|
||||||
<CardSkeleton />
|
<div className="media-feature-top">
|
||||||
<CardSkeleton />
|
<span>01 / Featured story</span>
|
||||||
<CardSkeleton />
|
<span>{lead.source}</span>
|
||||||
</>
|
</div>
|
||||||
) : (
|
<div className="media-feature-bottom">
|
||||||
articles.map((article, index) => (
|
<h2>{lead.title}</h2>
|
||||||
<div
|
<p>{lead.description}</p>
|
||||||
key={index}
|
<a href={lead.link} target="_blank" rel="noreferrer">
|
||||||
className={`animate-fade-in-up-delay-${Math.min(index + 3, 4)} card-hover`}
|
Read in Bucknell Magazine <ArrowUpRight aria-hidden="true" size={18} />
|
||||||
>
|
</a>
|
||||||
<Card>
|
</div>
|
||||||
<CardHeader>
|
</article>
|
||||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
)}
|
||||||
<CardTitle>{article.title}</CardTitle>
|
|
||||||
<Link
|
<div className="media-story-grid">
|
||||||
href={article.link}
|
{more.map((article, index) => (
|
||||||
target="_blank"
|
<article className="media-story" key={article.link}>
|
||||||
rel="noopener noreferrer"
|
<div className="media-story-top">
|
||||||
className="hidden items-center gap-1 text-sm text-muted-foreground hover:text-primary sm:flex sm:flex-shrink-0"
|
<span>0{index + 2} / {article.source}</span>
|
||||||
>
|
<ArrowUpRight aria-hidden="true" size={18} />
|
||||||
Read Article
|
|
||||||
<ArrowUpRight className="h-4 w-4" />
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
<CardDescription className="text-sm text-muted-foreground">
|
|
||||||
Written by {article.author}, found in {article.source}.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{article.description}
|
|
||||||
</p>
|
|
||||||
<Link
|
|
||||||
href={article.link}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-primary sm:hidden"
|
|
||||||
>
|
|
||||||
Read Article
|
|
||||||
<ArrowUpRight className="h-4 w-4" />
|
|
||||||
</Link>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
))
|
<h2>
|
||||||
)}
|
<a href={article.link} target="_blank" rel="noreferrer">
|
||||||
|
{article.title}
|
||||||
|
</a>
|
||||||
|
</h2>
|
||||||
|
<p>{article.description}</p>
|
||||||
|
<div className="media-story-bottom">
|
||||||
|
<span>By {article.author}</span>
|
||||||
|
<a href={article.link} target="_blank" rel="noreferrer">Read story ↗</a>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { notFound } from "next/navigation";
|
|||||||
import { Badge } from "~/components/ui/badge";
|
import { Badge } from "~/components/ui/badge";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { BreadcrumbUpdater } from "~/components/BreadcrumbUpdater";
|
import Link from "next/link";
|
||||||
|
|
||||||
interface BlogPostMetadata {
|
interface BlogPostMetadata {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -58,22 +58,19 @@ export default async function BlogPost({ params }: PageProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article className="animate-fade-in-up space-y-8">
|
<article className="article-page">
|
||||||
<BreadcrumbUpdater title={metadata.title} />
|
<Link href="/blog" className="parent-page-link">
|
||||||
<div className="mb-8">
|
← All writing
|
||||||
{/* <Button variant="ghost" asChild className="-ml-4 text-muted-foreground mb-4">
|
</Link>
|
||||||
<Link href="/blog">
|
<header className="article-page-header">
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
<p className="eyebrow">From the notebook</p>
|
||||||
Back to Blog
|
<h1>{metadata.title}</h1>
|
||||||
</Link>
|
{metadata.summary && <p className="article-page-summary">{metadata.summary}</p>}
|
||||||
</Button> */}
|
|
||||||
|
|
||||||
<h1 className="mb-4 text-3xl font-bold">{metadata.title}</h1>
|
<div className="article-page-meta">
|
||||||
|
|
||||||
<div className="mb-6 flex flex-wrap items-center gap-4 text-muted-foreground">
|
|
||||||
<time dateTime={metadata.publishedAt}>{metadata.publishedAt}</time>
|
<time dateTime={metadata.publishedAt}>{metadata.publishedAt}</time>
|
||||||
{metadata.tags && (
|
{metadata.tags && (
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="article-page-tags">
|
||||||
{metadata.tags.map((tag: string) => (
|
{metadata.tags.map((tag: string) => (
|
||||||
<Badge key={tag} variant="secondary">
|
<Badge key={tag} variant="secondary">
|
||||||
{tag}
|
{tag}
|
||||||
@@ -82,11 +79,12 @@ export default async function BlogPost({ params }: PageProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</header>
|
||||||
|
|
||||||
<div className="prose prose-zinc max-w-none dark:prose-invert">
|
<div className="article-page-body prose prose-zinc">
|
||||||
<Post />
|
<Post />
|
||||||
</div>
|
</div>
|
||||||
|
<Link href="/blog" className="article-page-return">← More writing</Link>
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,5 @@
|
|||||||
|
import { PageHeading } from "~/components/PageHeading";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
CardDescription,
|
|
||||||
} from "~/components/ui/card";
|
|
||||||
import { Badge } from "~/components/ui/badge";
|
|
||||||
import { BookOpen } from "lucide-react";
|
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
|
||||||
@@ -57,49 +49,28 @@ export default async function BlogPage() {
|
|||||||
const posts = await getBlogPosts();
|
const posts = await getBlogPosts();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="writing-page">
|
||||||
<section className="animate-fade-in-up prose prose-zinc max-w-none dark:prose-invert">
|
<PageHeading
|
||||||
<div className="flex items-start gap-3">
|
eyebrow={`The notebook / ${String(posts.length).padStart(2, "0")}`}
|
||||||
<BookOpen className="h-8 w-8 text-primary" />
|
title="Writing"
|
||||||
<div>
|
description="Notes on building things, working through ideas, and learning in public."
|
||||||
<h1 className="mb-2 text-2xl font-bold">Blog</h1>
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p className="mt-2 text-lg text-muted-foreground">
|
|
||||||
Deep dives into my projects, tutorials, and thoughts on technology.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<div className="animate-fade-in-up-delay-2 grid gap-6">
|
<div className="record-section">
|
||||||
{posts.map((post) => (
|
{posts.map((post) => (
|
||||||
<Link
|
<article key={post.slug} className="record-row writing-entry">
|
||||||
key={post.slug}
|
<div className="record-meta">
|
||||||
href={`/blog/${post.slug}`}
|
<time dateTime={post.publishedAt}>{post.publishedAt}</time>
|
||||||
className="card-hover block"
|
</div>
|
||||||
>
|
<div className="record-body">
|
||||||
<Card className="h-full transition-colors hover:bg-muted/50">
|
<h2><Link href={`/blog/${post.slug}`}>{post.title}</Link></h2>
|
||||||
<CardHeader>
|
<p>{post.summary}</p>
|
||||||
<div className="flex items-start justify-between">
|
<p className="record-kicker">{post.tags?.join(" · ")}</p>
|
||||||
<CardTitle className="mb-2 text-xl">{post.title}</CardTitle>
|
<Link className="writing-entry-link" href={`/blog/${post.slug}`}>
|
||||||
<span className="ml-4 whitespace-nowrap text-sm text-muted-foreground">
|
Read article <span aria-hidden="true">↗</span>
|
||||||
{post.publishedAt}
|
</Link>
|
||||||
</span>
|
</div>
|
||||||
</div>
|
</article>
|
||||||
<CardDescription className="text-base">
|
|
||||||
{post.summary}
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{post.tags?.map((tag: string) => (
|
|
||||||
<Badge key={tag} variant="secondary">
|
|
||||||
{tag}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</Link>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
.coffee-site-main { position: relative; z-index: 0; isolation: isolate; width: 100%; flex: 1; min-height: calc(100dvh - 72px); padding: 0; margin: 0; max-width: none; }
|
||||||
|
body:has(.coffee-mirror) { overflow: hidden; }
|
||||||
|
body:has(.coffee-mirror) .skip-link { z-index: 3000; }
|
||||||
|
.coffee-mirror { position: relative; width: 100%; height: calc(100dvh - 72px); overflow: hidden; background: #eceeea; color: #161b1b; font-family: Georgia, "Times New Roman", serif; --coffee-glass: rgba(255,255,255,.82); --coffee-border: rgba(0,0,0,.12); --coffee-text: #161b1b; --coffee-subtle: #5e6363; --coffee-hover: rgba(0,0,0,.07); }
|
||||||
|
.coffee-mirror * { box-sizing: border-box; }
|
||||||
|
.coffee-mirror button { cursor: pointer; }
|
||||||
|
.coffee-mirror-map { position: absolute; inset: 0; z-index: 0; }
|
||||||
|
.coffee-mirror-map-error { position: absolute; top: 45%; left: 50%; transform: translate(-50%,-50%); z-index: 3; padding: 16px; color: white; background: #35281e; border-radius: 10px; text-align: center; }
|
||||||
|
.coffee-mirror .leaflet-container { background: #eceeea; font-family: Georgia, "Times New Roman", serif; }
|
||||||
|
.coffee-mirror .leaflet-control-attribution { display: none; }
|
||||||
|
.coffee-mirror-pin { border: 2px solid white; border-radius: 50%; background: #8b4513; box-shadow: 0 2px 10px #0008; }
|
||||||
|
.coffee-mirror-pin span { display: block; width: 100%; height: 100%; border-radius: 50%; }
|
||||||
|
.coffee-mirror-pin.is-selected { background: #efa83f; box-shadow: 0 0 0 7px #efa83f55, 0 2px 10px #0008; }
|
||||||
|
.coffee-mirror-header { position: absolute; top: 16px; left: 16px; right: 16px; z-index: 1000; min-height: 58px; display: flex; align-items: center; justify-content: space-between; padding: 7px 10px; border: 1px solid var(--coffee-border); border-radius: 13px; background: var(--coffee-glass); color: var(--coffee-text); backdrop-filter: blur(22px); box-shadow: 0 8px 28px #0003; }
|
||||||
|
.coffee-mirror-icon-button { display: grid; place-items: center; width: 42px; height: 42px; border: 0; border-radius: 9px; color: var(--coffee-text); background: transparent; }
|
||||||
|
.coffee-mirror-icon-button:hover, .coffee-mirror-icon-button.is-active { color: #f5ab45; background: var(--coffee-hover); }
|
||||||
|
.coffee-mirror-brand { display: flex; align-items: center; gap: 12px; position: absolute; left: 50%; transform: translateX(-50%); border: 0; background: transparent; color: inherit; text-align: left; white-space: nowrap; }
|
||||||
|
.coffee-mirror-brand > svg { color: #f1a22e; flex: none; }
|
||||||
|
.coffee-mirror-brand strong, .coffee-mirror-brand small { display: block; }
|
||||||
|
.coffee-mirror-brand strong { font-size: 17px; line-height: 1.1; }
|
||||||
|
.coffee-mirror-brand small { color: var(--coffee-subtle); font-size: 12px; margin-top: 3px; }
|
||||||
|
.coffee-mirror-header-spacer { width: 42px; }
|
||||||
|
.coffee-mirror-drawer { position: absolute; z-index: 900; top: 92px; bottom: 32px; left: 16px; width: min(400px, calc(100vw - 32px)); overflow: hidden; border: 1px solid var(--coffee-border); border-radius: 13px; background: var(--coffee-glass); color: var(--coffee-text); backdrop-filter: blur(25px); box-shadow: 0 16px 38px #0004; transform: translateX(calc(-100% - 34px)); opacity: 0; pointer-events: none; transition: transform .3s ease, opacity .3s ease; }
|
||||||
|
.coffee-mirror-drawer.is-open { transform: translateX(0); opacity: 1; pointer-events: auto; }
|
||||||
|
.coffee-mirror-discovery, .coffee-mirror-detail { height: 100%; display: flex; flex-direction: column; }
|
||||||
|
.coffee-mirror-discovery-top { flex: none; padding: 24px 22px 15px; }
|
||||||
|
.coffee-mirror-discovery h2 { display: flex; align-items: center; gap: 9px; margin: 0 0 17px; color: var(--coffee-text); font-size: 23px; line-height: 1.2; }
|
||||||
|
.coffee-mirror-discovery h2 svg { color: #efa83f; }
|
||||||
|
.coffee-mirror-search { display: flex; align-items: center; gap: 8px; padding: 9px 12px; border: 1px solid var(--coffee-border); border-radius: 8px; background: var(--coffee-hover); color: var(--coffee-subtle); }
|
||||||
|
.coffee-mirror-search input { width: 100%; min-width: 0; border: 0; outline: 0; background: transparent; color: var(--coffee-text); font: 14px Arial, sans-serif; }
|
||||||
|
.coffee-mirror-search input::placeholder { color: var(--coffee-subtle); }
|
||||||
|
.coffee-mirror-list { overflow-y: auto; min-height: 0; padding: 0 14px 24px; scrollbar-width: thin; }
|
||||||
|
.coffee-mirror-list-label { color: #eeb05c; text-transform: uppercase; letter-spacing: .12em; font: 700 11px Arial, sans-serif; padding: 8px 12px; }
|
||||||
|
.coffee-mirror-list hr { border: 0; border-top: 1px solid var(--coffee-border); margin: 12px; }
|
||||||
|
.coffee-mirror-row { display: flex; align-items: center; gap: 5px; width: 100%; border: 1px solid transparent; border-radius: 9px; margin: 3px 0; padding: 8px; }
|
||||||
|
.coffee-mirror-row:hover, .coffee-mirror-row:focus-within { background: var(--coffee-hover); border-color: var(--coffee-border); }
|
||||||
|
.coffee-mirror-row-main { display: flex; align-items: center; gap: 12px; flex: 1; min-width: 0; text-align: left; color: var(--coffee-text); border: 0; background: transparent; padding: 0; }
|
||||||
|
.coffee-mirror-row-main img { width: 63px; height: 63px; border-radius: 6px; flex: none; object-fit: cover; }
|
||||||
|
.coffee-mirror-row-main span { min-width: 0; }
|
||||||
|
.coffee-mirror-row-main strong, .coffee-mirror-row-main small { display: block; }
|
||||||
|
.coffee-mirror-row-main strong { font-size: 15px; line-height: 1.25; }
|
||||||
|
.coffee-mirror-row-main small { margin-top: 5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--coffee-subtle); font: 12px Arial, sans-serif; }
|
||||||
|
.coffee-mirror-favorite { flex: none; border: 0; background: transparent; color: var(--coffee-subtle); padding: 8px; }
|
||||||
|
.coffee-mirror-favorite:hover, .coffee-mirror-favorite:has(svg[fill="currentColor"]) { color: #eb7d79; }
|
||||||
|
.coffee-mirror-empty { padding: 20px; text-align: center; color: var(--coffee-subtle); }
|
||||||
|
.coffee-mirror-photo { position: relative; height: 235px; flex: none; overflow: hidden; mask-image: linear-gradient(#000 75%, transparent 100%); }
|
||||||
|
.coffee-mirror-photo img { width: 100%; height: 100%; object-fit: cover; }
|
||||||
|
.coffee-mirror-detail { overflow-y: auto; }
|
||||||
|
.coffee-mirror-detail-actions { position: absolute; right: 13px; top: 13px; display: flex; gap: 7px; }
|
||||||
|
.coffee-mirror-detail-actions button { display: grid; place-items: center; width: 34px; height: 34px; border: 1px solid var(--coffee-border); border-radius: 50%; background: var(--coffee-glass); color: var(--coffee-text); backdrop-filter: blur(8px); }
|
||||||
|
.coffee-mirror-detail-copy { padding: 4px 24px 30px; }
|
||||||
|
.coffee-mirror-detail-copy h2 { margin: 0 0 12px; font-size: 29px; line-height: 1.15; }
|
||||||
|
.coffee-mirror-detail-copy p { color: var(--coffee-text); font: 14px/1.55 Arial, sans-serif; }
|
||||||
|
.coffee-mirror-detail-copy .coffee-mirror-address { display: flex; gap: 7px; align-items: flex-start; color: var(--coffee-subtle); }
|
||||||
|
.coffee-mirror-address svg { flex: none; color: #f1a22e; }
|
||||||
|
.coffee-mirror-directions { display: block; margin-top: 24px; padding: 12px; border-radius: 8px; background: #151515; color: white; text-align: center; font: 600 14px Arial, sans-serif; }
|
||||||
|
.coffee-mirror-detail-links { display: flex; gap: 10px; margin-top: 12px; }
|
||||||
|
.coffee-mirror-detail-links a { flex: 1; padding: 10px; border: 1px solid var(--coffee-border); border-radius: 8px; color: var(--coffee-text); text-align: center; font: 13px Arial, sans-serif; }
|
||||||
|
.coffee-mirror-controls { position: absolute; right: 16px; bottom: 72px; z-index: 1000; display: flex; flex-direction: column; align-items: flex-end; gap: 8px; }
|
||||||
|
.coffee-mirror-controls > button, .coffee-mirror-style-wrap > button { display: grid; place-items: center; width: 43px; height: 43px; border: 1px solid var(--coffee-border); border-radius: 12px; background: var(--coffee-glass); color: var(--coffee-text); backdrop-filter: blur(20px); box-shadow: 0 4px 14px #0003; }
|
||||||
|
.coffee-mirror-controls button:hover { background: var(--coffee-hover); }
|
||||||
|
.coffee-mirror-style-wrap { position: relative; }
|
||||||
|
.coffee-mirror-style-menu { position: absolute; bottom: 0; right: 52px; min-width: 115px; padding: 5px; border: 1px solid var(--coffee-border); border-radius: 10px; background: var(--coffee-glass); backdrop-filter: blur(20px); }
|
||||||
|
.coffee-mirror-style-menu button { display: block; width: 100%; padding: 9px; border: 0; border-radius: 6px; background: transparent; color: var(--coffee-text); text-align: left; font: 13px Arial, sans-serif; }
|
||||||
|
.coffee-mirror-style-menu button:hover, .coffee-mirror-style-menu button[aria-pressed="true"] { background: var(--coffee-hover); }
|
||||||
|
.coffee-mirror-location-error { max-width: 180px; padding: 8px; border-radius: 7px; background: var(--coffee-glass); color: var(--coffee-text); font: 12px Arial, sans-serif; }
|
||||||
|
.coffee-mirror-footer { position: absolute; z-index: 800; left: 16px; right: 16px; bottom: 8px; display: flex; justify-content: space-between; color: var(--coffee-text); text-shadow: 0 1px 4px #000; font: 11px Arial, sans-serif; opacity: .75; pointer-events: none; }
|
||||||
|
.coffee-mirror-modal-backdrop { position: absolute; z-index: 2000; inset: 0; display: grid; place-items: center; padding: 16px; background: #0009; backdrop-filter: blur(5px); }
|
||||||
|
.coffee-mirror-modal { position: relative; width: min(100%, 440px); padding: 32px; border: 1px solid var(--coffee-border); border-radius: 15px; background: var(--coffee-glass); color: var(--coffee-text); backdrop-filter: blur(25px); box-shadow: 0 22px 70px #0007; text-align: center; }
|
||||||
|
.coffee-mirror-modal > svg { color: #efa83f; margin: 0 auto 12px; }
|
||||||
|
.coffee-mirror-modal h2 { font-size: 27px; line-height: 1.2; margin: 0 0 12px; }
|
||||||
|
.coffee-mirror-modal p { margin: 0; color: var(--coffee-subtle); }
|
||||||
|
.coffee-mirror-modal small { display: block; color: #efa83f; margin-top: 7px; }
|
||||||
|
.coffee-mirror-modal ul { margin: 22px 0; padding: 18px 20px 18px 35px; border: 1px solid var(--coffee-border); border-radius: 9px; background: var(--coffee-hover); color: var(--coffee-subtle); text-align: left; font: 13px/1.8 Arial, sans-serif; }
|
||||||
|
.coffee-mirror-start { width: 100%; border: 0; border-radius: 8px; padding: 12px; background: #111; color: #fff; font: 600 14px Arial, sans-serif; }
|
||||||
|
.coffee-mirror-modal-close { position: absolute; right: 12px; top: 12px; border: 0; background: transparent; color: var(--coffee-text); }
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.coffee-mirror { color-scheme: dark; background: #182021; color: #f6f5f1; --coffee-glass: rgba(15, 18, 19, .76); --coffee-border: rgba(255,255,255,.16); --coffee-text: #f6f5f1; --coffee-subtle: #bec1c1; --coffee-hover: rgba(255,255,255,.12); }
|
||||||
|
.coffee-mirror .leaflet-container { background: #1c2526; }
|
||||||
|
.coffee-mirror:not(.is-satellite) .leaflet-tile-pane { filter: grayscale(1) invert(1) brightness(.8) contrast(1.1) hue-rotate(180deg); }
|
||||||
|
}
|
||||||
|
@media (max-width: 700px) { .coffee-site-main { min-height: calc(100dvh - 64px); } .coffee-mirror { height: calc(100dvh - 64px); } }
|
||||||
|
@media (max-width: 639px) { .coffee-mirror-header { top: 12px; left: 12px; right: 12px; }.coffee-mirror-brand { gap: 7px; }.coffee-mirror-brand strong { font-size: 14px; }.coffee-mirror-brand small { font-size: 10px; }.coffee-mirror-brand > svg { width: 21px; }.coffee-mirror-drawer { top: 82px; bottom: 16px; left: 12px; width: calc(100vw - 24px); }.coffee-mirror-controls { bottom: 58px; right: 12px; }.coffee-mirror-footer { left: 12px; right: 12px; } }
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { CoffeeMirror } from "~/components/CoffeeMirror";
|
||||||
|
import "./coffee.css";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Lewisburg Coffee Map",
|
||||||
|
description: "Explore coffee shops in Lewisburg, Pennsylvania.",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function CoffeePage() {
|
||||||
|
return <CoffeeMirror />;
|
||||||
|
}
|
||||||
@@ -1,525 +1,17 @@
|
|||||||
"use client";
|
import { PageHeading } from "~/components/PageHeading";
|
||||||
|
import { DocumentReader } from "~/components/DocumentReader";
|
||||||
|
|
||||||
import { useState, useRef, useEffect } from "react";
|
export const metadata = { title: "Résumé & CV" };
|
||||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "~/components/ui/tabs";
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardHeader,
|
|
||||||
CardContent,
|
|
||||||
CardTitle,
|
|
||||||
CardDescription,
|
|
||||||
} from "~/components/ui/card";
|
|
||||||
import { Button } from "~/components/ui/button";
|
|
||||||
|
|
||||||
import {
|
|
||||||
Download,
|
|
||||||
ExternalLink,
|
|
||||||
FileText,
|
|
||||||
ZoomIn,
|
|
||||||
ZoomOut,
|
|
||||||
RotateCw,
|
|
||||||
ChevronLeft,
|
|
||||||
ChevronRight,
|
|
||||||
AlertCircle,
|
|
||||||
Loader2,
|
|
||||||
Eye,
|
|
||||||
} from "lucide-react";
|
|
||||||
import Link from "next/link";
|
|
||||||
import type { PDFDocumentProxy, PDFPageProxy } from "pdfjs-dist";
|
|
||||||
|
|
||||||
// Local PDF file URLs
|
|
||||||
const CV_URL = "/publications/cv.pdf";
|
|
||||||
const RESUME_URL = "/publications/resume.pdf";
|
|
||||||
|
|
||||||
interface PDFViewerProps {
|
|
||||||
url: string;
|
|
||||||
title: string;
|
|
||||||
type: "cv" | "resume";
|
|
||||||
}
|
|
||||||
|
|
||||||
function PDFViewer({ url, title, type }: PDFViewerProps) {
|
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
||||||
const [pdfDoc, setPdfDoc] = useState<PDFDocumentProxy | null>(null);
|
|
||||||
const [pageNum, setPageNum] = useState(1);
|
|
||||||
const [pageCount, setPageCount] = useState(0);
|
|
||||||
const [scale, setScale] = useState(1.2);
|
|
||||||
const [rotation, setRotation] = useState(0);
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const [hasError, setHasError] = useState(false);
|
|
||||||
const [errorMessage, setErrorMessage] = useState("");
|
|
||||||
const [isClient, setIsClient] = useState(false);
|
|
||||||
const [pdfBlob, setPdfBlob] = useState<Uint8Array | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
||||||
setIsClient(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Download PDF and convert to blob for PDF.js
|
|
||||||
const downloadPDF = async (pdfUrl: string): Promise<Uint8Array> => {
|
|
||||||
try {
|
|
||||||
// Try direct fetch first
|
|
||||||
const response = await fetch(pdfUrl, {
|
|
||||||
mode: "cors",
|
|
||||||
headers: {
|
|
||||||
Accept: "application/pdf",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Failed to download PDF: ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const arrayBuffer = await response.arrayBuffer();
|
|
||||||
return new Uint8Array(arrayBuffer);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to download PDF:", error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isClient) return;
|
|
||||||
|
|
||||||
const loadPDF = async () => {
|
|
||||||
try {
|
|
||||||
setIsLoading(true);
|
|
||||||
setHasError(false);
|
|
||||||
|
|
||||||
// Download the PDF first
|
|
||||||
const pdfData = await downloadPDF(url);
|
|
||||||
setPdfBlob(pdfData);
|
|
||||||
|
|
||||||
// Dynamically import PDF.js
|
|
||||||
const pdfjsLib = await import("pdfjs-dist");
|
|
||||||
|
|
||||||
// Set worker path
|
|
||||||
pdfjsLib.GlobalWorkerOptions.workerSrc = "/pdf.worker.min.js";
|
|
||||||
|
|
||||||
// Load PDF from the downloaded data
|
|
||||||
const loadingTask = pdfjsLib.getDocument({ data: pdfData });
|
|
||||||
const pdf = await loadingTask.promise;
|
|
||||||
setPdfDoc(pdf);
|
|
||||||
setPageCount(pdf.numPages);
|
|
||||||
setPageNum(1);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error loading PDF:", error);
|
|
||||||
setHasError(true);
|
|
||||||
setErrorMessage(
|
|
||||||
error instanceof Error
|
|
||||||
? error.message
|
|
||||||
: "Failed to load PDF. Please try downloading it directly.",
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
void loadPDF();
|
|
||||||
}, [url, isClient]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const renderPage = async () => {
|
|
||||||
if (!pdfDoc || !canvasRef.current) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const page: PDFPageProxy = await pdfDoc.getPage(pageNum);
|
|
||||||
const canvas = canvasRef.current;
|
|
||||||
const context = canvas.getContext("2d");
|
|
||||||
|
|
||||||
if (!context) return;
|
|
||||||
|
|
||||||
// Get container width for responsive scaling
|
|
||||||
const container = canvas.parentElement;
|
|
||||||
const containerWidth = container
|
|
||||||
? container.clientWidth - 32
|
|
||||||
: window.innerWidth - 32; // Account for padding
|
|
||||||
|
|
||||||
// Calculate responsive scale
|
|
||||||
const baseViewport = page.getViewport({ scale: 1, rotation });
|
|
||||||
const responsiveScale = Math.min(
|
|
||||||
containerWidth / baseViewport.width,
|
|
||||||
scale,
|
|
||||||
);
|
|
||||||
|
|
||||||
const viewport = page.getViewport({ scale: responsiveScale, rotation });
|
|
||||||
|
|
||||||
// Fix blurriness by using device pixel ratio
|
|
||||||
const devicePixelRatio = window.devicePixelRatio || 1;
|
|
||||||
const outputScale = devicePixelRatio;
|
|
||||||
|
|
||||||
canvas.width = Math.floor(viewport.width * outputScale);
|
|
||||||
canvas.height = Math.floor(viewport.height * outputScale);
|
|
||||||
canvas.style.width = Math.floor(viewport.width) + "px";
|
|
||||||
canvas.style.height = Math.floor(viewport.height) + "px";
|
|
||||||
|
|
||||||
// Enable anti-aliasing and smoothing
|
|
||||||
context.imageSmoothingEnabled = true;
|
|
||||||
context.imageSmoothingQuality = "high";
|
|
||||||
|
|
||||||
const transform =
|
|
||||||
outputScale !== 1
|
|
||||||
? [outputScale, 0, 0, outputScale, 0, 0]
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const renderContext = {
|
|
||||||
canvasContext: context,
|
|
||||||
viewport: viewport,
|
|
||||||
transform: transform,
|
|
||||||
};
|
|
||||||
|
|
||||||
await page.render(renderContext).promise;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error rendering page:", error);
|
|
||||||
setHasError(true);
|
|
||||||
setErrorMessage("Failed to render PDF page.");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
void renderPage();
|
|
||||||
}, [pdfDoc, pageNum, scale, rotation]);
|
|
||||||
|
|
||||||
const nextPage = () => {
|
|
||||||
if (pageNum < pageCount) {
|
|
||||||
setPageNum(pageNum + 1);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const prevPage = () => {
|
|
||||||
if (pageNum > 1) {
|
|
||||||
setPageNum(pageNum - 1);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const zoomIn = () => {
|
|
||||||
if (scale < 3) {
|
|
||||||
setScale(scale + 0.2);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const zoomOut = () => {
|
|
||||||
if (scale > 0.5) {
|
|
||||||
setScale(scale - 0.2);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const rotate = () => {
|
|
||||||
setRotation((rotation + 90) % 360);
|
|
||||||
};
|
|
||||||
|
|
||||||
const downloadBlob = () => {
|
|
||||||
if (!pdfBlob) return;
|
|
||||||
|
|
||||||
const blob = new Blob([pdfBlob as BlobPart], { type: "application/pdf" });
|
|
||||||
const url = window.URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement("a");
|
|
||||||
a.href = url;
|
|
||||||
a.download = `${title.toLowerCase().replace(/\s+/g, "_")}.pdf`;
|
|
||||||
document.body.appendChild(a);
|
|
||||||
a.click();
|
|
||||||
document.body.removeChild(a);
|
|
||||||
window.URL.revokeObjectURL(url);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!isClient) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{/* Action Bar */}
|
|
||||||
<div className="flex items-center justify-between gap-4 rounded-lg border bg-muted/50 p-4">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<FileText className="h-5 w-5 text-muted-foreground" />
|
|
||||||
<div>
|
|
||||||
<h3 className="font-medium">{title}</h3>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{type === "cv"
|
|
||||||
? "Academic curriculum vitae"
|
|
||||||
: "Professional resume"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button variant="outline" size="sm" className="gap-2" disabled>
|
|
||||||
<Download className="h-4 w-4" />
|
|
||||||
<span className="hidden sm:inline">Download</span>
|
|
||||||
</Button>
|
|
||||||
<Button variant="outline" size="sm" className="gap-2" disabled>
|
|
||||||
<ExternalLink className="h-4 w-4" />
|
|
||||||
<span className="hidden sm:inline">Open</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Loading State */}
|
|
||||||
<div className="relative overflow-hidden rounded-lg border bg-background">
|
|
||||||
<div className="flex justify-center p-4">
|
|
||||||
<div className="h-[600px] w-full max-w-2xl animate-pulse rounded-lg bg-muted lg:h-[800px]" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasError) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{/* Action Bar */}
|
|
||||||
<div className="flex items-center justify-between gap-4 rounded-lg border bg-muted/50 p-4">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<FileText className="h-5 w-5 text-muted-foreground" />
|
|
||||||
<h3 className="font-medium">{title}</h3>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="button-hover gap-2"
|
|
||||||
onClick={pdfBlob ? downloadBlob : undefined}
|
|
||||||
disabled={!pdfBlob}
|
|
||||||
>
|
|
||||||
<Download className="h-4 w-4" />
|
|
||||||
<span className="hidden sm:inline">Download</span>
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
asChild
|
|
||||||
className="button-hover gap-2"
|
|
||||||
>
|
|
||||||
<Link href={url} target="_blank" rel="noopener noreferrer">
|
|
||||||
<Eye className="h-4 w-4" />
|
|
||||||
<span className="hidden sm:inline">View PDF</span>
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="pb-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<AlertCircle className="h-5 w-5 text-destructive" />
|
|
||||||
<CardTitle className="text-base">Unable to load PDF</CardTitle>
|
|
||||||
</div>
|
|
||||||
<CardDescription>{errorMessage}</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="button-hover gap-2"
|
|
||||||
onClick={pdfBlob ? downloadBlob : undefined}
|
|
||||||
disabled={!pdfBlob}
|
|
||||||
>
|
|
||||||
<Download className="h-4 w-4" />
|
|
||||||
<span className="hidden sm:inline">Download {title}</span>
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
asChild
|
|
||||||
className="button-hover gap-2"
|
|
||||||
>
|
|
||||||
<Link href={url} target="_blank" rel="noopener noreferrer">
|
|
||||||
<Eye className="h-4 w-4" />
|
|
||||||
<span className="hidden sm:inline">View PDF in New Tab</span>
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-md border border-dashed p-4 text-center">
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
PDF preview not available. Please use the download or open
|
|
||||||
buttons above.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{/* Action Bar */}
|
|
||||||
<div className="flex items-center justify-between gap-4 rounded-lg border bg-muted/50 p-4">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<FileText className="h-5 w-5 text-muted-foreground" />
|
|
||||||
<h3 className="font-medium">{title}</h3>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="button-hover gap-2"
|
|
||||||
onClick={pdfBlob ? downloadBlob : undefined}
|
|
||||||
disabled={!pdfBlob}
|
|
||||||
>
|
|
||||||
<Download className="h-4 w-4" />
|
|
||||||
<span className="hidden sm:inline">Download</span>
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
asChild
|
|
||||||
className="button-hover gap-2"
|
|
||||||
>
|
|
||||||
<Link href={url} target="_blank" rel="noopener noreferrer">
|
|
||||||
<Eye className="h-4 w-4" />
|
|
||||||
<span className="hidden sm:inline">View PDF</span>
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* PDF Controls */}
|
|
||||||
{!isLoading && pageCount > 0 && (
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2 rounded-lg border bg-background p-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={prevPage}
|
|
||||||
disabled={pageNum <= 1}
|
|
||||||
>
|
|
||||||
<ChevronLeft className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
{pageCount > 0 ? `${pageNum} / ${pageCount}` : "Loading..."}
|
|
||||||
</span>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={nextPage}
|
|
||||||
disabled={pageNum >= pageCount}
|
|
||||||
>
|
|
||||||
<ChevronRight className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={zoomOut}
|
|
||||||
disabled={scale <= 0.5}
|
|
||||||
>
|
|
||||||
<ZoomOut className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
{Math.round(scale * 100)}%
|
|
||||||
</span>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={zoomIn}
|
|
||||||
disabled={scale >= 3}
|
|
||||||
>
|
|
||||||
<ZoomIn className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<Button variant="outline" size="sm" onClick={rotate}>
|
|
||||||
<RotateCw className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* PDF Canvas */}
|
|
||||||
<div
|
|
||||||
className="relative overflow-hidden rounded-lg border bg-background"
|
|
||||||
tabIndex={0}
|
|
||||||
>
|
|
||||||
{isLoading && (
|
|
||||||
<>
|
|
||||||
<div className="absolute inset-0 z-10 flex items-center justify-center bg-background/80 backdrop-blur-sm">
|
|
||||||
<div className="flex flex-col items-center gap-2">
|
|
||||||
<Loader2 className="h-6 w-6 animate-spin text-primary" />
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Loading {title.toLowerCase()}...
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/* Loading Skeleton */}
|
|
||||||
<div className="flex justify-center p-4">
|
|
||||||
<div className="h-[600px] w-full max-w-2xl animate-pulse rounded-lg bg-muted lg:h-[800px]" />
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex justify-center overflow-auto p-4">
|
|
||||||
<canvas
|
|
||||||
ref={canvasRef}
|
|
||||||
className="h-auto w-full max-w-4xl shadow-lg"
|
|
||||||
style={{
|
|
||||||
display: isLoading ? "none" : "block",
|
|
||||||
maxWidth: "100%",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Mobile Notice */}
|
|
||||||
<div className="block rounded-lg border border-amber-200 bg-amber-50 p-4 md:hidden dark:border-amber-800 dark:bg-amber-950/50">
|
|
||||||
<div className="flex items-start gap-2">
|
|
||||||
<AlertCircle className="h-5 w-5 text-amber-600 dark:text-amber-400" />
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-amber-800 dark:text-amber-200">
|
|
||||||
Better on larger screens
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-amber-700 dark:text-amber-300">
|
|
||||||
For the best viewing experience, try opening this on a tablet or
|
|
||||||
desktop. You can also download the PDF directly.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function CVPage() {
|
export default function CVPage() {
|
||||||
const [activeTab, setActiveTab] = useState("cv");
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div>
|
||||||
<section className="animate-fade-in-up prose prose-zinc max-w-none dark:prose-invert">
|
<PageHeading
|
||||||
<div className="flex items-start gap-3">
|
eyebrow="The short version / the full record"
|
||||||
<FileText className="h-8 w-8 text-primary" />
|
title="Résumé & CV"
|
||||||
<div>
|
description="The résumé focuses on building products and running systems. The CV includes research, teaching, and publications."
|
||||||
<h1 className="mb-2 text-2xl font-bold">Curriculum Vitae</h1>
|
/>
|
||||||
</div>
|
<DocumentReader />
|
||||||
</div>
|
|
||||||
<p className="mt-2 text-lg text-muted-foreground">
|
|
||||||
My academic and professional experience in computer science, robotics,
|
|
||||||
and engineering.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<div className="animate-fade-in-up-delay-2">
|
|
||||||
<Tabs
|
|
||||||
value={activeTab}
|
|
||||||
onValueChange={setActiveTab}
|
|
||||||
orientation="horizontal"
|
|
||||||
className="flex flex-col space-y-6"
|
|
||||||
>
|
|
||||||
<TabsList className="flex w-fit self-start">
|
|
||||||
<TabsTrigger value="cv" className="gap-2">
|
|
||||||
<FileText className="h-4 w-4" />
|
|
||||||
Academic CV
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="resume" className="gap-2">
|
|
||||||
<FileText className="h-4 w-4" />
|
|
||||||
Resume
|
|
||||||
</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
|
|
||||||
<TabsContent value="cv" className="space-y-0">
|
|
||||||
<PDFViewer url={CV_URL} title="Academic CV" type="cv" />
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="resume" className="space-y-0">
|
|
||||||
<PDFViewer url={RESUME_URL} title="Resume" type="resume" />
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,5 @@
|
|||||||
import {
|
import Link from "next/link";
|
||||||
FlaskConical,
|
import { PageHeading } from "~/components/PageHeading";
|
||||||
GraduationCap,
|
|
||||||
Building,
|
|
||||||
Users,
|
|
||||||
Calendar,
|
|
||||||
MapPin,
|
|
||||||
Award,
|
|
||||||
ExternalLink,
|
|
||||||
} from "lucide-react";
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "~/components/ui/card";
|
|
||||||
import { Badge } from "~/components/ui/badge";
|
|
||||||
import {
|
import {
|
||||||
experiences,
|
experiences,
|
||||||
awards,
|
awards,
|
||||||
@@ -24,257 +8,129 @@ import {
|
|||||||
relevantCoursework,
|
relevantCoursework,
|
||||||
} from "~/lib/data";
|
} from "~/lib/data";
|
||||||
|
|
||||||
|
const sections = [
|
||||||
|
{
|
||||||
|
type: "professional",
|
||||||
|
title: "Professional",
|
||||||
|
id: "professional-experience",
|
||||||
|
},
|
||||||
|
{ type: "teaching", title: "Teaching", id: "teaching-experience" },
|
||||||
|
{ type: "research", title: "Research", id: "research-experience" },
|
||||||
|
{ type: "leadership", title: "Leadership", id: "leadership-and-activities" },
|
||||||
|
];
|
||||||
|
export const metadata = { title: "Experience" };
|
||||||
export default function ExperiencePage() {
|
export default function ExperiencePage() {
|
||||||
const researchExperience = experiences.filter(
|
|
||||||
(exp) => exp.type === "research",
|
|
||||||
);
|
|
||||||
const teachingExperience = experiences.filter(
|
|
||||||
(exp) => exp.type === "teaching",
|
|
||||||
);
|
|
||||||
const professionalExperience = experiences.filter(
|
|
||||||
(exp) => exp.type === "professional",
|
|
||||||
);
|
|
||||||
const leadershipExperience = experiences.filter(
|
|
||||||
(exp) => exp.type === "leadership",
|
|
||||||
);
|
|
||||||
|
|
||||||
const getIcon = (type: string) => {
|
|
||||||
switch (type) {
|
|
||||||
case "research":
|
|
||||||
return FlaskConical;
|
|
||||||
case "teaching":
|
|
||||||
return GraduationCap;
|
|
||||||
case "professional":
|
|
||||||
return Building;
|
|
||||||
case "leadership":
|
|
||||||
return Users;
|
|
||||||
default:
|
|
||||||
return Building;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderExperienceSection = (
|
|
||||||
title: string,
|
|
||||||
experiences: typeof researchExperience,
|
|
||||||
delay = 1,
|
|
||||||
) => (
|
|
||||||
<section className="animate-fade-in-up space-y-6">
|
|
||||||
<h2 className="text-2xl font-bold">{title}</h2>
|
|
||||||
<div className="space-y-6">
|
|
||||||
{experiences.map((exp, index) => {
|
|
||||||
const IconComponent = getIcon(exp.type);
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
className={`animate-fade-in-up-delay-${Math.min(delay + index, 4)} card-hover`}
|
|
||||||
>
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<IconComponent className="mt-1 h-6 w-6 flex-shrink-0 text-primary" />
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<CardTitle className="break-words text-lg leading-tight">
|
|
||||||
{exp.title}
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription className="mt-1 break-words text-base font-medium">
|
|
||||||
{exp.organization}
|
|
||||||
</CardDescription>
|
|
||||||
<div className="mt-2 flex flex-wrap gap-4 text-sm text-muted-foreground">
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<MapPin className="h-3 w-3" />
|
|
||||||
{exp.location}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<Calendar className="h-3 w-3" />
|
|
||||||
{exp.period}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<ul className="space-y-3">
|
|
||||||
{exp.description.map((item, itemIndex) => (
|
|
||||||
<li key={itemIndex} className="flex items-start gap-3">
|
|
||||||
<span className="mt-2 h-1.5 w-1.5 flex-shrink-0 rounded-full bg-primary" />
|
|
||||||
<span className="break-words leading-relaxed text-muted-foreground">
|
|
||||||
{item}
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-12">
|
<div>
|
||||||
{/* Header */}
|
<PageHeading
|
||||||
<section className="animate-fade-in-up prose prose-zinc max-w-none dark:prose-invert">
|
eyebrow="The background"
|
||||||
<div className="flex items-start gap-3">
|
title="Experience"
|
||||||
<Building className="h-8 w-8 text-primary" />
|
description="Building products, running systems, teaching, and the research that got me here."
|
||||||
<div>
|
/>
|
||||||
<h1 className="mb-2 text-2xl font-bold">Experience</h1>
|
<div className="record-intro">
|
||||||
</div>
|
<nav className="section-jumps" aria-label="Experience sections">
|
||||||
</div>
|
{sections.map((section) => (
|
||||||
<p className="mt-2 text-lg text-muted-foreground">
|
<a key={section.id} href={`#${section.id}`}>
|
||||||
Building products, running systems, and helping people use them.
|
{section.title}
|
||||||
My path through business, engineering, teaching, and research.
|
</a>
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{renderExperienceSection("Professional Experience", professionalExperience, 1)}
|
|
||||||
{renderExperienceSection("Teaching Experience", teachingExperience, 2)}
|
|
||||||
{renderExperienceSection("Research Experience", researchExperience, 3)}
|
|
||||||
|
|
||||||
{/* Leadership & Activities */}
|
|
||||||
{renderExperienceSection(
|
|
||||||
"Leadership & Activities",
|
|
||||||
leadershipExperience,
|
|
||||||
4,
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Technical Skills */}
|
|
||||||
<section className="animate-fade-in-up space-y-6">
|
|
||||||
<h2 className="text-2xl font-bold">Technical Skills</h2>
|
|
||||||
<div className="grid-equal-height grid gap-6 md:grid-cols-2">
|
|
||||||
{Object.entries(technicalSkills).map(([category, skills], index) => (
|
|
||||||
<div
|
|
||||||
key={category}
|
|
||||||
className={`animate-fade-in-up-delay-${Math.min(index + 1, 4)} card-hover`}
|
|
||||||
>
|
|
||||||
<Card className="card-full-height">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-lg">{category}</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="card-content-stretch">
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{skills.map((skill) => (
|
|
||||||
<Badge
|
|
||||||
key={skill}
|
|
||||||
variant="secondary"
|
|
||||||
className="text-xs"
|
|
||||||
>
|
|
||||||
{skill}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
<a href="#skills">Skills</a>
|
||||||
</section>
|
<a href="#recognition">Recognition</a>
|
||||||
|
</nav>
|
||||||
{/* Conferences & Presentations */}
|
<Link className="studio-link" href="/cv">
|
||||||
<section className="animate-fade-in-up space-y-6">
|
Résumé & CV ↗
|
||||||
<h2 className="text-2xl font-bold">Conferences & Presentations</h2>
|
</Link>
|
||||||
<div className="grid-equal-height grid gap-4 md:grid-cols-1 lg:grid-cols-2">
|
</div>
|
||||||
{conferences.map((conf, index) => (
|
{sections.map((section) => (
|
||||||
<div
|
<section key={section.id} id={section.id} className="record-section">
|
||||||
key={index}
|
<h2>{section.title}</h2>
|
||||||
className={`animate-fade-in-up-delay-${Math.min(index + 1, 4)} card-hover`}
|
{experiences
|
||||||
>
|
.filter((entry) => entry.type === section.type)
|
||||||
<Card className="card-full-height">
|
.map((entry) => (
|
||||||
<CardHeader>
|
<article
|
||||||
<div className="flex items-start justify-between">
|
className="record-row"
|
||||||
<div className="min-w-0">
|
key={entry.title + entry.organization}
|
||||||
<CardTitle className="break-words text-lg leading-tight">
|
>
|
||||||
{conf.title}
|
<div className="record-meta">
|
||||||
</CardTitle>
|
<p>{entry.period}</p>
|
||||||
<CardDescription className="mt-1 break-words">
|
<p>{entry.location}</p>
|
||||||
{conf.location} • {conf.date}
|
</div>
|
||||||
</CardDescription>
|
<div className="record-body">
|
||||||
</div>
|
<p className="record-kicker">{entry.organization}</p>
|
||||||
<Award className="h-5 w-5 flex-shrink-0 text-muted-foreground" />
|
<h3>{entry.title}</h3>
|
||||||
</div>
|
<p>{entry.description[0]}</p>
|
||||||
</CardHeader>
|
{entry.description.length > 1 && (
|
||||||
<CardContent className="space-y-3">
|
<details>
|
||||||
<p className="text-muted-foreground">{conf.presentation}</p>
|
<summary>More about this role</summary>
|
||||||
{conf.youtubeUrl && (
|
<ul>
|
||||||
<a
|
{entry.description.slice(1).map((line) => (
|
||||||
href={conf.youtubeUrl}
|
<li key={line}>{line}</li>
|
||||||
target="_blank"
|
))}
|
||||||
rel="noopener noreferrer"
|
</ul>
|
||||||
className="inline-flex items-center gap-1.5 text-sm text-primary hover:underline"
|
</details>
|
||||||
>
|
|
||||||
<ExternalLink className="h-3.5 w-3.5" />
|
|
||||||
Watch talk
|
|
||||||
</a>
|
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
</article>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
<section id="skills" className="record-section">
|
||||||
|
<h2>Tools & skills</h2>
|
||||||
|
<div className="skills-index">
|
||||||
|
{Object.entries(technicalSkills).map(([category, skills]) => (
|
||||||
|
<div key={category}>
|
||||||
|
<h3>{category}</h3>
|
||||||
|
<p>{skills.join(" · ")}</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
<section id="recognition" className="record-section">
|
||||||
{/* Awards & Recognition */}
|
<h2>Talks & recognition</h2>
|
||||||
<section className="animate-fade-in-up space-y-6">
|
{conferences.map((item) => (
|
||||||
<h2 className="text-2xl font-bold">Awards & Recognition</h2>
|
<article className="record-row" key={item.title}>
|
||||||
<div className="grid-equal-height grid gap-4 md:grid-cols-2">
|
<div className="record-meta">
|
||||||
{awards.map((award, index) => (
|
<p>{item.date}</p>
|
||||||
<div
|
<p>{item.location}</p>
|
||||||
key={index}
|
|
||||||
className={`animate-fade-in-up-delay-${Math.min(index + 1, 4)} card-hover`}
|
|
||||||
>
|
|
||||||
<Card className="card-full-height">
|
|
||||||
<CardHeader>
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<Award className="mt-1 h-5 w-5 flex-shrink-0 text-primary" />
|
|
||||||
<div className="min-w-0">
|
|
||||||
<CardTitle className="break-words text-base leading-tight">
|
|
||||||
{award.title}
|
|
||||||
</CardTitle>
|
|
||||||
{award.organization && (
|
|
||||||
<CardDescription className="mt-1 break-words">
|
|
||||||
{award.organization} • {award.year}
|
|
||||||
</CardDescription>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
{award.description && (
|
|
||||||
<CardContent className="card-content-stretch">
|
|
||||||
<p className="break-words text-sm leading-relaxed text-muted-foreground">
|
|
||||||
{award.description}
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="record-body">
|
||||||
|
<h3>{item.title}</h3>
|
||||||
|
<p>{item.presentation}</p>
|
||||||
|
{item.youtubeUrl && (
|
||||||
|
<a
|
||||||
|
className="studio-link"
|
||||||
|
href={item.youtubeUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
Watch talk ↗
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
<details className="record-disclosure">
|
||||||
|
<summary>
|
||||||
|
Awards & recognition <span>{awards.length}</span>
|
||||||
|
</summary>
|
||||||
|
{awards.map((item) => (
|
||||||
|
<article key={item.title}>
|
||||||
|
<h3>{item.title}</h3>
|
||||||
|
<p className="record-kicker">
|
||||||
|
{item.organization} · {item.year}
|
||||||
|
</p>
|
||||||
|
<p>{item.description}</p>
|
||||||
|
</article>
|
||||||
))}
|
))}
|
||||||
</div>
|
</details>
|
||||||
</section>
|
<details className="record-disclosure">
|
||||||
|
<summary>Relevant coursework</summary>
|
||||||
{/* Relevant Coursework */}
|
<ul>
|
||||||
<section className="animate-fade-in-up space-y-6">
|
{relevantCoursework.map((course) => (
|
||||||
<h2 className="text-2xl font-bold">Relevant Coursework</h2>
|
<li key={course}>{course}</li>
|
||||||
<div className="animate-fade-in-up-delay-1">
|
))}
|
||||||
<Card>
|
</ul>
|
||||||
<CardContent>
|
</details>
|
||||||
<ul className="space-y-3">
|
|
||||||
{relevantCoursework.map((course, index) => (
|
|
||||||
<li key={index} className="flex items-start gap-3">
|
|
||||||
<span className="mt-2 h-1.5 w-1.5 flex-shrink-0 rounded-full bg-primary" />
|
|
||||||
<span className="break-words leading-relaxed text-muted-foreground">
|
|
||||||
{course}
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { PageHeading } from "~/components/PageHeading";
|
||||||
|
import { IdentityChoices } from "~/components/IdentityChoices";
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Identity directions",
|
||||||
|
robots: { index: false, follow: false },
|
||||||
|
};
|
||||||
|
export default function IdentityPage() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<PageHeading
|
||||||
|
eyebrow="Identity · second round"
|
||||||
|
title="A little less formal"
|
||||||
|
description="Stronger shapes, fewer fine lines, and a better fit for the more personal site."
|
||||||
|
/>
|
||||||
|
<IdentityChoices />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,9 +4,8 @@ import { env } from "~/env";
|
|||||||
import { Footer } from "~/components/Footer";
|
import { Footer } from "~/components/Footer";
|
||||||
import { Navigation } from "~/components/Navigation";
|
import { Navigation } from "~/components/Navigation";
|
||||||
import { SiteFrame } from "~/components/SiteFrame";
|
import { SiteFrame } from "~/components/SiteFrame";
|
||||||
import { BreadcrumbProvider } from "~/context/BreadcrumbContext";
|
|
||||||
|
|
||||||
import { manrope, instrument } from "~/lib/fonts";
|
import { instrument } from "~/lib/fonts";
|
||||||
import { description, name } from "~/lib/data";
|
import { description, name } from "~/lib/data";
|
||||||
import "~/styles/globals.css";
|
import "~/styles/globals.css";
|
||||||
import { cn } from "~/lib/utils";
|
import { cn } from "~/lib/utils";
|
||||||
@@ -14,14 +13,14 @@ import { cn } from "~/lib/utils";
|
|||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: `${name[0]?.first} ${name[0]?.last}`,
|
title: `${name[0]?.first} ${name[0]?.last}`,
|
||||||
description: description,
|
description: description,
|
||||||
icons: [{ rel: "icon", url: "/monogram.svg", type: "image/svg+xml" }],
|
icons: [{ rel: "icon", url: "/favicon-dot.svg", type: "image/svg+xml" }],
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({ children }: React.PropsWithChildren) {
|
export default function RootLayout({ children }: React.PropsWithChildren) {
|
||||||
return (
|
return (
|
||||||
<html
|
<html
|
||||||
lang="en"
|
lang="en"
|
||||||
className={cn(manrope.variable, instrument.variable, "font-sans")}
|
className={cn(instrument.variable, "font-sans")}
|
||||||
suppressHydrationWarning
|
suppressHydrationWarning
|
||||||
>
|
>
|
||||||
<body
|
<body
|
||||||
@@ -43,10 +42,8 @@ export default function RootLayout({ children }: React.PropsWithChildren) {
|
|||||||
Skip to content
|
Skip to content
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<BreadcrumbProvider>
|
<Navigation />
|
||||||
<Navigation />
|
<SiteFrame>{children}</SiteFrame>
|
||||||
<SiteFrame>{children}</SiteFrame>
|
|
||||||
</BreadcrumbProvider>
|
|
||||||
<Footer />
|
<Footer />
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import { ContourLines } from "~/components/ContourLines";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
|
import { JusticeParagraph } from "~/components/JusticeParagraph";
|
||||||
import { ManyanglesPreview } from "~/components/ManyanglesPreview";
|
import { ManyanglesPreview } from "~/components/ManyanglesPreview";
|
||||||
import { BeenvoicePreview } from "~/components/BeenvoicePreview";
|
import { BeenvoicePreview } from "~/components/BeenvoicePreview";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
@@ -55,13 +57,14 @@ export default function HomePage() {
|
|||||||
return (
|
return (
|
||||||
<div className="studio-home">
|
<div className="studio-home">
|
||||||
<section className="studio-intro">
|
<section className="studio-intro">
|
||||||
|
<ContourLines />
|
||||||
<div className="intro-topline">
|
<div className="intro-topline">
|
||||||
<span>Software engineer & founder</span>
|
<span>Software engineer & founder</span>
|
||||||
<span>Boston, MA</span>
|
<span>Boston, MA</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="intro-main">
|
<div className="intro-main">
|
||||||
<div>
|
<div>
|
||||||
<h1>
|
<h1 id="homepage-name">
|
||||||
Sean O’Connor<span>.</span>
|
Sean O’Connor<span>.</span>
|
||||||
</h1>
|
</h1>
|
||||||
<p className="intro-statement">
|
<p className="intro-statement">
|
||||||
@@ -130,13 +133,13 @@ export default function HomePage() {
|
|||||||
sizes="(max-width: 700px) 100vw, 50vw"
|
sizes="(max-width: 700px) 100vw, 50vw"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{project.name === "Racetix" && (
|
{(project.name === "Racetix" || project.name === "beenvoice") && (
|
||||||
<div className="studio-racetix-logo">
|
<div className={`studio-product-logo ${project.name === "beenvoice" ? "beenvoice-logo" : ""}`}>
|
||||||
<Image
|
<Image
|
||||||
src="/portfolio/racetix-logo.svg"
|
src={project.name === "Racetix" ? "/portfolio/racetix-logo.svg" : "/portfolio/beenvoice-logo.svg"}
|
||||||
alt=""
|
alt=""
|
||||||
width={230}
|
width={project.name === "Racetix" ? 1614 : 2970}
|
||||||
height={44}
|
height={project.name === "Racetix" ? 304 : 436}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -149,7 +152,7 @@ export default function HomePage() {
|
|||||||
</h3>
|
</h3>
|
||||||
<span>{project.category}</span>
|
<span>{project.category}</span>
|
||||||
</div>
|
</div>
|
||||||
<p>{project.description}</p>
|
<JusticeParagraph text={project.description} />
|
||||||
<div className="studio-stack">{project.stack}</div>
|
<div className="studio-stack">{project.stack}</div>
|
||||||
</article>
|
</article>
|
||||||
))}
|
))}
|
||||||
@@ -159,12 +162,8 @@ export default function HomePage() {
|
|||||||
<section className="studio-hadlock">
|
<section className="studio-hadlock">
|
||||||
<div>
|
<div>
|
||||||
<p className="studio-label">My company</p>
|
<p className="studio-label">My company</p>
|
||||||
<h2>Hadlock Technologies</h2>
|
<h2 className="hadlock-brand"><Image src="/portfolio/hadlock-logo.svg" alt="Hadlock Technologies" width={340} height={79} /></h2>
|
||||||
<p>
|
<JusticeParagraph text="I started Hadlock in 2026 to bring my software and infrastructure work together. I work directly with businesses, from figuring out what they need to building it and keeping it running." />
|
||||||
I started Hadlock in 2026 to bring my software and infrastructure
|
|
||||||
work together. I work directly with businesses, from figuring out
|
|
||||||
what they need to building it and keeping it running.
|
|
||||||
</p>
|
|
||||||
<div className="studio-cms">
|
<div className="studio-cms">
|
||||||
<h3>hadlockCMS</h3>
|
<h3>hadlockCMS</h3>
|
||||||
<p>
|
<p>
|
||||||
@@ -199,24 +198,12 @@ export default function HomePage() {
|
|||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p>
|
<JusticeParagraph text="I started working on technology at Riverhead Raceway in 2020. Over time that grew from IT into building the website, live race systems, and software used around the track." />
|
||||||
I started working on technology at Riverhead Raceway in 2020. Over
|
<JusticeParagraph text="At Bucknell, I studied Computer Science and Engineering, built tools for robotics research, and helped other students learn to build things. Now I’m at BU, aiming to finish my master’s in a year while continuing my own projects." />
|
||||||
time that grew from IT into building the website, live race systems,
|
|
||||||
and software used around the track.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
At Bucknell, I studied Computer Science and Engineering, built tools
|
|
||||||
for robotics research, and helped other students learn to build
|
|
||||||
things. Now I’m at BU, aiming to finish my master’s in a year while
|
|
||||||
continuing my own projects.
|
|
||||||
</p>
|
|
||||||
<p>
|
<p>
|
||||||
Outside of software, I’m into specialty coffee, Formula One, and
|
Outside of software, I’m into specialty coffee, Formula One, and
|
||||||
travel. I cofounded Bucknell’s Coffee Society—and, naturally, made a
|
travel. I cofounded Bucknell’s Coffee Society—and, naturally, made a{" "}
|
||||||
<a
|
<a className="coffee-link" href="/coffee">
|
||||||
className="coffee-link"
|
|
||||||
href="https://lewisburgcoffee.soconnor.dev"
|
|
||||||
>
|
|
||||||
website for finding coffee in Lewisburg
|
website for finding coffee in Lewisburg
|
||||||
</a>
|
</a>
|
||||||
.
|
.
|
||||||
@@ -225,22 +212,64 @@ export default function HomePage() {
|
|||||||
<Link href="/experience">
|
<Link href="/experience">
|
||||||
Experience <ArrowUpRight size={16} />
|
Experience <ArrowUpRight size={16} />
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/travel">
|
<Link href="/cv">
|
||||||
Trips & interests <ArrowUpRight size={16} />
|
Résumé & CV <ArrowUpRight size={16} />
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="background-links" aria-label="More background">
|
||||||
|
<Link href="/blog">Writing</Link>
|
||||||
|
<Link href="/publications">Publications</Link>
|
||||||
|
<Link href="/articles">In the media</Link>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="studio-contact">
|
<section className="personal-corners" aria-labelledby="corners-title">
|
||||||
<div>
|
<div className="studio-section-heading">
|
||||||
<p className="studio-label">Get in touch</p>
|
<h2 id="corners-title">Away from the keyboard</h2>
|
||||||
<h2>Always happy to talk.</h2>
|
<Link href="/travel">
|
||||||
|
All trips <ArrowUpRight size={16} />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="corners-grid">
|
||||||
|
<a className="corner-card" href="/coffee">
|
||||||
|
<Image
|
||||||
|
src="/trips/sca2024/bean.webp"
|
||||||
|
alt="Chicago’s Cloud Gate during the Coffee Expo trip"
|
||||||
|
width={400}
|
||||||
|
height={280}
|
||||||
|
/>
|
||||||
|
<span>Coffee, usually.</span>
|
||||||
|
<p>
|
||||||
|
Cofounding a coffee society came with a map of favorite local
|
||||||
|
spots.
|
||||||
|
</p>
|
||||||
|
</a>
|
||||||
|
<Link className="corner-card" href="/travel">
|
||||||
|
<Image
|
||||||
|
src="/trips/silverstone/_1035852.webp"
|
||||||
|
alt="At the British Grand Prix"
|
||||||
|
width={400}
|
||||||
|
height={280}
|
||||||
|
/>
|
||||||
|
<span>A seat at the circuit.</span>
|
||||||
|
<p>
|
||||||
|
Formula One, trips with friends, and a camera along for the ride.
|
||||||
|
</p>
|
||||||
|
</Link>
|
||||||
|
<Link className="corner-card october-corner" href="/october">
|
||||||
|
<div aria-hidden="true" className="october-stamp">
|
||||||
|
<span>OCT</span>
|
||||||
|
<strong>2019</strong>
|
||||||
|
<small>still counting ↗</small>
|
||||||
|
</div>
|
||||||
|
<span>Is it still October?</span>
|
||||||
|
<p>A very small website with a very long month.</p>
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<a href="mailto:sean@soconnor.dev">
|
|
||||||
sean@soconnor.dev <ArrowUpRight size={23} />
|
|
||||||
</a>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,6 @@
|
|||||||
import Image from "next/image";
|
import { ProjectExplorer } from "~/components/ProjectExplorer";
|
||||||
import { ManyanglesPreview } from "~/components/ManyanglesPreview";
|
|
||||||
import { BeenvoicePreview } from "~/components/BeenvoicePreview";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { ArrowUpRight } from "lucide-react";
|
|
||||||
import type { Metadata } from "next";
|
|
||||||
import { projects } from "~/lib/data";
|
import { projects } from "~/lib/data";
|
||||||
|
import type { Metadata } from "next";
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Work — Sean O’Connor",
|
title: "Work — Sean O’Connor",
|
||||||
description:
|
description:
|
||||||
@@ -32,71 +27,18 @@ const orderedProjects = [...projects].sort((a, b) => {
|
|||||||
|
|
||||||
export default function ProjectsPage() {
|
export default function ProjectsPage() {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="portfolio-page">
|
||||||
<header className="archive-header">
|
<header className="archive-header">
|
||||||
<p className="eyebrow">The portfolio</p>
|
<p className="eyebrow">Work / Products & experiments</p>
|
||||||
<h1>
|
<h1>
|
||||||
Things I’ve built<span className="accent-text">.</span>
|
Things I’ve built<span className="accent-text">.</span>
|
||||||
</h1>
|
</h1>
|
||||||
<p>
|
<p>
|
||||||
Products, client work, and a few explorations. From the interface to
|
From the first sketch to the parts that run it. Products, client work,
|
||||||
the infrastructure, I like taking an idea all the way through.
|
and a few experiments along the way.
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
<div className="archive-grid">
|
<ProjectExplorer projects={orderedProjects} />
|
||||||
{orderedProjects.map((project, index) => (
|
|
||||||
<article className="archive-card" id={project.id} key={project.title}>
|
|
||||||
{project.image && (
|
|
||||||
<div className="archive-image">
|
|
||||||
{project.title === "beenvoice" ? (
|
|
||||||
<BeenvoicePreview priority={index < 2} />
|
|
||||||
) : project.title === "Manyangles" ? (
|
|
||||||
<ManyanglesPreview />
|
|
||||||
) : (
|
|
||||||
<Image
|
|
||||||
src={project.image}
|
|
||||||
alt={project.imageAlt ?? project.title}
|
|
||||||
fill
|
|
||||||
sizes="(max-width: 760px) 100vw, 50vw"
|
|
||||||
priority={index < 2}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="archive-body">
|
|
||||||
<h2>{project.title}</h2>
|
|
||||||
<p>{project.longDescription ?? project.description}</p>
|
|
||||||
<div className="project-tags">
|
|
||||||
{project.tags.map((tag) => (
|
|
||||||
<span key={tag}>{tag}</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="archive-links">
|
|
||||||
{project.websiteLink && (
|
|
||||||
<a className="text-link" href={project.websiteLink}>
|
|
||||||
Visit site <ArrowUpRight size={16} />
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
{project.gitLink && (
|
|
||||||
<a className="text-link" href={project.gitLink}>
|
|
||||||
View code <ArrowUpRight size={16} />
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
{project.link && (
|
|
||||||
<Link className="text-link" href={project.link}>
|
|
||||||
Read more <ArrowUpRight size={16} />
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
{project.slidesUrl && (
|
|
||||||
<a className="text-link" href={project.slidesUrl}>
|
|
||||||
View slides <ArrowUpRight size={16} />
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,238 +1,92 @@
|
|||||||
"use client";
|
import { readFile } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
import {
|
import { PageHeading } from "~/components/PageHeading";
|
||||||
ArrowUpRight,
|
|
||||||
BookOpenText,
|
|
||||||
FileText,
|
|
||||||
Presentation,
|
|
||||||
Monitor,
|
|
||||||
} from "lucide-react";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { Badge } from "~/components/ui/badge";
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "~/components/ui/card";
|
|
||||||
import { CardSkeleton } from "~/components/ui/skeletons";
|
|
||||||
import type { Publication } from "~/lib/bibtex";
|
|
||||||
import { parseBibtex } from "~/lib/bibtex";
|
import { parseBibtex } from "~/lib/bibtex";
|
||||||
// No custom tracking needed - Vercel automatically tracks page views
|
|
||||||
|
|
||||||
export default function PublicationsPage() {
|
|
||||||
const [publications, setPublications] = useState<Publication[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void fetch("/publications.bib")
|
|
||||||
.then((res) => res.text())
|
|
||||||
.then((text) => {
|
|
||||||
const pubs = parseBibtex(text);
|
|
||||||
setPublications(pubs);
|
|
||||||
setLoading(false);
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const downloadBibtex = (pub: Publication) => {
|
|
||||||
const {
|
|
||||||
title,
|
|
||||||
authors,
|
|
||||||
venue,
|
|
||||||
year,
|
|
||||||
doi,
|
|
||||||
abstract,
|
|
||||||
type,
|
|
||||||
citationType,
|
|
||||||
citationKey,
|
|
||||||
} = pub;
|
|
||||||
let bibtexEntry = `@${citationType}{${citationKey},\n`;
|
|
||||||
bibtexEntry += ` title = {${title}},\n`;
|
|
||||||
bibtexEntry += ` author = {${authors.join(" and ")}},\n`;
|
|
||||||
bibtexEntry += ` year = {${year}},\n`;
|
|
||||||
if (type === "conference" || type === "workshop") {
|
|
||||||
bibtexEntry += ` organization = {${venue}},\n`;
|
|
||||||
} else if (type === "journal") {
|
|
||||||
bibtexEntry += ` journal = {${venue}},\n`;
|
|
||||||
} else if (type === "thesis") {
|
|
||||||
bibtexEntry += ` school = {${venue}},\n`;
|
|
||||||
}
|
|
||||||
if (doi) {
|
|
||||||
bibtexEntry += ` doi = {${doi}},\n`;
|
|
||||||
}
|
|
||||||
if (abstract) {
|
|
||||||
bibtexEntry += ` abstract = {${abstract}},\n`;
|
|
||||||
}
|
|
||||||
bibtexEntry += `}\n`;
|
|
||||||
|
|
||||||
const blob = new Blob([bibtexEntry], { type: "text/plain" });
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement("a");
|
|
||||||
a.href = url;
|
|
||||||
a.download = `${citationKey}.bib`;
|
|
||||||
document.body.appendChild(a);
|
|
||||||
a.click();
|
|
||||||
document.body.removeChild(a);
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
};
|
|
||||||
|
|
||||||
// No custom click handlers needed - Vercel automatically tracks API route access
|
|
||||||
|
|
||||||
|
export const metadata = { title: "Publications" };
|
||||||
|
export default async function PublicationsPage() {
|
||||||
|
const source = await readFile(
|
||||||
|
path.join(process.cwd(), "public/publications.bib"),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
const publications = parseBibtex(source).sort((a, b) => b.year - a.year);
|
||||||
|
const entries = source.split(/(?=@\w+\s*\{)/);
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div>
|
||||||
<section className="animate-fade-in-up prose prose-zinc max-w-none dark:prose-invert">
|
<PageHeading
|
||||||
<div className="flex items-start gap-3">
|
eyebrow="From the lab"
|
||||||
<BookOpenText className="h-8 w-8 text-primary" />
|
title="Publications"
|
||||||
<div>
|
description="Papers and presentations from my work in human–robot interaction."
|
||||||
<h1 className="mb-2 text-2xl font-bold">
|
/>
|
||||||
Peer-Reviewed Publications
|
<div className="record-intro">
|
||||||
</h1>
|
<p className="studio-label">{publications.length} publications</p>
|
||||||
</div>
|
<a className="studio-link" href="/publications.bib" download>
|
||||||
</div>
|
Download bibliography ↓
|
||||||
<p className="mt-2 text-lg text-muted-foreground">
|
</a>
|
||||||
My research publications in human-robot interaction and robotics.
|
</div>
|
||||||
</p>
|
<div className="record-section">
|
||||||
</section>
|
{publications.map((pub) => {
|
||||||
|
const bib = entries.find((entry) =>
|
||||||
<div className="animate-fade-in-up-delay-2 space-y-6">
|
entry.includes(`{${pub.citationKey},`),
|
||||||
{loading ? (
|
);
|
||||||
<>
|
return (
|
||||||
<CardSkeleton />
|
<article className="record-row" key={pub.citationKey}>
|
||||||
<CardSkeleton />
|
<div className="record-meta">
|
||||||
<CardSkeleton />
|
<p>{pub.year}</p>
|
||||||
</>
|
<p className="capitalize">{pub.type}</p>
|
||||||
) : (
|
</div>
|
||||||
publications.map((pub, index) => (
|
<div className="record-body">
|
||||||
<div
|
<p className="record-kicker">{pub.venue}</p>
|
||||||
key={index}
|
<h2>{pub.title}</h2>
|
||||||
className={`animate-fade-in-up-delay-${Math.min(index + 3, 4)} card-hover`}
|
<p>{pub.authors.join(", ")}</p>
|
||||||
>
|
{pub.notes && <p className="record-kicker">{pub.notes}</p>}
|
||||||
<Card>
|
<div className="record-links">
|
||||||
<CardHeader className="pb-2">
|
{pub.paperUrl && (
|
||||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
|
<a href={pub.paperUrl} target="_blank" rel="noreferrer">
|
||||||
<CardTitle className="break-words leading-tight">
|
Paper ↗
|
||||||
{pub.title}
|
</a>
|
||||||
</CardTitle>
|
|
||||||
{pub.paperUrl && (
|
|
||||||
<Link
|
|
||||||
href={pub.paperUrl}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="flex-shrink-0 text-muted-foreground hover:text-primary"
|
|
||||||
>
|
|
||||||
<ArrowUpRight className="h-5 w-5" />
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<CardDescription className="break-words text-base leading-relaxed">
|
|
||||||
{pub.authors.join(", ")}
|
|
||||||
</CardDescription>
|
|
||||||
<CardDescription className="break-words text-sm">
|
|
||||||
{pub.venue} ({pub.year})
|
|
||||||
</CardDescription>
|
|
||||||
{pub.address && (
|
|
||||||
<CardDescription className="break-words text-sm text-muted-foreground">
|
|
||||||
{pub.address}
|
|
||||||
</CardDescription>
|
|
||||||
)}
|
)}
|
||||||
{pub.notes && (
|
{pub.posterUrl && (
|
||||||
<div className="mt-1">
|
<a href={pub.posterUrl} target="_blank" rel="noreferrer">
|
||||||
<Badge variant="outline" className="text-xs">
|
Poster ↗
|
||||||
{pub.notes}
|
</a>
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</CardHeader>
|
{pub.slidesUrl && (
|
||||||
<CardContent>
|
<a href={pub.slidesUrl} target="_blank" rel="noreferrer">
|
||||||
{pub.abstract && (
|
Slides ↗
|
||||||
<p className="break-words text-sm leading-relaxed text-muted-foreground">
|
</a>
|
||||||
{pub.abstract}
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
<div className="mt-4 flex flex-wrap gap-2">
|
{pub.doi && (
|
||||||
<Badge
|
<a
|
||||||
variant="secondary"
|
href={`https://doi.org/${pub.doi}`}
|
||||||
className="flex h-6 items-center capitalize"
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
>
|
>
|
||||||
{pub.type}
|
DOI ↗
|
||||||
</Badge>
|
</a>
|
||||||
{pub.doi && (
|
)}
|
||||||
<Link
|
{bib && (
|
||||||
href={`https://doi.org/${pub.doi}`}
|
<a
|
||||||
target="_blank"
|
href={
|
||||||
rel="noopener noreferrer"
|
"data:text/plain;charset=utf-8," +
|
||||||
>
|
encodeURIComponent(bib.trim())
|
||||||
<Badge
|
}
|
||||||
variant="secondary"
|
download={`${pub.citationKey}.bib`}
|
||||||
className="flex h-6 items-center capitalize"
|
|
||||||
>
|
|
||||||
<ArrowUpRight className="mr-1 h-3 w-3" />
|
|
||||||
DOI
|
|
||||||
</Badge>
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
{pub.paperUrl && (
|
|
||||||
<Link
|
|
||||||
href={pub.paperUrl}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<Badge
|
|
||||||
variant="secondary"
|
|
||||||
className="flex h-6 items-center capitalize"
|
|
||||||
>
|
|
||||||
<FileText className="mr-1 h-3 w-3" />
|
|
||||||
Paper
|
|
||||||
</Badge>
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
{pub.posterUrl && (
|
|
||||||
<Link
|
|
||||||
href={pub.posterUrl}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<Badge
|
|
||||||
variant="secondary"
|
|
||||||
className="flex h-6 items-center capitalize"
|
|
||||||
>
|
|
||||||
<Presentation className="mr-1 h-3 w-3" />
|
|
||||||
Poster
|
|
||||||
</Badge>
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
{pub.slidesUrl && (
|
|
||||||
<Link
|
|
||||||
href={pub.slidesUrl}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<Badge
|
|
||||||
variant="secondary"
|
|
||||||
className="flex h-6 items-center capitalize"
|
|
||||||
>
|
|
||||||
<Monitor className="mr-1 h-3 w-3" />
|
|
||||||
Slides
|
|
||||||
</Badge>
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
<Badge
|
|
||||||
onClick={() => downloadBibtex(pub)}
|
|
||||||
className="flex h-6 cursor-pointer items-center capitalize"
|
|
||||||
variant="secondary"
|
|
||||||
>
|
>
|
||||||
<BookOpenText className="mr-1 h-3 w-3" />
|
BibTeX ↓
|
||||||
BibTeX
|
</a>
|
||||||
</Badge>
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
{pub.abstract && (
|
||||||
</Card>
|
<details>
|
||||||
</div>
|
<summary>Read abstract</summary>
|
||||||
))
|
<p>{pub.abstract}</p>
|
||||||
)}
|
</details>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,102 +1,47 @@
|
|||||||
"use client";
|
import Image from "next/image";
|
||||||
|
import { PageHeading } from "~/components/PageHeading";
|
||||||
import { useEffect, useState } from "react";
|
import { JusticeParagraph } from "~/components/JusticeParagraph";
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
} from "~/components/ui/card";
|
|
||||||
import { CardSkeleton } from "~/components/ui/skeletons";
|
|
||||||
import { ImageWithSkeleton } from "~/components/ui/image-with-skeleton";
|
|
||||||
import { Badge } from "~/components/ui/badge";
|
|
||||||
import { Plane } from "lucide-react";
|
|
||||||
import { travel } from "~/lib/data";
|
import { travel } from "~/lib/data";
|
||||||
|
|
||||||
export default function TripsPage() {
|
export default function TripsPage() {
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
setLoading(false);
|
|
||||||
}, 1000);
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div>
|
||||||
<section className="animate-fade-in-up prose prose-zinc max-w-none dark:prose-invert">
|
<PageHeading
|
||||||
<div className="flex items-start gap-3">
|
eyebrow="Away from the keyboard"
|
||||||
<Plane className="h-8 w-8 text-primary" />
|
title="Trips & interests"
|
||||||
<div>
|
description="Coffee, race weekends, conferences, and places I’ve been lucky enough to visit."
|
||||||
<h1 className="mb-2 text-2xl font-bold">My Trips & Events</h1>
|
/>
|
||||||
</div>
|
<div className="travel-notes">
|
||||||
</div>
|
{travel.map((trip, index) => (
|
||||||
<p className="mt-2 text-lg text-muted-foreground">
|
<article key={trip.title} className="travel-note">
|
||||||
A collection of memorable trips and events I've attended.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<div className="animate-fade-in-up-delay-2 grid-equal-height grid grid-cols-1 gap-6 md:grid-cols-2">
|
|
||||||
{loading ? (
|
|
||||||
<>
|
|
||||||
<CardSkeleton />
|
|
||||||
<CardSkeleton />
|
|
||||||
<CardSkeleton />
|
|
||||||
<CardSkeleton />
|
|
||||||
<CardSkeleton />
|
|
||||||
<CardSkeleton />
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
travel.map((trip, index) => (
|
|
||||||
<div
|
<div
|
||||||
key={index}
|
className="travel-strip"
|
||||||
className={`animate-fade-in-up-delay-${Math.min(index + 3, 4)} card-hover`}
|
tabIndex={0}
|
||||||
|
role="region"
|
||||||
|
aria-label={`${trip.title} photo gallery; scroll horizontally for more photos`}
|
||||||
>
|
>
|
||||||
<Card className="card-full-height overflow-hidden">
|
{trip.images.map((src, i) => (
|
||||||
<CardHeader className="p-0">
|
<Image
|
||||||
<div className="flex flex-col">
|
key={src}
|
||||||
<div className="flex space-x-0 overflow-x-auto">
|
src={src}
|
||||||
{trip.images.map((image, imgIndex) => (
|
alt={trip.alts[i] ?? trip.title}
|
||||||
<div key={imgIndex} className="flex-shrink-0">
|
width={600}
|
||||||
<ImageWithSkeleton
|
height={400}
|
||||||
src={image}
|
sizes="(max-width:700px) 80vw, 500px"
|
||||||
alt={
|
priority={index === 0 && i === 0}
|
||||||
trip.alts?.[imgIndex] ?? `${trip.title} - image ${imgIndex + 1}`
|
/>
|
||||||
}
|
))}
|
||||||
width={250}
|
|
||||||
height={200}
|
|
||||||
className="max-h-[200px] min-h-[200px] object-cover"
|
|
||||||
containerClassName="max-h-[200px] min-h-[200px]"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="card-content-stretch">
|
|
||||||
<div className="flex flex-1 flex-col">
|
|
||||||
<div className="flex-1">
|
|
||||||
<CardTitle className="mb-2 mt-6">{trip.title}</CardTitle>
|
|
||||||
<CardDescription className="">
|
|
||||||
{trip.description}
|
|
||||||
</CardDescription>
|
|
||||||
</div>
|
|
||||||
{/* Show badges for tags */}
|
|
||||||
<div className="mt-4 flex flex-wrap gap-2">
|
|
||||||
{trip.tags.map((tag, tagIndex) => (
|
|
||||||
<Badge key={tagIndex} variant="secondary">
|
|
||||||
{tag}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
))
|
<div className="travel-caption">
|
||||||
)}
|
<span className="studio-label">
|
||||||
|
{String(index + 1).padStart(2, "0")} / {trip.tags.join(" · ")}
|
||||||
|
</span>
|
||||||
|
<h2>{trip.title}</h2>
|
||||||
|
<JusticeParagraph text={trip.description} />
|
||||||
|
<p className="gallery-hint">Scroll through the photos ↔</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { JusticeComparison } from "~/components/JusticeComparison";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Typography comparison — Sean O’Connor",
|
||||||
|
robots: { index: false, follow: false },
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function TypeStudyPage() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<header className="archive-header">
|
||||||
|
<p className="eyebrow">Typography study</p>
|
||||||
|
<h1>A little more considered.</h1>
|
||||||
|
<p>
|
||||||
|
Native wrapping and Justice, side by side with the same font, size,
|
||||||
|
and copy. This is a preview; the main site is unchanged.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
<JusticeComparison />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import Image from "next/image";
|
||||||
|
import type { Map as LeafletMap, Marker, TileLayer } from "leaflet";
|
||||||
|
import {
|
||||||
|
ChevronLeft, Coffee, Heart, Layers, LocateFixed, MapPin,
|
||||||
|
Minus, PanelLeft, Plus, Search, X,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { COFFEE_SHOPS, type CoffeeShop } from "~/lib/coffee-shops";
|
||||||
|
import "leaflet/dist/leaflet.css";
|
||||||
|
|
||||||
|
type MapStyle = "street" | "satellite";
|
||||||
|
|
||||||
|
const images = [
|
||||||
|
"amami-kitchen", "culture-coffee", "7th-st-cafe", "tastecraft-cafe",
|
||||||
|
"paris-bakery-caf", "cycleup-coffee", "cornerstone-kitchen", "gram-s-eatery",
|
||||||
|
"starbucks-coffee", "barnes-noble-caf", "starbucks-giant", "dunkin",
|
||||||
|
"alee-s-cafe", "all-star-bagels", "panera-bread", "street-of-shops",
|
||||||
|
];
|
||||||
|
|
||||||
|
function shopImage(shop: CoffeeShop) {
|
||||||
|
return `/coffee/shops/${images[shop.id - 1]}.jpg`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tiles: Record<MapStyle, { url: string; attribution: string }> = {
|
||||||
|
street: {
|
||||||
|
url: "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
|
||||||
|
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
||||||
|
},
|
||||||
|
satellite: {
|
||||||
|
url: "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}",
|
||||||
|
attribution: "Tiles © Esri",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CoffeeMirror() {
|
||||||
|
const [selected, setSelected] = useState<CoffeeShop | null>(null);
|
||||||
|
const [drawerOpen, setDrawerOpen] = useState(true);
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [favorites, setFavorites] = useState<number[]>([]);
|
||||||
|
const [style, setStyle] = useState<MapStyle>("street");
|
||||||
|
const [styleOpen, setStyleOpen] = useState(false);
|
||||||
|
const [welcomeOpen, setWelcomeOpen] = useState(false);
|
||||||
|
const [mapFailed, setMapFailed] = useState(false);
|
||||||
|
const [locationError, setLocationError] = useState("");
|
||||||
|
const mapElement = useRef<HTMLDivElement>(null);
|
||||||
|
const map = useRef<LeafletMap | null>(null);
|
||||||
|
const tileLayer = useRef<TileLayer | null>(null);
|
||||||
|
const markers = useRef(new Map<number, Marker>());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timeout = window.setTimeout(() => {
|
||||||
|
setDrawerOpen(window.innerWidth >= 640);
|
||||||
|
try {
|
||||||
|
const saved = JSON.parse(localStorage.getItem("lewisburg-coffee-favorites") ?? "[]") as unknown;
|
||||||
|
if (Array.isArray(saved)) setFavorites(saved.filter((id): id is number => typeof id === "number"));
|
||||||
|
setWelcomeOpen(!localStorage.getItem("hasSeenWelcome"));
|
||||||
|
} catch {
|
||||||
|
setFavorites([]);
|
||||||
|
}
|
||||||
|
}, 0);
|
||||||
|
return () => window.clearTimeout(timeout);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
let resizeObserver: ResizeObserver | undefined;
|
||||||
|
const savedMarkers = markers.current;
|
||||||
|
import("leaflet").then((L) => {
|
||||||
|
if (cancelled || !mapElement.current) return;
|
||||||
|
const instance = L.map(mapElement.current, {
|
||||||
|
center: [40.9645, -76.8845], zoom: 15, zoomControl: false,
|
||||||
|
});
|
||||||
|
map.current = instance;
|
||||||
|
tileLayer.current = L.tileLayer(tiles.street.url, {
|
||||||
|
attribution: tiles.street.attribution, maxZoom: 19,
|
||||||
|
}).addTo(instance);
|
||||||
|
COFFEE_SHOPS.forEach((shop) => {
|
||||||
|
const marker = L.marker([shop.lat, shop.lng], {
|
||||||
|
title: shop.name,
|
||||||
|
icon: L.divIcon({ className: "coffee-mirror-pin", html: "<span></span>", iconSize: [18, 18], iconAnchor: [9, 9] }),
|
||||||
|
}).addTo(instance);
|
||||||
|
marker.bindTooltip(shop.name);
|
||||||
|
marker.on("click", () => {
|
||||||
|
setSelected(shop);
|
||||||
|
setDrawerOpen(true);
|
||||||
|
});
|
||||||
|
markers.current.set(shop.id, marker);
|
||||||
|
});
|
||||||
|
resizeObserver = new ResizeObserver(() => instance.invalidateSize());
|
||||||
|
resizeObserver.observe(mapElement.current);
|
||||||
|
requestAnimationFrame(() => instance.invalidateSize());
|
||||||
|
}).catch(() => { if (!cancelled) setMapFailed(true); });
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
resizeObserver?.disconnect();
|
||||||
|
map.current?.remove();
|
||||||
|
map.current = null;
|
||||||
|
tileLayer.current = null;
|
||||||
|
savedMarkers.clear();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!map.current || !tileLayer.current) return;
|
||||||
|
const L = tileLayer.current;
|
||||||
|
L.setUrl(tiles[style].url);
|
||||||
|
L.options.attribution = tiles[style].attribution;
|
||||||
|
}, [style]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selected || !map.current) return;
|
||||||
|
const instance = map.current;
|
||||||
|
const target = instance.project([selected.lat, selected.lng], 16);
|
||||||
|
const offset = window.innerWidth >= 640 && drawerOpen ? 200 : 0;
|
||||||
|
instance.flyTo(instance.unproject(target.subtract([offset, 0]), 16), 16, { duration: 0.8 });
|
||||||
|
markers.current.forEach((marker, id) => marker.getElement()?.classList.toggle("is-selected", id === selected.id));
|
||||||
|
}, [selected, drawerOpen]);
|
||||||
|
|
||||||
|
const filtered = useMemo(() => COFFEE_SHOPS.filter((shop) =>
|
||||||
|
`${shop.name} ${shop.address}`.toLowerCase().includes(query.toLowerCase().trim()),
|
||||||
|
), [query]);
|
||||||
|
const favoriteShops = filtered.filter((shop) => favorites.includes(shop.id));
|
||||||
|
const otherShops = filtered.filter((shop) => !favorites.includes(shop.id));
|
||||||
|
|
||||||
|
function toggleFavorite(id: number) {
|
||||||
|
setFavorites((current) => {
|
||||||
|
const next = current.includes(id) ? current.filter((item) => item !== id) : [...current, id];
|
||||||
|
localStorage.setItem("lewisburg-coffee-favorites", JSON.stringify(next));
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeWelcome() {
|
||||||
|
setWelcomeOpen(false);
|
||||||
|
localStorage.setItem("hasSeenWelcome", "true");
|
||||||
|
}
|
||||||
|
|
||||||
|
function locate() {
|
||||||
|
setLocationError("");
|
||||||
|
if (!navigator.geolocation) { setLocationError("Location is unavailable in this browser."); return; }
|
||||||
|
navigator.geolocation.getCurrentPosition(
|
||||||
|
({ coords }) => map.current?.flyTo([coords.latitude, coords.longitude], 16),
|
||||||
|
() => setLocationError("Couldn’t get your location."),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`coffee-mirror ${style === "satellite" ? "is-satellite" : ""}`}>
|
||||||
|
<div ref={mapElement} className="coffee-mirror-map" aria-label="Interactive map of Lewisburg coffee shops" />
|
||||||
|
{mapFailed && <p className="coffee-mirror-map-error">The map could not load. You can still browse the coffee shops.</p>}
|
||||||
|
|
||||||
|
<header className="coffee-mirror-header">
|
||||||
|
<button className={`coffee-mirror-icon-button ${drawerOpen ? "is-active" : ""}`} type="button" onClick={() => setDrawerOpen((open) => !open)} aria-label="Toggle coffee shops" aria-expanded={drawerOpen}><PanelLeft size={21} /></button>
|
||||||
|
<button className="coffee-mirror-brand" type="button" onClick={() => setWelcomeOpen(true)}>
|
||||||
|
<Coffee size={25} />
|
||||||
|
<span><strong>Lewisburg Coffee Map</strong><small>Find your perfect brew</small></span>
|
||||||
|
</button>
|
||||||
|
<span className="coffee-mirror-header-spacer" />
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<aside className={`coffee-mirror-drawer ${drawerOpen || selected ? "is-open" : ""}`} aria-label="Discover coffee shops">
|
||||||
|
{selected ? (
|
||||||
|
<div className="coffee-mirror-detail">
|
||||||
|
<div className="coffee-mirror-photo"><Image src={shopImage(selected)} alt={selected.name} fill sizes="(max-width: 640px) 100vw, 400px" /></div>
|
||||||
|
<div className="coffee-mirror-detail-actions">
|
||||||
|
<button type="button" onClick={() => toggleFavorite(selected.id)} aria-label={favorites.includes(selected.id) ? "Remove favorite" : "Add favorite"}><Heart size={18} fill={favorites.includes(selected.id) ? "currentColor" : "none"} /></button>
|
||||||
|
<button type="button" onClick={() => setSelected(null)} aria-label="Back to coffee shops"><ChevronLeft size={20} /></button>
|
||||||
|
<button type="button" onClick={() => { setSelected(null); setDrawerOpen(false); }} aria-label="Close panel"><X size={19} /></button>
|
||||||
|
</div>
|
||||||
|
<div className="coffee-mirror-detail-copy">
|
||||||
|
<h2>{selected.name}</h2>
|
||||||
|
<p className="coffee-mirror-address"><MapPin size={17} /> {selected.address}</p>
|
||||||
|
<p>{selected.description}</p>
|
||||||
|
<a className="coffee-mirror-directions" href={`https://www.google.com/maps/dir/?api=1&destination=${selected.lat},${selected.lng}`} target="_blank" rel="noreferrer">Get Directions ↗</a>
|
||||||
|
<div className="coffee-mirror-detail-links"><a href={selected.website} target="_blank" rel="noreferrer">Website ↗</a><a href={`tel:${selected.phone.replace(/[^+\d]/g, "")}`}>Call</a></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="coffee-mirror-discovery">
|
||||||
|
<div className="coffee-mirror-discovery-top">
|
||||||
|
<h2><Coffee size={23} /> Coffee Shops</h2>
|
||||||
|
<label className="coffee-mirror-search"><Search size={17} /><input type="search" placeholder="Search coffee shops..." value={query} onChange={(event) => setQuery(event.target.value)} aria-label="Search coffee shops" /></label>
|
||||||
|
</div>
|
||||||
|
<div className="coffee-mirror-list">
|
||||||
|
{favoriteShops.length > 0 && <><p className="coffee-mirror-list-label">♥ Favorites</p>{favoriteShops.map((shop) => <ShopRow key={shop.id} shop={shop} favorite onSelect={setSelected} onFavorite={toggleFavorite} />)}<hr /></>}
|
||||||
|
{favoriteShops.length > 0 && <p className="coffee-mirror-list-label">All Shops</p>}
|
||||||
|
{otherShops.map((shop) => <ShopRow key={shop.id} shop={shop} favorite={false} onSelect={setSelected} onFavorite={toggleFavorite} />)}
|
||||||
|
{filtered.length === 0 && <p className="coffee-mirror-empty">No shops found matching “{query}”</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div className="coffee-mirror-controls">
|
||||||
|
{locationError && <span className="coffee-mirror-location-error" role="status">{locationError}</span>}
|
||||||
|
<button type="button" onClick={locate} aria-label="Find my location"><LocateFixed size={20} /></button>
|
||||||
|
<button type="button" onClick={() => map.current?.zoomIn()} aria-label="Zoom in"><Plus size={20} /></button>
|
||||||
|
<button type="button" onClick={() => map.current?.zoomOut()} aria-label="Zoom out"><Minus size={20} /></button>
|
||||||
|
<div className="coffee-mirror-style-wrap">
|
||||||
|
<button type="button" onClick={() => setStyleOpen((open) => !open)} aria-label="Change map style" aria-expanded={styleOpen}><Layers size={20} /></button>
|
||||||
|
{styleOpen && <div className="coffee-mirror-style-menu">{(["street", "satellite"] as const).map((option) => <button key={option} type="button" aria-pressed={style === option} onClick={() => { setStyle(option); setStyleOpen(false); }}>{option.charAt(0).toUpperCase() + option.slice(1)}</button>)}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer className="coffee-mirror-footer"><span>© {new Date().getFullYear()} Sean O’Connor</span><span>Map data © OpenStreetMap contributors</span></footer>
|
||||||
|
|
||||||
|
{welcomeOpen && <div className="coffee-mirror-modal-backdrop" onClick={closeWelcome}><div className="coffee-mirror-modal" role="dialog" aria-modal="true" aria-labelledby="coffee-welcome-title" onClick={(event) => event.stopPropagation()}><button className="coffee-mirror-modal-close" type="button" onClick={closeWelcome} aria-label="Close welcome"><X size={20} /></button><Coffee size={34} /><h2 id="coffee-welcome-title">Welcome to the Lewisburg Coffee Map</h2><p>Discover the best coffee spots in Lewisburg, PA.</p><small>Created by Sean O’Connor</small><ul><li>Explore the map and select a coffee shop.</li><li>Search the list to find your next cup.</li><li>Use the location button to see what’s nearby.</li></ul><button className="coffee-mirror-start" type="button" onClick={closeWelcome}>Start Exploring</button></div></div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ShopRow({ shop, favorite, onSelect, onFavorite }: { shop: CoffeeShop; favorite: boolean; onSelect: (shop: CoffeeShop) => void; onFavorite: (id: number) => void }) {
|
||||||
|
return <div className="coffee-mirror-row"><button type="button" className="coffee-mirror-row-main" onClick={() => onSelect(shop)}><Image src={shopImage(shop)} alt="" width={63} height={63} /><span><strong>{shop.name}</strong><small>{shop.address}</small></span></button><button type="button" className="coffee-mirror-favorite" onClick={() => onFavorite(shop.id)} aria-label={favorite ? `Remove ${shop.name} from favorites` : `Add ${shop.name} to favorites`}><Heart size={17} fill={favorite ? "currentColor" : "none"} /></button></div>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
export function ContourLines() {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
className="contour-lines"
|
||||||
|
viewBox="0 0 1000 500"
|
||||||
|
preserveAspectRatio="none"
|
||||||
|
fill="none"
|
||||||
|
aria-hidden="true"
|
||||||
|
focusable="false"
|
||||||
|
>
|
||||||
|
{[0, 1, 2, 3, 4, 5].map((line) => (
|
||||||
|
<path
|
||||||
|
key={line}
|
||||||
|
d={`M ${220 + line * 65} 540 C ${410 + line * 40} ${250 - line * 20}, ${710 + line * 30} ${120 + line * 16}, 1100 ${95 + line * 34}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
"use client";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import type {
|
||||||
|
PDFDocumentProxy,
|
||||||
|
PDFDocumentLoadingTask,
|
||||||
|
RenderTask,
|
||||||
|
} from "pdfjs-dist";
|
||||||
|
|
||||||
|
const documents = [
|
||||||
|
{ id: "resume", title: "Résumé", url: "/publications/resume.pdf" },
|
||||||
|
{ id: "cv", title: "Academic CV", url: "/publications/cv.pdf" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function DocumentPage({
|
||||||
|
pdf,
|
||||||
|
page,
|
||||||
|
width,
|
||||||
|
zoom,
|
||||||
|
title,
|
||||||
|
}: {
|
||||||
|
pdf: PDFDocumentProxy;
|
||||||
|
page: number;
|
||||||
|
width: number;
|
||||||
|
zoom: number;
|
||||||
|
title: string;
|
||||||
|
}) {
|
||||||
|
const canvas = useRef<HTMLCanvasElement>(null);
|
||||||
|
const [error, setError] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!width) return;
|
||||||
|
let disposed = false;
|
||||||
|
let render: RenderTask | undefined;
|
||||||
|
void pdf
|
||||||
|
.getPage(page)
|
||||||
|
.then(async (sheet) => {
|
||||||
|
if (disposed || !canvas.current) return;
|
||||||
|
const target = canvas.current;
|
||||||
|
const context = target.getContext("2d");
|
||||||
|
if (!context) return;
|
||||||
|
const base = sheet.getViewport({ scale: 1 });
|
||||||
|
const viewport = sheet.getViewport({
|
||||||
|
scale: (Math.min(width, 900) / base.width) * zoom,
|
||||||
|
});
|
||||||
|
const ratio = window.devicePixelRatio || 1;
|
||||||
|
target.width = Math.floor(viewport.width * ratio);
|
||||||
|
target.height = Math.floor(viewport.height * ratio);
|
||||||
|
target.style.width = viewport.width + "px";
|
||||||
|
target.style.height = viewport.height + "px";
|
||||||
|
render = sheet.render({
|
||||||
|
canvasContext: context,
|
||||||
|
viewport,
|
||||||
|
transform: [ratio, 0, 0, ratio, 0, 0],
|
||||||
|
});
|
||||||
|
await render.promise;
|
||||||
|
})
|
||||||
|
.catch((reason: unknown) => {
|
||||||
|
if (
|
||||||
|
!disposed &&
|
||||||
|
!(
|
||||||
|
reason instanceof Error &&
|
||||||
|
reason.name === "RenderingCancelledException"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
setError(true);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
disposed = true;
|
||||||
|
render?.cancel();
|
||||||
|
};
|
||||||
|
}, [pdf, page, zoom, width]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="document-sheet" data-page={page}>
|
||||||
|
{error ? (
|
||||||
|
<p role="status">
|
||||||
|
Page {page} couldn’t render. Use Open PDF above to read it.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<canvas
|
||||||
|
ref={canvas}
|
||||||
|
role="img"
|
||||||
|
aria-label={`${title}, page ${page}. Open the PDF for selectable text and screen-reader access.`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Preview({ url, title }: { url: string; title: string }) {
|
||||||
|
const frame = useRef<HTMLDivElement>(null);
|
||||||
|
const [pdf, setPdf] = useState<PDFDocumentProxy | null>(null);
|
||||||
|
const [error, setError] = useState(false);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [zoom, setZoom] = useState(1);
|
||||||
|
const [width, setWidth] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let disposed = false;
|
||||||
|
let task: PDFDocumentLoadingTask | undefined;
|
||||||
|
void import("pdfjs-dist")
|
||||||
|
.then(async (lib) => {
|
||||||
|
if (disposed) return;
|
||||||
|
lib.GlobalWorkerOptions.workerSrc = "/pdf.worker.min.js";
|
||||||
|
task = lib.getDocument(url);
|
||||||
|
const document = await task.promise;
|
||||||
|
if (!disposed) setPdf(document);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!disposed) setError(true);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
disposed = true;
|
||||||
|
void task?.destroy();
|
||||||
|
};
|
||||||
|
}, [url]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!frame.current) return;
|
||||||
|
const observer = new ResizeObserver(([entry]) => {
|
||||||
|
if (entry) setWidth(Math.max(1, entry.contentRect.width - 32));
|
||||||
|
});
|
||||||
|
observer.observe(frame.current);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const updateCurrentPage = () => {
|
||||||
|
const container = frame.current;
|
||||||
|
if (!container) return;
|
||||||
|
const readingLine =
|
||||||
|
container.getBoundingClientRect().top +
|
||||||
|
Math.min(120, container.clientHeight / 4);
|
||||||
|
const sheets = Array.from(
|
||||||
|
container.querySelectorAll<HTMLElement>("[data-page]"),
|
||||||
|
);
|
||||||
|
const current = sheets.find(
|
||||||
|
(sheet) => sheet.getBoundingClientRect().bottom > readingLine,
|
||||||
|
);
|
||||||
|
if (current) setPage(Number(current.dataset.page));
|
||||||
|
};
|
||||||
|
|
||||||
|
const goToPage = (next: number) => {
|
||||||
|
const container = frame.current;
|
||||||
|
const sheet = container?.querySelector<HTMLElement>(
|
||||||
|
`[data-page="${next}"]`,
|
||||||
|
);
|
||||||
|
if (!container || !sheet) return;
|
||||||
|
container.scrollTo({
|
||||||
|
top:
|
||||||
|
container.scrollTop +
|
||||||
|
sheet.getBoundingClientRect().top -
|
||||||
|
container.getBoundingClientRect().top -
|
||||||
|
16,
|
||||||
|
behavior: "auto",
|
||||||
|
});
|
||||||
|
setPage(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="document-controls">
|
||||||
|
<div className="control-group">
|
||||||
|
<button
|
||||||
|
aria-label="Previous page"
|
||||||
|
disabled={!pdf || page <= 1}
|
||||||
|
onClick={() => goToPage(page - 1)}
|
||||||
|
>
|
||||||
|
←
|
||||||
|
</button>
|
||||||
|
<span aria-live="polite">
|
||||||
|
{pdf ? `Page ${page} of ${pdf.numPages}` : "Loading PDF…"}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
aria-label="Next page"
|
||||||
|
disabled={!pdf || page >= pdf.numPages}
|
||||||
|
onClick={() => goToPage(page + 1)}
|
||||||
|
>
|
||||||
|
→
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="control-group">
|
||||||
|
<button
|
||||||
|
aria-label="Zoom out"
|
||||||
|
disabled={zoom <= 0.75}
|
||||||
|
onClick={() => setZoom((n) => Math.max(0.75, n - 0.25))}
|
||||||
|
>
|
||||||
|
−
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setZoom(1)} aria-label="Fit PDF to width">
|
||||||
|
{zoom === 1 ? "Fit width" : Math.round(zoom * 100) + "%"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
aria-label="Zoom in"
|
||||||
|
disabled={zoom >= 2}
|
||||||
|
onClick={() => setZoom((n) => Math.min(2, n + 0.25))}
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="document-paper"
|
||||||
|
ref={frame}
|
||||||
|
onScroll={updateCurrentPage}
|
||||||
|
tabIndex={0}
|
||||||
|
role="region"
|
||||||
|
aria-label={title + " PDF preview"}
|
||||||
|
>
|
||||||
|
{error ? (
|
||||||
|
<p className="document-message" role="status">
|
||||||
|
The preview couldn’t load. You can still open or download the PDF
|
||||||
|
above.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{pdf &&
|
||||||
|
Array.from({ length: pdf.numPages }, (_, index) => (
|
||||||
|
<DocumentPage
|
||||||
|
key={index + 1}
|
||||||
|
pdf={pdf}
|
||||||
|
page={index + 1}
|
||||||
|
width={width}
|
||||||
|
zoom={zoom}
|
||||||
|
title={title}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{!pdf && (
|
||||||
|
<p className="document-message" role="status">
|
||||||
|
Preparing your preview…
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DocumentReader() {
|
||||||
|
const [active, setActive] = useState("resume");
|
||||||
|
const document = documents.find((item) => item.id === active)!;
|
||||||
|
return (
|
||||||
|
<section className="document-reader" aria-label="Résumé and CV documents">
|
||||||
|
<div className="document-topbar">
|
||||||
|
<div
|
||||||
|
className="document-switch"
|
||||||
|
role="group"
|
||||||
|
aria-label="Choose a document"
|
||||||
|
>
|
||||||
|
{documents.map((item) => (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
aria-pressed={active === item.id}
|
||||||
|
onClick={() => setActive(item.id)}
|
||||||
|
>
|
||||||
|
{item.title}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="record-links">
|
||||||
|
<a href={document.url} download={`Sean-OConnor-${document.id}.pdf`}>
|
||||||
|
Download PDF ↓
|
||||||
|
</a>
|
||||||
|
<a href={document.url} target="_blank" rel="noreferrer">
|
||||||
|
Open PDF ↗
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Preview key={document.id} url={document.url} title={document.title} />
|
||||||
|
<p className="reader-note">
|
||||||
|
Open the PDF for text selection, search, and printing.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,19 +1,41 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Monogram } from "~/components/Monogram";
|
import { usePathname } from "next/navigation";
|
||||||
|
import { ArrowUpRight } from "lucide-react";
|
||||||
|
import { ContourLines } from "~/components/ContourLines";
|
||||||
|
|
||||||
export function Footer() {
|
export function Footer() {
|
||||||
|
const pathname = usePathname();
|
||||||
|
if (pathname === "/coffee") return null;
|
||||||
return (
|
return (
|
||||||
<footer className="site-footer">
|
<footer className="site-footer">
|
||||||
|
<ContourLines />
|
||||||
|
{pathname === "/" && (
|
||||||
|
<section id="contact" className="studio-contact">
|
||||||
|
<div>
|
||||||
|
<p className="studio-label">Get in touch</p>
|
||||||
|
<h2>Always happy to talk.</h2>
|
||||||
|
</div>
|
||||||
|
<a href="mailto:sean@soconnor.dev">
|
||||||
|
sean@soconnor.dev <ArrowUpRight size={23} />
|
||||||
|
</a>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
<div className="footer-top">
|
<div className="footer-top">
|
||||||
<Link href="/" className="wordmark">
|
<Link href="/" className="wordmark">
|
||||||
<Monogram />
|
|
||||||
<span className="wordmark-name">Sean O’Connor</span>
|
<span className="wordmark-name">Sean O’Connor</span>
|
||||||
</Link>
|
</Link>
|
||||||
<div className="footer-links">
|
<div className="footer-links">
|
||||||
|
<Link href="/projects">Projects</Link>
|
||||||
|
<Link href="/blog">Writing</Link>
|
||||||
<Link href="/experience">Experience</Link>
|
<Link href="/experience">Experience</Link>
|
||||||
<Link href="/publications">Research</Link>
|
<Link href="/publications">Research</Link>
|
||||||
<Link href="/articles">In the press</Link>
|
<Link href="/articles">In the press</Link>
|
||||||
<Link href="/travel">Elsewhere</Link>
|
<Link href="/coffee">Coffee map</Link>
|
||||||
|
<Link href="/travel">Trips & interests</Link>
|
||||||
|
<Link href="/october">October</Link>
|
||||||
|
<Link href="/cv">Résumé & CV</Link>
|
||||||
<a href="https://github.com/soconnor0919">GitHub</a>
|
<a href="https://github.com/soconnor0919">GitHub</a>
|
||||||
<a href="https://linkedin.com/in/bu-soconnor">LinkedIn</a>
|
<a href="https://linkedin.com/in/bu-soconnor">LinkedIn</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
type Direction = "linked" | "roundel" | "name";
|
||||||
|
function Mark({ direction }: { direction: Direction }) {
|
||||||
|
if (direction === "name")
|
||||||
|
return (
|
||||||
|
<span className="name-only-mark">
|
||||||
|
Sean O’Connor<span>.</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 100 100" fill="none" aria-hidden="true">
|
||||||
|
{direction === "linked" ? (
|
||||||
|
<g
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="8"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M43 28C35 20 18 22 16 33C13 48 45 44 43 61C41 75 22 77 14 68" />
|
||||||
|
<path d="M66 24C50 24 43 35 43 49C43 65 50 76 66 76C82 76 88 65 88 49C88 35 82 24 66 24Z" />
|
||||||
|
</g>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<circle cx="50" cy="50" r="38" fill="currentColor" />
|
||||||
|
<path
|
||||||
|
d="M66 31H43C29 31 28 48 42 48H57C72 48 72 68 57 68H32"
|
||||||
|
stroke="var(--mark-paper, #faf8f2)"
|
||||||
|
strokeWidth="8"
|
||||||
|
strokeLinecap="square"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M69 20L77 28"
|
||||||
|
stroke="var(--mark-paper, #faf8f2)"
|
||||||
|
strokeWidth="5"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const choices = [
|
||||||
|
{
|
||||||
|
id: "linked" as const,
|
||||||
|
letter: "A",
|
||||||
|
title: "Linked initials",
|
||||||
|
description:
|
||||||
|
"A bolder S and O sharing a curve. Compact, readable, and less formal.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "roundel" as const,
|
||||||
|
letter: "B",
|
||||||
|
title: "The roundel",
|
||||||
|
description:
|
||||||
|
"An S cut into an O-shaped disc. A distinct little symbol, even at favicon size.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "name" as const,
|
||||||
|
letter: "C",
|
||||||
|
title: "Name first",
|
||||||
|
description:
|
||||||
|
"A clean typographic signature. Your name becomes the identity.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
export function IdentityChoices() {
|
||||||
|
const [selected, setSelected] = useState<Direction>("linked");
|
||||||
|
return (
|
||||||
|
<div className="mark-study">
|
||||||
|
<div className="mark-live-header" aria-label="Example site header">
|
||||||
|
<div className="mark-live-brand">
|
||||||
|
<Mark direction={selected} />
|
||||||
|
{selected !== "name" && <span>Sean O’Connor</span>}
|
||||||
|
</div>
|
||||||
|
<span className="mark-example-links">
|
||||||
|
Projects <span>Writing</span> <span>About</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="mark-helper" aria-live="polite">
|
||||||
|
Previewing {choices.find((c) => c.id === selected)?.title}. Choose an
|
||||||
|
option below to see it in the header.
|
||||||
|
</p>
|
||||||
|
<div className="mark-choice-grid">
|
||||||
|
{choices.map((choice) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="mark-choice"
|
||||||
|
aria-pressed={selected === choice.id}
|
||||||
|
key={choice.id}
|
||||||
|
onClick={() => setSelected(choice.id)}
|
||||||
|
>
|
||||||
|
<div className="mark-large">
|
||||||
|
<Mark direction={choice.id} />
|
||||||
|
</div>
|
||||||
|
<div className="mark-choice-body">
|
||||||
|
<h2>
|
||||||
|
<span>{choice.letter}</span> {choice.title}
|
||||||
|
</h2>
|
||||||
|
<p>{choice.description}</p>
|
||||||
|
<div className="mark-samples">
|
||||||
|
<div>
|
||||||
|
<Mark direction={choice.id} />
|
||||||
|
</div>
|
||||||
|
<div className="mark-reversed">
|
||||||
|
<Mark direction={choice.id} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="mark-helper">
|
||||||
|
My preference is A for the site, with B worth considering if you want a
|
||||||
|
stronger standalone icon. These are previews; the current site mark
|
||||||
|
hasn’t changed.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { JusticeParagraph } from "~/components/JusticeParagraph";
|
||||||
|
|
||||||
|
const samples = [
|
||||||
|
{
|
||||||
|
label: "About",
|
||||||
|
paragraphs: [
|
||||||
|
"I started working on technology at Riverhead Raceway in 2020. Over time that grew from IT into building the website, live race systems, and software used around the track.",
|
||||||
|
"At Bucknell, I studied Computer Science and Engineering, built tools for robotics research, and helped other students learn to build things. Now I’m at BU, aiming to finish my master’s in a year while continuing my own projects.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Project description · beenvoice",
|
||||||
|
paragraphs: [
|
||||||
|
"Manage clients, track time, send recurring invoices, and follow payments across a web app and mobile companion. The Expo app includes iOS widgets, while shared domain logic and a background job worker support the product across platforms.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function JusticeComparison() {
|
||||||
|
const [width, setWidth] = useState(440);
|
||||||
|
return (
|
||||||
|
<div className="type-study">
|
||||||
|
<div className="type-study-controls">
|
||||||
|
<label htmlFor="column-width">
|
||||||
|
Column width <output>{width}px</output>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="column-width"
|
||||||
|
type="range"
|
||||||
|
min="280"
|
||||||
|
max="500"
|
||||||
|
step="10"
|
||||||
|
value={width}
|
||||||
|
onChange={(event) => setWidth(Number(event.target.value))}
|
||||||
|
/>
|
||||||
|
<p>
|
||||||
|
Both columns use Manrope at 15px. Drag to compare line breaks; columns
|
||||||
|
shrink to fit smaller screens.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{samples.map((sample) => (
|
||||||
|
<section className="type-study-sample" key={sample.label}>
|
||||||
|
<h2>{sample.label}</h2>
|
||||||
|
<div className="type-study-grid">
|
||||||
|
<div>
|
||||||
|
<h3>
|
||||||
|
Native wrapping <span>Current site</span>
|
||||||
|
</h3>
|
||||||
|
<div className="type-study-column" style={{ maxWidth: width }}>
|
||||||
|
{sample.paragraphs.map((text) => (
|
||||||
|
<p className="type-study-paragraph" key={text}>
|
||||||
|
{text}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3>
|
||||||
|
Justice <span>Justified · conservative spacing</span>
|
||||||
|
</h3>
|
||||||
|
<div className="type-study-column" style={{ maxWidth: width }}>
|
||||||
|
{sample.paragraphs.map((text) => (
|
||||||
|
<JusticeParagraph
|
||||||
|
className="type-study-paragraph"
|
||||||
|
text={text}
|
||||||
|
key={text}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { lineText, prepare, solve } from "@kitlangton/justice";
|
||||||
|
|
||||||
|
type DisplayLine = {
|
||||||
|
text: string;
|
||||||
|
spacing: number;
|
||||||
|
tracking: number;
|
||||||
|
opening: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function JusticeParagraph({
|
||||||
|
text,
|
||||||
|
className = "",
|
||||||
|
}: {
|
||||||
|
text: string;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const ref = useRef<HTMLParagraphElement>(null);
|
||||||
|
const [lines, setLines] = useState<DisplayLine[] | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const paragraph = ref.current;
|
||||||
|
if (!paragraph) return;
|
||||||
|
let disposed = false;
|
||||||
|
let observer: ResizeObserver | undefined;
|
||||||
|
let frame = 0;
|
||||||
|
|
||||||
|
void document.fonts.ready.then(() => {
|
||||||
|
if (disposed) return;
|
||||||
|
let prepared: ReturnType<typeof prepare> | null = null;
|
||||||
|
let measuredFont = "";
|
||||||
|
const update = () => {
|
||||||
|
if (disposed) return;
|
||||||
|
const width = paragraph.getBoundingClientRect().width;
|
||||||
|
if (width <= 0) return;
|
||||||
|
const style = getComputedStyle(paragraph);
|
||||||
|
const fontKey = [
|
||||||
|
style.fontFamily,
|
||||||
|
style.fontSize,
|
||||||
|
style.fontWeight,
|
||||||
|
style.fontStyle,
|
||||||
|
style.fontStretch,
|
||||||
|
].join("|");
|
||||||
|
if (!prepared || measuredFont !== fontKey) {
|
||||||
|
const probe = document.createElement("span");
|
||||||
|
Object.assign(probe.style, {
|
||||||
|
position: "absolute",
|
||||||
|
visibility: "hidden",
|
||||||
|
whiteSpace: "pre",
|
||||||
|
fontFamily: style.fontFamily,
|
||||||
|
fontSize: style.fontSize,
|
||||||
|
fontWeight: style.fontWeight,
|
||||||
|
fontStyle: style.fontStyle,
|
||||||
|
fontStretch: style.fontStretch,
|
||||||
|
fontKerning: "none",
|
||||||
|
fontVariantLigatures: "none",
|
||||||
|
letterSpacing: "0px",
|
||||||
|
wordSpacing: "0px",
|
||||||
|
});
|
||||||
|
probe.setAttribute("aria-hidden", "true");
|
||||||
|
document.body.appendChild(probe);
|
||||||
|
prepared = prepare(text, (fragment) => {
|
||||||
|
probe.textContent = fragment;
|
||||||
|
return probe.getBoundingClientRect().width;
|
||||||
|
});
|
||||||
|
prepared.hyphenation = undefined;
|
||||||
|
probe.remove();
|
||||||
|
measuredFont = fontKey;
|
||||||
|
}
|
||||||
|
const measured = prepared;
|
||||||
|
const layout = solve(measured, width, {
|
||||||
|
mode: "balanced",
|
||||||
|
tracking: 0.3,
|
||||||
|
stretch: 0.6,
|
||||||
|
shrink: 0.25,
|
||||||
|
hanging: 0,
|
||||||
|
opening: 0,
|
||||||
|
});
|
||||||
|
setLines(
|
||||||
|
layout.lines.length &&
|
||||||
|
layout.lines.every((line) => line.residual >= -0.5)
|
||||||
|
? layout.lines.map((line) => ({
|
||||||
|
text: lineText(measured, line),
|
||||||
|
spacing: line.wordSpacing,
|
||||||
|
tracking: line.tracking,
|
||||||
|
opening: line.opening,
|
||||||
|
}))
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
let previousWidth = -1;
|
||||||
|
observer = new ResizeObserver(([entry]) => {
|
||||||
|
const width = entry?.contentRect.width ?? 0;
|
||||||
|
if (Math.abs(width - previousWidth) < 0.25) return;
|
||||||
|
previousWidth = width;
|
||||||
|
cancelAnimationFrame(frame);
|
||||||
|
frame = requestAnimationFrame(update);
|
||||||
|
});
|
||||||
|
observer.observe(paragraph);
|
||||||
|
update();
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
disposed = true;
|
||||||
|
observer?.disconnect();
|
||||||
|
cancelAnimationFrame(frame);
|
||||||
|
};
|
||||||
|
}, [text]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
ref={ref}
|
||||||
|
className={`justice-paragraph ${className}`}
|
||||||
|
data-typeset={lines ? "justice" : "native"}
|
||||||
|
>
|
||||||
|
{lines
|
||||||
|
? lines.map((line, i) => (
|
||||||
|
<span
|
||||||
|
key={i}
|
||||||
|
className="justice-line"
|
||||||
|
style={{
|
||||||
|
wordSpacing: line.spacing,
|
||||||
|
letterSpacing: line.tracking,
|
||||||
|
marginLeft: -line.opening,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{line.text}
|
||||||
|
{i < lines.length - 1 ? " " : ""}
|
||||||
|
</span>
|
||||||
|
))
|
||||||
|
: text}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,83 +1,142 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ArrowUpRight, Menu, X } from "lucide-react";
|
|
||||||
import { usePathname } from "next/navigation";
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Monogram } from "~/components/Monogram";
|
import { usePathname } from "next/navigation";
|
||||||
import { useRef, useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import { ChevronDown, Menu, X, Home, Book, Newspaper, FolderGit2, BookOpenText, Briefcase, Plane, FileText } from "lucide-react";
|
||||||
|
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import {
|
||||||
|
NavigationMenu, NavigationMenuList, NavigationMenuItem, NavigationMenuLink,
|
||||||
|
} from "~/components/ui/navigation-menu";
|
||||||
|
import {
|
||||||
|
DropdownMenu, DropdownMenuTrigger, DropdownMenuContent,
|
||||||
|
DropdownMenuGroup, DropdownMenuItem,
|
||||||
|
} from "~/components/ui/dropdown-menu";
|
||||||
|
|
||||||
const items = [
|
const backgroundPages = [
|
||||||
{ href: "/projects", label: "Work" },
|
{ href: "/experience", label: "Experience" },
|
||||||
{ href: "/#about", label: "About" },
|
{ href: "/publications", label: "Publications" },
|
||||||
{ href: "/blog", label: "Writing" },
|
{ href: "/articles", label: "In the media" },
|
||||||
{ href: "/cv", label: "Résumé" },
|
|
||||||
];
|
];
|
||||||
|
const mainPages = [
|
||||||
|
{ href: "/#work", label: "Work" },
|
||||||
|
{ href: "/#about", label: "About" },
|
||||||
|
{ href: "/#contact", label: "Contact" },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Restored from the September 10 header (52a2177).
|
||||||
|
const mobilePages = [
|
||||||
|
{ href: "/", label: "About", icon: Home },
|
||||||
|
{ href: "/blog", label: "Blog", icon: Book },
|
||||||
|
{ href: "/articles", label: "Articles", icon: Newspaper },
|
||||||
|
{ href: "/projects", label: "Projects", icon: FolderGit2 },
|
||||||
|
{ href: "/publications", label: "Publications", icon: BookOpenText },
|
||||||
|
{ href: "/experience", label: "Experience", icon: Briefcase },
|
||||||
|
{ href: "/travel", label: "Travel", icon: Plane },
|
||||||
|
{ href: "/cv", label: "Résumé & CV", icon: FileText },
|
||||||
|
];
|
||||||
|
|
||||||
|
function HeaderDropdown({ mobile = false }: { mobile?: boolean }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const pathname = usePathname();
|
||||||
|
const reducedMotion = useReducedMotion();
|
||||||
|
const items = (pages: typeof mainPages) => pages.map((item) => (
|
||||||
|
<DropdownMenuItem key={item.href} asChild>
|
||||||
|
<Link href={item.href} aria-current={pathname === item.href ? "page" : undefined}>
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{mobile && typeof document !== "undefined" && createPortal(
|
||||||
|
<AnimatePresence>
|
||||||
|
{open && (
|
||||||
|
<motion.div
|
||||||
|
className="mobile-menu-backdrop"
|
||||||
|
aria-hidden="true"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
transition={{ duration: reducedMotion ? 0 : 0.24 }}
|
||||||
|
onPointerDown={() => setOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>,
|
||||||
|
document.body,
|
||||||
|
)}
|
||||||
|
<DropdownMenu open={open} onOpenChange={setOpen} modal={false}>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size={mobile ? "icon" : "sm"} className="min-h-11" aria-label={mobile ? (open ? "Close menu" : "Open menu") : undefined}>
|
||||||
|
{mobile ? (open ? <X aria-hidden="true" /> : <Menu aria-hidden="true" />) : "Background"}
|
||||||
|
{!mobile && <motion.span
|
||||||
|
className="inline-flex"
|
||||||
|
animate={{ rotate: open ? 180 : 0 }}
|
||||||
|
transition={{ duration: reducedMotion ? 0 : 0.18 }}
|
||||||
|
>
|
||||||
|
<ChevronDown aria-hidden="true" data-icon="inline-end" />
|
||||||
|
</motion.span>}
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" alignOffset={mobile ? -20 : 0} sideOffset={mobile ? 10 : 8} collisionPadding={mobile ? 0 : 16} className={mobile ? "restored-header-menu" : "w-56"}>
|
||||||
|
<motion.div
|
||||||
|
initial={reducedMotion ? false : { opacity: 0, y: -4 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: reducedMotion ? 0 : 0.16, ease: "easeOut" }}
|
||||||
|
>
|
||||||
|
<DropdownMenuGroup>
|
||||||
|
{mobile ? mobilePages.map((item) => (
|
||||||
|
<DropdownMenuItem key={item.href} asChild>
|
||||||
|
<Link href={item.href} aria-current={pathname === item.href ? "page" : undefined}>
|
||||||
|
<item.icon aria-hidden="true" />
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)) : items(backgroundPages)}
|
||||||
|
</DropdownMenuGroup>
|
||||||
|
</motion.div>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function Navigation() {
|
export function Navigation() {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const toggle = useRef<HTMLButtonElement>(null);
|
|
||||||
return (
|
return (
|
||||||
<header
|
<header className="site-header">
|
||||||
className="site-header"
|
<div className="nav-inner">
|
||||||
onKeyDown={(event) => {
|
<Link href="/" className="wordmark" aria-label="Sean O’Connor home">
|
||||||
if (event.key === "Escape" && open) {
|
|
||||||
setOpen(false);
|
|
||||||
toggle.current?.focus();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<nav className="nav-inner" aria-label="Main navigation">
|
|
||||||
<Link
|
|
||||||
href="/"
|
|
||||||
className="wordmark"
|
|
||||||
onClick={() => setOpen(false)}
|
|
||||||
aria-label="Sean O'Connor home"
|
|
||||||
>
|
|
||||||
<Monogram />
|
|
||||||
<span className="wordmark-name">Sean O’Connor</span>
|
<span className="wordmark-name">Sean O’Connor</span>
|
||||||
</Link>
|
</Link>
|
||||||
<div className="desktop-nav">
|
<NavigationMenu viewport={false} className="header-desktop-navigation" aria-label="Main navigation">
|
||||||
{items.map((item) => (
|
<NavigationMenuList className="gap-2">
|
||||||
<Link
|
{mainPages.slice(0, 2).map((item) => (
|
||||||
key={item.href}
|
<NavigationMenuItem key={item.href}>
|
||||||
href={item.href}
|
<NavigationMenuLink asChild><Link href={item.href}>{item.label}</Link></NavigationMenuLink>
|
||||||
aria-current={pathname === item.href ? "page" : undefined}
|
</NavigationMenuItem>
|
||||||
>
|
))}
|
||||||
{item.label}
|
<NavigationMenuItem><HeaderDropdown /></NavigationMenuItem>
|
||||||
</Link>
|
<NavigationMenuItem>
|
||||||
))}
|
<Button asChild variant="outline" size="sm">
|
||||||
<a className="nav-contact" href="mailto:sean@soconnor.dev">
|
<Link href="/cv" aria-current={pathname === "/cv" ? "page" : undefined}>Résumé & CV</Link>
|
||||||
Get in touch <ArrowUpRight size={16} />
|
</Button>
|
||||||
</a>
|
</NavigationMenuItem>
|
||||||
</div>
|
<NavigationMenuItem>
|
||||||
<button
|
<NavigationMenuLink asChild><Link href="/#contact">Contact</Link></NavigationMenuLink>
|
||||||
ref={toggle}
|
</NavigationMenuItem>
|
||||||
className="mobile-toggle"
|
</NavigationMenuList>
|
||||||
aria-label={open ? "Close menu" : "Open menu"}
|
</NavigationMenu>
|
||||||
aria-expanded={open}
|
<nav className="header-mobile-navigation" aria-label="Main navigation">
|
||||||
aria-controls="mobile-menu"
|
<Button asChild variant="outline" size="sm" className="min-h-11">
|
||||||
onClick={() => setOpen(!open)}
|
<Link href="/cv" aria-current={pathname === "/cv" ? "page" : undefined}>Résumé & CV</Link>
|
||||||
>
|
</Button>
|
||||||
{open ? <X /> : <Menu />}
|
<HeaderDropdown mobile />
|
||||||
</button>
|
</nav>
|
||||||
</nav>
|
</div>
|
||||||
{open && (
|
|
||||||
<div id="mobile-menu" className="mobile-menu">
|
|
||||||
{items.map((item) => (
|
|
||||||
<Link
|
|
||||||
key={item.href}
|
|
||||||
href={item.href}
|
|
||||||
onClick={() => setOpen(false)}
|
|
||||||
>
|
|
||||||
{item.label}
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
<a href="mailto:sean@soconnor.dev">
|
|
||||||
Get in touch <ArrowUpRight size={18} />
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
export function PageHeading({
|
||||||
|
eyebrow,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
}: {
|
||||||
|
eyebrow: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<header className="archive-header">
|
||||||
|
<p className="eyebrow">{eyebrow}</p>
|
||||||
|
<h1>
|
||||||
|
{title}
|
||||||
|
<span className="accent-text">.</span>
|
||||||
|
</h1>
|
||||||
|
<p>{description}</p>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import Image from "next/image";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { ArrowUpRight, Search } from "lucide-react";
|
||||||
|
import { BeenvoicePreview } from "~/components/BeenvoicePreview";
|
||||||
|
import { ManyanglesPreview } from "~/components/ManyanglesPreview";
|
||||||
|
import { JusticeParagraph } from "~/components/JusticeParagraph";
|
||||||
|
import type { Project } from "~/lib/data";
|
||||||
|
|
||||||
|
const productNames = new Set([
|
||||||
|
"Racetix", "beenvoice", "Puter", "Manyangles", "hadlockCMS",
|
||||||
|
"Real-time Racing Statistics Platform",
|
||||||
|
]);
|
||||||
|
|
||||||
|
function ProjectCard({
|
||||||
|
project,
|
||||||
|
index,
|
||||||
|
compact = false,
|
||||||
|
}: {
|
||||||
|
project: Project;
|
||||||
|
index: number;
|
||||||
|
compact?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<article
|
||||||
|
className={`archive-card ${compact ? "archive-card--compact" : "archive-card--featured"}`}
|
||||||
|
id={project.id}
|
||||||
|
>
|
||||||
|
<div className={`archive-image ${project.title === "Racetix" ? "archive-image--photo" : ""} ${!project.image ? "archive-image--fallback" : ""}`}>
|
||||||
|
{project.image ? (
|
||||||
|
project.title === "beenvoice" ? (
|
||||||
|
<BeenvoicePreview priority={index < 2} />
|
||||||
|
) : project.title === "Manyangles" ? (
|
||||||
|
<ManyanglesPreview />
|
||||||
|
) : (
|
||||||
|
<Image
|
||||||
|
src={project.image}
|
||||||
|
alt={project.imageAlt ?? project.title}
|
||||||
|
fill
|
||||||
|
sizes={compact ? "(max-width: 700px) 100vw, 240px" : "(max-width: 700px) 100vw, 50vw"}
|
||||||
|
priority={index < 2}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<span aria-hidden="true">{project.title}</span>
|
||||||
|
)}
|
||||||
|
{(project.title === "Racetix" || project.title === "beenvoice") && (
|
||||||
|
<div className={`studio-product-logo ${project.title === "beenvoice" ? "beenvoice-logo" : ""}`}>
|
||||||
|
<Image
|
||||||
|
src={project.title === "Racetix" ? "/portfolio/racetix-logo.svg" : "/portfolio/beenvoice-logo.svg"}
|
||||||
|
alt=""
|
||||||
|
width={project.title === "Racetix" ? 1614 : 2970}
|
||||||
|
height={project.title === "Racetix" ? 304 : 436}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="archive-body">
|
||||||
|
<div className="archive-card-heading">
|
||||||
|
<h2>{project.title}</h2>
|
||||||
|
<span aria-hidden="true">{String(index + 1).padStart(2, "0")}</span>
|
||||||
|
</div>
|
||||||
|
<p>{project.description}</p>
|
||||||
|
<div className="project-tags" aria-label="Technologies">
|
||||||
|
{project.tags.slice(0, compact ? 3 : 4).map((tag) => <span key={tag}>{tag}</span>)}
|
||||||
|
</div>
|
||||||
|
<div className="archive-links">
|
||||||
|
{project.websiteLink && (
|
||||||
|
<a className="text-link" href={project.websiteLink}>
|
||||||
|
Visit site <ArrowUpRight size={16} />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{project.gitLink && (
|
||||||
|
<a className="text-link" href={project.gitLink}>
|
||||||
|
View code <ArrowUpRight size={16} />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{project.link && (
|
||||||
|
<Link className="text-link" href={project.link}>
|
||||||
|
Read more <ArrowUpRight size={16} />
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
{project.slidesUrl && (
|
||||||
|
<a className="text-link" href={project.slidesUrl}>
|
||||||
|
View slides <ArrowUpRight size={16} />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{project.longDescription && (
|
||||||
|
<details className="project-detail">
|
||||||
|
<summary>Project details</summary>
|
||||||
|
<JusticeParagraph text={project.longDescription} />
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProjectExplorer({ projects }: { projects: Project[] }) {
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [filter, setFilter] = useState("All");
|
||||||
|
const filtered = projects.filter(
|
||||||
|
(project) =>
|
||||||
|
(filter === "All" ||
|
||||||
|
(filter === "Products" ? productNames.has(project.title) : !productNames.has(project.title))) &&
|
||||||
|
`${project.title} ${project.description} ${project.tags.join(" ")}`
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(query.toLowerCase()),
|
||||||
|
);
|
||||||
|
const showSpotlights = filter === "All" && query.trim() === "";
|
||||||
|
const spotlights = showSpotlights ? filtered.slice(0, 4) : [];
|
||||||
|
const remaining = showSpotlights ? filtered.slice(4) : filtered;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="portfolio-explorer">
|
||||||
|
<div className="project-tools">
|
||||||
|
<label>
|
||||||
|
<span className="sr-only">Find a project</span>
|
||||||
|
<Search aria-hidden="true" size={18} />
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
placeholder="Search by name or technology"
|
||||||
|
value={query}
|
||||||
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="project-filters" aria-label="Filter projects">
|
||||||
|
{["All", "Products", "Research & experiments"].map((label) => (
|
||||||
|
<button key={label} aria-pressed={filter === label} onClick={() => setFilter(label)}>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p aria-live="polite">{filtered.length} {filtered.length === 1 ? "project" : "projects"}</p>
|
||||||
|
</div>
|
||||||
|
{filtered.length === 0 && (
|
||||||
|
<div className="project-empty">
|
||||||
|
<p>No projects match that search.</p>
|
||||||
|
<button onClick={() => { setQuery(""); setFilter("All"); }}>Show all projects</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{spotlights.length > 0 && (
|
||||||
|
<section className="portfolio-spotlights" aria-labelledby="spotlights-title">
|
||||||
|
<div className="portfolio-section-heading">
|
||||||
|
<span>01 / Selected work</span>
|
||||||
|
<h2 id="spotlights-title">A few things in use.</h2>
|
||||||
|
</div>
|
||||||
|
<div className="portfolio-feature-grid">
|
||||||
|
{spotlights.map((project, index) => <ProjectCard key={project.title} project={project} index={index} />)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
{remaining.length > 0 && (
|
||||||
|
<section className="portfolio-more" aria-labelledby="more-work-title">
|
||||||
|
<div className="portfolio-section-heading">
|
||||||
|
<span>{showSpotlights ? "02 / The rest" : "The archive"}</span>
|
||||||
|
<h2 id="more-work-title">{showSpotlights ? "More work & experiments." : "Projects."}</h2>
|
||||||
|
</div>
|
||||||
|
<div className="portfolio-more-list">
|
||||||
|
{remaining.map((project, index) => <ProjectCard key={project.title} project={project} index={index + spotlights.length} compact />)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,138 +1,50 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { name, contact, location } from "~/lib/data";
|
import { ArrowUpRight } from "lucide-react";
|
||||||
|
import { siteGroups } from "~/lib/site-navigation";
|
||||||
import { ImageWithSkeleton } from "~/components/ui/image-with-skeleton";
|
|
||||||
|
|
||||||
export function Sidebar() {
|
export function Sidebar() {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const isHomePage = pathname === "/";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<aside className="site-rail">
|
||||||
{/* Mobile layout - horizontal intro bar only on home page */}
|
<nav aria-label="Site directory">
|
||||||
{isHomePage && (
|
{siteGroups.map((group) => (
|
||||||
<div className="w-full space-y-3 px-4 pb-2 pt-4 sm:px-6 lg:hidden">
|
<div className="rail-group" key={group.label}>
|
||||||
<div className="flex items-center gap-4">
|
<p>{group.label}</p>
|
||||||
<ImageWithSkeleton
|
{group.items.map((item) => (
|
||||||
src="/headshot.webp"
|
<Link
|
||||||
alt={`${name[0]?.first} ${name[0]?.last}`}
|
key={item.href}
|
||||||
width={240}
|
href={item.href}
|
||||||
height={240}
|
aria-current={
|
||||||
containerClassName="h-20 w-20 rounded-xl border border-border/50 shadow-sm"
|
pathname === item.href ||
|
||||||
className="object-cover"
|
(item.href !== "/" && pathname.startsWith(item.href + "/"))
|
||||||
priority
|
? "page"
|
||||||
/>
|
: undefined
|
||||||
<div className="flex flex-col space-y-1">
|
}
|
||||||
<h2 className="text-xl font-bold transition-colors hover:text-primary">
|
>
|
||||||
{name[0]?.first} {name[0]?.last}
|
{item.label}
|
||||||
</h2>
|
<span aria-hidden="true">↗</span>
|
||||||
{location.map((item) => (
|
</Link>
|
||||||
<a
|
))}
|
||||||
key={item.label}
|
|
||||||
href={item.href}
|
|
||||||
className="flex items-center gap-2 text-sm text-muted-foreground"
|
|
||||||
{...(item.href && {
|
|
||||||
target: "_blank",
|
|
||||||
rel: "noopener noreferrer",
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{item.icon && <item.icon className="h-4 w-4" />}
|
|
||||||
{item.label}
|
|
||||||
</a>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
))}
|
||||||
<div className="space-y-3">
|
</nav>
|
||||||
<h3 className="text-xs font-medium uppercase text-muted-foreground">
|
<div className="rail-elsewhere">
|
||||||
Contact
|
<a href="https://hadlock.tech">
|
||||||
</h3>
|
Hadlock Technologies <ArrowUpRight size={13} />
|
||||||
<div className="grid grid-cols-2 gap-2">
|
</a>
|
||||||
{contact.map((item) => (
|
<a href="https://github.com/soconnor0919">
|
||||||
<a
|
GitHub <ArrowUpRight size={13} />
|
||||||
key={item.href}
|
</a>
|
||||||
href={item.href}
|
<a href="https://linkedin.com/in/bu-soconnor">
|
||||||
className="flex items-center gap-2 text-sm text-muted-foreground transition-colors hover:text-primary"
|
LinkedIn <ArrowUpRight size={13} />
|
||||||
{...(item.external && {
|
</a>
|
||||||
target: "_blank",
|
<a href="mailto:sean@soconnor.dev">
|
||||||
rel: "noopener noreferrer",
|
Say hello <ArrowUpRight size={13} />
|
||||||
})}
|
</a>
|
||||||
>
|
|
||||||
<item.icon className="h-4 w-4" />
|
|
||||||
<span>{item.label}</span>
|
|
||||||
</a>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Desktop layout - pinned sidebar */}
|
|
||||||
<div className="fixed left-0 top-16 bottom-0 w-80 border-r border-border/50 bg-background/80 backdrop-blur-md hidden lg:block">
|
|
||||||
<div className="flex h-full flex-col">
|
|
||||||
{/* Profile Section */}
|
|
||||||
<div className="flex-shrink-0 border-b border-border/50 px-8 py-8">
|
|
||||||
<div className="flex flex-col items-center space-y-4">
|
|
||||||
<ImageWithSkeleton
|
|
||||||
src="/headshot.webp"
|
|
||||||
alt={`${name[0]?.first} ${name[0]?.last}`}
|
|
||||||
width={400}
|
|
||||||
height={400}
|
|
||||||
containerClassName="h-40 w-40 border border-border/50 rounded-3xl shadow-sm"
|
|
||||||
className="h-full w-full object-cover"
|
|
||||||
priority
|
|
||||||
/>
|
|
||||||
<div className="text-center">
|
|
||||||
<h2 className="text-lg font-semibold tracking-tight">
|
|
||||||
{name[0]?.first} {name[0]?.last}
|
|
||||||
</h2>
|
|
||||||
{location.map((item) => (
|
|
||||||
<a
|
|
||||||
key={item.label}
|
|
||||||
href={item.href}
|
|
||||||
className="mt-1 block text-sm text-muted-foreground transition-colors hover:text-foreground"
|
|
||||||
{...(item.href && {
|
|
||||||
target: "_blank",
|
|
||||||
rel: "noopener noreferrer",
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{item.label}
|
|
||||||
</a>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Contact Links Section */}
|
|
||||||
<div className="flex-1 px-8 py-6">
|
|
||||||
<div className="space-y-1">
|
|
||||||
{contact.map((item) => (
|
|
||||||
<a
|
|
||||||
key={item.href}
|
|
||||||
href={item.href}
|
|
||||||
className="sidebar-contact-link flex items-center gap-3 px-3 py-2 text-sm text-muted-foreground transition-all hover:bg-accent hover:text-accent-foreground"
|
|
||||||
{...(item.external && {
|
|
||||||
target: "_blank",
|
|
||||||
rel: "noopener noreferrer",
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<item.icon className="h-4 w-4 flex-shrink-0" />
|
|
||||||
<span>{item.label}</span>
|
|
||||||
</a>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
<div className="flex-shrink-0 border-t border-border/50 px-8 py-4">
|
|
||||||
<p className="text-center text-xs text-muted-foreground">
|
|
||||||
© {new Date().getFullYear()} {name[0]?.first} {name[0]?.last}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</aside>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { BreadcrumbWrapper } from "~/components/BreadcrumbWrapper";
|
|
||||||
|
|
||||||
export function SiteFrame({ children }: React.PropsWithChildren) {
|
export function SiteFrame({ children }: React.PropsWithChildren) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
return (
|
return (
|
||||||
<main
|
<>
|
||||||
id="main-content"
|
<main
|
||||||
className={pathname === "/" ? "site-main" : "site-main interior-page"}
|
id="main-content"
|
||||||
>
|
className={pathname === "/coffee" ? "coffee-site-main" : pathname === "/" ? "site-main" : "site-main interior-page"}
|
||||||
{pathname !== "/" && <BreadcrumbWrapper />}
|
>
|
||||||
{children}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { cva } from "class-variance-authority"
|
||||||
|
import { cn } from "~/lib/utils"
|
||||||
|
import { NavigationMenu as NavigationMenuPrimitive } from "radix-ui"
|
||||||
|
import { ChevronDownIcon } from "lucide-react"
|
||||||
|
|
||||||
|
function NavigationMenu({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
viewport = true,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof NavigationMenuPrimitive.Root> & {
|
||||||
|
viewport?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<NavigationMenuPrimitive.Root
|
||||||
|
data-slot="navigation-menu"
|
||||||
|
data-viewport={viewport}
|
||||||
|
className={cn(
|
||||||
|
"group/navigation-menu relative flex max-w-max flex-1 items-center justify-center",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{viewport && <NavigationMenuViewport />}
|
||||||
|
</NavigationMenuPrimitive.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NavigationMenuList({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof NavigationMenuPrimitive.List>) {
|
||||||
|
return (
|
||||||
|
<NavigationMenuPrimitive.List
|
||||||
|
data-slot="navigation-menu-list"
|
||||||
|
className={cn(
|
||||||
|
"group flex flex-1 list-none items-center justify-center gap-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NavigationMenuItem({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof NavigationMenuPrimitive.Item>) {
|
||||||
|
return (
|
||||||
|
<NavigationMenuPrimitive.Item
|
||||||
|
data-slot="navigation-menu-item"
|
||||||
|
className={cn("relative", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const navigationMenuTriggerStyle = cva(
|
||||||
|
"group/navigation-menu-trigger inline-flex h-9 w-max items-center justify-center rounded-lg px-2.5 py-1.5 text-xs/relaxed font-medium transition-all outline-none hover:bg-muted focus:bg-muted focus-visible:ring-2 focus-visible:ring-ring/30 focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-popup-open:bg-muted/50 data-popup-open:hover:bg-muted data-open:bg-muted/50 data-open:hover:bg-muted data-open:focus:bg-muted"
|
||||||
|
)
|
||||||
|
|
||||||
|
function NavigationMenuTrigger({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof NavigationMenuPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<NavigationMenuPrimitive.Trigger
|
||||||
|
data-slot="navigation-menu-trigger"
|
||||||
|
className={cn(navigationMenuTriggerStyle(), "group", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}{" "}
|
||||||
|
<ChevronDownIcon className="relative top-px ml-1 size-3 transition duration-300 group-data-popup-open/navigation-menu-trigger:rotate-180 group-data-open/navigation-menu-trigger:rotate-180" aria-hidden="true" />
|
||||||
|
</NavigationMenuPrimitive.Trigger>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NavigationMenuContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof NavigationMenuPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<NavigationMenuPrimitive.Content
|
||||||
|
data-slot="navigation-menu-content"
|
||||||
|
className={cn(
|
||||||
|
"top-0 left-0 w-full p-1.5 ease-[cubic-bezier(0.22,1,0.36,1)] group-data-[viewport=false]/navigation-menu:top-full group-data-[viewport=false]/navigation-menu:mt-1.5 group-data-[viewport=false]/navigation-menu:overflow-hidden group-data-[viewport=false]/navigation-menu:rounded-xl group-data-[viewport=false]/navigation-menu:bg-popover group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:shadow-md group-data-[viewport=false]/navigation-menu:ring-1 group-data-[viewport=false]/navigation-menu:ring-foreground/10 group-data-[viewport=false]/navigation-menu:duration-300 data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 data-[motion^=from-]:animate-in data-[motion^=from-]:fade-in data-[motion^=to-]:animate-out data-[motion^=to-]:fade-out **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none md:absolute md:w-auto group-data-[viewport=false]/navigation-menu:data-open:animate-in group-data-[viewport=false]/navigation-menu:data-open:fade-in-0 group-data-[viewport=false]/navigation-menu:data-open:zoom-in-95 group-data-[viewport=false]/navigation-menu:data-closed:animate-out group-data-[viewport=false]/navigation-menu:data-closed:fade-out-0 group-data-[viewport=false]/navigation-menu:data-closed:zoom-out-95",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NavigationMenuViewport({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof NavigationMenuPrimitive.Viewport>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"absolute top-full left-0 isolate z-50 flex justify-center"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<NavigationMenuPrimitive.Viewport
|
||||||
|
data-slot="navigation-menu-viewport"
|
||||||
|
className={cn(
|
||||||
|
"origin-top-center relative mt-1.5 h-(--radix-navigation-menu-viewport-height) w-full overflow-hidden rounded-xl bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 md:w-(--radix-navigation-menu-viewport-width) data-open:animate-in data-open:zoom-in-90 data-closed:animate-out data-closed:zoom-out-90",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NavigationMenuLink({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof NavigationMenuPrimitive.Link>) {
|
||||||
|
return (
|
||||||
|
<NavigationMenuPrimitive.Link
|
||||||
|
data-slot="navigation-menu-link"
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1.5 rounded-lg p-2 text-xs/relaxed transition-all outline-none hover:bg-muted focus:bg-muted focus-visible:ring-2 focus-visible:ring-ring/30 focus-visible:outline-1 in-data-[slot=navigation-menu-content]:rounded-md data-[active=true]:bg-muted/50 data-[active=true]:hover:bg-muted data-[active=true]:focus:bg-muted [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NavigationMenuIndicator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof NavigationMenuPrimitive.Indicator>) {
|
||||||
|
return (
|
||||||
|
<NavigationMenuPrimitive.Indicator
|
||||||
|
data-slot="navigation-menu-indicator"
|
||||||
|
className={cn(
|
||||||
|
"top-full z-1 flex h-1.5 items-end justify-center overflow-hidden data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:animate-in data-[state=visible]:fade-in",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div className="relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm bg-border shadow-md" />
|
||||||
|
</NavigationMenuPrimitive.Indicator>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
NavigationMenu,
|
||||||
|
NavigationMenuList,
|
||||||
|
NavigationMenuItem,
|
||||||
|
NavigationMenuContent,
|
||||||
|
NavigationMenuTrigger,
|
||||||
|
NavigationMenuLink,
|
||||||
|
NavigationMenuIndicator,
|
||||||
|
NavigationMenuViewport,
|
||||||
|
navigationMenuTriggerStyle,
|
||||||
|
}
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
// Shop listings carried over from the original Lewisburg Coffee map.
|
||||||
|
|
||||||
|
export interface CoffeeShop {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
lat: number;
|
||||||
|
lng: number;
|
||||||
|
address: string;
|
||||||
|
phone: string;
|
||||||
|
website: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const COFFEE_SHOPS: CoffeeShop[] = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: "Amami Kitchen",
|
||||||
|
description:
|
||||||
|
"A beloved culinary spot offering a blend of high-quality Italian espresso and American coffee bar options, alongside mouthwatering baked goods and paninis.",
|
||||||
|
lat: 40.96112008888467,
|
||||||
|
lng: -76.88692162812505,
|
||||||
|
address: "103 S 6th St, Lewisburg, PA 17837",
|
||||||
|
phone: "(570) 490-7857",
|
||||||
|
website: "https://www.amamiespresso.com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: "Culture Coffee",
|
||||||
|
description:
|
||||||
|
"A modern coffee shop focusing on unique brewing methods for specialty coffee and traditional techniques for matcha. Known for its 'Red Velvet Latte' and croissants.",
|
||||||
|
lat: 40.966214038235066,
|
||||||
|
lng: -76.88549177840001,
|
||||||
|
address: "216 St. John St, Lewisburg, PA 17837",
|
||||||
|
phone: "(570) 601-7903",
|
||||||
|
website: "https://www.instagram.com/culturecoffeee_",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
name: "7th St Cafe",
|
||||||
|
description:
|
||||||
|
"Bucknell's ultimate on-campus dessert and coffee shop. A cozy spot for students and visitors to enjoy milkshakes, ice cream, and specialty espresso drinks.",
|
||||||
|
lat: 40.95773659979501,
|
||||||
|
lng: -76.88483933057073,
|
||||||
|
address: "420 S 7th St, Lewisburg, PA 17837",
|
||||||
|
phone: "(570) 577-1240",
|
||||||
|
website:
|
||||||
|
"https://www.bucknell.edu/life-bucknell/dining-services/places-eat",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
name: "Tastecraft Cafe",
|
||||||
|
description:
|
||||||
|
"Downtown cafe known for its in-house roasted coffee and over 20 flavors of French macarons. A perfect spot to relax with a fresh brew and a sweet treat.",
|
||||||
|
lat: 40.962987142339166,
|
||||||
|
lng: -76.88753610710032,
|
||||||
|
address: "512 Market St, Lewisburg, PA 17837",
|
||||||
|
phone: "(570) 768-5340",
|
||||||
|
website: "https://www.tastecraftcafe.com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 5,
|
||||||
|
name: "Paris Bakery & Café",
|
||||||
|
description:
|
||||||
|
"Authentic French bakery offering legendary croissants, pain au chocolat, and artisanal breads. Experience a taste of France in the heart of Lewisburg.",
|
||||||
|
lat: 40.96380135223123,
|
||||||
|
lng: -76.88561321931094,
|
||||||
|
address: "335 Market St, Lewisburg, PA 17837",
|
||||||
|
phone: "(570) 884-2138",
|
||||||
|
website: "https://parisbakery.cafe",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 6,
|
||||||
|
name: "CycleUp Coffee",
|
||||||
|
description:
|
||||||
|
"A unique bike shop cafe serving as a community hub. Enjoy coffee, light bites, and bike services in a cozy, cycling-themed atmosphere.",
|
||||||
|
lat: 40.96325677827358,
|
||||||
|
lng: -76.88663292782749,
|
||||||
|
address: "429 Market St, Lewisburg, PA 17837",
|
||||||
|
phone: "(570) 413-1705",
|
||||||
|
website: "https://www.cycleupcafe.com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 7,
|
||||||
|
name: "Cornerstone Kitchen",
|
||||||
|
description:
|
||||||
|
"Located in the Miller Center, offering fresh, healthy, and delicious cafe food. A place to connect over real food and a shared table.",
|
||||||
|
lat: 40.96125715212586,
|
||||||
|
lng: -76.89719763291119,
|
||||||
|
address: "120 Hardwood Dr, Lewisburg, PA 17837",
|
||||||
|
phone: "(570) 556-4191",
|
||||||
|
website: "https://millercenterlewisburg.com/cornerstone-kitchen",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 8,
|
||||||
|
name: "Gram's Eatery",
|
||||||
|
description:
|
||||||
|
"A local favorite for homestyle breakfast and brunch. Known for generous portions, locally sourced ingredients, and a welcoming atmosphere.",
|
||||||
|
lat: 40.9648823532259,
|
||||||
|
lng: -76.88542268394536,
|
||||||
|
address: "21 N 3rd St, Lewisburg, PA 17837",
|
||||||
|
phone: "(570) 522-0230",
|
||||||
|
website: "https://www.facebook.com/gramseaterylewisburg",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 9,
|
||||||
|
name: "Starbucks Coffee",
|
||||||
|
description:
|
||||||
|
"Seattle-based coffeehouse chain known for its signature roasts, light bites and WiFi availability.",
|
||||||
|
lat: 40.98121817592506,
|
||||||
|
lng: -76.88452326979083,
|
||||||
|
address: "7431 Westbranch Hwy, Lewisburg, PA 17837",
|
||||||
|
phone: "(570) 452-2370",
|
||||||
|
website: "https://www.starbucks.com/store-locator/store/1010326/",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 10,
|
||||||
|
name: "Barnes & Noble Café",
|
||||||
|
description:
|
||||||
|
"Barnes & Noble Café at Bucknell University. Serving Starbucks coffee and a variety of pastries and sandwiches in a comfortable bookstore setting.",
|
||||||
|
lat: 40.963756201655016,
|
||||||
|
lng: -76.88622019668863,
|
||||||
|
address: "400 Market St, Lewisburg, PA 17837",
|
||||||
|
phone: "(570) 577-3960",
|
||||||
|
website: "https://bucknell.bncollege.com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 11,
|
||||||
|
name: "Starbucks (Giant)",
|
||||||
|
description:
|
||||||
|
"Conveniently located inside Giant Food Store. Perfect for grabbing your favorite coffee while grocery shopping.",
|
||||||
|
lat: 40.96169263125371,
|
||||||
|
lng: -76.89522292676229,
|
||||||
|
address: "224 Hardwood Dr, Lewisburg, PA 17837",
|
||||||
|
phone: "(570) 523-3200",
|
||||||
|
website: "https://www.starbucks.com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 12,
|
||||||
|
name: "Dunkin'",
|
||||||
|
description:
|
||||||
|
"Long-running chain serving signature breakfast items & a variety of coffee drinks.",
|
||||||
|
lat: 40.969956373480606,
|
||||||
|
lng: -76.89248274516008,
|
||||||
|
address: "600 N Derr Dr, Lewisburg, PA 17837",
|
||||||
|
phone: "(570) 524-4900",
|
||||||
|
website: "https://www.dunkindonuts.com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 13,
|
||||||
|
name: "Alee's Cafe",
|
||||||
|
description:
|
||||||
|
"A cozy local spot offering a variety of coffee drinks and light fare in the heart of downtown.",
|
||||||
|
lat: 40.964708961176,
|
||||||
|
lng: -76.8846770451187,
|
||||||
|
address: "232 Market St, Lewisburg, PA 17837",
|
||||||
|
phone: "(570) 523-1234",
|
||||||
|
website: "https://www.aleescafe.com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 14,
|
||||||
|
name: "All Star Bagels",
|
||||||
|
description:
|
||||||
|
"Freshly baked bagels and coffee. A popular morning stop for locals and students.",
|
||||||
|
lat: 40.96445033682396,
|
||||||
|
lng: -76.88506518736742,
|
||||||
|
address: "300 Market St, Lewisburg, PA 17837",
|
||||||
|
phone: "(570) 524-1000",
|
||||||
|
website: "https://www.allstarbagels.com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 15,
|
||||||
|
name: "Panera Bread",
|
||||||
|
description:
|
||||||
|
"Counter-serve bakery/cafe chain serving sandwiches, salads & more, known for its bread & free WiFi.",
|
||||||
|
lat: 40.97531008164084,
|
||||||
|
lng: -76.8897556844414,
|
||||||
|
address: "6951 Westbranch Hwy, Lewisburg, PA 17837",
|
||||||
|
phone: "(570) 524-0000",
|
||||||
|
website: "https://www.panerabread.com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 16,
|
||||||
|
name: "Street of Shops Restaurant",
|
||||||
|
description:
|
||||||
|
"Part of a shopping village, this retro eatery serves down-home American fare & breakfast.",
|
||||||
|
lat: 40.96779250919243,
|
||||||
|
lng: -76.88264400489504,
|
||||||
|
address: "100 N Water St, Lewisburg, PA 17837",
|
||||||
|
phone: "(570) 523-0420",
|
||||||
|
website: "https://streetofshops.net",
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -159,7 +159,7 @@ export const contact = [
|
|||||||
export const articles = [
|
export const articles = [
|
||||||
{
|
{
|
||||||
title: "Positively Innovative: Robotics for Good",
|
title: "Positively Innovative: Robotics for Good",
|
||||||
link: "https://magazine.bucknell.edu/issue/fall-2024/robotics-for-good/",
|
link: "https://magazine.bucknell.edu/issue/bucknell/fall-2024/robotics-for-good#p14",
|
||||||
author: "Kate Willard",
|
author: "Kate Willard",
|
||||||
description:
|
description:
|
||||||
"Sean O'Connor '26 is using his interest in robotics to fuel forward-thinking research and lead important conversations about the impact robots can have on society.",
|
"Sean O'Connor '26 is using his interest in robotics to fuel forward-thinking research and lead important conversations about the impact robots can have on society.",
|
||||||
@@ -175,7 +175,7 @@ export const articles = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Shaping the Future: Exploring the Social Impact of Robots",
|
title: "Shaping the Future: Exploring the Social Impact of Robots",
|
||||||
link: "https://magazine.bucknell.edu/college-of-engineering/2024-college-report/",
|
link: "https://publications.bucknell.edu/issue/bucknellpublications/engineering-report-2024/distinctive-by-design?cs=redirect#p10",
|
||||||
author: "Bucknell Publications",
|
author: "Bucknell Publications",
|
||||||
description:
|
description:
|
||||||
"RoboLab provides an environment for scholarly and creative conversations.",
|
"RoboLab provides an environment for scholarly and creative conversations.",
|
||||||
@@ -577,7 +577,7 @@ export const projects: Project[] = [
|
|||||||
longDescription:
|
longDescription:
|
||||||
"Aggregates coffee shop data from various sources and displays it in a user-friendly interface. Use it to find your next brew!",
|
"Aggregates coffee shop data from various sources and displays it in a user-friendly interface. Use it to find your next brew!",
|
||||||
tags: ["Coffee", "Lewisburg, PA", "Bucknell"],
|
tags: ["Coffee", "Lewisburg, PA", "Bucknell"],
|
||||||
websiteLink: "https://lewisburgcoffee.soconnor.dev",
|
websiteLink: "/coffee",
|
||||||
gitLink: "https://github.com/soconnor0919/lewisburg-coffee",
|
gitLink: "https://github.com/soconnor0919/lewisburg-coffee",
|
||||||
image: "/images/lewisburg-coffee.webp",
|
image: "/images/lewisburg-coffee.webp",
|
||||||
imageAlt: "Screenshot of the Lewisburg, PA Coffee Map website",
|
imageAlt: "Screenshot of the Lewisburg, PA Coffee Map website",
|
||||||
|
|||||||
@@ -1,10 +1,4 @@
|
|||||||
import { Instrument_Serif, Manrope } from "next/font/google";
|
import { Instrument_Serif } from "next/font/google";
|
||||||
|
|
||||||
export const manrope = Manrope({
|
|
||||||
subsets: ["latin"],
|
|
||||||
variable: "--font-sans",
|
|
||||||
display: "swap",
|
|
||||||
});
|
|
||||||
|
|
||||||
export const instrument = Instrument_Serif({
|
export const instrument = Instrument_Serif({
|
||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
export const siteGroups = [
|
||||||
|
{
|
||||||
|
label: "Make & share",
|
||||||
|
items: [
|
||||||
|
{ href: "/", label: "Home" },
|
||||||
|
{ href: "/projects", label: "Projects" },
|
||||||
|
{ href: "/blog", label: "Writing" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Background",
|
||||||
|
items: [
|
||||||
|
{ href: "/experience", label: "Experience" },
|
||||||
|
{ href: "/publications", label: "Publications" },
|
||||||
|
{ href: "/articles", label: "In the media" },
|
||||||
|
{ href: "/cv", label: "Résumé & CV" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Beyond work",
|
||||||
|
items: [
|
||||||
|
{ href: "/coffee", label: "Coffee map" },
|
||||||
|
{ href: "/travel", label: "Trips & interests" },
|
||||||
|
{ href: "/october", label: "October, forever" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||