Compare commits
19 Commits
9410af0fb4
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
5c37015702
|
|||
|
7c37a72a5e
|
|||
|
bbbafe89c7
|
|||
|
96a8b655f0
|
|||
|
ac58aaffc8
|
|||
|
c5e6afff6a
|
|||
|
1f78f1bf63
|
|||
|
bd381fbed2
|
|||
|
ec7b546645
|
|||
|
f9f8cc4ad6
|
|||
|
cc6e00d777
|
|||
|
03e82380db
|
|||
|
b19b5b1bd2
|
|||
|
780bb5b65a
|
|||
|
86682f6c92
|
|||
|
31c93b0876
|
|||
|
d2c59f878d
|
|||
|
1779ce0bcf
|
|||
| d219024719 |
@@ -0,0 +1,18 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
Dockerfile
|
||||||
|
.dockerignore
|
||||||
|
README.md
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
name: Build and Publish Docker Image
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Build Docker Image
|
||||||
|
run: |
|
||||||
|
docker build -t git.alexav.gg/${{ gitea.repository }}:latest .
|
||||||
|
docker build -t git.alexav.gg/${{ gitea.repository }}:${{ gitea.sha }} .
|
||||||
|
|
||||||
|
- name: Login to Gitea Registry
|
||||||
|
run: |
|
||||||
|
echo "${{ secrets.RUNNER_TOKEN }}" | docker login git.alexav.gg -u ${{ gitea.actor }} --password-stdin
|
||||||
|
|
||||||
|
- name: Push Docker Image
|
||||||
|
run: |
|
||||||
|
docker push git.alexav.gg/${{ gitea.repository }}:latest
|
||||||
|
docker push git.alexav.gg/${{ gitea.repository }}:${{ gitea.sha }}
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
needs: build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: https://git.alexav.gg/alex/deploy-action@main
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.RUNNER_TOKEN }}
|
||||||
|
task: website_deploy
|
||||||
+35
@@ -0,0 +1,35 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
|
# --- build: needs devDependencies (vite) to produce dist/client + dist/server
|
||||||
|
FROM node:22-alpine AS build
|
||||||
|
WORKDIR /app
|
||||||
|
RUN npm install -g pnpm@10
|
||||||
|
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||||
|
RUN pnpm install --frozen-lockfile
|
||||||
|
COPY . .
|
||||||
|
RUN pnpm build
|
||||||
|
|
||||||
|
# --- deps: runtime dependencies only (express, sirv, compression, react)
|
||||||
|
FROM node:22-alpine AS deps
|
||||||
|
WORKDIR /app
|
||||||
|
RUN npm install -g pnpm@10
|
||||||
|
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||||
|
RUN pnpm install --frozen-lockfile --prod
|
||||||
|
|
||||||
|
# --- runtime
|
||||||
|
FROM node:22-alpine AS runtime
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV PORT=5173
|
||||||
|
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY --from=build /app/dist ./dist
|
||||||
|
COPY package.json server.js ./
|
||||||
|
|
||||||
|
EXPOSE 5173
|
||||||
|
USER node
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s \
|
||||||
|
CMD wget -qO- "http://127.0.0.1:${PORT}/" >/dev/null || exit 1
|
||||||
|
|
||||||
|
CMD ["node", "server.js"]
|
||||||
+28
-24
@@ -3,35 +3,39 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Pathway+Gothic+One&display=swap" rel="stylesheet">
|
|
||||||
<link href="https://mst.fntz.net/@alex" rel="me" />
|
|
||||||
|
|
||||||
<!-- opengraph tags -->
|
<!-- icons -->
|
||||||
<meta property="og:image" content="https://storage.alexav.gg/content/picturev2.jpg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta property="og:title" content="alex" />
|
<link rel="manifest" href="/site.webmanifest" />
|
||||||
<meta property="og:description" content="hi, i'm alex! i'm obsessed with the last of us and i do a lot of code things. about it tbh" />
|
<meta name="theme-color" content="#000000" />
|
||||||
<meta property="og:url" content="https://alexav.gg" />
|
|
||||||
<meta property="og:site_name" content="alex" />
|
|
||||||
|
|
||||||
<!-- twitter tags -->
|
<!-- self-hosted font, declared in src/assets/main.css. preloading the
|
||||||
<meta name="twitter:title" content="alex" />
|
latin regular face keeps it off the discovery critical path -->
|
||||||
<meta name="twitter:description" content="hi, i'm alex! i'm obsessed with the last of us and i do a lot of code things. about it tbh" />
|
<link
|
||||||
<meta name="twitter:url" content="https://alexav.gg" />
|
rel="preload"
|
||||||
<meta name="twitter:image" content="https://storage.alexav.gg/content/picturev2.jpg" />
|
href="/fonts/open-sans-latin-normal.woff2"
|
||||||
<meta name="twitter:card" content="summary" />
|
as="font"
|
||||||
|
type="font/woff2"
|
||||||
|
crossorigin
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- metadata -->
|
<!-- images and live data come from these origins -->
|
||||||
<meta name="title" content="alex" />
|
<link rel="preconnect" href="https://storage.alexav.gg" crossorigin />
|
||||||
<meta name="description" content="hi, i'm alex! i'm obsessed with the last of us and i do a lot of code things. about it tbh" />
|
<link rel="dns-prefetch" href="https://api.alexav.gg" />
|
||||||
<meta name="keywords" content="alex frantz, frantz, alex, qa, javascript, html, react, python, java, alexavier frantz, alexavier, alexav, alexfrantz" />
|
|
||||||
|
|
||||||
<title>alex</title>
|
<script
|
||||||
|
defer
|
||||||
|
data-domain="alexav.gg"
|
||||||
|
src="https://metrics.alexav.gg/js/script.hash.outbound-links.js"
|
||||||
|
></script>
|
||||||
|
|
||||||
|
<!-- title, description, canonical, og/twitter and JSON-LD are injected
|
||||||
|
per route by src/utils/seo.ts. do not add page-level tags here, or
|
||||||
|
they will be duplicated on every url. -->
|
||||||
|
<!--app-head-->
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"><!--app-html--></div>
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
<script type="module" src="/src/entry-client.tsx"></script>
|
||||||
<script defer data-domain="alexav.gg" src="https://metrics.alexav.gg/js/script.js"></script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Generated
-3888
File diff suppressed because it is too large
Load Diff
+13
-5
@@ -4,25 +4,33 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "node server",
|
||||||
"dev:expose": "vite --host",
|
"build": "npm run build:client && npm run build:server",
|
||||||
"build": "tsc -b && vite build",
|
"build:client": "vite build --outDir dist/client",
|
||||||
"lint": "eslint .",
|
"build:server": "vite build --ssr src/entry-server.tsx --outDir dist/server",
|
||||||
"preview": "vite preview"
|
"preview": "cross-env NODE_ENV=production node server",
|
||||||
|
"start": "cross-env NODE_ENV=production node server"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tailwindcss/vite": "4.1.4",
|
"@tailwindcss/vite": "4.1.4",
|
||||||
|
"compression": "^1.8.1",
|
||||||
|
"express": "^5.2.1",
|
||||||
"lucide-react": "0.488.0",
|
"lucide-react": "0.488.0",
|
||||||
"react": "19.0.0",
|
"react": "19.0.0",
|
||||||
"react-dom": "19.0.0",
|
"react-dom": "19.0.0",
|
||||||
"react-router-dom": "7.6.2",
|
"react-router-dom": "7.6.2",
|
||||||
|
"sirv": "^3.0.2",
|
||||||
"tailwindcss": "4.1.4"
|
"tailwindcss": "4.1.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "9.22.0",
|
"@eslint/js": "9.22.0",
|
||||||
|
"@types/compression": "^1.8.1",
|
||||||
|
"@types/express": "^5.0.6",
|
||||||
|
"@types/node": "^26.1.1",
|
||||||
"@types/react": "19.0.10",
|
"@types/react": "19.0.10",
|
||||||
"@types/react-dom": "19.0.4",
|
"@types/react-dom": "19.0.4",
|
||||||
"@vitejs/plugin-react": "4.3.4",
|
"@vitejs/plugin-react": "4.3.4",
|
||||||
|
"cross-env": "^10.1.0",
|
||||||
"eslint": "9.22.0",
|
"eslint": "9.22.0",
|
||||||
"eslint-plugin-react-hooks": "5.2.0",
|
"eslint-plugin-react-hooks": "5.2.0",
|
||||||
"eslint-plugin-react-refresh": "0.4.19",
|
"eslint-plugin-react-refresh": "0.4.19",
|
||||||
|
|||||||
Generated
+3247
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
|||||||
|
allowBuilds:
|
||||||
|
esbuild: false
|
||||||
Binary file not shown.
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" role="img" aria-label="A">
|
||||||
|
<rect width="32" height="32" rx="7" fill="#000000"/>
|
||||||
|
<path d="M8 25 L16 8 L24 25" fill="none" stroke="#bef264" stroke-width="3.2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M11.6 19.4 H20.4" fill="none" stroke="#bef264" stroke-width="3.2" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 379 B |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -2,3 +2,5 @@
|
|||||||
|
|
||||||
User-Agent: *
|
User-Agent: *
|
||||||
Disallow:
|
Disallow:
|
||||||
|
|
||||||
|
Sitemap: https://alexav.gg/sitemap.xml
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import express from "express";
|
||||||
|
|
||||||
|
const isProduction = process.env.NODE_ENV === "production";
|
||||||
|
const port = process.env.PORT || 5173;
|
||||||
|
const base = process.env.BASE || "/";
|
||||||
|
|
||||||
|
// resolve against this file rather than the working directory, so the server
|
||||||
|
// boots the same whether it is started from the repo root or anywhere else
|
||||||
|
const root = import.meta.dirname;
|
||||||
|
const clientDir = path.resolve(root, "dist/client");
|
||||||
|
|
||||||
|
const templateHtml = isProduction
|
||||||
|
? await fs.readFile(path.resolve(clientDir, "index.html"), "utf-8")
|
||||||
|
: "";
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
|
||||||
|
let vite;
|
||||||
|
if (!isProduction) {
|
||||||
|
const { createServer } = await import("vite");
|
||||||
|
vite = await createServer({
|
||||||
|
server: { middlewareMode: true },
|
||||||
|
appType: "custom",
|
||||||
|
base,
|
||||||
|
});
|
||||||
|
app.use(vite.middlewares);
|
||||||
|
} else {
|
||||||
|
const compression = (await import("compression")).default;
|
||||||
|
const sirv = (await import("sirv")).default;
|
||||||
|
app.use(compression());
|
||||||
|
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
|
||||||
|
app.use(base, (req, res, next) => {
|
||||||
|
if (req.path.startsWith("/assets/")) {
|
||||||
|
res.status(404).end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 = stripBase(req.originalUrl);
|
||||||
|
|
||||||
|
let template;
|
||||||
|
if (!isProduction) {
|
||||||
|
template = await fs.readFile(path.resolve(root, "index.html"), "utf-8");
|
||||||
|
template = await vite.transformIndexHtml(url, template);
|
||||||
|
} else {
|
||||||
|
template = templateHtml;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { render } = await loadServerEntry();
|
||||||
|
const rendered = await render(url);
|
||||||
|
const html = template
|
||||||
|
?.replace("<!--app-head-->", rendered.head ?? "")
|
||||||
|
.replace("<!--app-html-->", rendered.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);
|
||||||
|
res.status(500).end(e.stack);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.listen(port, () => {
|
||||||
|
console.log(`Server started at http://localhost:${port}`);
|
||||||
|
});
|
||||||
+60
-2
@@ -1,12 +1,70 @@
|
|||||||
@import "tailwindcss";
|
@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 {
|
body {
|
||||||
font-family: "Pathway Gothic One", sans-serif;
|
font-family:
|
||||||
|
"Open Sans",
|
||||||
|
ui-sans-serif,
|
||||||
|
system-ui,
|
||||||
|
-apple-system,
|
||||||
|
"Segoe UI",
|
||||||
|
sans-serif;
|
||||||
background-color: black;
|
background-color: black;
|
||||||
|
|
||||||
::-webkit-scrollbar {
|
::-webkit-scrollbar {
|
||||||
width: 5px;
|
width: 5px;
|
||||||
height: 10px;
|
height: 5px;
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
background-color: white;
|
background-color: white;
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 17 KiB |
@@ -0,0 +1,46 @@
|
|||||||
|
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() {
|
||||||
|
// 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<BlogPost | null>(posts?.[0] ?? null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch(`${PROFILES.blog}/api/posts`, {
|
||||||
|
method: "GET",
|
||||||
|
}).then(async (res) => {
|
||||||
|
if (res.ok) {
|
||||||
|
try {
|
||||||
|
const data = await res.json();
|
||||||
|
if (Array.isArray(data) && data[0]) setPost(data[0]);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("An error occurred fetching blog posts!", e);
|
||||||
|
}
|
||||||
|
} else console.error("Failed to fetch blog posts!");
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (post) {
|
||||||
|
return (
|
||||||
|
<article>
|
||||||
|
<a
|
||||||
|
className="block space-y-1 hover:underline"
|
||||||
|
href={`${PROFILES.blog}/post/${post.slug}`}
|
||||||
|
>
|
||||||
|
<h3 className="font-semibold">{post.title}</h3>
|
||||||
|
<p className="text-sm">{post.description}</p>
|
||||||
|
<p className="text-xs opacity-50">
|
||||||
|
<time dateTime={post.createdAt}>
|
||||||
|
Posted {new Date(post.createdAt).toLocaleDateString()}
|
||||||
|
</time>
|
||||||
|
</p>
|
||||||
|
</a>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
} else return <ErrorState label="nothing posted yet :(" />;
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<a
|
||||||
|
href={`mailto:${AUTHOR_EMAIL}`}
|
||||||
|
className={className}
|
||||||
|
title="Click to copy"
|
||||||
|
onClick={(e) => {
|
||||||
|
if (!navigator.clipboard) return;
|
||||||
|
e.preventDefault();
|
||||||
|
navigator.clipboard.writeText(AUTHOR_EMAIL).then(
|
||||||
|
() => {
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 1500);
|
||||||
|
},
|
||||||
|
() => {},
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{copied ? "copied!" : AUTHOR_EMAIL}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,31 +1,9 @@
|
|||||||
export default function ErrorState({
|
export default function ErrorState({ label }: { label: string }) {
|
||||||
type = "error",
|
|
||||||
}: {
|
|
||||||
type: "error" | "loading" | "unavailable";
|
|
||||||
}) {
|
|
||||||
if (type === "error") {
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col bg-black text-white">
|
<div className="flex flex-col bg-black text-white">
|
||||||
<div className="px-2 text-center">
|
<div className="px-2 text-center">
|
||||||
<p>whoops, something went wrong</p>
|
<p>{label}</p>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
} else if (type === "loading") {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col bg-black text-white">
|
|
||||||
<div className="px-2 text-center">
|
|
||||||
<h1 className="font-semibold">loading..</h1>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
} else if (type === "unavailable") {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col bg-black text-white">
|
|
||||||
<div className="px-2 text-center">
|
|
||||||
<p>no game launched recently</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|||||||
@@ -18,25 +18,18 @@ export default function FantasyStatus() {
|
|||||||
)) as NFLAPIData;
|
)) as NFLAPIData;
|
||||||
|
|
||||||
// rosters
|
// rosters
|
||||||
const ownMatchup = data.matchups[1] as NFLMatchupState;
|
const ownMatchup = data.team2 as NFLMatchupState;
|
||||||
const oppMatchup = data.matchups[0] as NFLMatchupState;
|
const oppMatchup = data.team1 as NFLMatchupState;
|
||||||
|
|
||||||
setPersonalRoster({
|
setPersonalRoster(ownMatchup);
|
||||||
...ownMatchup,
|
setOpposingRoster(oppMatchup);
|
||||||
owner: data.users[1],
|
|
||||||
});
|
|
||||||
setOpposingRoster({
|
|
||||||
...oppMatchup,
|
|
||||||
owner: data.users[0],
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fetchData();
|
fetchData();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-2">
|
<div className="mt-2 space-y-4">
|
||||||
<div className="mb-4 space-y-4">
|
|
||||||
{personalRoster && opposingRoster && (
|
{personalRoster && opposingRoster && (
|
||||||
<>
|
<>
|
||||||
<TeamRow
|
<TeamRow
|
||||||
@@ -50,6 +43,5 @@ export default function FantasyStatus() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import EmailLink from "./EmailLink";
|
||||||
|
|
||||||
|
export default function Footer() {
|
||||||
|
return (
|
||||||
|
<footer className="w-full flex justify-between">
|
||||||
|
<EmailLink className="text-xs opacity-85 hover:underline" />
|
||||||
|
<p className="text-xs opacity-85">© 2026 Alex Frantz</p>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { PROFILES } from "../utils/site";
|
||||||
|
|
||||||
|
export default function Header() {
|
||||||
|
return (
|
||||||
|
<header className="flex flex-wrap items-center justify-between gap-x-4 gap-y-1 py-4">
|
||||||
|
{/* site chrome, not the page topic — this was an <h1> on every page */}
|
||||||
|
<p className="font-bold">
|
||||||
|
<Link to="/">
|
||||||
|
Alex <span className="font-normal text-blue-500">/</span> dev + QA
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
<nav
|
||||||
|
aria-label="Site"
|
||||||
|
className="flex flex-wrap gap-3 text-sm sm:text-base"
|
||||||
|
>
|
||||||
|
<Link to="/projects" className="hover:underline">
|
||||||
|
Projects
|
||||||
|
</Link>
|
||||||
|
<Link to="/certifications" className="hover:underline">
|
||||||
|
Certifications
|
||||||
|
</Link>
|
||||||
|
<a
|
||||||
|
href={PROFILES.github}
|
||||||
|
target="_blank"
|
||||||
|
rel="me noopener noreferrer"
|
||||||
|
className="hover:underline"
|
||||||
|
>
|
||||||
|
GitHub
|
||||||
|
</a>
|
||||||
|
<a href={PROFILES.blog} rel="me" className="hover:underline">
|
||||||
|
Blog
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<InitialDataContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</InitialDataContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
import Header from "./Header";
|
||||||
|
|
||||||
export default function Layout({ children }: { children: ReactNode }) {
|
export default function Layout({ children }: { children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-neutral-800 text-white min-h-screen flex justify-center items-center select-none">
|
<div className="bg-black text-white min-h-screen w-full max-w-[42rem] 2xl:max-w-[48rem] mx-auto px-5">
|
||||||
|
<Header />
|
||||||
<div className="relative">{children}</div>
|
<div className="relative">{children}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import type { Project } from "../data/projects";
|
||||||
|
|
||||||
|
function Pill({ name }: { name: string }) {
|
||||||
|
return (
|
||||||
|
<div className="bg-zinc-500 rounded-lg text-xs p-1 font-semibold">
|
||||||
|
{name}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProjectCard({ project }: { project: Project }) {
|
||||||
|
return (
|
||||||
|
<div className="bg-neutral-800 rounded-lg p-2 border border-white">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<h3 className="text-base font-bold">
|
||||||
|
<Link
|
||||||
|
to={
|
||||||
|
!project.isCert
|
||||||
|
? `/projects/${project.slug}`
|
||||||
|
: `/certifications/${project.slug}`
|
||||||
|
}
|
||||||
|
className="hover:underline"
|
||||||
|
>
|
||||||
|
{project.title}
|
||||||
|
</Link>
|
||||||
|
</h3>
|
||||||
|
<Link
|
||||||
|
to={
|
||||||
|
!project.isCert
|
||||||
|
? `/projects/${project.slug}`
|
||||||
|
: `/certifications/${project.slug}`
|
||||||
|
}
|
||||||
|
className="shrink-0"
|
||||||
|
>
|
||||||
|
<button className="bg-gray-500 border border-zinc-500 rounded-lg px-2 py-1 text-sm whitespace-nowrap">
|
||||||
|
View >
|
||||||
|
</button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm">{project.description}</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{project.pills.map((v) => (
|
||||||
|
<Pill key={v} name={v} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -14,9 +14,12 @@ export default function PlayerCell({ player }: { player: NFLPlayer | null }) {
|
|||||||
|
|
||||||
if (player && player.full_name) {
|
if (player && player.full_name) {
|
||||||
return (
|
return (
|
||||||
<div className="flex-wrap flex space-x-2">
|
<div className="flex items-center gap-2">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="font-semibold text-1xl">{player.abv_name}</h1>
|
{/* a player name is data, not a document section — this was an <h1>,
|
||||||
|
and with a full roster per team it produced most of the page's
|
||||||
|
heading elements */}
|
||||||
|
<p className="font-semibold text-base">{player.full_name}</p>
|
||||||
<p>
|
<p>
|
||||||
{player?.position} · {player?.team} {injuryStatus && "·"}{" "}
|
{player?.position} · {player?.team} {injuryStatus && "·"}{" "}
|
||||||
{player?.injury_status && (
|
{player?.injury_status && (
|
||||||
|
|||||||
@@ -8,23 +8,29 @@ export function TeamRow({
|
|||||||
roster: NFLMatchupState;
|
roster: NFLMatchupState;
|
||||||
isWinning?: boolean;
|
isWinning?: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
const teamName = roster?.owner.metadata.team_name;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex flex-wrap mb-2">
|
<div className="flex flex-wrap mb-2">
|
||||||
<img
|
<img
|
||||||
width="30"
|
width="30"
|
||||||
className="rounded-lg"
|
height="30"
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
alt={teamName ? `${teamName} avatar` : ""}
|
||||||
|
className="rounded-lg h-[30px] object-cover"
|
||||||
src={roster?.owner.metadata.avatar}
|
src={roster?.owner.metadata.avatar}
|
||||||
/>
|
/>
|
||||||
<h1 className="flex justify-center items-center mx-2 font-semibold">
|
<h4 className="flex justify-center items-center mx-2 font-semibold">
|
||||||
{roster?.owner.metadata.team_name} |
|
{teamName} |
|
||||||
<span className={`${isWinning && "text-green-500"} pl-1`}>
|
<span className={`${isWinning && "text-green-500"} pl-1`}>
|
||||||
{roster?.points}pt
|
{roster?.points}pt
|
||||||
</span>
|
</span>
|
||||||
</h1>
|
</h4>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2 space-x-2 whitespace-nowrap overflow-x-scroll">
|
<div className="space-x-2 whitespace-nowrap overflow-x-auto pb-2">
|
||||||
{roster?.starters.map((v, k) => {
|
{roster?.starters.map((v, k) => {
|
||||||
return (
|
return (
|
||||||
<div className="inline-block" key={k}>
|
<div className="inline-block" key={k}>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import ErrorState from "../ErrorState";
|
import { PROFILES } from "../../utils/site";
|
||||||
import { Gamepad } from "lucide-react";
|
|
||||||
|
|
||||||
type GameObject = {
|
type GameObject = {
|
||||||
currentlyPlaying: CurrentlyPlaying;
|
currentlyPlaying: CurrentlyPlaying;
|
||||||
@@ -34,29 +33,22 @@ export default function GameComponent() {
|
|||||||
fetchGame();
|
fetchGame();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (game) {
|
|
||||||
return (
|
return (
|
||||||
<a href={`https://medal.tv/u/alexav`} target="_blank">
|
<a
|
||||||
<div className="flex flex-col bg-black border border-black text-white">
|
href={PROFILES.medal}
|
||||||
<div className="flex">
|
target="_blank"
|
||||||
<Gamepad className="w-full h-full bg-white text-black" />
|
rel="me noopener noreferrer"
|
||||||
<div className="pl-2 pr-1">
|
aria-label="My Medal.tv profile"
|
||||||
<p className="w-[150px] truncate overflow-hidden whitespace-nowrap">
|
>
|
||||||
{game.currentlyPlaying.game}
|
<div className="bg-white p-2 rounded-lg w-full min-h-full flex justify-center text-black">
|
||||||
</p>
|
<div className="flex justify-center items-center">
|
||||||
<p>
|
<div className="px-2">
|
||||||
{" "}
|
<p className="text-sm font-semibold">
|
||||||
since{" "}
|
{game?.currentlyPlaying.game || "Nothing playing"}
|
||||||
{new Date(
|
|
||||||
game.currentlyPlaying.startedPlayingAt,
|
|
||||||
).toLocaleTimeString()}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
} else {
|
|
||||||
return <ErrorState type="unavailable" />;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import type { LastFMMusicObject } from "../../utils/types";
|
import type { LastFMMusicObject } from "../../utils/types";
|
||||||
import ErrorState from "../ErrorState";
|
|
||||||
|
|
||||||
export default function MusicComponent() {
|
export default function MusicComponent() {
|
||||||
const [music, setMusic] = useState<LastFMMusicObject | null>(null);
|
const [music, setMusic] = useState<LastFMMusicObject | null>(null);
|
||||||
const [failedFetch, setFailedFetch] = useState<boolean>(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function fetchMusic() {
|
async function fetchMusic() {
|
||||||
@@ -13,10 +11,9 @@ export default function MusicComponent() {
|
|||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
setMusic(data as LastFMMusicObject);
|
setMusic(data as LastFMMusicObject);
|
||||||
} else setFailedFetch(true);
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to fetch music!", e);
|
console.error("Failed to fetch music!", e);
|
||||||
setFailedFetch(true);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,27 +23,35 @@ export default function MusicComponent() {
|
|||||||
fetchMusic();
|
fetchMusic();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (!failedFetch) {
|
|
||||||
if (music) {
|
|
||||||
return (
|
return (
|
||||||
<a href="https://koito.fntz.net" target="_blank">
|
<a
|
||||||
<div className="w-full bg-black">
|
href="https://koito.fntz.net"
|
||||||
<div className="text-center">
|
target="_blank"
|
||||||
<div className="pl-2 pr-1">
|
rel="noopener noreferrer"
|
||||||
<p className="text-lg">
|
aria-label="My listening history"
|
||||||
<span className="font-semibold">Currently Playing - </span>
|
>
|
||||||
<span className="underline">{music.title}</span> by{" "}
|
<div className="bg-white p-2 rounded-lg w-full min-h-full text-black">
|
||||||
<span className="underline">{music.artist}</span>
|
<div className="flex justify-center items-center">
|
||||||
</p>
|
{music && (
|
||||||
|
<img
|
||||||
|
src={
|
||||||
|
music.images.filter((v) => 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"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="px-2">
|
||||||
|
<p className="text-sm font-semibold">{music?.title}</p>
|
||||||
|
<p className="text-xs">{music?.artist}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
} else {
|
|
||||||
return <ErrorState type="loading" />;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return <ErrorState type="error" />;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
import { useState, useEffect } from "react";
|
|
||||||
|
|
||||||
export default function Clock() {
|
|
||||||
const [clock, setClock] = useState(
|
|
||||||
new Date().toLocaleTimeString("en-GB", {
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setInterval(() => {
|
|
||||||
const date = new Date().toLocaleTimeString("en-GB", {
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
});
|
|
||||||
if (clock != date) {
|
|
||||||
setClock(date);
|
|
||||||
}
|
|
||||||
}, 500);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<h1 className="text-center bg-white text-black max-w-fit mx-auto px-1">
|
|
||||||
{clock}
|
|
||||||
</h1>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
import { useEffect, useRef } from "react";
|
|
||||||
|
|
||||||
export default function CountdownModule() {
|
|
||||||
const countdownRef = useRef<HTMLParagraphElement | null>(null);
|
|
||||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const dateOfStart = new Date("Sep 7, 2025 20:15:00").getTime();
|
|
||||||
|
|
||||||
setInterval(() => {
|
|
||||||
const currentDate = new Date().getTime();
|
|
||||||
const difference = dateOfStart - currentDate;
|
|
||||||
|
|
||||||
const days = Math.floor(difference / (1000 * 60 * 60 * 24));
|
|
||||||
const hours = Math.floor(
|
|
||||||
(difference % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)
|
|
||||||
);
|
|
||||||
const minutes = Math.floor((difference % (1000 * 60 * 60)) / (1000 * 60));
|
|
||||||
const seconds = Math.floor((difference % (1000 * 60)) / 1000);
|
|
||||||
|
|
||||||
if (countdownRef?.current) {
|
|
||||||
let timerString = "";
|
|
||||||
timerString += `${days || "0"}d, `;
|
|
||||||
timerString += `${hours || "0"}h, `;
|
|
||||||
timerString += `${minutes || "0"}m, `;
|
|
||||||
timerString += `${seconds || "0"}s`;
|
|
||||||
|
|
||||||
if (
|
|
||||||
days <= 0 &&
|
|
||||||
hours <= 0 &&
|
|
||||||
minutes <= 0 &&
|
|
||||||
seconds <= 0 &&
|
|
||||||
timerString === ""
|
|
||||||
) {
|
|
||||||
timerString = "GO BILLS";
|
|
||||||
}
|
|
||||||
|
|
||||||
countdownRef.current.innerText = timerString;
|
|
||||||
}
|
|
||||||
}, 1000);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="border border-white bg-blue-800 border-solid rounded-lg w-full mb-4">
|
|
||||||
<div className="flex items-center justify-between p-2">
|
|
||||||
<img
|
|
||||||
onClick={() => {
|
|
||||||
if (audioRef?.current) {
|
|
||||||
audioRef.current.play();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
width="50"
|
|
||||||
height="50"
|
|
||||||
className="hover:cursor-pointer"
|
|
||||||
src={"https://storage.alexav.gg/content/bills.png"}
|
|
||||||
/>
|
|
||||||
<p className="text-2xl font-bold" ref={countdownRef}></p>
|
|
||||||
</div>
|
|
||||||
<audio
|
|
||||||
ref={audioRef}
|
|
||||||
autoPlay={false}
|
|
||||||
src={
|
|
||||||
"https://storage.alexav.gg/content/0f4ce103-62d0-4ff1-8b8b-e38becced35d.mp3"
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,33 +1,77 @@
|
|||||||
const images = [
|
// alt text lives next to each src so descriptions cannot drift from the image.
|
||||||
"https://storage.alexav.gg/content/1b81a43e-7b6b-4969-944a-f2f334405477.jpg",
|
// the alt values below are placeholders — replace them with what is actually
|
||||||
"https://storage.alexav.gg/content/91bce3da-36c3-497f-aaa9-b782d60c9cd7.jpg",
|
// in each photo, since that is what makes them findable in image search
|
||||||
"https://storage.alexav.gg/content/8ce1c14f-864e-4915-bff4-4cbf73247023.jpg",
|
const images: { src: string; alt: string }[] = [
|
||||||
"https://storage.alexav.gg/content/6687d643-fb69-42f6-8480-51869cdb7efb.jpg",
|
{
|
||||||
"https://storage.alexav.gg/content/f8252684-eb5a-4344-baa1-46f4eab46916.jpg",
|
src: "https://storage.alexav.gg/content/1b81a43e-7b6b-4969-944a-f2f334405477.jpg",
|
||||||
"https://storage.alexav.gg/content/363308c8-ecb6-4b2f-8dba-1c7f7f26f087.jpg",
|
alt: "A photo from Alex's camera roll",
|
||||||
"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",
|
src: "https://storage.alexav.gg/content/91bce3da-36c3-497f-aaa9-b782d60c9cd7.jpg",
|
||||||
"https://storage.alexav.gg/content/d057af80-3d03-45ca-88b2-680c1e7c63c1.jpg",
|
alt: "A photo from Alex's camera roll",
|
||||||
"https://storage.alexav.gg/content/1d717aab-3058-405a-b04d-324d7ab3e658.jpg",
|
},
|
||||||
"https://storage.alexav.gg/content/2f36c1dd-eaf8-4fd3-a3cd-1d25fe8f5a07.jpg",
|
{
|
||||||
|
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() {
|
export default function PictureCarousel() {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg">
|
<ul className="my-2 grid grid-cols-2 sm:grid-cols-3 gap-2 list-none">
|
||||||
<div className="my-2 space-x-2 space-y-2">
|
{images.map((image, k) => {
|
||||||
{images.map((v, k) => {
|
|
||||||
return (
|
return (
|
||||||
|
<li key={image.src}>
|
||||||
<img
|
<img
|
||||||
key={k}
|
// the first row is above the fold on most viewports, so lazy
|
||||||
loading="lazy"
|
// loading it would only delay the LCP candidate
|
||||||
src={v}
|
loading={k < 3 ? "eager" : "lazy"}
|
||||||
className="inline w-[200px] max-h-[100px] object-cover"
|
decoding="async"
|
||||||
|
src={image.src}
|
||||||
|
alt={image.alt}
|
||||||
|
width={320}
|
||||||
|
height={96}
|
||||||
|
className="w-full h-24 object-cover rounded-md"
|
||||||
/>
|
/>
|
||||||
|
</li>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</ul>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,80 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { BillsScore, SabresScore } from "../../utils/types";
|
import { BillsScore, SabresScore, YankeesScore } from "../../utils/types";
|
||||||
|
|
||||||
|
interface TeamRowProps {
|
||||||
|
functionClick?: () => void;
|
||||||
|
width?: number;
|
||||||
|
venue: string;
|
||||||
|
date: string;
|
||||||
|
homeTeam: {
|
||||||
|
name: string;
|
||||||
|
abbreviation: string;
|
||||||
|
score: number;
|
||||||
|
logo: string;
|
||||||
|
};
|
||||||
|
awayTeam: {
|
||||||
|
name: string;
|
||||||
|
abbreviation: string;
|
||||||
|
score: number;
|
||||||
|
logo: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function TeamRow({
|
||||||
|
homeTeam,
|
||||||
|
awayTeam,
|
||||||
|
width = 50,
|
||||||
|
venue,
|
||||||
|
date,
|
||||||
|
functionClick,
|
||||||
|
}: TeamRowProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-row justify-between">
|
||||||
|
<div className="flex flex-row py-2 space-x-2">
|
||||||
|
<img
|
||||||
|
width={width}
|
||||||
|
height={80}
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
alt={`${homeTeam.name} logo`}
|
||||||
|
className="object-cover h-20"
|
||||||
|
src={homeTeam.logo}
|
||||||
|
/>
|
||||||
|
<img
|
||||||
|
onClick={() => {
|
||||||
|
if (
|
||||||
|
(awayTeam.abbreviation === "BUF" ||
|
||||||
|
homeTeam.abbreviation === "BUF") &&
|
||||||
|
functionClick
|
||||||
|
) {
|
||||||
|
functionClick();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
width={width}
|
||||||
|
height={80}
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
alt={`${awayTeam.name} logo`}
|
||||||
|
className="object-cover h-20"
|
||||||
|
src={awayTeam.logo}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="p-2 text-2xl flex items-end flex-col justify-center">
|
||||||
|
<p>
|
||||||
|
{awayTeam.abbreviation} {awayTeam.score} - {homeTeam.abbreviation}{" "}
|
||||||
|
{homeTeam.score}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm">
|
||||||
|
{venue} - {new Date(date || Date.now()).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function ScoreModule() {
|
export default function ScoreModule() {
|
||||||
const [billsScore, setBillsScore] = useState<BillsScore | null>(null);
|
const [billsScore, setBillsScore] = useState<BillsScore | null>(null);
|
||||||
const [sabresScore, setSabresScore] = useState<SabresScore | null>(null);
|
const [sabresScore, setSabresScore] = useState<SabresScore | null>(null);
|
||||||
|
const [yankeesScore, setYankeesScore] = useState<YankeesScore | null>(null);
|
||||||
const [warpath, setWarpath] = useState<boolean>(false);
|
const [warpath, setWarpath] = useState<boolean>(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -31,38 +102,25 @@ export default function ScoreModule() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg w-full mb-2">
|
<div className="rounded-lg w-full">
|
||||||
<div className="flex flex-row justify-between">
|
{sabresScore?.homeTeam && sabresScore.awayTeam && (
|
||||||
<div className="flex flex-row py-2">
|
<TeamRow
|
||||||
<img
|
homeTeam={sabresScore.homeTeam}
|
||||||
onClick={() => {
|
awayTeam={sabresScore.awayTeam}
|
||||||
if (sabresScore?.homeTeam.abbreviation === "BUF")
|
date={sabresScore.date}
|
||||||
setWarpath(true);
|
venue={sabresScore.venue}
|
||||||
}}
|
functionClick={() => setWarpath(true)}
|
||||||
width="80"
|
|
||||||
className="shrink-0"
|
|
||||||
src={sabresScore?.homeTeam.logo}
|
|
||||||
/>
|
/>
|
||||||
<img
|
)}
|
||||||
onClick={() => {
|
{billsScore?.homeTeam && billsScore.awayTeam && (
|
||||||
if (sabresScore?.awayTeam.abbreviation === "BUF")
|
<TeamRow
|
||||||
setWarpath(true);
|
homeTeam={billsScore.homeTeam}
|
||||||
}}
|
awayTeam={billsScore.awayTeam}
|
||||||
width="80"
|
width={50}
|
||||||
className="shrink-0"
|
date={billsScore.gameDay}
|
||||||
src={sabresScore?.awayTeam.logo}
|
venue={billsScore.venue}
|
||||||
/>
|
/>
|
||||||
</div>
|
)}
|
||||||
<div className="p-2 text-2xl flex items-end flex-col">
|
|
||||||
<p>
|
|
||||||
{sabresScore?.homeTeam.abbreviation} {sabresScore?.homeTeam.score} -{" "}
|
|
||||||
{sabresScore?.awayTeam.abbreviation} {sabresScore?.awayTeam.score}
|
|
||||||
</p>
|
|
||||||
<p className="text-sm">
|
|
||||||
{sabresScore?.venue} -{" "}
|
|
||||||
{new Date(sabresScore?.date || Date.now()).toLocaleDateString()}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{warpath && (
|
{warpath && (
|
||||||
<audio
|
<audio
|
||||||
@@ -76,59 +134,5 @@ export default function ScoreModule() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-row justify-between">
|
|
||||||
<div className="flex flex-row space-x-6 px-4 items-center">
|
|
||||||
<img
|
|
||||||
width="80"
|
|
||||||
className="w-full h-[50px]"
|
|
||||||
src={billsScore?.homeTeam.logo}
|
|
||||||
/>
|
|
||||||
<img
|
|
||||||
width="80"
|
|
||||||
className="w-full h-[50px]"
|
|
||||||
src={billsScore?.awayTeam.logo}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="p-2 text-2xl flex items-end flex-col">
|
|
||||||
<p>
|
|
||||||
{billsScore?.homeTeam.abbreviation} {billsScore?.homeTeam.score} -{" "}
|
|
||||||
{billsScore?.awayTeam.abbreviation} {billsScore?.awayTeam.score}
|
|
||||||
</p>
|
|
||||||
<p className="text-sm">
|
|
||||||
{billsScore?.venue} -{" "}
|
|
||||||
{new Date(billsScore?.gameDay || Date.now()).toLocaleDateString()}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/* <div className="flex justify-between">
|
|
||||||
<div>
|
|
||||||
<img
|
|
||||||
onClick={() => {
|
|
||||||
if (type === "sabres") {
|
|
||||||
setWarpath(true);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
width="100"
|
|
||||||
className="p-4"
|
|
||||||
src={IMAGES[type]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-center items-center px-6">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl">
|
|
||||||
{type === "bills"
|
|
||||||
? billsScore?.name
|
|
||||||
: `${sabresScore?.awayTeam.abbrev || "BUF"} @ ${sabresScore?.homeTeam.abbrev || "BUF"}`}
|
|
||||||
</h1>
|
|
||||||
<p className="float-right text-[20px]">
|
|
||||||
{type === "bills"
|
|
||||||
? billsScore?.score
|
|
||||||
: `${sabresScore && sabresScore?.sabresScore > sabresScore?.oppScore ? "W" : "L"} ${sabresScore?.sabresScore || 0}-${sabresScore?.oppScore || 0}`}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
*/}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,7 +40,11 @@ export default function StatModule() {
|
|||||||
if (!failedFetch) {
|
if (!failedFetch) {
|
||||||
if (data) {
|
if (data) {
|
||||||
return (
|
return (
|
||||||
<a href={`https://tracker.alexav.gg/info/${data?.id}`} target="_blank">
|
<a
|
||||||
|
href={`https://tracker.alexav.gg/info/${data?.id}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
<div className="flex flex-col bg-black border border-black">
|
<div className="flex flex-col bg-black border border-black">
|
||||||
<div className="flex">
|
<div className="flex">
|
||||||
<div className="pl-2 pr-1">
|
<div className="pl-2 pr-1">
|
||||||
@@ -61,15 +65,20 @@ export default function StatModule() {
|
|||||||
<img
|
<img
|
||||||
className="w-[50px] h-[50px] object-cover"
|
className="w-[50px] h-[50px] object-cover"
|
||||||
src={data?.image}
|
src={data?.image}
|
||||||
|
alt={data?.title ? `${data.title} cover art` : ""}
|
||||||
|
width={50}
|
||||||
|
height={50}
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
return <ErrorState type="loading" />;
|
return <ErrorState label="loading.." />;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
return <ErrorState type="error" />;
|
return <ErrorState label="something went wrong! :(" />;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
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[];
|
||||||
|
isCert?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
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 const CERTIFICATIONS: Project[] = [
|
||||||
|
{
|
||||||
|
slug: "foundational-csharp",
|
||||||
|
title: "Foundational C# (Microsoft)",
|
||||||
|
description:
|
||||||
|
"Foundational C# course created by Microsoft, issued by FreeCodeCamp",
|
||||||
|
link: "https://www.freecodecamp.org/certification/alexavf/foundational-c-sharp-with-microsoft",
|
||||||
|
pills: ["Software Development", "C#"],
|
||||||
|
body: [
|
||||||
|
"A course created by Microsoft, a comprehensive intro to the core concepts and the syntax of C#.",
|
||||||
|
"Worked with creating basic, logical console applications and how to work with data and properly debug using Visual Studio.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: "introduction-to-frontend-development",
|
||||||
|
title: "Intro to Frontend Development (Meta)",
|
||||||
|
description: "Intro to frontend created by Meta, issued by Coursera",
|
||||||
|
link: "https://coursera.org/share/df3426a13f7bcdaa9f82f88e77554425",
|
||||||
|
pills: ["Software Development", "JavaScript", "HTML", "CSS"],
|
||||||
|
body: [
|
||||||
|
"A course that took me through the basics of frontend development with JS, CSS, HTML and more.",
|
||||||
|
"Currently working on the full certification from Meta, including experience with React, Git, Figma and more.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function findProject(slug: string | undefined) {
|
||||||
|
return PROJECTS.find((p) => p.slug === slug);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findCertification(slug: string | undefined) {
|
||||||
|
return CERTIFICATIONS.find((p) => p.slug === slug);
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
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<string, InitialData | undefined>)[
|
||||||
|
INITIAL_DATA_GLOBAL
|
||||||
|
] ?? {};
|
||||||
|
|
||||||
|
hydrateRoot(
|
||||||
|
document.getElementById("root")!,
|
||||||
|
<BrowserRouter>
|
||||||
|
<InitialDataProvider value={initialData}>
|
||||||
|
<App />
|
||||||
|
</InitialDataProvider>
|
||||||
|
</BrowserRouter>,
|
||||||
|
);
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { renderToString } from "react-dom/server";
|
||||||
|
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";
|
||||||
|
|
||||||
|
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) };
|
||||||
|
|
||||||
|
const html = renderToString(
|
||||||
|
<StaticRouter location={location}>
|
||||||
|
<InitialDataProvider value={data}>
|
||||||
|
<App />
|
||||||
|
</InitialDataProvider>
|
||||||
|
</StaticRouter>,
|
||||||
|
);
|
||||||
|
|
||||||
|
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) => ` <url><loc>${absoluteUrl(path)}</loc></url>`)
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||||
|
${urls}
|
||||||
|
</urlset>
|
||||||
|
`;
|
||||||
|
}
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import { createRoot } from "react-dom/client";
|
|
||||||
import { createBrowserRouter, RouterProvider } from "react-router-dom";
|
|
||||||
import "./assets/main.css";
|
|
||||||
import App from "./pages/App.tsx";
|
|
||||||
|
|
||||||
const router = createBrowserRouter([
|
|
||||||
{
|
|
||||||
path: "/",
|
|
||||||
element: <App />,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
createRoot(document.getElementById("root")!).render(
|
|
||||||
<RouterProvider router={router} />
|
|
||||||
);
|
|
||||||
+32
-55
@@ -1,63 +1,40 @@
|
|||||||
import { useState } from "react";
|
import { useEffect } from "react";
|
||||||
|
import { Routes, Route, useLocation } from "react-router-dom";
|
||||||
import Layout from "../components/Layout";
|
import Layout from "../components/Layout";
|
||||||
import Clock from "../components/modules/Clock";
|
import { routes } from "../routes";
|
||||||
import Button from "../components/Button";
|
import { resolveMeta } from "../utils/resolveMeta";
|
||||||
import Home from "./Home";
|
|
||||||
import ScoreModule from "../components/modules/ScoreModule";
|
/**
|
||||||
import PicturesPage from "./Pictures";
|
* SSR sets the title in the document head; this keeps it correct after a
|
||||||
import FantasyStatus from "../components/FantasyStatus";
|
* 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<HTMLLinkElement>(
|
||||||
|
'link[rel="canonical"]',
|
||||||
|
);
|
||||||
|
if (canonical) {
|
||||||
|
canonical.href = new URL(meta.path, window.location.origin).href;
|
||||||
|
}
|
||||||
|
}, [pathname]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [tab, setTab] = useState<"home" | "scores" | "fantasy" | "pictures">(
|
|
||||||
"home",
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout>
|
<Layout>
|
||||||
<div className="border-3 bg-gradient-to-t from-[#003087] to-[#FFB81C] border-black w-full sm:w-[500px]">
|
<TitleSync />
|
||||||
<div className="bg-[#003087] w-full">
|
<Routes>
|
||||||
<div className="grid grid-cols-3 gap-2 p-2 text-2xl">
|
{routes.map((route) => (
|
||||||
<h1 className="font-semibold text-white">welcome</h1>
|
<Route key={route.path} path={route.path} element={route.element} />
|
||||||
<Clock />
|
))}
|
||||||
<h1 className="text-right text-transparent font-bold bg-clip-text bg-gradient-to-t from-[#003087] to-[#FFB81C]">
|
</Routes>
|
||||||
GO SABRES!
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<img
|
|
||||||
className="h-[100px] object-[25%_35%] w-full object-cover border-b-black border-b-2 border-t-2 border-t-black"
|
|
||||||
style={{
|
|
||||||
objectPosition: "",
|
|
||||||
}}
|
|
||||||
src="https://storage.alexav.gg/content/sabres-board.jpg"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex flex-row space-x-2 justify-center text-2xl my-2">
|
|
||||||
<Button active={tab === "home"} onClick={() => setTab("home")}>
|
|
||||||
home
|
|
||||||
</Button>
|
|
||||||
<Button active={tab === "scores"} onClick={() => setTab("scores")}>
|
|
||||||
scores
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
active={tab === "pictures"}
|
|
||||||
onClick={() => setTab("pictures")}
|
|
||||||
>
|
|
||||||
pictures
|
|
||||||
</Button>
|
|
||||||
<Button active={tab === "fantasy"} onClick={() => setTab("fantasy")}>
|
|
||||||
fantasy
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<hr className="border-black border-1" />
|
|
||||||
|
|
||||||
{tab === "home" && <Home />}
|
|
||||||
{tab === "scores" && <ScoreModule />}
|
|
||||||
{tab === "pictures" && <PicturesPage />}
|
|
||||||
{tab === "fantasy" && <FantasyStatus />}
|
|
||||||
</div>
|
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { Link, useParams } from "react-router-dom";
|
||||||
|
import Footer from "../components/Footer";
|
||||||
|
import NotFound from "./NotFound";
|
||||||
|
import { findCertification } from "../data/projects";
|
||||||
|
|
||||||
|
export default function CertificationDetail() {
|
||||||
|
const { slug } = useParams();
|
||||||
|
const cert = findCertification(slug);
|
||||||
|
|
||||||
|
if (!cert) return <NotFound />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="my-12 sm:my-20">
|
||||||
|
<nav aria-label="Breadcrumb" className="py-2">
|
||||||
|
<p className="opacity-85 text-sm text-left">
|
||||||
|
<Link to="/certifications" className="hover:underline">
|
||||||
|
CERTIFICATIONS
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="py-2">
|
||||||
|
<h1 className="text-3xl sm:text-4xl text-balance py-2">{cert.title}</h1>
|
||||||
|
<div className="flex flex-wrap gap-2 py-2">
|
||||||
|
{cert.pills.map((pill) => (
|
||||||
|
<div
|
||||||
|
key={pill}
|
||||||
|
className="bg-zinc-500 rounded-lg text-xs p-1 font-semibold"
|
||||||
|
>
|
||||||
|
{pill}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="py-2 space-y-6">
|
||||||
|
{cert.body.map((paragraph, i) => (
|
||||||
|
<p key={i}>{paragraph}</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="py-4">
|
||||||
|
<a
|
||||||
|
href={cert.link}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-lime-300 hover:underline"
|
||||||
|
>
|
||||||
|
View the certificate >
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr className="my-4 border-zinc-800" />
|
||||||
|
|
||||||
|
<p className="text-sm">
|
||||||
|
<Link to="/certifications" className="underline">
|
||||||
|
< All certifications
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<hr className="my-4 border-zinc-800" />
|
||||||
|
|
||||||
|
<Footer />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import ProjectCard from "../components/ProjectCard";
|
||||||
|
import Footer from "../components/Footer";
|
||||||
|
import { CERTIFICATIONS } from "../data/projects";
|
||||||
|
|
||||||
|
export default function Certifications() {
|
||||||
|
return (
|
||||||
|
<main className="my-12 sm:my-20">
|
||||||
|
<div className="py-2">
|
||||||
|
<p className="opacity-85 text-sm text-left">CERTIFICATIONS</p>
|
||||||
|
<h1 className="text-3xl sm:text-4xl text-balance py-2">
|
||||||
|
Professional Certifications
|
||||||
|
</h1>
|
||||||
|
<p className="py-2">
|
||||||
|
All of my personal professional certifications for different things in
|
||||||
|
the software dev /QA testing space.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr className="my-4 border-zinc-800" />
|
||||||
|
|
||||||
|
<section aria-labelledby="all-certifications">
|
||||||
|
<h2
|
||||||
|
id="all-certifications"
|
||||||
|
className="opacity-85 text-sm text-left pb-2"
|
||||||
|
>
|
||||||
|
ALL CERTIFICATIONS
|
||||||
|
</h2>
|
||||||
|
<div className="my-2 space-y-2">
|
||||||
|
{CERTIFICATIONS.map((project) => (
|
||||||
|
<ProjectCard
|
||||||
|
key={project.slug}
|
||||||
|
project={{ ...project, isCert: true }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<hr className="my-4 border-zinc-800" />
|
||||||
|
|
||||||
|
<p className="text-sm">
|
||||||
|
<Link to="/" className="underline">
|
||||||
|
< Back home
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<hr className="my-4 border-zinc-800" />
|
||||||
|
|
||||||
|
<Footer />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
+118
-63
@@ -1,78 +1,133 @@
|
|||||||
|
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 MusicComponent from "../components/info/MusicComponent";
|
||||||
|
import EmailLink from "../components/EmailLink";
|
||||||
|
import { PROJECTS, CERTIFICATIONS } from "../data/projects";
|
||||||
|
import { PROFILES } from "../utils/site";
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
return (
|
return (
|
||||||
<>
|
<main className="my-12 sm:my-20">
|
||||||
<div className="p-2">
|
<div className="py-2">
|
||||||
<div className="grid grid-cols-3 gap-2">
|
<p className="opacity-85 text-sm text-left">HI, I'M ALEX</p>
|
||||||
<div>
|
<h1 className="text-3xl sm:text-4xl text-balance py-2">
|
||||||
<h1 className="text-2xl font-semibold">alex (19)</h1>
|
I write software sometimes, then I spend twice as long breaking it
|
||||||
<p>professional yapper</p>
|
</h1>
|
||||||
<p>qa stuff n things @ medal.tv</p>
|
|
||||||
<p>sabres sth, go sabres</p>
|
|
||||||
<p>khalil shakir truther</p>
|
|
||||||
<p>scrappy doo 6 truther</p>
|
|
||||||
</div>
|
</div>
|
||||||
<img
|
<div className="py-2 space-y-6">
|
||||||
className="h-40 w-40 object-cover object-top rounded-lg"
|
|
||||||
src="https://storage.alexav.gg/content/f49b355a-25cd-48ba-8421-da702620fcc6.jpeg"
|
|
||||||
/>
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-bold text-center">socials</h1>
|
|
||||||
<ul>
|
|
||||||
<a href="https://twitter.com/onelonesabre" target="_blank">
|
|
||||||
<li className="border-b-2 border-b-black">twitter</li>
|
|
||||||
</a>
|
|
||||||
<a href="https://instagram.com/alexfrantz07" target="_blank">
|
|
||||||
<li className="border-b-2 border-b-black">instagram</li>
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href="https://steamcommunity.com/id/bayharborsmoocher/"
|
|
||||||
target="_blank"
|
|
||||||
>
|
|
||||||
<li className="border-b-2 border-b-black">steam</li>
|
|
||||||
</a>
|
|
||||||
<a href="https://medal.tv/u/alexav" target="_blank">
|
|
||||||
<li className="border-b-2 border-b-black">medal</li>
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href="https://open.spotify.com/user/vdcj47vkp0cp9jkusnz5nckbj?si=825f8fdef40746ca"
|
|
||||||
target="_blank"
|
|
||||||
>
|
|
||||||
<li className="border-b-2 border-b-black">spotify</li>
|
|
||||||
</a>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<MusicComponent />
|
|
||||||
<div className="p-2">
|
|
||||||
<div className="flex flex-row justify-between">
|
|
||||||
<div className="w-[240px]">
|
|
||||||
<h1 className="text-2xl font-semibold">about me</h1>
|
|
||||||
<p>
|
<p>
|
||||||
born and raised buffalonian + wnyer. doing qa work for 4 years and
|
My day job is Senior QA at{" "}
|
||||||
learning software engineering on the side. i also play acoustic
|
<a
|
||||||
guitar, been gaming since i was 4 and love going to games in
|
className="text-lime-300 hover:underline"
|
||||||
Buffalo. incredibly awkward, but that's besides the point
|
href={PROFILES.medal}
|
||||||
|
target="_blank"
|
||||||
|
rel="me noopener noreferrer"
|
||||||
|
>
|
||||||
|
Medal.tv
|
||||||
|
</a>{" "}
|
||||||
|
(
|
||||||
|
<a
|
||||||
|
href="https://medal.tv/careers"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="underline"
|
||||||
|
>
|
||||||
|
we're hiring!
|
||||||
|
</a>
|
||||||
|
), then by night I play with my{" "}
|
||||||
|
<Link to="/projects/homelab" className="underline">
|
||||||
|
homelab rack
|
||||||
|
</Link>{" "}
|
||||||
|
or write some mediocre JavaScript.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Based in Western New York (go bills, go sabres). Working on learning
|
||||||
|
Java, C#. Pointless software for funzies is my passion. You can see{" "}
|
||||||
|
<Link to="/sports" className="underline">
|
||||||
|
what my teams are doing
|
||||||
|
</Link>{" "}
|
||||||
|
or reach me at <EmailLink className="underline" />.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-2xl w-[200px] mx-auto my-auto">
|
<hr className="my-4 border-zinc-800" />
|
||||||
|
|
||||||
|
<section aria-labelledby="latest-post">
|
||||||
|
<h2
|
||||||
|
id="latest-post"
|
||||||
|
className="opacity-85 text-sm text-left pb-2 underline"
|
||||||
|
>
|
||||||
|
<a href={PROFILES.blog} rel="me">
|
||||||
|
LATEST BLOG POST
|
||||||
|
</a>
|
||||||
|
</h2>
|
||||||
|
<BlogRow />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<hr className="my-4 border-zinc-800" />
|
||||||
|
|
||||||
|
<section aria-labelledby="things-ive-built">
|
||||||
|
<h2 id="things-ive-built" className="opacity-85 text-sm text-left pb-2">
|
||||||
|
THINGS I'VE BUILT
|
||||||
|
</h2>
|
||||||
|
<div className="my-2 space-y-2">
|
||||||
|
{PROJECTS.map((project) => (
|
||||||
|
<ProjectCard key={project.slug} project={project} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<hr className="my-4 border-zinc-800" />
|
||||||
|
|
||||||
|
<section aria-labelledby="certifications">
|
||||||
|
<h2 id="certifications" className="opacity-85 text-sm text-left pb-2">
|
||||||
|
CERTIFICATIONS
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="my-2 space-y-2">
|
||||||
|
{CERTIFICATIONS.map((project) => (
|
||||||
|
<ProjectCard
|
||||||
|
key={project.slug}
|
||||||
|
project={{ ...project, isCert: true }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<hr className="my-4 border-zinc-800" />
|
||||||
|
|
||||||
|
<section aria-labelledby="my-activities">
|
||||||
|
<h2 id="my-activities" className="opacity-85 text-sm text-left pb-2">
|
||||||
|
MY ACTIVITIES
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="grid sm:grid-cols-2 grid-cols-1 gap-2">
|
||||||
|
<GameComponent />
|
||||||
|
<MusicComponent />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="my-2 space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<span className="bg-black text-white px-1">shows</span> dexter,
|
<h3 className="text-sm opacity-85 font-semibold">Fantasy</h3>
|
||||||
pitt, tlou, twd, yellowstone
|
<FantasyStatus />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="bg-black text-white px-1">music</span> ptv, 5sos,
|
<h3 className="text-sm opacity-85 font-semibold">
|
||||||
treaty oak, zach top, zach bryan
|
My Life, Pictured
|
||||||
</div>
|
</h3>
|
||||||
<div>
|
<PictureCarousel />
|
||||||
<span className="bg-black text-white px-1">teams</span> bills,
|
|
||||||
sabres, jays
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
</div>
|
|
||||||
</>
|
<hr className="my-4 border-zinc-800" />
|
||||||
|
|
||||||
|
<Footer />
|
||||||
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import Footer from "../components/Footer";
|
||||||
|
|
||||||
|
export default function NotFound() {
|
||||||
|
return (
|
||||||
|
<main className="my-12 sm:my-20">
|
||||||
|
<div className="py-2">
|
||||||
|
<p className="opacity-85 text-sm text-left">404</p>
|
||||||
|
<h1 className="text-3xl sm:text-4xl text-balance py-2">
|
||||||
|
There's nothing here
|
||||||
|
</h1>
|
||||||
|
<p className="py-2">
|
||||||
|
That page doesn't exist — it may have moved, or it may never have
|
||||||
|
existed in the first place.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className="py-2 space-y-1 list-disc list-inside">
|
||||||
|
<li>
|
||||||
|
<Link to="/" className="underline">
|
||||||
|
Home
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link to="/projects" className="underline">
|
||||||
|
Projects
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link to="/sports" className="underline">
|
||||||
|
Sports
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<hr className="my-4 border-zinc-800" />
|
||||||
|
|
||||||
|
<Footer />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
+29
-7
@@ -1,12 +1,34 @@
|
|||||||
|
import { Link } from "react-router-dom";
|
||||||
import PictureCarousel from "../components/modules/PictureCarousel";
|
import PictureCarousel from "../components/modules/PictureCarousel";
|
||||||
export default function PicturesPage() {
|
import Footer from "../components/Footer";
|
||||||
return (
|
|
||||||
<div className="p-2 flex flex-col justify-center">
|
export default function Pictures() {
|
||||||
<h1 className="text-2xl font-bold">pictures</h1>
|
return (
|
||||||
|
<main className="my-12 sm:my-20">
|
||||||
|
<div className="py-2">
|
||||||
|
<p className="opacity-85 text-sm text-left">MY LIFE, PICTURED</p>
|
||||||
|
<h1 className="text-3xl sm:text-4xl text-balance py-2">Pictures</h1>
|
||||||
|
<p className="py-2">
|
||||||
|
A rolling selection of photos from my camera roll — Western New York,
|
||||||
|
the homelab, and whatever else I pointed a camera at.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr className="my-4 border-zinc-800" />
|
||||||
|
|
||||||
<div className="h-96 overflow-y-auto">
|
|
||||||
<PictureCarousel />
|
<PictureCarousel />
|
||||||
</div>
|
|
||||||
</div>
|
<hr className="my-4 border-zinc-800" />
|
||||||
|
|
||||||
|
<p className="text-sm">
|
||||||
|
<Link to="/" className="underline">
|
||||||
|
< Back home
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<hr className="my-4 border-zinc-800" />
|
||||||
|
|
||||||
|
<Footer />
|
||||||
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { Link, useParams } from "react-router-dom";
|
||||||
|
import Footer from "../components/Footer";
|
||||||
|
import NotFound from "./NotFound";
|
||||||
|
import { findProject } from "../data/projects";
|
||||||
|
|
||||||
|
export default function ProjectDetail() {
|
||||||
|
const { slug } = useParams();
|
||||||
|
const project = findProject(slug);
|
||||||
|
|
||||||
|
if (!project) return <NotFound />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="my-12 sm:my-20">
|
||||||
|
<nav aria-label="Breadcrumb" className="py-2">
|
||||||
|
<p className="opacity-85 text-sm text-left">
|
||||||
|
<Link to="/projects" className="hover:underline">
|
||||||
|
PROJECTS
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="py-2">
|
||||||
|
<h1 className="text-3xl sm:text-4xl text-balance py-2">
|
||||||
|
{project.title}
|
||||||
|
</h1>
|
||||||
|
<div className="flex flex-wrap gap-2 py-2">
|
||||||
|
{project.pills.map((pill) => (
|
||||||
|
<div
|
||||||
|
key={pill}
|
||||||
|
className="bg-zinc-500 rounded-lg text-xs p-1 font-semibold"
|
||||||
|
>
|
||||||
|
{pill}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="py-2 space-y-6">
|
||||||
|
{project.body.map((paragraph, i) => (
|
||||||
|
<p key={i}>{paragraph}</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="py-4">
|
||||||
|
<a
|
||||||
|
href={project.link}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-lime-300 hover:underline"
|
||||||
|
>
|
||||||
|
View the source >
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr className="my-4 border-zinc-800" />
|
||||||
|
|
||||||
|
<p className="text-sm">
|
||||||
|
<Link to="/projects" className="underline">
|
||||||
|
< All projects
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<hr className="my-4 border-zinc-800" />
|
||||||
|
|
||||||
|
<Footer />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import ProjectCard from "../components/ProjectCard";
|
||||||
|
import Footer from "../components/Footer";
|
||||||
|
import { PROJECTS } from "../data/projects";
|
||||||
|
|
||||||
|
export default function Projects() {
|
||||||
|
return (
|
||||||
|
<main className="my-12 sm:my-20">
|
||||||
|
<div className="py-2">
|
||||||
|
<p className="opacity-85 text-sm text-left">THINGS I'VE BUILT</p>
|
||||||
|
<h1 className="text-3xl sm:text-4xl text-balance py-2">Projects</h1>
|
||||||
|
<p className="py-2">
|
||||||
|
Mostly pointless software, built for the fun of building it — a
|
||||||
|
physical scoreboard, a subscription tracker, a data-export scraper,
|
||||||
|
and the homelab that runs all of this.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr className="my-4 border-zinc-800" />
|
||||||
|
|
||||||
|
<section aria-labelledby="all-projects">
|
||||||
|
<h2 id="all-projects" className="opacity-85 text-sm text-left pb-2">
|
||||||
|
ALL PROJECTS
|
||||||
|
</h2>
|
||||||
|
<div className="my-2 space-y-2">
|
||||||
|
{PROJECTS.map((project) => (
|
||||||
|
<ProjectCard key={project.slug} project={project} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<hr className="my-4 border-zinc-800" />
|
||||||
|
|
||||||
|
<p className="text-sm">
|
||||||
|
<Link to="/" className="underline">
|
||||||
|
< Back home
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<hr className="my-4 border-zinc-800" />
|
||||||
|
|
||||||
|
<Footer />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import FantasyStatus from "../components/FantasyStatus";
|
||||||
|
import ScoreModule from "../components/modules/ScoreModule";
|
||||||
|
import Footer from "../components/Footer";
|
||||||
|
|
||||||
|
export default function Sports() {
|
||||||
|
return (
|
||||||
|
<main className="my-12 sm:my-20">
|
||||||
|
<div className="py-2">
|
||||||
|
<p className="opacity-85 text-sm text-left">GO BILLS, GO SABRES</p>
|
||||||
|
<h1 className="text-3xl sm:text-4xl text-balance py-2">Sports</h1>
|
||||||
|
<p className="py-2">
|
||||||
|
Live scores for the Buffalo Bills, Buffalo Sabres and New York
|
||||||
|
Yankees, plus however badly my fantasy team is doing this week.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr className="my-4 border-zinc-800" />
|
||||||
|
|
||||||
|
<section aria-labelledby="scores">
|
||||||
|
<h2 id="scores" className="opacity-85 text-sm text-left pb-2">
|
||||||
|
LATEST SCORES
|
||||||
|
</h2>
|
||||||
|
<ScoreModule />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<hr className="my-4 border-zinc-800" />
|
||||||
|
|
||||||
|
<section aria-labelledby="fantasy">
|
||||||
|
<h2 id="fantasy" className="opacity-85 text-sm text-left pb-2">
|
||||||
|
FANTASY
|
||||||
|
</h2>
|
||||||
|
<FantasyStatus />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<hr className="my-4 border-zinc-800" />
|
||||||
|
|
||||||
|
<p className="text-sm">
|
||||||
|
<Link to="/" className="underline">
|
||||||
|
< Back home
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<hr className="my-4 border-zinc-800" />
|
||||||
|
|
||||||
|
<Footer />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
+153
@@ -0,0 +1,153 @@
|
|||||||
|
import type { ReactElement } from "react";
|
||||||
|
import Home from "./pages/Home";
|
||||||
|
import Projects from "./pages/Projects";
|
||||||
|
import ProjectDetail from "./pages/ProjectDetail";
|
||||||
|
import Sports from "./pages/Sports";
|
||||||
|
import Pictures from "./pages/Pictures";
|
||||||
|
import NotFound from "./pages/NotFound";
|
||||||
|
import {
|
||||||
|
CERTIFICATIONS,
|
||||||
|
PROJECTS,
|
||||||
|
findProject,
|
||||||
|
findCertification,
|
||||||
|
} from "./data/projects";
|
||||||
|
import type { PageMeta } from "./utils/seo";
|
||||||
|
import {
|
||||||
|
profilePageGraph,
|
||||||
|
projectListGraph,
|
||||||
|
projectGraph,
|
||||||
|
certificationGraph,
|
||||||
|
certificationListGraph,
|
||||||
|
} from "./utils/structuredData";
|
||||||
|
import Certifications from "./pages/Certifications";
|
||||||
|
import CertificationDetail from "./pages/CertificationDetail";
|
||||||
|
|
||||||
|
type Params = Record<string, string | undefined>;
|
||||||
|
|
||||||
|
export type AppRoute = {
|
||||||
|
path: string;
|
||||||
|
element: ReactElement;
|
||||||
|
/**
|
||||||
|
* Head metadata for the route. Returns null when the path matched but the
|
||||||
|
* resource does not exist (an unknown project slug), which the server turns
|
||||||
|
* into a real 404 rather than a soft one.
|
||||||
|
*/
|
||||||
|
meta: (params: Params) => PageMeta | null;
|
||||||
|
/** concrete urls to list in the sitemap; omit to exclude the route */
|
||||||
|
sitemapPaths?: () => string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const NOT_FOUND_META: PageMeta = {
|
||||||
|
title: "Page not found — Alex Frantz",
|
||||||
|
description: "That page doesn't exist.",
|
||||||
|
path: "/404",
|
||||||
|
noindex: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const routes: AppRoute[] = [
|
||||||
|
{
|
||||||
|
path: "/",
|
||||||
|
element: <Home />,
|
||||||
|
sitemapPaths: () => ["/"],
|
||||||
|
meta: () => ({
|
||||||
|
title: "Alex Frantz — Senior QA Engineer & Developer",
|
||||||
|
description:
|
||||||
|
"Senior QA engineer at Medal.tv, based in Western New York. I write software, then spend twice as long breaking it — homelab, JavaScript and Python projects.",
|
||||||
|
path: "/",
|
||||||
|
type: "profile",
|
||||||
|
jsonLd: profilePageGraph(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/projects",
|
||||||
|
element: <Projects />,
|
||||||
|
sitemapPaths: () => ["/projects"],
|
||||||
|
meta: () => ({
|
||||||
|
title: "Projects — Alex Frantz",
|
||||||
|
description:
|
||||||
|
"The things I've built: an LED matrix scoreboard, a subscription tracker, a Snapchat export extractor, and the homelab that runs all of it.",
|
||||||
|
path: "/projects",
|
||||||
|
jsonLd: projectListGraph(PROJECTS),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/projects/:slug",
|
||||||
|
element: <ProjectDetail />,
|
||||||
|
sitemapPaths: () => PROJECTS.map((p) => `/projects/${p.slug}`),
|
||||||
|
meta: (params) => {
|
||||||
|
const project = findProject(params.slug);
|
||||||
|
if (!project) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: `${project.title} — Alex Frantz`,
|
||||||
|
description: project.description,
|
||||||
|
path: `/projects/${project.slug}`,
|
||||||
|
type: "article",
|
||||||
|
jsonLd: projectGraph(project),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/certifications",
|
||||||
|
element: <Certifications />,
|
||||||
|
sitemapPaths: () => ["/certifications"],
|
||||||
|
meta: () => ({
|
||||||
|
title: "Certifications — Alex Frantz",
|
||||||
|
description:
|
||||||
|
"All of my professionally-issued certifications. From software development to software testing, I am always studying something.",
|
||||||
|
path: "/certifications",
|
||||||
|
jsonLd: certificationListGraph(PROJECTS),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/certifications/:slug",
|
||||||
|
element: <CertificationDetail />,
|
||||||
|
sitemapPaths: () => CERTIFICATIONS.map((p) => `/certifications/${p.slug}`),
|
||||||
|
meta: (params) => {
|
||||||
|
const certification = findCertification(params.slug);
|
||||||
|
if (!certification) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: `${certification.title} — Alex Frantz`,
|
||||||
|
description: certification.description,
|
||||||
|
path: `/certifications/${certification.slug}`,
|
||||||
|
type: "article",
|
||||||
|
jsonLd: certificationGraph(certification),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/sports",
|
||||||
|
element: <Sports />,
|
||||||
|
sitemapPaths: () => ["/sports"],
|
||||||
|
meta: () => ({
|
||||||
|
title: "Bills, Sabres & Yankees Scores — Alex Frantz",
|
||||||
|
description:
|
||||||
|
"Live Buffalo Bills and Buffalo Sabres scores, plus how my fantasy football matchup is going this week.",
|
||||||
|
path: "/sports",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/pictures",
|
||||||
|
element: <Pictures />,
|
||||||
|
sitemapPaths: () => ["/pictures"],
|
||||||
|
meta: () => ({
|
||||||
|
title: "Pictures — Alex Frantz",
|
||||||
|
description:
|
||||||
|
"Photos from my camera roll: Western New York, the homelab rack, and whatever else I pointed a camera at.",
|
||||||
|
path: "/pictures",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// the in-app catch-all. the server independently answers 404 for anything
|
||||||
|
// that lands here, so this never becomes a soft 404
|
||||||
|
path: "*",
|
||||||
|
element: <NotFound />,
|
||||||
|
meta: () => NOT_FOUND_META,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/** every indexable url, for sitemap.xml */
|
||||||
|
export function sitemapPaths(): string[] {
|
||||||
|
return routes.flatMap((route) => route.sitemapPaths?.() ?? []);
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import type { BlogPost } from "./types";
|
||||||
|
|
||||||
|
const FEED_URL = "https://blog.alexav.gg/api/posts";
|
||||||
|
const TTL_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
let cache: { at: number; posts: BlogPost[] } | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server-side fetch of the blog index, memoized for a few minutes so a crawl
|
||||||
|
* does not hammer blog.alexav.gg. Never throws — a dead feed should degrade to
|
||||||
|
* an empty section, not a 500.
|
||||||
|
*/
|
||||||
|
export async function fetchPosts(): Promise<BlogPost[]> {
|
||||||
|
if (cache && Date.now() - cache.at < TTL_MS) return cache.posts;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(FEED_URL, {
|
||||||
|
signal: AbortSignal.timeout(3000),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`feed responded ${res.status}`);
|
||||||
|
|
||||||
|
const data = (await res.json()) as BlogPost[];
|
||||||
|
// project down to the fields the ui uses. the api also returns the full
|
||||||
|
// post body and internal ids, and all of that would otherwise be inlined
|
||||||
|
// into every html response
|
||||||
|
const posts = (Array.isArray(data) ? data : []).map(
|
||||||
|
({ id, title, description, slug, createdAt }) => ({
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
slug,
|
||||||
|
createdAt,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
cache = { at: Date.now(), posts };
|
||||||
|
return posts;
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to fetch blog posts during SSR:", e);
|
||||||
|
// serve the stale copy rather than nothing if we ever had one
|
||||||
|
return cache?.posts ?? [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { createContext, useContext } from "react";
|
||||||
|
import type { BlogPost } from "./types";
|
||||||
|
|
||||||
|
export type InitialData = {
|
||||||
|
posts?: BlogPost[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Data fetched during SSR and handed to the client, so content that used to
|
||||||
|
* appear only after a `useEffect` is present in the served HTML. Passed
|
||||||
|
* through context rather than a module global, because a module global on the
|
||||||
|
* server would be shared between concurrent requests.
|
||||||
|
*/
|
||||||
|
export const InitialDataContext = createContext<InitialData>({});
|
||||||
|
|
||||||
|
export function useInitialData() {
|
||||||
|
return useContext(InitialDataContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const INITIAL_DATA_GLOBAL = "__INITIAL_DATA__";
|
||||||
|
|
||||||
|
/** the <script> that carries SSR data across to hydration */
|
||||||
|
export function serializeInitialData(data: InitialData) {
|
||||||
|
// </script> inside a json string would close the tag early; escaping the
|
||||||
|
// slash keeps the json valid while making the sequence un-parseable
|
||||||
|
const json = JSON.stringify(data).replace(/</g, "\u003c");
|
||||||
|
return `<script>window.${INITIAL_DATA_GLOBAL}=${json}</script>`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { matchRoutes } from "react-router-dom";
|
||||||
|
import { routes, NOT_FOUND_META, type AppRoute } from "../routes";
|
||||||
|
import type { PageMeta } from "./seo";
|
||||||
|
|
||||||
|
export type Resolved = { meta: PageMeta; status: 200 | 404 };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map a request url to its head metadata and http status. Shared by the server
|
||||||
|
* (which needs the status) and the client (which needs the title on
|
||||||
|
* navigation), so the two can never disagree about what a url is.
|
||||||
|
*/
|
||||||
|
export function resolveMeta(url: string): Resolved {
|
||||||
|
const pathname = normalizePath(url);
|
||||||
|
|
||||||
|
const matches = matchRoutes(
|
||||||
|
routes.map((route) => ({ path: route.path, meta: route.meta })),
|
||||||
|
pathname,
|
||||||
|
);
|
||||||
|
const match = matches?.[matches.length - 1];
|
||||||
|
if (!match) return { meta: NOT_FOUND_META, status: 404 };
|
||||||
|
|
||||||
|
const route = match.route as unknown as AppRoute;
|
||||||
|
// the in-app catch-all resolves, but it is still a miss
|
||||||
|
if (route.path === "*") return { meta: NOT_FOUND_META, status: 404 };
|
||||||
|
|
||||||
|
// meta() returns null when the path shape matched but the resource does not
|
||||||
|
// exist, e.g. /projects/not-a-real-slug
|
||||||
|
const meta = route.meta(match.params);
|
||||||
|
if (!meta) return { meta: NOT_FOUND_META, status: 404 };
|
||||||
|
|
||||||
|
return { meta, status: 200 };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizePath(url: string) {
|
||||||
|
const pathname = url.split("?")[0].split("#")[0];
|
||||||
|
if (!pathname) return "/";
|
||||||
|
return pathname.startsWith("/") ? pathname : `/${pathname}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import {
|
||||||
|
SITE_NAME,
|
||||||
|
SITE_LOCALE,
|
||||||
|
DEFAULT_OG_IMAGE,
|
||||||
|
DEFAULT_OG_IMAGE_ALT,
|
||||||
|
absoluteUrl,
|
||||||
|
} from "./site";
|
||||||
|
|
||||||
|
export type PageMeta = {
|
||||||
|
/** full <title> text, already including any " — Alex Frantz" suffix */
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
/** site-relative path, used for the canonical and og:url */
|
||||||
|
path: string;
|
||||||
|
image?: string;
|
||||||
|
imageAlt?: string;
|
||||||
|
imageWidth?: number;
|
||||||
|
imageHeight?: number;
|
||||||
|
/** og:type, defaults to "website" */
|
||||||
|
type?: string;
|
||||||
|
noindex?: boolean;
|
||||||
|
/** one or more JSON-LD objects to emit as ld+json */
|
||||||
|
jsonLd?: object[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/** escape a value for interpolation into a double-quoted html attribute */
|
||||||
|
function attr(value: string) {
|
||||||
|
return value
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** escape for text content, e.g. inside <title> */
|
||||||
|
function text(value: string) {
|
||||||
|
return value
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">");
|
||||||
|
}
|
||||||
|
|
||||||
|
// </script> inside a json string would close the tag early. escaping the
|
||||||
|
// slash keeps the json valid while making the sequence un-parseable as a tag
|
||||||
|
function jsonLdPayload(data: object) {
|
||||||
|
return JSON.stringify(data).replace(/</g, "\u003c");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the per-route <head> fragment injected at the `<!--app-head-->`
|
||||||
|
* placeholder in index.html. Plain string building rather than a helmet-style
|
||||||
|
* library, because SSR already owns the document head.
|
||||||
|
*/
|
||||||
|
export function renderHead(meta: PageMeta): string {
|
||||||
|
const canonical = absoluteUrl(meta.path);
|
||||||
|
const image = meta.image ?? DEFAULT_OG_IMAGE;
|
||||||
|
const imageAlt = meta.imageAlt ?? DEFAULT_OG_IMAGE_ALT;
|
||||||
|
const type = meta.type ?? "website";
|
||||||
|
|
||||||
|
// a noindex page should not claim a canonical url of its own
|
||||||
|
const canonicalTags = meta.noindex
|
||||||
|
? []
|
||||||
|
: [
|
||||||
|
`<link rel="canonical" href="${attr(canonical)}" />`,
|
||||||
|
`<meta property="og:url" content="${attr(canonical)}" />`,
|
||||||
|
];
|
||||||
|
|
||||||
|
const tags: string[] = [
|
||||||
|
`<title>${text(meta.title)}</title>`,
|
||||||
|
`<meta name="description" content="${attr(meta.description)}" />`,
|
||||||
|
...canonicalTags,
|
||||||
|
meta.noindex
|
||||||
|
? `<meta name="robots" content="noindex, follow" />`
|
||||||
|
: `<meta name="robots" content="index, follow, max-image-preview:large" />`,
|
||||||
|
|
||||||
|
`<meta property="og:type" content="${attr(type)}" />`,
|
||||||
|
`<meta property="og:site_name" content="${attr(SITE_NAME)}" />`,
|
||||||
|
`<meta property="og:locale" content="${attr(SITE_LOCALE)}" />`,
|
||||||
|
`<meta property="og:title" content="${attr(meta.title)}" />`,
|
||||||
|
`<meta property="og:description" content="${attr(meta.description)}" />`,
|
||||||
|
`<meta property="og:image" content="${attr(image)}" />`,
|
||||||
|
`<meta property="og:image:alt" content="${attr(imageAlt)}" />`,
|
||||||
|
|
||||||
|
`<meta name="twitter:card" content="summary_large_image" />`,
|
||||||
|
`<meta name="twitter:title" content="${attr(meta.title)}" />`,
|
||||||
|
`<meta name="twitter:description" content="${attr(meta.description)}" />`,
|
||||||
|
`<meta name="twitter:image" content="${attr(image)}" />`,
|
||||||
|
`<meta name="twitter:image:alt" content="${attr(imageAlt)}" />`,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (meta.imageWidth && meta.imageHeight) {
|
||||||
|
tags.push(
|
||||||
|
`<meta property="og:image:width" content="${meta.imageWidth}" />`,
|
||||||
|
`<meta property="og:image:height" content="${meta.imageHeight}" />`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const block of meta.jsonLd ?? []) {
|
||||||
|
tags.push(
|
||||||
|
`<script type="application/ld+json">${jsonLdPayload(block)}</script>`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return tags.join("\n ");
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
// the public origin, in one place. previously hardcoded in four separate
|
||||||
|
// places in index.html, which is why nothing could build a canonical url
|
||||||
|
export const SITE_ORIGIN = "https://alexav.gg";
|
||||||
|
|
||||||
|
export const SITE_NAME = "Alex Frantz";
|
||||||
|
export const SITE_LOCALE = "en_US";
|
||||||
|
export const AUTHOR_EMAIL = "alex@alexav.gg";
|
||||||
|
|
||||||
|
export const DEFAULT_OG_IMAGE =
|
||||||
|
"https://storage.alexav.gg/content/picturev2.jpg";
|
||||||
|
export const DEFAULT_OG_IMAGE_ALT = "Alex Frantz";
|
||||||
|
|
||||||
|
// external properties, kept together so the JSON-LD `sameAs` graph and the
|
||||||
|
// rendered links cannot drift apart
|
||||||
|
export const PROFILES = {
|
||||||
|
github: "https://github.com/azpha",
|
||||||
|
blog: "https://blog.alexav.gg",
|
||||||
|
medal: "https://medal.tv/u/alexav",
|
||||||
|
gitea: "https://git.alexav.gg/alex",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** absolute url for a site-relative path, with no trailing slash */
|
||||||
|
export function absoluteUrl(path: string) {
|
||||||
|
if (path === "/" || path === "") return SITE_ORIGIN;
|
||||||
|
const withSlash = path.startsWith("/") ? path : `/${path}`;
|
||||||
|
return SITE_ORIGIN + withSlash.replace(/\/+$/, "");
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import {
|
||||||
|
SITE_ORIGIN,
|
||||||
|
SITE_NAME,
|
||||||
|
AUTHOR_EMAIL,
|
||||||
|
PROFILES,
|
||||||
|
absoluteUrl,
|
||||||
|
} from "./site";
|
||||||
|
import type { Project } from "../data/projects";
|
||||||
|
|
||||||
|
const PERSON_ID = `${SITE_ORIGIN}/#alex`;
|
||||||
|
|
||||||
|
/** the Person + WebSite graph, the main signal for a name-query result */
|
||||||
|
export function personGraph(): object[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "Person",
|
||||||
|
"@id": PERSON_ID,
|
||||||
|
name: "Alex Frantz",
|
||||||
|
alternateName: ["alexav", "Alexavier Frantz"],
|
||||||
|
url: SITE_ORIGIN,
|
||||||
|
email: `mailto:${AUTHOR_EMAIL}`,
|
||||||
|
jobTitle: "Senior QA Engineer",
|
||||||
|
worksFor: {
|
||||||
|
"@type": "Organization",
|
||||||
|
name: "Medal.tv",
|
||||||
|
url: "https://medal.tv",
|
||||||
|
},
|
||||||
|
address: {
|
||||||
|
"@type": "PostalAddress",
|
||||||
|
addressRegion: "NY",
|
||||||
|
addressCountry: "US",
|
||||||
|
addressLocality: "Western New York",
|
||||||
|
},
|
||||||
|
knowsAbout: [
|
||||||
|
"Quality Assurance",
|
||||||
|
"Test Automation",
|
||||||
|
"JavaScript",
|
||||||
|
"TypeScript",
|
||||||
|
"React",
|
||||||
|
"Python",
|
||||||
|
"Java",
|
||||||
|
"C#",
|
||||||
|
"Docker",
|
||||||
|
"Homelab infrastructure",
|
||||||
|
],
|
||||||
|
sameAs: [PROFILES.github, PROFILES.blog, PROFILES.medal, PROFILES.gitea],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "WebSite",
|
||||||
|
"@id": `${SITE_ORIGIN}/#website`,
|
||||||
|
url: SITE_ORIGIN,
|
||||||
|
name: SITE_NAME,
|
||||||
|
publisher: { "@id": PERSON_ID },
|
||||||
|
inLanguage: "en-US",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ProfilePage wrapper for the homepage, which is primarily about a person */
|
||||||
|
export function profilePageGraph(): object[] {
|
||||||
|
return [
|
||||||
|
...personGraph(),
|
||||||
|
{
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "ProfilePage",
|
||||||
|
url: SITE_ORIGIN,
|
||||||
|
mainEntity: { "@id": PERSON_ID },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function projectListGraph(projects: Project[]): object[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "CollectionPage",
|
||||||
|
url: absoluteUrl("/projects"),
|
||||||
|
name: "Projects",
|
||||||
|
about: { "@id": PERSON_ID },
|
||||||
|
mainEntity: {
|
||||||
|
"@type": "ItemList",
|
||||||
|
itemListElement: projects.map((project, index) => ({
|
||||||
|
"@type": "ListItem",
|
||||||
|
position: index + 1,
|
||||||
|
url: absoluteUrl(`/projects/${project.slug}`),
|
||||||
|
name: project.title,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function projectGraph(project: Project): object[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "SoftwareSourceCode",
|
||||||
|
name: project.title,
|
||||||
|
description: project.description,
|
||||||
|
url: absoluteUrl(`/projects/${project.slug}`),
|
||||||
|
codeRepository: project.link,
|
||||||
|
programmingLanguage: project.pills,
|
||||||
|
author: { "@id": PERSON_ID },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "BreadcrumbList",
|
||||||
|
itemListElement: [
|
||||||
|
{
|
||||||
|
"@type": "ListItem",
|
||||||
|
position: 1,
|
||||||
|
name: "Projects",
|
||||||
|
item: absoluteUrl("/projects"),
|
||||||
|
},
|
||||||
|
{ "@type": "ListItem", position: 2, name: project.title },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function certificationListGraph(certifications: Project[]): object[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "CollectionPage",
|
||||||
|
url: absoluteUrl("/projects"),
|
||||||
|
name: "Projects",
|
||||||
|
about: { "@id": PERSON_ID },
|
||||||
|
mainEntity: {
|
||||||
|
"@type": "ItemList",
|
||||||
|
itemListElement: certifications.map((project, index) => ({
|
||||||
|
"@type": "ListItem",
|
||||||
|
position: index + 1,
|
||||||
|
url: absoluteUrl(`/certifications/${project.slug}`),
|
||||||
|
name: project.title,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function certificationGraph(certificate: Project): object[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "SoftwareSourceCode",
|
||||||
|
name: certificate.title,
|
||||||
|
description: certificate.description,
|
||||||
|
url: absoluteUrl(`/projects/${certificate.slug}`),
|
||||||
|
codeRepository: certificate.link,
|
||||||
|
programmingLanguage: certificate.pills,
|
||||||
|
author: { "@id": PERSON_ID },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "BreadcrumbList",
|
||||||
|
itemListElement: [
|
||||||
|
{
|
||||||
|
"@type": "ListItem",
|
||||||
|
position: 1,
|
||||||
|
name: "Projects",
|
||||||
|
item: absoluteUrl("/certifications"),
|
||||||
|
},
|
||||||
|
{ "@type": "ListItem", position: 2, name: certificate.title },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
+28
-2
@@ -12,13 +12,15 @@ type LastFMImageObject = {
|
|||||||
// nfl stuff
|
// nfl stuff
|
||||||
interface NFLAPIData {
|
interface NFLAPIData {
|
||||||
nflState: NFLState;
|
nflState: NFLState;
|
||||||
matchups: NFLMatchupState[];
|
team1: NFLMatchupState;
|
||||||
|
team2: NFLMatchupState;
|
||||||
users: SleeperUser[];
|
users: SleeperUser[];
|
||||||
}
|
}
|
||||||
interface NFLMatchupState {
|
interface NFLMatchupState {
|
||||||
points: number;
|
points: number;
|
||||||
owner: SleeperUser;
|
owner: SleeperUser;
|
||||||
starters: NFLPlayer[];
|
starters: NFLPlayer[];
|
||||||
|
players: NFLPlayer[];
|
||||||
starters_points: number[];
|
starters_points: number[];
|
||||||
players_points: object;
|
players_points: object;
|
||||||
}
|
}
|
||||||
@@ -63,9 +65,17 @@ type SabresScore = {
|
|||||||
awayTeam: NHLTeam;
|
awayTeam: NHLTeam;
|
||||||
homeTeam: NHLTeam;
|
homeTeam: NHLTeam;
|
||||||
};
|
};
|
||||||
|
type YankeesScore = {
|
||||||
|
season: number;
|
||||||
|
week: number;
|
||||||
|
date: string;
|
||||||
|
venue: string;
|
||||||
|
awayTeam: MLBTeam;
|
||||||
|
homeTeam: MLBTeam;
|
||||||
|
};
|
||||||
type NFLTeam = {
|
type NFLTeam = {
|
||||||
abbreviation: string;
|
abbreviation: string;
|
||||||
score: string;
|
score: number;
|
||||||
name: string;
|
name: string;
|
||||||
nickname: string;
|
nickname: string;
|
||||||
logo: string;
|
logo: string;
|
||||||
@@ -76,6 +86,20 @@ type NHLTeam = {
|
|||||||
score: number;
|
score: number;
|
||||||
logo: string;
|
logo: string;
|
||||||
};
|
};
|
||||||
|
type MLBTeam = {
|
||||||
|
name: string;
|
||||||
|
abbreviation: string;
|
||||||
|
logo: string;
|
||||||
|
score: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface BlogPost {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
slug: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
LastFMMusicObject,
|
LastFMMusicObject,
|
||||||
@@ -87,4 +111,6 @@ export type {
|
|||||||
NFLPlayer,
|
NFLPlayer,
|
||||||
BillsScore,
|
BillsScore,
|
||||||
SabresScore,
|
SabresScore,
|
||||||
|
YankeesScore,
|
||||||
|
BlogPost,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
"moduleDetection": "force",
|
"moduleDetection": "force",
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
|
"types": ["node"],
|
||||||
|
|
||||||
/* Linting */
|
/* Linting */
|
||||||
"strict": true,
|
"strict": true,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"moduleDetection": "force",
|
"moduleDetection": "force",
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
|
"types": ["node"],
|
||||||
|
|
||||||
/* Linting */
|
/* Linting */
|
||||||
"strict": true,
|
"strict": true,
|
||||||
|
|||||||
@@ -5,4 +5,19 @@ import tailwindcss from "@tailwindcss/vite";
|
|||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react(), tailwindcss()],
|
plugins: [react(), tailwindcss()],
|
||||||
|
ssr: {
|
||||||
|
// react-router-dom's "node" export condition points at its CommonJS build,
|
||||||
|
// so SSR would otherwise get a module whose named exports all hang off
|
||||||
|
// `.default` — every `import { Routes } from "react-router-dom"` throws at
|
||||||
|
// request time in dev. "module-sync" selects the ESM entry instead.
|
||||||
|
resolve: {
|
||||||
|
conditions: [
|
||||||
|
"module-sync",
|
||||||
|
"module",
|
||||||
|
"browser",
|
||||||
|
"development|production",
|
||||||
|
],
|
||||||
|
externalConditions: ["module-sync", "node", "import"],
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user