diff --git a/bun.lock b/bun.lock index 8aeaeda..6bebd0d 100644 --- a/bun.lock +++ b/bun.lock @@ -7,6 +7,7 @@ "dependencies": { "@base-ui/react": "^1.4.1", "@icons-pack/react-simple-icons": "^13.13.0", + "@kitlangton/justice": "^0.3.0", "@lucide/lab": "^0.1.2", "@mdx-js/loader": "^3.1.1", "@mdx-js/react": "^3.1.1", @@ -20,7 +21,10 @@ "@types/pdfjs-dist": "2.10.378", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "cn": "^0.4.0", + "leaflet": "^1.9.4", "lucide-react": "^1.11.0", + "motion": "^13.4.1", "next": "^16.2.6", "pdfjs-dist": "4.10.38", "radix-ui": "^1.4.3", @@ -36,7 +40,9 @@ }, "devDependencies": { "@tailwindcss/typography": "^0.5.19", + "@types/leaflet": "^1.9.22", "@types/node": "^22.19.7", + "@types/react-dom": "^19.3.0", "eslint": "9.39.2", "eslint-config-next": "16.1.3", "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=="], + "@kitlangton/justice": ["@kitlangton/justice@0.3.0", "", {}, "sha512-C6jHgoiqhlkWyYa6S30MydKxFoHvxnivTNgL59DEFgpVuMzv5gsxJWKiWep3kryfKpyI/dNVNtMyqZn8y9/Y1A=="], + "@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=="], @@ -457,12 +465,16 @@ "@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/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], "@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/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-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/statuses": ["@types/statuses@2.0.6", "", {}, "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA=="], @@ -661,6 +675,8 @@ "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=="], "collapse-white-space": ["collapse-white-space@2.1.0", "", {}, "sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw=="], @@ -895,6 +911,8 @@ "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=="], "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=="], + "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=="], "lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="], @@ -1245,6 +1265,12 @@ "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=="], "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=="], diff --git a/package.json b/package.json index 8fbc8ae..7bc81a6 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "dependencies": { "@base-ui/react": "^1.4.1", "@icons-pack/react-simple-icons": "^13.13.0", + "@kitlangton/justice": "^0.3.0", "@lucide/lab": "^0.1.2", "@mdx-js/loader": "^3.1.1", "@mdx-js/react": "^3.1.1", @@ -30,7 +31,10 @@ "@types/pdfjs-dist": "2.10.378", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "cn": "^0.4.0", + "leaflet": "^1.9.4", "lucide-react": "^1.11.0", + "motion": "^13.4.1", "next": "^16.2.6", "pdfjs-dist": "4.10.38", "radix-ui": "^1.4.3", @@ -46,7 +50,9 @@ }, "devDependencies": { "@tailwindcss/typography": "^0.5.19", + "@types/leaflet": "^1.9.22", "@types/node": "^22.19.7", + "@types/react-dom": "^19.3.0", "eslint": "9.39.2", "eslint-config-next": "16.1.3", "prettier": "^3.8.0", diff --git a/public/coffee/shops/7th-st-cafe.jpg b/public/coffee/shops/7th-st-cafe.jpg new file mode 100644 index 0000000..12064e8 Binary files /dev/null and b/public/coffee/shops/7th-st-cafe.jpg differ diff --git a/public/coffee/shops/alee-s-cafe.jpg b/public/coffee/shops/alee-s-cafe.jpg new file mode 100644 index 0000000..86ef5ba Binary files /dev/null and b/public/coffee/shops/alee-s-cafe.jpg differ diff --git a/public/coffee/shops/all-star-bagels.jpg b/public/coffee/shops/all-star-bagels.jpg new file mode 100644 index 0000000..63973e9 Binary files /dev/null and b/public/coffee/shops/all-star-bagels.jpg differ diff --git a/public/coffee/shops/amami-kitchen.jpg b/public/coffee/shops/amami-kitchen.jpg new file mode 100644 index 0000000..a813ed0 Binary files /dev/null and b/public/coffee/shops/amami-kitchen.jpg differ diff --git a/public/coffee/shops/barnes-noble-caf.jpg b/public/coffee/shops/barnes-noble-caf.jpg new file mode 100644 index 0000000..b5c5b11 Binary files /dev/null and b/public/coffee/shops/barnes-noble-caf.jpg differ diff --git a/public/coffee/shops/cornerstone-kitchen.jpg b/public/coffee/shops/cornerstone-kitchen.jpg new file mode 100644 index 0000000..db19e5e Binary files /dev/null and b/public/coffee/shops/cornerstone-kitchen.jpg differ diff --git a/public/coffee/shops/culture-coffee.jpg b/public/coffee/shops/culture-coffee.jpg new file mode 100644 index 0000000..90793b3 Binary files /dev/null and b/public/coffee/shops/culture-coffee.jpg differ diff --git a/public/coffee/shops/cycleup-coffee.jpg b/public/coffee/shops/cycleup-coffee.jpg new file mode 100644 index 0000000..4b8690b Binary files /dev/null and b/public/coffee/shops/cycleup-coffee.jpg differ diff --git a/public/coffee/shops/dunkin.jpg b/public/coffee/shops/dunkin.jpg new file mode 100644 index 0000000..76b4916 Binary files /dev/null and b/public/coffee/shops/dunkin.jpg differ diff --git a/public/coffee/shops/gram-s-eatery.jpg b/public/coffee/shops/gram-s-eatery.jpg new file mode 100644 index 0000000..8b3affd Binary files /dev/null and b/public/coffee/shops/gram-s-eatery.jpg differ diff --git a/public/coffee/shops/panera-bread.jpg b/public/coffee/shops/panera-bread.jpg new file mode 100644 index 0000000..3efbfe2 Binary files /dev/null and b/public/coffee/shops/panera-bread.jpg differ diff --git a/public/coffee/shops/paris-bakery-caf.jpg b/public/coffee/shops/paris-bakery-caf.jpg new file mode 100644 index 0000000..295751c Binary files /dev/null and b/public/coffee/shops/paris-bakery-caf.jpg differ diff --git a/public/coffee/shops/starbucks-coffee.jpg b/public/coffee/shops/starbucks-coffee.jpg new file mode 100644 index 0000000..72f0866 Binary files /dev/null and b/public/coffee/shops/starbucks-coffee.jpg differ diff --git a/public/coffee/shops/starbucks-giant.jpg b/public/coffee/shops/starbucks-giant.jpg new file mode 100644 index 0000000..eca81e8 Binary files /dev/null and b/public/coffee/shops/starbucks-giant.jpg differ diff --git a/public/coffee/shops/street-of-shops.jpg b/public/coffee/shops/street-of-shops.jpg new file mode 100644 index 0000000..49d9a95 Binary files /dev/null and b/public/coffee/shops/street-of-shops.jpg differ diff --git a/public/coffee/shops/tastecraft-cafe.jpg b/public/coffee/shops/tastecraft-cafe.jpg new file mode 100644 index 0000000..c2086a4 Binary files /dev/null and b/public/coffee/shops/tastecraft-cafe.jpg differ diff --git a/public/favicon-dot.svg b/public/favicon-dot.svg new file mode 100644 index 0000000..3749d13 --- /dev/null +++ b/public/favicon-dot.svg @@ -0,0 +1 @@ + diff --git a/public/portfolio/beenvoice-logo.svg b/public/portfolio/beenvoice-logo.svg new file mode 100644 index 0000000..8c0afdd --- /dev/null +++ b/public/portfolio/beenvoice-logo.svg @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/app/articles/page.tsx b/src/app/articles/page.tsx index 10031ed..c8fa53b 100644 --- a/src/app/articles/page.tsx +++ b/src/app/articles/page.tsx @@ -1,94 +1,57 @@ -"use client"; - -import { ArrowUpRight, Newspaper } from "lucide-react"; - -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 { ArrowUpRight } from "lucide-react"; +import { PageHeading } from "~/components/PageHeading"; +import { ContourLines } from "~/components/ContourLines"; import { articles } from "~/lib/data"; -export default function ArticlesPage() { - const [loading, setLoading] = useState(true); +export const metadata = { title: "In the media" }; - useEffect(() => { - const fetchArticles = async () => { - await new Promise((resolve) => setTimeout(resolve, 0)); - setLoading(false); - }; - void fetchArticles(); - }, []); +export default function ArticlesPage() { + const [lead, ...more] = articles; return ( -
-
-
- -
-

In the Media

-
-
-

- I have been lucky enough to have a few wonderful articles written - about me and my work. Go check them out! -

-
+
+ -
- {loading ? ( - <> - - - - - ) : ( - articles.map((article, index) => ( -
- - -
- {article.title} - - Read Article - - -
- - Written by {article.author}, found in {article.source}. - -
- -

- {article.description} -

- - Read Article - - -
-
+ {lead && ( + + )} + +
+ {more.map((article, index) => ( + + ))}
); diff --git a/src/app/blog/[slug]/page.tsx b/src/app/blog/[slug]/page.tsx index 1b9e260..9037bcc 100644 --- a/src/app/blog/[slug]/page.tsx +++ b/src/app/blog/[slug]/page.tsx @@ -2,7 +2,7 @@ import { notFound } from "next/navigation"; import { Badge } from "~/components/ui/badge"; import fs from "fs"; import path from "path"; -import { BreadcrumbUpdater } from "~/components/BreadcrumbUpdater"; +import Link from "next/link"; interface BlogPostMetadata { title: string; @@ -58,22 +58,19 @@ export default async function BlogPost({ params }: PageProps) { } return ( -
- -
- {/* */} +
+ + ← All writing + +
+

From the notebook

+

{metadata.title}

+ {metadata.summary &&

{metadata.summary}

} -

{metadata.title}

- -
+
{metadata.tags && ( -
+
{metadata.tags.map((tag: string) => ( {tag} @@ -82,11 +79,12 @@ export default async function BlogPost({ params }: PageProps) {
)}
-
+
-
+
+ ← More writing
); } diff --git a/src/app/blog/page.tsx b/src/app/blog/page.tsx index 22e23b6..045bea4 100644 --- a/src/app/blog/page.tsx +++ b/src/app/blog/page.tsx @@ -1,13 +1,5 @@ +import { PageHeading } from "~/components/PageHeading"; 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 path from "path"; @@ -57,49 +49,28 @@ export default async function BlogPage() { const posts = await getBlogPosts(); return ( -
-
-
- -
-

Blog

-
-
-

- Deep dives into my projects, tutorials, and thoughts on technology. -

-
+
+ -
+
{posts.map((post) => ( - - - -
- {post.title} - - {post.publishedAt} - -
- - {post.summary} - -
- -
- {post.tags?.map((tag: string) => ( - - {tag} - - ))} -
-
-
- +
+
+ +
+
+

{post.title}

+

{post.summary}

+

{post.tags?.join(" · ")}

+ + Read article + +
+
))}
diff --git a/src/app/coffee/coffee.css b/src/app/coffee/coffee.css new file mode 100644 index 0000000..3969cb9 --- /dev/null +++ b/src/app/coffee/coffee.css @@ -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; } } diff --git a/src/app/coffee/page.tsx b/src/app/coffee/page.tsx new file mode 100644 index 0000000..977ab85 --- /dev/null +++ b/src/app/coffee/page.tsx @@ -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 ; +} diff --git a/src/app/cv/page.tsx b/src/app/cv/page.tsx index 8048bbe..aa395b6 100644 --- a/src/app/cv/page.tsx +++ b/src/app/cv/page.tsx @@ -1,525 +1,17 @@ -"use client"; +import { PageHeading } from "~/components/PageHeading"; +import { DocumentReader } from "~/components/DocumentReader"; -import { useState, useRef, useEffect } from "react"; -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(null); - const [pdfDoc, setPdfDoc] = useState(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(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 => { - 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 ( -
- {/* Action Bar */} -
-
- -
-

{title}

-

- {type === "cv" - ? "Academic curriculum vitae" - : "Professional resume"} -

-
-
-
- - -
-
- - {/* Loading State */} -
-
-
-
-
-
- ); - } - - if (hasError) { - return ( -
- {/* Action Bar */} -
-
- -

{title}

-
-
- - -
-
- - - -
- - Unable to load PDF -
- {errorMessage} -
- -
- - -
-
-

- PDF preview not available. Please use the download or open - buttons above. -

-
-
-
-
- ); - } - - return ( -
- {/* Action Bar */} -
-
- -

{title}

-
-
- - -
-
- - {/* PDF Controls */} - {!isLoading && pageCount > 0 && ( -
-
- - - {pageCount > 0 ? `${pageNum} / ${pageCount}` : "Loading..."} - - -
- -
- - - {Math.round(scale * 100)}% - - - -
-
- )} - - {/* PDF Canvas */} -
- {isLoading && ( - <> -
-
- -

- Loading {title.toLowerCase()}... -

-
-
- {/* Loading Skeleton */} -
-
-
- - )} - -
- -
-
- - {/* Mobile Notice */} -
-
- -
-

- Better on larger screens -

-

- For the best viewing experience, try opening this on a tablet or - desktop. You can also download the PDF directly. -

-
-
-
-
- ); -} +export const metadata = { title: "Résumé & CV" }; export default function CVPage() { - const [activeTab, setActiveTab] = useState("cv"); - return ( -
-
-
- -
-

Curriculum Vitae

-
-
-

- My academic and professional experience in computer science, robotics, - and engineering. -

-
- -
- - - - - Academic CV - - - - Resume - - - - - - - - - - - -
+
+ +
); } diff --git a/src/app/experience/page.tsx b/src/app/experience/page.tsx index 429135c..bc39101 100644 --- a/src/app/experience/page.tsx +++ b/src/app/experience/page.tsx @@ -1,21 +1,5 @@ -import { - FlaskConical, - 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 Link from "next/link"; +import { PageHeading } from "~/components/PageHeading"; import { experiences, awards, @@ -24,257 +8,129 @@ import { relevantCoursework, } 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() { - 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, - ) => ( -
-

{title}

-
- {experiences.map((exp, index) => { - const IconComponent = getIcon(exp.type); - return ( -
- - -
-
-
- -
- - {exp.title} - - - {exp.organization} - -
-
- - {exp.location} -
-
- - {exp.period} -
-
-
-
-
-
-
- -
    - {exp.description.map((item, itemIndex) => ( -
  • - - - {item} - -
  • - ))} -
-
-
-
- ); - })} -
-
- ); - return ( -
- {/* Header */} -
-
- -
-

Experience

-
-
-

- Building products, running systems, and helping people use them. - My path through business, engineering, teaching, and research. -

-
- - {renderExperienceSection("Professional Experience", professionalExperience, 1)} - {renderExperienceSection("Teaching Experience", teachingExperience, 2)} - {renderExperienceSection("Research Experience", researchExperience, 3)} - - {/* Leadership & Activities */} - {renderExperienceSection( - "Leadership & Activities", - leadershipExperience, - 4, - )} - - {/* Technical Skills */} -
-

Technical Skills

-
- {Object.entries(technicalSkills).map(([category, skills], index) => ( -
- - - {category} - - -
- {skills.map((skill) => ( - - {skill} - - ))} -
-
-
-
+
+ +
+
-
- - {/* Conferences & Presentations */} -
-

Conferences & Presentations

-
- {conferences.map((conf, index) => ( -
- - -
-
- - {conf.title} - - - {conf.location} • {conf.date} - -
- -
-
- -

{conf.presentation}

- {conf.youtubeUrl && ( - - - Watch talk - + Skills + Recognition + + + Résumé & CV ↗ + +
+ {sections.map((section) => ( +
+

{section.title}

+ {experiences + .filter((entry) => entry.type === section.type) + .map((entry) => ( +
+
+

{entry.period}

+

{entry.location}

+
+
+

{entry.organization}

+

{entry.title}

+

{entry.description[0]}

+ {entry.description.length > 1 && ( +
+ More about this role +
    + {entry.description.slice(1).map((line) => ( +
  • {line}
  • + ))} +
+
)} - - +
+
+ ))} +
+ ))} +
+

Tools & skills

+
+ {Object.entries(technicalSkills).map(([category, skills]) => ( +
+

{category}

+

{skills.join(" · ")}

))}
- - {/* Awards & Recognition */} -
-

Awards & Recognition

-
- {awards.map((award, index) => ( -
- - -
- -
- - {award.title} - - {award.organization && ( - - {award.organization} • {award.year} - - )} -
-
-
- {award.description && ( - -

- {award.description} -

-
- )} -
+
+

Talks & recognition

+ {conferences.map((item) => ( +
+
+

{item.date}

+

{item.location}

+
+

{item.title}

+

{item.presentation}

+ {item.youtubeUrl && ( + + Watch talk ↗ + + )} +
+
+ ))} +
+ + Awards & recognition {awards.length} + + {awards.map((item) => ( +
+

{item.title}

+

+ {item.organization} · {item.year} +

+

{item.description}

+
))} -
-
- - {/* Relevant Coursework */} -
-

Relevant Coursework

-
- - -
    - {relevantCoursework.map((course, index) => ( -
  • - - - {course} - -
  • - ))} -
-
-
-
+ +
+ Relevant coursework +
    + {relevantCoursework.map((course) => ( +
  • {course}
  • + ))} +
+
); diff --git a/src/app/identity/page.tsx b/src/app/identity/page.tsx new file mode 100644 index 0000000..bbe4f9a --- /dev/null +++ b/src/app/identity/page.tsx @@ -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 ( +
+ + +
+ ); +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 2123b86..60c516e 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -4,9 +4,8 @@ import { env } from "~/env"; import { Footer } from "~/components/Footer"; import { Navigation } from "~/components/Navigation"; 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 "~/styles/globals.css"; import { cn } from "~/lib/utils"; @@ -14,14 +13,14 @@ import { cn } from "~/lib/utils"; export const metadata: Metadata = { title: `${name[0]?.first} ${name[0]?.last}`, 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) { return ( - - - {children} - + + {children}
diff --git a/src/app/page.tsx b/src/app/page.tsx index df0cca1..635468e 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,4 +1,6 @@ +import { ContourLines } from "~/components/ContourLines"; import Image from "next/image"; +import { JusticeParagraph } from "~/components/JusticeParagraph"; import { ManyanglesPreview } from "~/components/ManyanglesPreview"; import { BeenvoicePreview } from "~/components/BeenvoicePreview"; import Link from "next/link"; @@ -55,13 +57,14 @@ export default function HomePage() { return (
+
Software engineer & founder Boston, MA
-

+

Sean O’Connor.

@@ -130,13 +133,13 @@ export default function HomePage() { sizes="(max-width: 700px) 100vw, 50vw" /> )} - {project.name === "Racetix" && ( -

+ {(project.name === "Racetix" || project.name === "beenvoice") && ( +
)} @@ -149,7 +152,7 @@ export default function HomePage() { {project.category}
-

{project.description}

+
{project.stack}
))} @@ -159,12 +162,8 @@ export default function HomePage() {

My company

-

Hadlock Technologies

-

- 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. -

+

Hadlock Technologies

+

hadlockCMS

@@ -199,24 +198,12 @@ export default function HomePage() {

-

- I started working on technology at Riverhead Raceway in 2020. Over - time that grew from IT into building the website, live race systems, - and software used around the track. -

-

- At Bucknell, I studied Computer Science and Engineering, built tools - for robotics research, and helped other students learn to build - things. Now I’m at BU, aiming to finish my master’s in a year while - continuing my own projects. -

+ +

Outside of software, I’m into specialty coffee, Formula One, and - travel. I cofounded Bucknell’s Coffee Society—and, naturally, made a - + travel. I cofounded Bucknell’s Coffee Society—and, naturally, made a{" "} + website for finding coffee in Lewisburg . @@ -225,22 +212,64 @@ export default function HomePage() { Experience - - Trips & interests + + Résumé & CV

+
+ Writing + Publications + In the media +
-
-
-

Get in touch

-

Always happy to talk.

+
+
+

Away from the keyboard

+ + All trips + +
+
+ + Chicago’s Cloud Gate during the Coffee Expo trip + Coffee, usually. +

+ Cofounding a coffee society came with a map of favorite local + spots. +

+
+ + At the British Grand Prix + A seat at the circuit. +

+ Formula One, trips with friends, and a camera along for the ride. +

+ + + + Is it still October? +

A very small website with a very long month.

+
- - sean@soconnor.dev -
+ +
); } diff --git a/src/app/projects/page.tsx b/src/app/projects/page.tsx index 67496be..2099007 100644 --- a/src/app/projects/page.tsx +++ b/src/app/projects/page.tsx @@ -1,11 +1,6 @@ -import Image from "next/image"; -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 { ProjectExplorer } from "~/components/ProjectExplorer"; import { projects } from "~/lib/data"; - +import type { Metadata } from "next"; export const metadata: Metadata = { title: "Work — Sean O’Connor", description: @@ -32,71 +27,18 @@ const orderedProjects = [...projects].sort((a, b) => { export default function ProjectsPage() { return ( -
+
-

The portfolio

+

Work / Products & experiments

Things I’ve built.

- Products, client work, and a few explorations. From the interface to - the infrastructure, I like taking an idea all the way through. + From the first sketch to the parts that run it. Products, client work, + and a few experiments along the way.

-
- {orderedProjects.map((project, index) => ( -
- {project.image && ( -
- {project.title === "beenvoice" ? ( - - ) : project.title === "Manyangles" ? ( - - ) : ( - {project.imageAlt - )} -
- )} -
-

{project.title}

-

{project.longDescription ?? project.description}

-
- {project.tags.map((tag) => ( - {tag} - ))} -
-
- {project.websiteLink && ( - - Visit site - - )} - {project.gitLink && ( - - View code - - )} - {project.link && ( - - Read more - - )} - {project.slidesUrl && ( - - View slides - - )} -
-
-
- ))} -
+
); } diff --git a/src/app/publications/page.tsx b/src/app/publications/page.tsx index bbdd923..83baf8c 100644 --- a/src/app/publications/page.tsx +++ b/src/app/publications/page.tsx @@ -1,238 +1,92 @@ -"use client"; - -import { - 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 { readFile } from "node:fs/promises"; +import path from "node:path"; +import { PageHeading } from "~/components/PageHeading"; import { parseBibtex } from "~/lib/bibtex"; -// No custom tracking needed - Vercel automatically tracks page views - -export default function PublicationsPage() { - const [publications, setPublications] = useState([]); - 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 ( -
-
-
- -
-

- Peer-Reviewed Publications -

-
-
-

- My research publications in human-robot interaction and robotics. -

-
- -
- {loading ? ( - <> - - - - - ) : ( - publications.map((pub, index) => ( -
- - -
- - {pub.title} - - {pub.paperUrl && ( - - - - )} -
- - {pub.authors.join(", ")} - - - {pub.venue} ({pub.year}) - - {pub.address && ( - - {pub.address} - +
+ +
+

{publications.length} publications

+ + Download bibliography ↓ + +
+
+ {publications.map((pub) => { + const bib = entries.find((entry) => + entry.includes(`{${pub.citationKey},`), + ); + return ( +
+
+

{pub.year}

+

{pub.type}

+
+
+

{pub.venue}

+

{pub.title}

+

{pub.authors.join(", ")}

+ {pub.notes &&

{pub.notes}

} +
+ {pub.paperUrl && ( + + Paper ↗ + )} - {pub.notes && ( -
- - {pub.notes} - -
+ {pub.posterUrl && ( + + Poster ↗ + )} - - - {pub.abstract && ( -

- {pub.abstract} -

+ {pub.slidesUrl && ( + + Slides ↗ + )} -
- - {pub.type} - - {pub.doi && ( - - - - DOI - - - )} - {pub.paperUrl && ( - - - - Paper - - - )} - {pub.posterUrl && ( - - - - Poster - - - )} - {pub.slidesUrl && ( - - - - Slides - - - )} - downloadBibtex(pub)} - className="flex h-6 cursor-pointer items-center capitalize" - variant="secondary" + DOI ↗ + + )} + {bib && ( + - - BibTeX - -
-
- -
- )) - )} + BibTeX ↓ + + )} +
+ {pub.abstract && ( +
+ Read abstract +

{pub.abstract}

+
+ )} +
+ + ); + })}
); diff --git a/src/app/travel/page.tsx b/src/app/travel/page.tsx index 5c1974e..9e43714 100644 --- a/src/app/travel/page.tsx +++ b/src/app/travel/page.tsx @@ -1,102 +1,47 @@ -"use client"; - -import { useEffect, useState } from "react"; -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 Image from "next/image"; +import { PageHeading } from "~/components/PageHeading"; +import { JusticeParagraph } from "~/components/JusticeParagraph"; import { travel } from "~/lib/data"; export default function TripsPage() { - const [loading, setLoading] = useState(true); - - useEffect(() => { - const timer = setTimeout(() => { - setLoading(false); - }, 1000); - return () => clearTimeout(timer); - }, []); - return ( -
-
-
- -
-

My Trips & Events

-
-
-

- A collection of memorable trips and events I've attended. -

-
- -
- {loading ? ( - <> - - - - - - - - ) : ( - travel.map((trip, index) => ( +
+ +
+ {travel.map((trip, index) => ( +
- - -
-
- {trip.images.map((image, imgIndex) => ( -
- -
- ))} -
-
-
- -
-
- {trip.title} - - {trip.description} - -
- {/* Show badges for tags */} -
- {trip.tags.map((tag, tagIndex) => ( - - {tag} - - ))} -
-
-
-
+ {trip.images.map((src, i) => ( + {trip.alts[i] + ))}
- )) - )} +
+ + {String(index + 1).padStart(2, "0")} / {trip.tags.join(" · ")} + +

{trip.title}

+ +

Scroll through the photos ↔

+
+
+ ))}
); diff --git a/src/app/type-study/page.tsx b/src/app/type-study/page.tsx new file mode 100644 index 0000000..2e8f5c3 --- /dev/null +++ b/src/app/type-study/page.tsx @@ -0,0 +1,23 @@ +import type { Metadata } from "next"; +import { JusticeComparison } from "~/components/JusticeComparison"; + +export const metadata: Metadata = { + title: "Typography comparison — Sean O’Connor", + robots: { index: false, follow: false }, +}; + +export default function TypeStudyPage() { + return ( +
+
+

Typography study

+

A little more considered.

+

+ Native wrapping and Justice, side by side with the same font, size, + and copy. This is a preview; the main site is unchanged. +

+
+ +
+ ); +} diff --git a/src/components/CoffeeMirror.tsx b/src/components/CoffeeMirror.tsx new file mode 100644 index 0000000..af9cc6c --- /dev/null +++ b/src/components/CoffeeMirror.tsx @@ -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 = { + street: { + url: "https://tile.openstreetmap.org/{z}/{x}/{y}.png", + attribution: '© OpenStreetMap contributors', + }, + satellite: { + url: "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}", + attribution: "Tiles © Esri", + }, +}; + +export function CoffeeMirror() { + const [selected, setSelected] = useState(null); + const [drawerOpen, setDrawerOpen] = useState(true); + const [query, setQuery] = useState(""); + const [favorites, setFavorites] = useState([]); + const [style, setStyle] = useState("street"); + const [styleOpen, setStyleOpen] = useState(false); + const [welcomeOpen, setWelcomeOpen] = useState(false); + const [mapFailed, setMapFailed] = useState(false); + const [locationError, setLocationError] = useState(""); + const mapElement = useRef(null); + const map = useRef(null); + const tileLayer = useRef(null); + const markers = useRef(new Map()); + + 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: "", iconSize: [18, 18], iconAnchor: [9, 9] }), + }).addTo(instance); + marker.bindTooltip(shop.name); + marker.on("click", () => { + setSelected(shop); + setDrawerOpen(true); + }); + markers.current.set(shop.id, marker); + }); + resizeObserver = new ResizeObserver(() => instance.invalidateSize()); + resizeObserver.observe(mapElement.current); + requestAnimationFrame(() => instance.invalidateSize()); + }).catch(() => { if (!cancelled) setMapFailed(true); }); + return () => { + cancelled = true; + resizeObserver?.disconnect(); + map.current?.remove(); + map.current = null; + tileLayer.current = null; + savedMarkers.clear(); + }; + }, []); + + useEffect(() => { + if (!map.current || !tileLayer.current) return; + const L = tileLayer.current; + L.setUrl(tiles[style].url); + L.options.attribution = tiles[style].attribution; + }, [style]); + + useEffect(() => { + if (!selected || !map.current) return; + const instance = map.current; + const target = instance.project([selected.lat, selected.lng], 16); + const offset = window.innerWidth >= 640 && drawerOpen ? 200 : 0; + instance.flyTo(instance.unproject(target.subtract([offset, 0]), 16), 16, { duration: 0.8 }); + markers.current.forEach((marker, id) => marker.getElement()?.classList.toggle("is-selected", id === selected.id)); + }, [selected, drawerOpen]); + + const filtered = useMemo(() => COFFEE_SHOPS.filter((shop) => + `${shop.name} ${shop.address}`.toLowerCase().includes(query.toLowerCase().trim()), + ), [query]); + const favoriteShops = filtered.filter((shop) => favorites.includes(shop.id)); + const otherShops = filtered.filter((shop) => !favorites.includes(shop.id)); + + function toggleFavorite(id: number) { + setFavorites((current) => { + const next = current.includes(id) ? current.filter((item) => item !== id) : [...current, id]; + localStorage.setItem("lewisburg-coffee-favorites", JSON.stringify(next)); + return next; + }); + } + + function closeWelcome() { + setWelcomeOpen(false); + localStorage.setItem("hasSeenWelcome", "true"); + } + + function locate() { + setLocationError(""); + if (!navigator.geolocation) { setLocationError("Location is unavailable in this browser."); return; } + navigator.geolocation.getCurrentPosition( + ({ coords }) => map.current?.flyTo([coords.latitude, coords.longitude], 16), + () => setLocationError("Couldn’t get your location."), + ); + } + + return ( +
+
+ {mapFailed &&

The map could not load. You can still browse the coffee shops.

} + +
+ + + +
+ + + +
+ {locationError && {locationError}} + + + +
+ + {styleOpen &&
{(["street", "satellite"] as const).map((option) => )}
} +
+
+ +
© {new Date().getFullYear()} Sean O’ConnorMap data © OpenStreetMap contributors
+ + {welcomeOpen &&
event.stopPropagation()}>

Welcome to the Lewisburg Coffee Map

Discover the best coffee spots in Lewisburg, PA.

Created by Sean O’Connor
  • Explore the map and select a coffee shop.
  • Search the list to find your next cup.
  • Use the location button to see what’s nearby.
} +
+ ); +} + +function ShopRow({ shop, favorite, onSelect, onFavorite }: { shop: CoffeeShop; favorite: boolean; onSelect: (shop: CoffeeShop) => void; onFavorite: (id: number) => void }) { + return
; +} diff --git a/src/components/ContourLines.tsx b/src/components/ContourLines.tsx new file mode 100644 index 0000000..6c31d9b --- /dev/null +++ b/src/components/ContourLines.tsx @@ -0,0 +1,19 @@ +export function ContourLines() { + return ( + + ); +} diff --git a/src/components/DocumentReader.tsx b/src/components/DocumentReader.tsx new file mode 100644 index 0000000..7e80622 --- /dev/null +++ b/src/components/DocumentReader.tsx @@ -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(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 ( +
+ {error ? ( +

+ Page {page} couldn’t render. Use Open PDF above to read it. +

+ ) : ( + + )} +
+ ); +} + +function Preview({ url, title }: { url: string; title: string }) { + const frame = useRef(null); + const [pdf, setPdf] = useState(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("[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( + `[data-page="${next}"]`, + ); + if (!container || !sheet) return; + container.scrollTo({ + top: + container.scrollTop + + sheet.getBoundingClientRect().top - + container.getBoundingClientRect().top - + 16, + behavior: "auto", + }); + setPage(next); + }; + + return ( + <> +
+
+ + + {pdf ? `Page ${page} of ${pdf.numPages}` : "Loading PDF…"} + + +
+
+ + + +
+
+
+ {error ? ( +

+ The preview couldn’t load. You can still open or download the PDF + above. +

+ ) : ( + <> + {pdf && + Array.from({ length: pdf.numPages }, (_, index) => ( + + ))} + {!pdf && ( +

+ Preparing your preview… +

+ )} + + )} +
+ + ); +} + +export function DocumentReader() { + const [active, setActive] = useState("resume"); + const document = documents.find((item) => item.id === active)!; + return ( +
+
+
+ {documents.map((item) => ( + + ))} +
+ +
+ +

+ Open the PDF for text selection, search, and printing. +

+
+ ); +} diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx index 3c79c84..a8d5f69 100644 --- a/src/components/Footer.tsx +++ b/src/components/Footer.tsx @@ -1,19 +1,41 @@ +"use client"; + 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() { + const pathname = usePathname(); + if (pathname === "/coffee") return null; return (