diff --git a/index.html b/index.html index 4da9af0..bc8245a 100644 --- a/index.html +++ b/index.html @@ -2,58 +2,36 @@ - - - + + + + + + + - - - - - - + + + - - - - - - - - - - - - - alex + + diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 0000000..02fad02 --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/fonts/open-sans-latin-ext-italic.woff2 b/public/fonts/open-sans-latin-ext-italic.woff2 new file mode 100644 index 0000000..bc2aea0 Binary files /dev/null and b/public/fonts/open-sans-latin-ext-italic.woff2 differ diff --git a/public/fonts/open-sans-latin-ext-normal.woff2 b/public/fonts/open-sans-latin-ext-normal.woff2 new file mode 100644 index 0000000..08bed85 Binary files /dev/null and b/public/fonts/open-sans-latin-ext-normal.woff2 differ diff --git a/public/fonts/open-sans-latin-italic.woff2 b/public/fonts/open-sans-latin-italic.woff2 new file mode 100644 index 0000000..5de3fea Binary files /dev/null and b/public/fonts/open-sans-latin-italic.woff2 differ diff --git a/public/fonts/open-sans-latin-normal.woff2 b/public/fonts/open-sans-latin-normal.woff2 new file mode 100644 index 0000000..b5d54e7 Binary files /dev/null and b/public/fonts/open-sans-latin-normal.woff2 differ diff --git a/public/robots.txt b/public/robots.txt index 6bd9971..51dca72 100644 --- a/public/robots.txt +++ b/public/robots.txt @@ -1,4 +1,6 @@ # allow all crawlers idc User-Agent: * -Disallow: \ No newline at end of file +Disallow: + +Sitemap: https://alexav.gg/sitemap.xml diff --git a/public/site.webmanifest b/public/site.webmanifest new file mode 100644 index 0000000..4e1cf0c --- /dev/null +++ b/public/site.webmanifest @@ -0,0 +1,17 @@ +{ + "name": "Alex Frantz", + "short_name": "Alex", + "description": "Senior QA engineer at Medal.tv, based in Western New York.", + "start_url": "/", + "display": "minimal-ui", + "background_color": "#000000", + "theme_color": "#000000", + "icons": [ + { + "src": "/favicon.svg", + "sizes": "any", + "type": "image/svg+xml", + "purpose": "any" + } + ] +} diff --git a/server.js b/server.js index bd11b87..86386a2 100644 --- a/server.js +++ b/server.js @@ -30,7 +30,24 @@ if (!isProduction) { const compression = (await import("compression")).default; const sirv = (await import("sirv")).default; app.use(compression()); - app.use(base, sirv(clientDir, { extensions: [] })); + app.use( + base, + sirv(clientDir, { + extensions: [], + setHeaders(res, pathname) { + if (pathname.startsWith("/assets/")) { + // vite hashes these filenames, so the content can never change + // under a given url + res.setHeader("Cache-Control", "public, max-age=31536000, immutable"); + } else if (pathname.startsWith("/fonts/")) { + // stable but unhashed, so cache for a while rather than forever + res.setHeader("Cache-Control", "public, max-age=2592000"); + } else { + res.setHeader("Cache-Control", "public, max-age=3600"); + } + }, + }), + ); // a hashed asset that sirv did not find is genuinely gone, usually a stale // cached document asking for a previous build. 404 it here, otherwise it // falls through to the catch-all and answers a .js request with html @@ -43,26 +60,74 @@ if (!isProduction) { }); } +/** load the SSR bundle, from source in dev and from the build in production */ +async function loadServerEntry() { + if (!isProduction) { + return await vite.ssrLoadModule("/src/entry-server.tsx"); + } + return await import("./dist/server/entry-server.js"); +} + +/** + * Strip the deploy base from a request url, always leaving a rooted path. + * The previous `originalUrl.replace(base, "")` dropped the leading slash when + * base was "/", which would hand the router a relative location. + */ +function stripBase(originalUrl) { + if (!originalUrl) return "/"; + if (base === "/") return originalUrl; + if (originalUrl.startsWith(base)) { + const rest = originalUrl.slice(base.length); + return rest.startsWith("/") ? rest : `/${rest}`; + } + return originalUrl; +} + +// generated from the route table rather than checked in, so it cannot go stale +app.get(`${base.replace(/\/$/, "")}/sitemap.xml`, async (req, res) => { + try { + const { renderSitemap } = await loadServerEntry(); + res + .status(200) + .set({ + "Content-Type": "application/xml; charset=utf-8", + "Cache-Control": "public, max-age=3600", + }) + .send(renderSitemap()); + } catch (e) { + vite?.ssrFixStacktrace(e); + console.error(e.stack); + res.status(500).end(); + } +}); + app.use("*all", async (req, res) => { try { - const url = req.originalUrl.replace(base, ""); + const url = stripBase(req.originalUrl); let template; - let render; if (!isProduction) { template = await fs.readFile(path.resolve(root, "index.html"), "utf-8"); template = await vite.transformIndexHtml(url, template); - render = (await vite.ssrLoadModule("/src/entry-server.tsx")).render; } else { template = templateHtml; - render = (await import("./dist/server/entry-server.js")).render; } + const { render } = await loadServerEntry(); const rendered = await render(url); const html = template ?.replace("", rendered.head ?? "") .replace("", rendered.html ?? ""); - res.status(200).set({ "Content-Type": "text/html" }).send(html); + + res + .status(rendered.statusCode ?? 200) + .set({ + "Content-Type": "text/html", + // short shared cache window; the page embeds live-ish data + "Cache-Control": + "public, max-age=0, s-maxage=60, stale-while-revalidate=300", + }) + .send(html); } catch (e) { vite?.ssrFixStacktrace(e); console.log(e.stack); diff --git a/src/assets/main.css b/src/assets/main.css index dc69f2d..03c95fc 100644 --- a/src/assets/main.css +++ b/src/assets/main.css @@ -1,7 +1,65 @@ @import "tailwindcss"; +/* Open Sans, self-hosted. previously a render-blocking stylesheet request to + fonts.googleapis.com plus a second hop to fonts.gstatic.com for the files. + latin + latin-ext only; regenerate from Google Fonts if more subsets are + ever needed. */ +@font-face { + font-family: "Open Sans"; + font-style: italic; + font-weight: 300 800; + font-display: swap; + src: url("/fonts/open-sans-latin-ext-italic.woff2") format("woff2"); + unicode-range: + U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, + U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, + U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} + +@font-face { + font-family: "Open Sans"; + font-style: italic; + font-weight: 300 800; + font-display: swap; + src: url("/fonts/open-sans-latin-italic.woff2") format("woff2"); + unicode-range: + U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, + U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, + U+2215, U+FEFF, U+FFFD; +} + +@font-face { + font-family: "Open Sans"; + font-style: normal; + font-weight: 300 800; + font-display: swap; + src: url("/fonts/open-sans-latin-ext-normal.woff2") format("woff2"); + unicode-range: + U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, + U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, + U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} + +@font-face { + font-family: "Open Sans"; + font-style: normal; + font-weight: 300 800; + font-display: swap; + src: url("/fonts/open-sans-latin-normal.woff2") format("woff2"); + unicode-range: + U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, + U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, + U+2215, U+FEFF, U+FFFD; +} + body { - font-family: "Open Sans", sans-serif; + font-family: + "Open Sans", + ui-sans-serif, + system-ui, + -apple-system, + "Segoe UI", + sans-serif; background-color: black; ::-webkit-scrollbar { diff --git a/src/components/BlogRow.tsx b/src/components/BlogRow.tsx index 31468c6..b627609 100644 --- a/src/components/BlogRow.tsx +++ b/src/components/BlogRow.tsx @@ -1,18 +1,23 @@ import ErrorState from "./ErrorState"; import { useState, useEffect } from "react"; import type { BlogPost } from "../utils/types"; +import { useInitialData } from "../utils/initialData"; +import { PROFILES } from "../utils/site"; export default function BlogRow() { - const [post, setPost] = useState(null); + // seeded from the SSR payload so the post is in the served HTML, then + // refreshed on the client to pick up anything published since + const { posts } = useInitialData(); + const [post, setPost] = useState(posts?.[0] ?? null); useEffect(() => { - fetch("https://blog.alexav.gg/api/posts", { + fetch(`${PROFILES.blog}/api/posts`, { method: "GET", }).then(async (res) => { if (res.ok) { try { const data = await res.json(); - setPost(data[0]); + if (Array.isArray(data) && data[0]) setPost(data[0]); } catch (e) { console.error("An error occurred fetching blog posts!", e); } @@ -22,18 +27,20 @@ export default function BlogRow() { if (post) { return ( -
+
+ ); } else return ; } diff --git a/src/components/EmailLink.tsx b/src/components/EmailLink.tsx new file mode 100644 index 0000000..57ba40d --- /dev/null +++ b/src/components/EmailLink.tsx @@ -0,0 +1,32 @@ +import { useState } from "react"; +import { AUTHOR_EMAIL } from "../utils/site"; + +/** + * A real `mailto:` anchor. The old `href="#"` + onClick version was a crawl + * dead-end and unusable by keyboard, so the address is the href now and the + * copy-to-clipboard is layered on top as an enhancement. + */ +export default function EmailLink({ className = "" }: { className?: string }) { + const [copied, setCopied] = useState(false); + + return ( + { + if (!navigator.clipboard) return; + e.preventDefault(); + navigator.clipboard.writeText(AUTHOR_EMAIL).then( + () => { + setCopied(true); + setTimeout(() => setCopied(false), 1500); + }, + () => {}, + ); + }} + > + {copied ? "copied!" : AUTHOR_EMAIL} + + ); +} diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx index c9a79b4..106fdd2 100644 --- a/src/components/Footer.tsx +++ b/src/components/Footer.tsx @@ -1,14 +1,10 @@ +import EmailLink from "./EmailLink"; + export default function Footer() { return ( -
- navigator.clipboard.writeText("alex@alexav.gg")} - className="text-xs opacity-85 hover:underline" - > - alex@alexav.gg - +
+

© 2026 Alex Frantz

-
+ ); } diff --git a/src/components/Header.tsx b/src/components/Header.tsx index a3eae3a..e1ee757 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -1,32 +1,39 @@ +import { Link } from "react-router-dom"; +import EmailLink from "./EmailLink"; +import { PROFILES } from "../utils/site"; + export default function Header() { return ( -
-

- Alex / dev + QA -

-
+
+ {/* site chrome, not the page topic — this was an

on every page */} +

+ + Alex / dev + QA + +

+

-
+ + + ); } diff --git a/src/components/InitialDataProvider.tsx b/src/components/InitialDataProvider.tsx new file mode 100644 index 0000000..24f63e2 --- /dev/null +++ b/src/components/InitialDataProvider.tsx @@ -0,0 +1,16 @@ +import type { ReactNode } from "react"; +import { InitialDataContext, type InitialData } from "../utils/initialData"; + +export default function InitialDataProvider({ + value, + children, +}: { + value: InitialData; + children: ReactNode; +}) { + return ( + + {children} + + ); +} diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index 3ecfef6..41ca539 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -3,7 +3,7 @@ import Header from "./Header"; export default function Layout({ children }: { children: ReactNode }) { return ( -
+
{children}
diff --git a/src/components/ProjectCard.tsx b/src/components/ProjectCard.tsx index dffc3fa..157b03c 100644 --- a/src/components/ProjectCard.tsx +++ b/src/components/ProjectCard.tsx @@ -1,44 +1,36 @@ -interface ProjectCardProps { - title: string; - description: string; - link: string; - pills: string[]; -} -interface PillProps { - name: string; +import { Link } from "react-router-dom"; +import type { Project } from "../data/projects"; + +function Pill({ name }: { name: string }) { + return ( +
+ {name} +
+ ); } -export default function ProjectCard({ - title, - description, - link, - pills, -}: ProjectCardProps) { - const Pill = ({ name }: PillProps) => { - return ( -
- {name} -
- ); - }; - +export default function ProjectCard({ project }: { project: Project }) { return (
-

{description}

+

{project.description}

- {pills.map((v, k) => { - return ; - })} + {project.pills.map((v) => ( + + ))}
diff --git a/src/components/fantasy/PlayerCell.tsx b/src/components/fantasy/PlayerCell.tsx index a2d3064..6ce3470 100644 --- a/src/components/fantasy/PlayerCell.tsx +++ b/src/components/fantasy/PlayerCell.tsx @@ -16,7 +16,10 @@ export default function PlayerCell({ player }: { player: NFLPlayer | null }) { return (
-

{player.full_name}

+ {/* a player name is data, not a document section — this was an

, + and with a full roster per team it produced most of the page's + heading elements */} +

{player.full_name}

{player?.position} · {player?.team} {injuryStatus && "·"}{" "} {player?.injury_status && ( diff --git a/src/components/fantasy/TeamRow.tsx b/src/components/fantasy/TeamRow.tsx index 8ef7c33..8c1235a 100644 --- a/src/components/fantasy/TeamRow.tsx +++ b/src/components/fantasy/TeamRow.tsx @@ -8,20 +8,26 @@ export function TeamRow({ roster: NFLMatchupState; isWinning?: boolean; }) { + const teamName = roster?.owner.metadata.team_name; + return (

{teamName -

- {roster?.owner.metadata.team_name} | +

+ {teamName} | {roster?.points}pt -

+

diff --git a/src/components/info/GameComponent.tsx b/src/components/info/GameComponent.tsx index b68440a..f37a4a0 100644 --- a/src/components/info/GameComponent.tsx +++ b/src/components/info/GameComponent.tsx @@ -1,6 +1,5 @@ import { useState, useEffect } from "react"; -import ErrorState from "../ErrorState"; -import { Gamepad } from "lucide-react"; +import { PROFILES } from "../../utils/site"; type GameObject = { currentlyPlaying: CurrentlyPlaying; @@ -35,7 +34,12 @@ export default function GameComponent() { }, []); return ( - +
diff --git a/src/components/info/MusicComponent.tsx b/src/components/info/MusicComponent.tsx index 69d3640..a6a8ada 100644 --- a/src/components/info/MusicComponent.tsx +++ b/src/components/info/MusicComponent.tsx @@ -24,16 +24,28 @@ export default function MusicComponent() { }, []); return ( - +
- v.size === "medium")[0]["url"] || - music?.images[0].url - } - className="border-green-500 border-2" - /> + {music && ( + v.size === "medium")[0]["url"] || + music.images[0].url + } + alt={`${music.title} by ${music.artist} album artwork`} + width={64} + height={64} + loading="lazy" + decoding="async" + className="border-green-500 border-2 h-16 w-16 object-cover" + /> + )}

{music?.title}

{music?.artist}

diff --git a/src/components/modules/PictureCarousel.tsx b/src/components/modules/PictureCarousel.tsx index 7aec84d..12bc984 100644 --- a/src/components/modules/PictureCarousel.tsx +++ b/src/components/modules/PictureCarousel.tsx @@ -1,31 +1,77 @@ -const images = [ - "https://storage.alexav.gg/content/1b81a43e-7b6b-4969-944a-f2f334405477.jpg", - "https://storage.alexav.gg/content/91bce3da-36c3-497f-aaa9-b782d60c9cd7.jpg", - "https://storage.alexav.gg/content/8ce1c14f-864e-4915-bff4-4cbf73247023.jpg", - "https://storage.alexav.gg/content/6687d643-fb69-42f6-8480-51869cdb7efb.jpg", - "https://storage.alexav.gg/content/f8252684-eb5a-4344-baa1-46f4eab46916.jpg", - "https://storage.alexav.gg/content/363308c8-ecb6-4b2f-8dba-1c7f7f26f087.jpg", - "https://storage.alexav.gg/content/1aca5645-0d3d-43bf-a7b7-bd34bcc374b4.png", - "https://storage.alexav.gg/content/1f822215-0768-424d-893a-13bcd10aa98b.jpg", - "https://storage.alexav.gg/content/de890eaf-fc73-42a4-9fbe-a62313394a4c.jpg", - "https://storage.alexav.gg/content/d057af80-3d03-45ca-88b2-680c1e7c63c1.jpg", - "https://storage.alexav.gg/content/1d717aab-3058-405a-b04d-324d7ab3e658.jpg", - "https://storage.alexav.gg/content/2f36c1dd-eaf8-4fd3-a3cd-1d25fe8f5a07.jpg", +// alt text lives next to each src so descriptions cannot drift from the image. +// the alt values below are placeholders — replace them with what is actually +// in each photo, since that is what makes them findable in image search +const images: { src: string; alt: string }[] = [ + { + src: "https://storage.alexav.gg/content/1b81a43e-7b6b-4969-944a-f2f334405477.jpg", + alt: "A photo from Alex's camera roll", + }, + { + src: "https://storage.alexav.gg/content/91bce3da-36c3-497f-aaa9-b782d60c9cd7.jpg", + alt: "A photo from Alex's camera roll", + }, + { + src: "https://storage.alexav.gg/content/8ce1c14f-864e-4915-bff4-4cbf73247023.jpg", + alt: "A photo from Alex's camera roll", + }, + { + src: "https://storage.alexav.gg/content/6687d643-fb69-42f6-8480-51869cdb7efb.jpg", + alt: "A photo from Alex's camera roll", + }, + { + src: "https://storage.alexav.gg/content/f8252684-eb5a-4344-baa1-46f4eab46916.jpg", + alt: "A photo from Alex's camera roll", + }, + { + src: "https://storage.alexav.gg/content/363308c8-ecb6-4b2f-8dba-1c7f7f26f087.jpg", + alt: "A photo from Alex's camera roll", + }, + { + src: "https://storage.alexav.gg/content/1aca5645-0d3d-43bf-a7b7-bd34bcc374b4.png", + alt: "A photo from Alex's camera roll", + }, + { + src: "https://storage.alexav.gg/content/1f822215-0768-424d-893a-13bcd10aa98b.jpg", + alt: "A photo from Alex's camera roll", + }, + { + src: "https://storage.alexav.gg/content/de890eaf-fc73-42a4-9fbe-a62313394a4c.jpg", + alt: "A photo from Alex's camera roll", + }, + { + src: "https://storage.alexav.gg/content/d057af80-3d03-45ca-88b2-680c1e7c63c1.jpg", + alt: "A photo from Alex's camera roll", + }, + { + src: "https://storage.alexav.gg/content/1d717aab-3058-405a-b04d-324d7ab3e658.jpg", + alt: "A photo from Alex's camera roll", + }, + { + src: "https://storage.alexav.gg/content/2f36c1dd-eaf8-4fd3-a3cd-1d25fe8f5a07.jpg", + alt: "A photo from Alex's camera roll", + }, ]; export default function PictureCarousel() { return ( -
- {images.map((v, k) => { +
    + {images.map((image, k) => { return ( - +
  • + {image.alt} +
  • ); })} -
+ ); } diff --git a/src/components/modules/ScoreModule.tsx b/src/components/modules/ScoreModule.tsx index 6913de2..cbbb78a 100644 --- a/src/components/modules/ScoreModule.tsx +++ b/src/components/modules/ScoreModule.tsx @@ -30,7 +30,15 @@ function TeamRow({ return (
- + {`${homeTeam.name} { if ( @@ -42,6 +50,10 @@ function TeamRow({ } }} width={width} + height={80} + loading="lazy" + decoding="async" + alt={`${awayTeam.name} logo`} className="object-cover h-20" src={awayTeam.logo} /> @@ -84,17 +96,7 @@ export default function ScoreModule() { console.error("Failed to fetch Sabres score; " + res.statusText); } } - async function fetchYankeesScore() { - const res = await fetch("https://api.alexav.gg/v4/sports/scores/yankees"); - if (res.ok) { - const data = await res.json(); - setYankeesScore(data); - } else { - console.error("Failed to fetch Yankees score; " + res.statusText); - } - } - fetchYankeesScore(); fetchBillsScore(); fetchSabresScore(); }, []); @@ -119,14 +121,6 @@ export default function ScoreModule() { venue={billsScore.venue} /> )} - {yankeesScore?.homeTeam && yankeesScore.awayTeam && ( - - )} {warpath && (
@@ -61,6 +65,11 @@ export default function StatModule() { {data?.title
diff --git a/src/data/projects.ts b/src/data/projects.ts new file mode 100644 index 0000000..86237aa --- /dev/null +++ b/src/data/projects.ts @@ -0,0 +1,70 @@ +export type Project = { + slug: string; + title: string; + /** one-line summary, used on cards and as the meta description */ + description: string; + /** where the source lives */ + link: string; + pills: string[]; + /** paragraphs of prose for the detail page */ + body: string[]; +}; + +export const PROJECTS: Project[] = [ + { + slug: "led-matrix-scoreboard", + title: "LED Matrix Scoreboard", + description: + "A physical scoreboard driven by an LED matrix, pulls live sports data and renders pixel by pixel.", + link: "https://git.alexav.gg/alex/scoreboard", + pills: ["Python", "Raspberry Pi", "Hardware"], + body: [ + "I wanted live scores on a wall instead of on a phone, so I built a physical scoreboard: a Raspberry Pi driving an RGB LED matrix, polling live sports data and drawing the result pixel by pixel.", + "The interesting constraint is the display itself. An LED matrix has no text rendering, no layout engine, and very few pixels to spend, so every score, team abbreviation, and clock has to be composed by hand and kept legible from across a room. The Python service handles fetching and caching the game state, then hands a frame to the matrix driver.", + "Written in Python and running on a Raspberry Pi. The source is on my Gitea instance if you want to build one.", + ], + }, + { + slug: "subber", + title: "Subber", + description: + "Keeps track of your subscriptions and generates clear metrics for you to manage.", + link: "https://github.com/azpha/subber", + pills: ["TypeScript", "Tailwind", "Prisma"], + body: [ + "Subber tracks recurring subscriptions and turns them into metrics you can actually act on — what you are paying monthly versus annually, and where the money is going.", + "The problem with most subscription trackers is that they are a list. A list tells you what you signed up for; it does not tell you what it costs you over a year, or which renewals are about to land in the same week. Subber is built around the aggregate view first and the list second.", + "TypeScript throughout, Prisma for the data layer, and Tailwind for the interface.", + ], + }, + { + slug: "snapchat-memory-extractor", + title: "Snapchat Memory Extractor", + description: + "Extracts your Snapchat memories from your data export in a cleaner format than their horrible index.html.", + link: "https://github.com/azpha/snapchat-memory-extractor", + pills: ["Python", "Automation"], + body: [ + "When you request your data from Snapchat, your memories arrive as an enormous index.html full of links rather than as files you can actually keep. This tool takes that export and pulls the memories out into a sane folder of real media files.", + "It is the kind of small, unglamorous automation that saves an afternoon of clicking. Point it at the export, let it walk the index, and it resolves and downloads each memory for you.", + "Written in Python with no heavy dependencies, so it runs anywhere you can get a Python interpreter.", + ], + }, + { + slug: "homelab", + title: "Homelab", + description: + "My tinkering sandbox. Full CI/CD pipeline for people to learn & take inspiration from.", + link: "https://git.alexav.gg/alex/homelab", + pills: ["Docker", "Ansible", "Python", "Automation"], + body: [ + "My homelab is where everything else on this site actually runs — this website, my Gitea instance, my analytics, my personal API. The whole thing is defined as code and deployed through its own CI/CD pipeline.", + "I keep the repository public because homelab writeups tend to stop at the hardware photos, and the useful part is the boring middle: how services get configured, how deploys happen, how it recovers when something breaks. Docker for the services, Ansible for the configuration, Python for the glue.", + "Take whatever is useful. It is a sandbox, so expect it to change.", + ], + }, +]; + +export function findProject(slug: string | undefined) { + return PROJECTS.find((p) => p.slug === slug); +} diff --git a/src/entry-client.tsx b/src/entry-client.tsx index 03a9727..3e30316 100644 --- a/src/entry-client.tsx +++ b/src/entry-client.tsx @@ -2,10 +2,19 @@ import { hydrateRoot } from "react-dom/client"; import { BrowserRouter } from "react-router-dom"; import "./assets/main.css"; import App from "./pages/App.tsx"; +import InitialDataProvider from "./components/InitialDataProvider"; +import { INITIAL_DATA_GLOBAL, type InitialData } from "./utils/initialData"; + +const initialData: InitialData = + (window as unknown as Record)[ + INITIAL_DATA_GLOBAL + ] ?? {}; hydrateRoot( document.getElementById("root")!, - + + + , ); diff --git a/src/entry-server.tsx b/src/entry-server.tsx index 183e114..80eaa28 100644 --- a/src/entry-server.tsx +++ b/src/entry-server.tsx @@ -1,17 +1,53 @@ import { renderToString } from "react-dom/server"; -import * as RouterExports from "react-router-dom"; import App from "./pages/App"; +import { StaticRouter } from "react-router-dom"; +import { resolveMeta, normalizePath } from "./utils/resolveMeta"; +import { renderHead } from "./utils/seo"; +import { fetchPosts } from "./utils/blogFeed"; +import { sitemapPaths } from "./routes"; +import { absoluteUrl } from "./utils/site"; +import InitialDataProvider from "./components/InitialDataProvider"; +import { serializeInitialData, type InitialData } from "./utils/initialData"; -// Vite's SSR runner resolves react-router-dom to its CommonJS build, so the -// named exports land on `.default`; fall back to the namespace when not. -const { StaticRouter } = - (RouterExports as { default?: typeof RouterExports }).default ?? RouterExports; +export async function render(url: string) { + const location = normalizePath(url); + const { meta, status } = resolveMeta(location); + + // fetched here rather than in a useEffect so the content is in the served + // html. memoized in blogFeed, and it never throws. + const posts = await fetchPosts(); + const data: InitialData = { posts: posts.slice(0, 3) }; -export function render(url: string) { const html = renderToString( - - + + + + , ); - return { html }; + + return { + html, + // the data script goes in the head so it runs before the deferred client + // module reads it, and so it cannot cause a hydration mismatch inside #root + head: `${renderHead(meta)}\n ${serializeInitialData(data)}`, + statusCode: status, + }; +} + +/** + * sitemap.xml, generated from the same route table the router uses, so it can + * never list a url that does not exist or miss one that does. No lastmod: + * fabricated dates are worse than none, and Google ignores unreliable ones. + */ +export function renderSitemap() { + const urls = sitemapPaths() + .map((path) => ` ${absoluteUrl(path)}`) + .join("\n"); + + return ` + +${urls} + +`; } diff --git a/src/pages/App.tsx b/src/pages/App.tsx index af675b3..abbf3d8 100644 --- a/src/pages/App.tsx +++ b/src/pages/App.tsx @@ -1,122 +1,40 @@ +import { useEffect } from "react"; +import { Routes, Route, useLocation } from "react-router-dom"; import Layout from "../components/Layout"; -import ProjectCard from "../components/ProjectCard"; -import FantasyStatus from "../components/FantasyStatus"; -import PictureCarousel from "../components/modules/PictureCarousel"; -import Footer from "../components/Footer"; -import BlogRow from "../components/BlogRow"; -import GameComponent from "../components/info/GameComponent"; -import MusicComponent from "../components/info/MusicComponent"; +import { routes } from "../routes"; +import { resolveMeta } from "../utils/resolveMeta"; + +/** + * SSR sets the title in the document head; this keeps it correct after a + * client-side navigation, which never re-runs that head injection. + */ +function TitleSync() { + const { pathname } = useLocation(); + + useEffect(() => { + const { meta } = resolveMeta(pathname); + document.title = meta.title; + + const canonical = document.querySelector( + 'link[rel="canonical"]', + ); + if (canonical) { + canonical.href = new URL(meta.path, window.location.origin).href; + } + }, [pathname]); + + return null; +} export default function App() { return ( -
-
-

HI, I'M ALEX

-

- I write software sometimes, then I spend twice as long breaking it -

-
-
-

- My day job is Senior QA at{" "} - - Medal.tv - {" "} - ( - - we're hiring! - - ), then by night I play with my homelab rack or write some mediocre - JavaScript. -

-

- Based in Western New York (go bills, go sabres). Working on learning - Java, C#. Pointless software for funzies is my passion. -

-
- -
- -

- - LATEST BLOG POST - -

- - -
- -

THINGS I'VE BUILT

-
- - - - -
- -
- -

MY ACTIVITIES

- -
- - -
- -
-
-

Fantasy

- -
-
-

- My Life, Pictured -

- -
-
- -
- -
-
+ + + {routes.map((route) => ( + + ))} + ); } diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx new file mode 100644 index 0000000..7e28948 --- /dev/null +++ b/src/pages/Home.tsx @@ -0,0 +1,116 @@ +import { Link } from "react-router-dom"; +import ProjectCard from "../components/ProjectCard"; +import FantasyStatus from "../components/FantasyStatus"; +import PictureCarousel from "../components/modules/PictureCarousel"; +import Footer from "../components/Footer"; +import BlogRow from "../components/BlogRow"; +import GameComponent from "../components/info/GameComponent"; +import MusicComponent from "../components/info/MusicComponent"; +import EmailLink from "../components/EmailLink"; +import { PROJECTS } from "../data/projects"; +import { PROFILES } from "../utils/site"; + +export default function Home() { + return ( +
+
+

HI, I'M ALEX

+

+ I write software sometimes, then I spend twice as long breaking it +

+
+
+

+ My day job is Senior QA at{" "} + + Medal.tv + {" "} + ( + + we're hiring! + + ), then by night I play with my{" "} + + homelab rack + {" "} + or write some mediocre JavaScript. +

+

+ Based in Western New York (go bills, go sabres). Working on learning + Java, C#. Pointless software for funzies is my passion. You can see{" "} + + what my teams are doing + {" "} + or reach me at . +

+
+ +
+ +
+

+ + LATEST BLOG POST + +

+ +
+ +
+ +
+

+ THINGS I'VE BUILT +

+
+ {PROJECTS.map((project) => ( + + ))} +
+
+ +
+ +
+

+ MY ACTIVITIES +

+ +
+ + +
+ +
+
+

Fantasy

+ +
+
+

+ My Life, Pictured +

+ +
+
+
+ +
+ +
+
+ ); +} diff --git a/src/pages/NotFound.tsx b/src/pages/NotFound.tsx new file mode 100644 index 0000000..02dd785 --- /dev/null +++ b/src/pages/NotFound.tsx @@ -0,0 +1,41 @@ +import { Link } from "react-router-dom"; +import Footer from "../components/Footer"; + +export default function NotFound() { + return ( +
+
+

404

+

+ There's nothing here +

+

+ That page doesn't exist — it may have moved, or it may never have + existed in the first place. +

+
+ +
    +
  • + + Home + +
  • +
  • + + Projects + +
  • +
  • + + Sports + +
  • +
+ +
+ +
+
+ ); +} diff --git a/src/pages/Pictures.tsx b/src/pages/Pictures.tsx index 9c086b1..f1e5355 100644 --- a/src/pages/Pictures.tsx +++ b/src/pages/Pictures.tsx @@ -1,10 +1,34 @@ +import { Link } from "react-router-dom"; import PictureCarousel from "../components/modules/PictureCarousel"; -export default function PicturesPage() { +import Footer from "../components/Footer"; + +export default function Pictures() { return ( -
-
- +
+
+

MY LIFE, PICTURED

+

Pictures

+

+ A rolling selection of photos from my camera roll — Western New York, + the homelab, and whatever else I pointed a camera at. +

-
+ +
+ + + +
+ +

+ + < Back home + +

+ +
+ +