Refine portfolio experience and integrate coffee map

This commit is contained in:
2026-09-22 16:41:12 -04:00
parent b72108e103
commit 844819731d
52 changed files with 4589 additions and 1582 deletions
+26
View File
@@ -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=="],
+6
View File
@@ -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",
Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

+1
View File
@@ -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

+42
View File
@@ -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

+47 -84
View File
@@ -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>
); );
+14 -16
View File
@@ -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>
); );
} }
+21 -50
View File
@@ -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>
+83
View File
@@ -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; } }
+12
View File
@@ -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 />;
}
+10 -518
View File
@@ -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>
); );
} }
+115 -259
View File
@@ -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>
); );
+19
View File
@@ -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>
);
}
+5 -8
View File
@@ -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>
+67 -38
View File
@@ -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 OConnor<span>.</span> Sean OConnor<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 Im at BU, aiming to finish my masters 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 Im at BU, aiming to finish my masters in a year while
continuing my own projects.
</p>
<p> <p>
Outside of software, Im into specialty coffee, Formula One, and Outside of software, Im into specialty coffee, Formula One, and
travel. I cofounded Bucknells Coffee Societyand, naturally, made a travel. I cofounded Bucknells Coffee Societyand, 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="Chicagos 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>
); );
} }
+7 -65
View File
@@ -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 OConnor", title: "Work — Sean OConnor",
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 Ive built<span className="accent-text">.</span> Things Ive 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>
); );
} }
+81 -227
View File
@@ -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 humanrobot 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>
); );
+37 -92
View File
@@ -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 Ive 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&apos;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>
); );
+23
View File
@@ -0,0 +1,23 @@
import type { Metadata } from "next";
import { JusticeComparison } from "~/components/JusticeComparison";
export const metadata: Metadata = {
title: "Typography comparison — Sean OConnor",
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>
);
}
+216
View File
@@ -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: '&copy; <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 &copy; 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("Couldnt 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 OConnor</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 OConnor</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 whats 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>;
}
+19
View File
@@ -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>
);
}
+274
View File
@@ -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} couldnt 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 couldnt 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>
);
}
+25 -3
View File
@@ -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 OConnor</span> <span className="wordmark-name">Sean OConnor</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>
+120
View File
@@ -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 OConnor<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 OConnor</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
hasnt changed.
</p>
</div>
);
}
+79
View File
@@ -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 Im at BU, aiming to finish my masters 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>
);
}
+135
View File
@@ -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>
);
}
+128 -69
View File
@@ -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 OConnor 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 OConnor</span> <span className="wordmark-name">Sean OConnor</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>
); );
} }
+20
View File
@@ -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>
);
}
+169
View File
@@ -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>
);
}
+39 -127
View File
@@ -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}&nbsp;${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}&nbsp;{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">
&copy; {new Date().getFullYear()} {name[0]?.first} {name[0]?.last}
</p>
</div>
</div>
</div> </div>
</> </aside>
); );
} }
+8 -8
View File
@@ -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>
</>
); );
} }
+165
View File
@@ -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,
}
+192
View File
@@ -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",
},
];
+3 -3
View File
@@ -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 -7
View File
@@ -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"],
+27
View File
@@ -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" },
],
},
];
+2353 -8
View File
File diff suppressed because it is too large Load Diff