website v3

This commit is contained in:
zoop 2026-09-08 19:05:10 -04:00
commit 2a3f249c78
No known key found for this signature in database
GPG Key ID: 15C0D336C9E08F3F
62 changed files with 10597 additions and 0 deletions

14
.dev.vars.example Normal file
View File

@ -0,0 +1,14 @@
# copy to .dev.vars (git-ignored) for local dev, and set the same in
# `wrangler secret put` / Cloudflare project settings for prod.
# admin panel (/admin)
ADMIN_PASSWORD=
# 32+ random bytes, e.g. `openssl rand -hex 32`
SESSION_SECRET=
# one-time: get a refresh token via the spotify authorization-code flow with
# scopes: user-read-currently-playing user-read-recently-played user-top-read
# without these the "now playing" / "on repeat" sections just stay hidden.
SPOTIFY_CLIENT_ID=
SPOTIFY_CLIENT_SECRET=
SPOTIFY_REFRESH_TOKEN=

30
.gitignore vendored Normal file
View File

@ -0,0 +1,30 @@
# build output
dist/
# generated types
.astro/
# dependencies
node_modules/
# logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# environment variables
.env
.env.production
# macOS-specific files
.DS_Store
# jetbrains setting folder
.idea/
# local env
.dev.vars
# wrangler local state (miniflare KV/D1/etc.)
.wrangler/

4
.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,4 @@
{
"recommendations": ["astro-build.astro-vscode"],
"unwantedRecommendations": []
}

11
.vscode/launch.json vendored Normal file
View File

@ -0,0 +1,11 @@
{
"version": "0.2.0",
"configurations": [
{
"command": "./node_modules/.bin/astro dev",
"name": "Development server",
"request": "launch",
"type": "node-terminal"
}
]
}

22
AGENTS.md Normal file
View File

@ -0,0 +1,22 @@
## Development
When starting the dev server, use background mode:
```
astro dev --background
```
Manage the background server with `astro dev stop`, `astro dev status`, and `astro dev logs`.
## Documentation
Full documentation: https://docs.astro.build
Consult these guides before working on related tasks:
- [Adding pages, dynamic routes, or middleware](https://docs.astro.build/en/guides/routing/)
- [Working with Astro components](https://docs.astro.build/en/basics/astro-components/)
- [Using React, Vue, Svelte, or other framework components](https://docs.astro.build/en/guides/framework-components/)
- [Adding or managing content](https://docs.astro.build/en/guides/content-collections/)
- [Adding styles or using Tailwind](https://docs.astro.build/en/guides/styling/)
- [Supporting multiple languages](https://docs.astro.build/en/guides/internationalization/)

1
CLAUDE.md Symbolic link
View File

@ -0,0 +1 @@
AGENTS.md

50
README.md Normal file
View File

@ -0,0 +1,50 @@
# zachy.cc — v3
Third pass at the personal site. Astro + the Cloudflare adapter (matches where
v2 is hosted). Calm single-column layout, serif body / mono labels, light + dark
following the OS with a manual toggle.
## Stack
- **Astro 7**, `@astrojs/cloudflare` adapter (mostly static; one on-demand route)
- Self-hosted fonts: Newsreader (body), JetBrains Mono (labels/meta), Unbounded (wordmark)
- No UI framework, minimal client JS
## Layout
```
src/
data/site.ts all copy lives here — bio, work, values, links, buttons
layouts/Base.astro <head>, top bar, footer, theme toggle (no-flash script)
components/
Music.astro "now playing" + "on repeat" shells
CopyEmail.astro click-to-copy with a little tooltip
Webrings.astro hotline + online webrings
Buttons.astro 88x31 wall — images in public/badges/
scripts/music.ts hydrates the music sections from /api/spotify.json
pages/
index.astro
projects.astro placeholder list — swap in real projects
notes/ content collection (src/content/notes/*.md)
api/spotify.json.ts Spotify Web API proxy (on-demand)
```
## Editing
- **Text**: `src/data/site.ts`.
- **Notes**: drop a `.md` in `src/content/notes/` with `title` / `date` / `summary`.
- **Buttons**: add to `buttons` in `site.ts` and put the gif in `public/badges/`.
## Spotify (optional)
`now playing` / `top tracks` need three env vars — see `.dev.vars.example`.
Without them the sections stay hidden and everything else works.
Add `?mockmusic` to any URL to preview those sections with fake data.
## Commands
```
npm run dev # localhost:4321
npm run build
npm run preview # wrangler, runs the built worker
```

24
astro.config.mjs Normal file
View File

@ -0,0 +1,24 @@
import { defineConfig } from 'astro/config';
import cloudflare from '@astrojs/cloudflare';
export default defineConfig({
output: 'server',
adapter: cloudflare({
platformProxy: { enabled: true },
}),
vite: {
server: {
watch: {
ignored: ['**/.wrangler/**', '**/.dev.vars', '**/.mf/**'],
},
},
},
});

143
migrated-projects.json Normal file
View File

@ -0,0 +1,143 @@
[
{
"id": "drop",
"title": "drop",
"blurb": "encrypted, no-account file transfer",
"year": "2025",
"tags": [
"workers",
"d1",
"r2"
],
"featured": false,
"draft": false,
"href": "https://drop.zachy.cc",
"repo": "https://github.com/zoop-dev/drop"
},
{
"id": "b3am",
"title": "b3am",
"blurb": "end-to-end encrypted messenger",
"year": "2026",
"tags": [
"workers",
"d1",
"websocket"
],
"featured": false,
"draft": false,
"href": "https://b3am.zo0p.dev"
},
{
"id": "pulse",
"title": "pulse",
"blurb": "Garmin companion app",
"year": "2026",
"tags": [
"kotlin",
"java"
],
"featured": false,
"draft": false,
"href": "https://fdroid.zachy.cc"
},
{
"id": "foyer",
"title": "foyer",
"blurb": "personal website framework + CMS",
"year": "2026",
"tags": [
"node",
"cloudflare"
],
"featured": false,
"draft": false,
"href": "https://foyer.zo0p.dev"
},
{
"id": "my-portfolio",
"title": "my portfolio",
"blurb": "codes",
"year": "2026",
"tags": [
"supabase",
"js"
],
"featured": false,
"draft": false,
"href": "https://portfolio.zo0p.dev"
},
{
"id": "sp33d",
"title": "sp33d",
"blurb": "network speed tester",
"year": "2026",
"tags": [],
"featured": false,
"draft": false,
"href": "https://sp33d.zo0p.dev"
},
{
"id": "pr0xy",
"title": "pr0xy",
"blurb": "web proxy",
"year": "2026",
"tags": [],
"featured": false,
"draft": false,
"href": "https://pr0xy.zo0p.dev"
},
{
"id": "m0rph",
"title": "m0rph",
"blurb": "",
"year": "2026",
"tags": [],
"featured": false,
"draft": false,
"href": "https://m0rph.zo0p.dev"
},
{
"id": "shortl3y",
"title": "shortl3y",
"blurb": "url shortener",
"year": "2026",
"tags": [],
"featured": false,
"draft": false,
"href": "https://go.zo0p.dev"
},
{
"id": "backly",
"title": "backly",
"blurb": "a little code backup tool",
"year": "",
"tags": [
"cli",
"javascript"
],
"featured": false,
"draft": false,
"repo": "https://github.com/zoop-dev/backly"
},
{
"id": "pwa-stuff",
"title": "pwa stuff",
"blurb": "cool apps",
"year": "",
"tags": [],
"featured": false,
"draft": false,
"href": "https://pwa.zachy.cc"
},
{
"id": "cm",
"title": "cm",
"blurb": "compression",
"year": "",
"tags": [],
"featured": false,
"draft": false,
"href": "https://cm.loop82.com"
}
]

5687
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

28
package.json Normal file
View File

@ -0,0 +1,28 @@
{
"name": "website-v3",
"type": "module",
"version": "0.0.1",
"engines": {
"node": ">=22.12.0"
},
"scripts": {
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview",
"astro": "astro",
"generate-types": "wrangler types",
"commit": "node scripts/commit.mjs"
},
"dependencies": {
"@astrojs/cloudflare": "^14.3.0",
"@fontsource-variable/jetbrains-mono": "^5.3.0",
"@fontsource-variable/newsreader": "^5.3.0",
"@fontsource/unbounded": "^5.3.0",
"astro": "^7.3.1",
"wrangler": "^4.129.0"
},
"allowScripts": {
"esbuild": true,
"workerd@1.20260903.1": true
}
}

BIN
public/badges/anins1der.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 571 B

BIN
public/badges/brave.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

BIN
public/badges/daudix.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 518 B

BIN
public/badges/fedora.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

BIN
public/badges/ghostk.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

BIN
public/badges/mambuco.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 582 B

BIN
public/badges/matrix.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

BIN
public/badges/matrix.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 638 B

BIN
public/badges/pixl_xip.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 794 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 483 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 444 B

BIN
public/badges/sky.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

BIN
public/badges/sky.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

BIN
public/badges/snug-nook.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

BIN
public/badges/zachy.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

BIN
public/badges/zachy.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 693 B

BIN
public/cork.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

12
public/favicon.svg Normal file
View File

@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<defs>
<linearGradient id="g" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="rgb(255,224,138)"/>
<stop offset="33%" stop-color="rgb(255,171,94)"/>
<stop offset="66%" stop-color="rgb(255,95,109)"/>
<stop offset="100%" stop-color="rgb(200,75,214)"/>
</linearGradient>
</defs>
<rect width="32" height="32" rx="6" fill="#000000"/>
<path d="M9 8 L23 8 L23 11.5 L13.5 20.5 L23 20.5 L23 24 L9 24 L9 20.5 L18.5 11.5 L9 11.5 Z" fill="url(#g)"/>
</svg>

After

Width:  |  Height:  |  Size: 566 B

BIN
public/og-image.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

478
scripts/commit.mjs Normal file
View File

@ -0,0 +1,478 @@
#!/usr/bin/env node
import { execFileSync } from "node:child_process";
import {
copyFileSync,
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
readdirSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { dirname, extname, join, relative } from "node:path";
import { createInterface } from "node:readline/promises";
import { pathToFileURL } from "node:url";
import { transform } from "esbuild";
const ROOT = process.cwd();
const DRY = process.argv.includes("--dry-run");
const MSG_ARG = process.argv[2] && !process.argv[2].startsWith("--") ? process.argv[2] : undefined;
const SKIP_DIRS = new Set([".git", "node_modules", "dist", ".astro", ".wrangler", "public", ".vscode"]);
const SKIP_FILES = new Set([".dev.vars", ".dev.vars.example", "package-lock.json"]);
const PROCESS_EXT = new Set([
".ts", ".mts", ".cts", ".js", ".mjs", ".cjs", ".jsx", ".tsx", ".astro", ".jsonc", ".css",
]);
const LOADER_BY_EXT = {
".ts": "ts", ".mts": "ts", ".cts": "ts",
".js": "js", ".mjs": "js", ".cjs": "js",
".jsx": "jsx", ".tsx": "tsx",
};
const KW_REGEX = new Set([
"return", "typeof", "instanceof", "in", "of", "yield", "await", "case",
"delete", "void", "throw", "new", "do", "else",
]);
let backup = null;
let changed = [];
function restore() {
if (DRY || !backup) return;
for (const rel of changed) {
const b = join(backup, rel);
if (existsSync(b)) copyFileSync(b, join(ROOT, rel));
}
}
process.on("SIGINT", () => {
console.error("\ninterrupted — restoring comments");
restore();
process.exit(130);
});
function stripJs(code, { lineComments = true, stats } = {}) {
const out = [];
const n = code.length;
let i = 0;
let state = "code";
const frames = [];
let braceDepth = 0;
let inClass = false;
let lastSig = "";
let lastWord = "";
const emit = (s) => {
for (const ch of s) {
out.push(ch);
if (/\s/.test(ch)) continue;
lastSig = ch;
if (/[A-Za-z0-9_$]/.test(ch)) lastWord += ch;
else lastWord = "";
}
};
while (i < n) {
const c = code[i];
const nx = code[i + 1];
switch (state) {
case "code":
if (c === "/" && nx === "/" && lineComments) {
if (stats) stats.removed++;
state = "line";
i += 2;
} else if (c === "/" && nx === "*") {
if (stats) stats.removed++;
state = "block";
i += 2;
} else if (c === "'") {
state = "sq";
emit(c);
i++;
} else if (c === '"') {
state = "dq";
emit(c);
i++;
} else if (c === "`") {
frames.push({ kind: "tpl", prev: state });
state = "tpl";
emit(c);
i++;
} else if (c === "/") {
const isRegex =
lastSig === "" ||
"([{=,:;!&|?".includes(lastSig) ||
(/[A-Za-z0-9_$]/.test(lastSig) && KW_REGEX.has(lastWord));
state = isRegex ? "regex" : "code";
if (isRegex) inClass = false;
emit(c);
i++;
} else if (c === "}") {
const top = frames[frames.length - 1];
if (top && top.kind === "interp" && braceDepth === top.depth) {
frames.pop();
state = "tpl";
} else if (braceDepth > 0) {
braceDepth--;
}
emit(c);
i++;
} else {
if (c === "{") braceDepth++;
emit(c);
i++;
}
break;
case "line":
if (c === "\r" || c === "\n") {
if (c === "\r" && nx === "\n") {
emit("\r\n");
i += 2;
} else {
emit(c);
i++;
}
state = "code";
} else {
i++;
}
break;
case "block":
if (c === "*" && nx === "/") {
emit(" ");
i += 2;
state = "code";
} else {
i++;
}
break;
case "sq":
case "dq":
emit(c);
if (c === "\\" && nx) {
emit(nx);
i += 2;
} else {
if ((state === "sq" && c === "'") || (state === "dq" && c === '"')) state = "code";
i++;
}
break;
case "tpl":
emit(c);
if (c === "\\" && nx) {
emit(nx);
i += 2;
} else if (c === "`") {
frames.pop();
state = "code";
i++;
} else if (c === "$" && nx === "{") {
frames.push({ kind: "interp", depth: braceDepth });
emit("{");
state = "code";
i += 2;
} else {
i++;
}
break;
case "regex":
emit(c);
if (c === "\\" && nx) {
emit(nx);
i += 2;
} else if (c === "[") {
inClass = true;
i++;
} else if (c === "]") {
inClass = false;
i++;
} else if (c === "/" && !inClass) {
state = "code";
i++;
} else {
i++;
}
break;
}
}
return out.join("");
}
export { stripJs, stripCss, stripAstro };
function stripCss(code, stats) {
return stripJs(code, { lineComments: false, stats });
}
function stripAstro(code, stats, parts) {
let rest = code;
let head = "";
const open = /^---\r?\n/.exec(code);
if (open) {
const m = /^---\r?\n([\s\S]*?)\r?\n---(\r?\n|$)/.exec(code);
if (m) {
const fm = stripJs(m[1], { stats });
parts.push({ code: fm, loader: "ts", label: "frontmatter" });
head = "---\n" + fm + "\n---" + (m[2] === "\r\n" ? "\r\n" : "\n");
rest = code.slice(m[0].length);
}
}
let i = 0;
const n = rest.length;
while (i < n) {
if (rest.startsWith("<script", i)) {
const tagEnd = rest.indexOf(">", i);
const close = rest.indexOf("</script>", tagEnd + 1);
if (tagEnd === -1 || close === -1) {
head += rest.slice(i);
break;
}
const inner = stripJs(rest.slice(tagEnd + 1, close), { stats });
parts.push({ code: inner, loader: "ts", label: "<script>" });
head += rest.slice(i, tagEnd + 1) + inner + rest.slice(close, close + "</script>".length);
i = close + "</script>".length;
} else if (rest.startsWith("<style", i)) {
const tagEnd = rest.indexOf(">", i);
const close = rest.indexOf("</style>", tagEnd + 1);
if (tagEnd === -1 || close === -1) {
head += rest.slice(i);
break;
}
const inner = stripCss(rest.slice(tagEnd + 1, close), stats);
head += rest.slice(i, tagEnd + 1) + inner + rest.slice(close, close + "</style>".length);
i = close + "</style>".length;
} else if (rest.startsWith("<!--", i)) {
const end = rest.indexOf("-->", i + 4);
if (end === -1) {
i = n;
break;
}
if (stats) stats.removed++;
i = end + 3;
} else if (rest.startsWith("{/*", i)) {
const end = rest.indexOf("*/}", i + 3);
if (end === -1) {
i = n;
break;
}
if (stats) stats.removed++;
i = end + 3;
} else {
head += rest[i];
i++;
}
}
return head;
}
function collect() {
const out = [];
const walk = (dir) => {
for (const ent of readdirSync(dir, { withFileTypes: true })) {
const p = join(dir, ent.name);
if (ent.isDirectory()) {
if (SKIP_DIRS.has(ent.name)) continue;
walk(p);
} else if (ent.isFile()) {
if (SKIP_FILES.has(ent.name)) continue;
const rel = relative(ROOT, p);
if (PROCESS_EXT.has(extname(p))) out.push(rel);
}
}
};
walk(ROOT);
return out.sort();
}
function stripFile(rel) {
const stats = { removed: 0 };
const src = readFileSync(join(ROOT, rel), "utf8");
const ext = extname(rel);
const parts = [];
let out;
if (ext === ".astro") {
out = stripAstro(src, stats, parts);
} else if (ext === ".css") {
out = stripCss(src, stats);
} else {
out = stripJs(src, { stats });
if (LOADER_BY_EXT[ext]) parts.push({ code: out, loader: LOADER_BY_EXT[ext], label: "file" });
}
return { rel, src, out, stats, parts, changed: src !== out };
}
async function validate(r) {
const problems = [];
for (const { code, loader, label } of r.parts) {
try {
await transform(code, { loader });
} catch (e) {
problems.push({ fatal: true, msg: `${r.rel} (${label}): ${e.errors?.[0]?.text ?? e.message}` });
}
}
if (extname(r.rel) === ".jsonc") {
try {
JSON.parse(r.out);
} catch {
problems.push({ fatal: false, msg: `${r.rel}: no longer strict JSON after strip (ok if it's jsonc with trailing commas)` });
}
}
return problems;
}
async function main() {
if (DRY) {
console.log("dry run — stripping in memory, touching nothing\n");
} else {
try {
execFileSync("git", ["rev-parse", "--is-inside-work-tree"], { stdio: "pipe" });
} catch {
console.error("not a git repository — `git init` + `git remote add origin …` first, then run again");
process.exit(1);
}
const remotes = String(execFileSync("git", ["remote"], { stdio: "pipe" })).trim();
if (!remotes) {
console.error("no git remote configured — add one (`git remote add origin …`) so the push has somewhere to go");
process.exit(1);
}
backup = mkdtempSync(join(tmpdir(), "commit-clean-"));
}
let stripped = 0;
const problems = [];
for (const rel of collect()) {
const r = stripFile(rel);
problems.push(...(await validate(r)));
if (!r.changed) continue;
stripped += r.stats.removed;
changed.push(rel);
if (DRY) {
console.log(` ${rel} ${r.stats.removed} comment${r.stats.removed === 1 ? "" : "s"}`);
} else {
const dest = join(backup, rel);
mkdirSync(dirname(dest), { recursive: true });
copyFileSync(join(ROOT, rel), dest);
writeFileSync(join(ROOT, rel), r.out);
}
}
const fatal = problems.filter((p) => p.fatal);
if (fatal.length) {
console.error("\nvalidation failed — aborting, nothing was committed:");
for (const p of fatal) console.error(" " + p.msg);
restore();
process.exit(1);
}
for (const p of problems) console.warn("warn: " + p.msg);
if (!changed.length) {
console.log("no comments found — nothing to strip or commit");
return;
}
console.log(`\n${changed.length} file${changed.length === 1 ? "" : "s"}, ${stripped} comment${stripped === 1 ? "" : "s"} stripped`);
if (DRY) return;
try {
execFileSync("git", ["add", "-A"], { stdio: "inherit" });
let hasChanges = true;
try {
execFileSync("git", ["diff", "--cached", "--quiet"], { stdio: "pipe" });
hasChanges = false;
} catch {}
if (!hasChanges) {
restore();
console.log("stripped snapshot already matches the last commit — nothing new to commit; local comments restored");
return;
}
const stat = String(execFileSync("git", ["diff", "--cached", "--stat"], { stdio: "pipe" }));
const branch = String(execFileSync("git", ["branch", "--show-current"], { stdio: "pipe" })).trim();
console.log(`\nstaged for commit on ${branch || "(detached HEAD)"}:\n${stat}`);
let message = MSG_ARG;
if (!message) {
const rl = createInterface({ input: process.stdin, output: process.stdout });
message = (await rl.question("Commit message: ")).trim();
rl.close();
}
if (!message) {
console.error("empty commit message — aborting");
throw new Error("abort");
}
execFileSync("git", ["commit", "-m", message], { stdio: "inherit" });
try {
execFileSync("git", ["push"], { stdio: "inherit" });
} catch {
console.error("\npush failed — commit is local. if this branch has no upstream yet:\n git push -u origin HEAD");
throw new Error("push");
}
console.log("\ncommitted (comment-stripped) and pushed ✓");
} finally {
restore();
console.log("local comments restored ✓");
}
console.log("note: the committed copy has no comments; your working tree keeps them, so git will show\n those files as modified until the next code change");
if (backup) rmSync(backup, { recursive: true, force: true });
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
main().catch((e) => {
restore();
if (e.message !== "abort" && e.message !== "push") console.error(e.message);
if (backup) console.error(`originals backed up at ${backup} — restore with:\n cp -r ${backup}/. .`);
process.exit(1);
});
}

74
scripts/gen-cork.mjs Normal file
View File

@ -0,0 +1,74 @@
import sharp from "sharp";
const W = 56, H = 56;
function mulberry32(a) {
return function () {
a |= 0; a = (a + 0x6D2B79F5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
const rand = mulberry32(20260906);
const shades = [
[0x60, 0x47, 0x2b],
[0x6b, 0x4f, 0x30], [0x6b, 0x4f, 0x30], [0x6b, 0x4f, 0x30],
[0x73, 0x56, 0x36], [0x73, 0x56, 0x36],
[0x7c, 0x5e, 0x3c],
[0x58, 0x40, 0x26],
[0x85, 0x66, 0x43],
];
const grid = new Array(W * H);
const wrap = (v, m) => ((v % m) + m) % m;
const idx = (x, y) => wrap(y, H) * W + wrap(x, W);
for (let i = 0; i < W * H; i++) grid[i] = shades[Math.floor(rand() * shades.length)];
for (let pass = 0; pass < 3; pass++) {
const next = grid.slice();
for (let y = 0; y < H; y++)
for (let x = 0; x < W; x++) {
if (rand() < 0.55) {
const dx = Math.floor(rand() * 3) - 1;
const dy = Math.floor(rand() * 3) - 1;
next[idx(x, y)] = grid[idx(x + dx, y + dy)];
}
}
for (let i = 0; i < W * H; i++) grid[i] = next[i];
}
const dark = [0x40, 0x2e, 0x1b];
const darker = [0x33, 0x24, 0x14];
const light = [0x9c, 0x7c, 0x54];
const lighter = [0xb0, 0x8e, 0x62];
for (let n = 0; n < 150; n++) {
const cx = Math.floor(rand() * W);
const cy = Math.floor(rand() * H);
const bright = rand() < 0.42;
grid[idx(cx, cy)] = bright ? (rand() < 0.5 ? light : lighter) : (rand() < 0.55 ? dark : darker);
if (rand() < 0.28) {
const dx = rand() < 0.5 ? 1 : 0;
const dy = dx ? 0 : 1;
grid[idx(cx + dx, cy + dy)] = bright ? light : dark;
}
}
const buf = Buffer.alloc(W * H * 3);
for (let i = 0; i < W * H; i++) {
buf[i * 3] = grid[i][0];
buf[i * 3 + 1] = grid[i][1];
buf[i * 3 + 2] = grid[i][2];
}
await sharp(buf, { raw: { width: W, height: H, channels: 3 } })
.png({ compressionLevel: 9, palette: true })
.toFile("public/cork.png");
console.log("wrote public/cork.png", W + "x" + H);

30
scripts/gen-favicon.mjs Normal file
View File

@ -0,0 +1,30 @@
import { execFileSync } from "node:child_process";
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import sharp from "sharp";
const svg = readFileSync("public/favicon.svg");
const sizes = [16, 32, 48];
const tmp = mkdtempSync(join(tmpdir(), "favicon-"));
try {
const pngs = [];
for (const s of sizes) {
const out = join(tmp, `${s}.png`);
await sharp(svg, { density: 384 })
.resize(s, s, { fit: "contain", background: { r: 0, g: 0, b: 0, alpha: 0 } })
.png()
.toFile(out);
pngs.push(out);
}
execFileSync("magick", [...pngs, "public/favicon.ico"], { stdio: "inherit" });
console.log(`wrote public/favicon.ico (${sizes.join(", ")} px)`);
} finally {
rmSync(tmp, { recursive: true, force: true });
}

View File

@ -0,0 +1,410 @@
---
import { buttons, site } from "../data/site";
import CopyText from "./CopyText.astro";
import Egg from "./Egg.astro";
const rnd = (i: number, seed: number) => {
const x = Math.sin((i + 1) * 127.1 + seed * 311.7) * 43758.5453;
return x - Math.floor(x);
};
const r1 = (i: number, seed: number, spread: number) =>
Math.round((rnd(i, seed) - 0.5) * spread * 100) / 100;
const PINS = [
["#ff9a9a", "#d64545", "#7a1f1f"],
["#9ec9ff", "#4a7fd6", "#1f3f7a"],
["#ffd98a", "#e0a83c", "#8a5f16"],
["#a8e6b0", "#4caf68", "#1f5c33"],
["#d9b8ff", "#9a6cff", "#4a2f8a"],
];
const scatter = (i: number) => {
const [pl, pb, pd] = PINS[i % PINS.length];
return `--t:${r1(i, 1, 12)}deg;--x:${r1(i, 2, 14)}px;--y:${r1(i, 3, 12)}px;--pl:${pl};--pb:${pb};--pd:${pd}`;
};
const preview = buttons.slice(0, 3);
const badgeUrl = `https://${site.domain}/badges/zachy.gif`;
---
<section class="section badges" id="badges">
<h2 class="label badges__label">badges</h2>
<button
class="badges__trigger"
type="button"
data-badges-open
aria-haspopup="dialog"
data-umami-event="badges-open"
>
<span class="stack">
{
preview.map((b: any, i: number) => (
<span class="stack__item" style={`--t:${(i - 1) * 7}deg;--x:${(i - 1) * 30}px;--z:${3 - i}`}>
{b.iframe ? (
<span class="stack__blank" />
) : (
<img src={b.img} alt="" width="88" height="31" loading="lazy" decoding="async" />
)}
</span>
))
}
</span>
<span class="badges__count">view all {buttons.length} &#8594;</span>
</button>
<dialog class="wall" data-badges-dialog aria-label="badge wall">
<div class="wall__bar">
<span class="wall__titlewrap">
<span class="wall__title">badges</span>
<Egg
note="this is probably my favorite part of the site, i spent so long getting it animated perfectly lol."
/>
</span>
<button class="wall__close" type="button" data-badges-close aria-label="close" autofocus>&#10005;</button>
</div>
<div class="pile">
{
buttons.map((b: any, i: number) => {
const inner = b.iframe ? (
// src/bg are swapped in only when the wall is first opened, so this
// live-counter iframe isn't polling incr.easrng.net on every page load
<iframe
class="badge__img"
data-src={b.iframe}
data-bg={b.bg}
title={b.label}
width="88"
height="31"
frameborder="0"
loading="lazy"
/>
) : (
<>
<img
class={b.still ? "badge__img badge__img--moving" : "badge__img"}
src={b.img}
alt={b.label}
width="88"
height="31"
loading="lazy"
decoding="async"
/>
{b.still && (
<img
class="badge__img badge__img--still"
src={b.still}
alt={b.label}
width="88"
height="31"
loading="lazy"
decoding="async"
/>
)}
</>
);
const style = scatter(i);
return b.href ? (
<a
class="badge"
href={b.href}
target="_blank"
rel="noopener"
title={b.label}
style={style}
data-umami-event="badge-click"
data-umami-event-badge={b.label}
>
{inner}
</a>
) : (
<span class="badge" title={b.label} style={style}>
{inner}
</span>
);
})
}
</div>
<p class="wall__note">grab mine &#183; <CopyText value={badgeUrl} event="copy-badge-url" /></p>
</dialog>
</section>
<style>
.badges__trigger {
display: flex;
align-items: center;
gap: 1.1rem;
background: none;
border: 0;
padding: 0.4rem 0;
cursor: pointer;
color: inherit;
font: inherit;
}
.stack {
position: relative;
display: block;
width: 158px;
height: 42px;
flex: none;
}
.stack__item {
position: absolute;
top: 50%;
left: 50%;
z-index: var(--z);
transform: translate(-50%, -50%) translateX(var(--x)) rotate(var(--t));
transition: transform 0.4s cubic-bezier(0.17, 0.89, 0.32, 1.28);
}
.stack__item img,
.stack__blank {
display: block;
width: 88px;
height: 31px;
image-rendering: pixelated;
border: 1px solid var(--faint);
background: var(--paper-2);
opacity: 0.85;
}
.badges__trigger:hover .stack__item:nth-child(1) {
transform: translate(-50%, -50%) translateX(calc(var(--x) - 14px)) rotate(-13deg);
}
.badges__trigger:hover .stack__item:nth-child(3) {
transform: translate(-50%, -50%) translateX(calc(var(--x) + 14px)) rotate(13deg);
}
.badges__count {
font-family: var(--mono);
font-size: 0.78rem;
letter-spacing: 0.03em;
color: var(--muted);
transition: color 0.18s ease;
}
.badges__trigger:hover .badges__count {
color: var(--accent-2);
}
.badges__trigger:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 4px;
border-radius: 3px;
}
.wall {
margin: auto;
width: min(34rem, calc(100vw - 1.5rem));
max-height: min(80vh, 40rem);
padding: 0;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--paper-2);
color: var(--ink);
box-shadow: 0 24px 60px -20px rgba(0, 0, 0, 0.75);
overflow: hidden;
}
.wall[open] {
animation: wall-in 0.28s cubic-bezier(0.17, 0.89, 0.32, 1.28);
}
@keyframes wall-in {
from {
opacity: 0;
transform: translateY(8px) scale(0.96);
}
}
.wall::backdrop {
background: rgba(6, 5, 4, 0.68);
backdrop-filter: blur(3px);
}
.wall__bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.6rem 0.85rem;
border-bottom: 1px solid var(--line);
}
.wall__titlewrap {
display: inline-flex;
align-items: center;
}
.wall__title {
font-family: var(--mono);
font-size: 0.72rem;
letter-spacing: 0.1em;
color: var(--faint);
}
.wall__close {
background: none;
border: 0;
color: var(--muted);
cursor: pointer;
font-size: 0.85rem;
line-height: 1;
padding: 0.3rem;
}
.wall__close:hover {
color: var(--accent);
}
.wall__note {
display: flex;
align-items: center;
gap: 0.5em;
padding: 0.35rem 1rem 1rem;
font-family: var(--mono);
font-size: 0.72rem;
color: var(--faint);
letter-spacing: 0.03em;
}
.wall__note :global(.copy) {
font-size: 0.72rem;
min-width: 0;
}
.pile {
display: flex;
flex-wrap: wrap;
justify-content: center;
align-items: center;
column-gap: 22px;
row-gap: 30px;
margin: 0.9rem;
padding: 2.6rem 1.8rem 2.1rem;
border-radius: 4px;
background-color: #6b4f30;
background-image:
radial-gradient(120% 80% at 50% 0%, rgba(0, 0, 0, 0) 52%, rgba(0, 0, 0, 0.32) 100%),
url("/cork.png");
background-size:
100% 100%,
168px 168px;
background-repeat: no-repeat, repeat;
image-rendering: pixelated;
box-shadow:
inset 0 0 0 1px rgba(0, 0, 0, 0.4),
inset 0 0 28px rgba(0, 0, 0, 0.42);
}
.badge {
display: block;
line-height: 0;
outline: none;
position: relative;
z-index: 0;
transform: translate(var(--x), var(--y)) rotate(var(--t));
transition: transform 0.4s cubic-bezier(0.17, 0.89, 0.32, 1.28);
}
.badge::before {
content: "";
position: absolute;
top: -6px;
left: 50%;
width: 13px;
height: 13px;
transform: translateX(-50%);
border-radius: 50%;
background:
radial-gradient(circle at 34% 30%, var(--pl), var(--pb) 58%, var(--pd) 100%);
box-shadow:
0 3px 4px -1px rgba(0, 0, 0, 0.55),
inset 0 -2px 3px rgba(0, 0, 0, 0.35),
inset 0 2px 2px rgba(255, 255, 255, 0.25);
z-index: 5;
pointer-events: none;
transition:
top 0.35s cubic-bezier(0.17, 0.89, 0.32, 1.28),
opacity 0.2s ease;
}
.badge:hover,
.badge:focus-visible {
z-index: 3;
transform: translate(var(--x), calc(var(--y) - 5px)) rotate(0deg) scale(1.2);
}
.badge:hover::before,
.badge:focus-visible::before {
top: 3px;
}
.badge:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 6px;
}
.badge__img {
box-sizing: content-box;
width: 88px;
height: 31px;
display: block;
position: relative;
z-index: 1;
image-rendering: pixelated;
border: 1px solid var(--faint);
opacity: 0.9;
filter: saturate(0.9);
box-shadow: 0 2px 6px -2px rgba(0, 0, 0, 0.6);
transition:
opacity 0.18s ease,
filter 0.18s ease,
border-color 0.18s ease,
box-shadow 0.3s ease;
}
.badge:hover .badge__img,
.badge:focus-visible .badge__img {
opacity: 1;
filter: none;
border-color: var(--muted);
box-shadow: 0 12px 24px -8px rgba(0, 0, 0, 0.7);
z-index: 10;
}
.badge__img--still {
display: none;
}
@media (prefers-reduced-motion: reduce) {
.badge,
.stack__item {
transform: none !important;
transition: none;
}
.badge:hover,
.badge:focus-visible {
transform: none !important;
}
.badge__img--moving {
display: none;
}
.badge__img--still {
display: block;
}
}
</style>
<script>
const dialog = document.querySelector<HTMLDialogElement>("[data-badges-dialog]");
const open = document.querySelector<HTMLButtonElement>("[data-badges-open]");
const close = document.querySelector<HTMLButtonElement>("[data-badges-close]");
let embedsLoaded = false;
const loadEmbeds = () => {
if (embedsLoaded) return;
embedsLoaded = true;
dialog?.querySelectorAll<HTMLIFrameElement>("iframe[data-src]").forEach((f) => {
if (f.dataset.bg) f.style.background = `url(${f.dataset.bg})`;
f.src = f.dataset.src!;
});
};
open?.addEventListener("click", () => {
loadEmbeds();
dialog?.showModal();
});
close?.addEventListener("click", () => dialog?.close());
dialog?.addEventListener("click", (e) => {
if (e.target === dialog) dialog.close();
});
</script>

View File

@ -0,0 +1,269 @@
---
import { socials } from "../data/site";
const ICONS: Record<string, string> = {
email:
"M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4-8 5-8-5V6l8 5 8-5v2z",
github:
"M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12",
signal:
"M12 0q-.934 0-1.83.139l.17 1.111a11 11 0 0 1 3.32 0l.172-1.111A12 12 0 0 0 12 0M9.152.34A12 12 0 0 0 5.77 1.742l.584.961a10.8 10.8 0 0 1 3.066-1.27zm5.696 0-.268 1.094a10.8 10.8 0 0 1 3.066 1.27l.584-.962A12 12 0 0 0 14.848.34M12 2.25a9.75 9.75 0 0 0-8.539 14.459c.074.134.1.292.064.441l-1.013 4.338 4.338-1.013a.62.62 0 0 1 .441.064A9.7 9.7 0 0 0 12 21.75c5.385 0 9.75-4.365 9.75-9.75S17.385 2.25 12 2.25m-7.092.068a12 12 0 0 0-2.59 2.59l.909.664a11 11 0 0 1 2.345-2.345zm14.184 0-.664.909a11 11 0 0 1 2.345 2.345l.909-.664a12 12 0 0 0-2.59-2.59M1.742 5.77A12 12 0 0 0 .34 9.152l1.094.268a10.8 10.8 0 0 1 1.269-3.066zm20.516 0-.961.584a10.8 10.8 0 0 1 1.27 3.066l1.093-.268a12 12 0 0 0-1.402-3.383M.138 10.168A12 12 0 0 0 0 12q0 .934.139 1.83l1.111-.17A11 11 0 0 1 1.125 12q0-.848.125-1.66zm23.723.002-1.111.17q.125.812.125 1.66c0 .848-.042 1.12-.125 1.66l1.111.172a12.1 12.1 0 0 0 0-3.662M1.434 14.58l-1.094.268a12 12 0 0 0 .96 2.591l-.265 1.14 1.096.255.36-1.539-.188-.365a10.8 10.8 0 0 1-.87-2.35m21.133 0a10.8 10.8 0 0 1-1.27 3.067l.962.584a12 12 0 0 0 1.402-3.383zm-1.793 3.848a11 11 0 0 1-2.345 2.345l.664.909a12 12 0 0 0 2.59-2.59zm-19.959 1.1L.357 21.48a1.8 1.8 0 0 0 2.162 2.161l1.954-.455-.256-1.095-1.953.455a.675.675 0 0 1-.81-.81l.454-1.954zm16.832 1.769a10.8 10.8 0 0 1-3.066 1.27l.268 1.093a12 12 0 0 0 3.382-1.402zm-10.94.213-1.54.36.256 1.095 1.139-.266c.814.415 1.683.74 2.591.961l.268-1.094a10.8 10.8 0 0 1-2.35-.869zm3.634 1.24-.172 1.111a12.1 12.1 0 0 0 3.662 0l-.17-1.111q-.812.125-1.66.125a11 11 0 0 1-1.66-.125",
matrix:
"M.632.55v22.9H2.28V24H0V0h2.28v.55zm7.043 7.26v1.157h.033c.309-.443.683-.784 1.117-1.024.433-.245.936-.365 1.5-.365.54 0 1.033.107 1.481.314.448.208.785.582 1.02 1.108.254-.374.6-.706 1.034-.992.434-.287.95-.43 1.546-.43.453 0 .872.056 1.26.167.388.11.716.286.993.53.276.245.489.559.646.951.152.392.23.863.23 1.417v5.728h-2.349V11.52c0-.286-.01-.559-.032-.812a1.755 1.755 0 0 0-.18-.66 1.106 1.106 0 0 0-.438-.448c-.194-.11-.457-.166-.785-.166-.332 0-.6.064-.803.189a1.38 1.38 0 0 0-.48.499 1.946 1.946 0 0 0-.231.696 5.56 5.56 0 0 0-.06.785v4.768h-2.35v-4.8c0-.254-.004-.503-.018-.752a2.074 2.074 0 0 0-.143-.688 1.052 1.052 0 0 0-.415-.503c-.194-.125-.476-.19-.854-.19-.111 0-.259.024-.439.074-.18.051-.36.143-.53.282-.171.138-.319.337-.439.595-.12.259-.18.6-.18 1.02v4.966H5.46V7.81zm15.693 15.64V.55H21.72V0H24v24h-2.28v-.55z",
};
const isExt = (href: string) => href.startsWith("http");
---
<ul class="bx">
{
socials.map((s) => {
const ext = isExt(s.href);
return (
<li class={`bx__cell bx__cell--${s.label}`}>
<a
class="bx__item"
href={s.href}
target={ext ? "_blank" : undefined}
rel={ext ? "me noopener" : undefined}
aria-label={`${s.label} — ${s.handle} — ${s.blurb}`}
data-umami-event="contact"
data-umami-event-via={s.label}
>
<svg class="bx__icon" viewBox="0 0 24 24" aria-hidden="true">
<path d={ICONS[s.icon]} />
</svg>
<span class="bx__name">{s.label}</span>
<span class="bx__arw" aria-hidden="true">{ext ? "↗" : "→"}</span>
</a>
<span class="bx__more" aria-hidden="true">
<span class="bx__handle">{s.handle}</span>
<span class="bx__blurb">{s.blurb}</span>
</span>
</li>
);
})
}
</ul>
<style>
.bx {
list-style: none;
padding: 0;
margin: 1.6rem 0 0;
display: grid;
grid-template-columns: repeat(6, 1fr);
grid-auto-rows: 3.75rem;
gap: 8px;
}
.bx__cell--email {
grid-column: span 4;
}
.bx__cell--github {
grid-column: span 2;
}
.bx__cell--signal {
grid-column: span 2;
}
.bx__cell--matrix {
grid-column: span 4;
}
.bx__cell {
position: relative;
display: flex;
z-index: 0;
transition: z-index 0s linear 0.2s;
}
.bx__cell:hover,
.bx__cell:focus-within {
z-index: 6;
transition-delay: 0s;
}
.bx__item {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0 1rem;
border: 1px solid var(--line);
border-radius: 7px;
background: var(--paper-2);
color: var(--ink);
text-decoration: none;
transform-origin: center;
transition:
transform 0.26s cubic-bezier(0.17, 0.89, 0.32, 1.28),
border-color 0.18s ease,
background 0.18s ease,
box-shadow 0.24s ease;
}
.bx__cell:hover .bx__item,
.bx__cell:focus-within .bx__item {
transform: scale(1.06);
border-color: var(--faint);
background: #26241f;
box-shadow: 0 18px 38px -16px rgba(0, 0, 0, 0.7);
}
.bx__cell--email .bx__item {
transform-origin: left center;
}
.bx__cell--github .bx__item {
transform-origin: right center;
}
.bx__cell--signal .bx__item {
transform-origin: left center;
}
.bx__cell--matrix .bx__item {
transform-origin: right center;
}
.bx__cell:focus-within .bx__item {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.bx__icon {
flex: none;
width: 1.25rem;
height: 1.25rem;
fill: var(--muted);
transition: fill 0.18s ease;
}
.bx__cell:hover .bx__icon,
.bx__cell:focus-within .bx__icon {
fill: var(--ink);
}
.bx__name {
font-family: var(--serif);
font-size: 1rem;
line-height: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: var(--muted);
transition: color 0.18s ease;
}
.bx__cell:hover .bx__name,
.bx__cell:focus-within .bx__name {
color: var(--ink);
}
.bx__arw {
flex: none;
margin-left: auto;
font-family: var(--mono);
font-size: 0.78rem;
color: var(--faint);
overflow: hidden;
max-width: 0;
opacity: 0;
transition:
max-width 0.25s ease,
opacity 0.18s ease;
}
.bx__cell:hover .bx__arw,
.bx__cell:focus-within .bx__arw {
max-width: 2ch;
opacity: 1;
}
.bx__more {
position: absolute;
left: 0;
top: calc(100% + 6px);
z-index: 5;
min-width: 100%;
width: max-content;
max-width: 17rem;
display: flex;
flex-direction: column;
gap: 0.2rem;
padding: 0.7rem 0.85rem;
border: 1px solid var(--faint);
border-radius: 7px;
background: var(--paper-2);
box-shadow: 0 16px 34px -14px rgba(0, 0, 0, 0.7);
opacity: 0;
transform: translateY(-5px);
pointer-events: none;
transition:
opacity 0.18s ease,
transform 0.26s cubic-bezier(0.17, 0.89, 0.32, 1.28);
}
.bx__cell:hover .bx__more,
.bx__cell:focus-within .bx__more {
opacity: 1;
transform: none;
}
.bx__cell--github .bx__more,
.bx__cell--matrix .bx__more {
left: auto;
right: 0;
}
.bx__handle {
font-family: var(--mono);
font-size: 0.73rem;
color: var(--faint);
}
.bx__blurb {
font-size: 0.8rem;
color: var(--muted);
text-wrap: pretty;
}
@media (max-width: 34rem) {
.bx {
display: flex;
flex-direction: column;
}
.bx__cell {
display: block;
}
.bx__cell:hover .bx__item,
.bx__cell:focus-within .bx__item {
transform: none;
box-shadow: none;
}
.bx__name {
max-width: none;
opacity: 1;
}
.bx__arw {
max-width: 2ch;
opacity: 1;
}
.bx__item {
padding: 0.85rem 1rem;
}
.bx__more {
position: static;
min-width: 0;
max-width: none;
opacity: 1;
transform: none;
border: 0;
padding: 0.2rem 0 0.1rem 2.35rem;
box-shadow: none;
}
}
@media (prefers-reduced-motion: reduce) {
.bx__item,
.bx__more,
.bx__arw,
.bx__name,
.bx__icon {
transition: none;
}
.bx__cell:hover .bx__item,
.bx__cell:focus-within .bx__item {
transform: none;
}
}
</style>

View File

@ -0,0 +1,192 @@
---
import { site, socials } from "../data/site";
import CopyText from "./CopyText.astro";
import Egg from "./Egg.astro";
const by = Object.fromEntries(socials.map((s) => [s.label, s]));
type Row =
| { k: string; kind: "text"; v: string }
| { k: string; kind: "copy"; v: string; event: string }
| { k: string; kind: "link"; v: string; href: string };
const rows: Row[] = [
{ k: "name", kind: "text", v: `${site.name} (zoop)` },
{ k: "email", kind: "copy", v: site.email, event: "copy-email" },
{ k: "github", kind: "link", v: "@zoop-dev", href: by.github.href },
{ k: "signal", kind: "link", v: "message me", href: by.signal.href },
{ k: "matrix", kind: "link", v: "@imzach:tchncs.de", href: by.matrix.href },
];
---
<div class="term">
<span class="term__egg">
<Egg align="right" note="edited the command twice to make it more realistic lol" />
</span>
<p class="term__cmd"><span class="term__sigil">$</span> cat {site.name}/contact.md<span class="term__caret" aria-hidden="true"></span></p>
<dl class="finger">
{
rows.map((r) => {
const tip = by[r.k]?.blurb as string | undefined;
return (
<>
<dt>{r.k}</dt>
<dd class={tip ? "has-tip" : undefined}>
{r.kind === "link" ? (
<a
href={r.href}
target="_blank"
rel="me noopener"
data-umami-event="contact"
data-umami-event-via={r.k}
>
{r.v}
<span class="term__out" aria-hidden="true">↗</span>
</a>
) : r.kind === "copy" ? (
<CopyText value={r.v} event={r.event} />
) : (
r.v
)}
{tip && (
<span class="finger__tip" role="tooltip">
{tip}
</span>
)}
</dd>
</>
);
})
}
</dl>
</div>
<style>
.term {
position: relative;
width: fit-content;
max-width: 100%;
margin: 1.7rem 0 0;
padding: 1rem 1.15rem 1.1rem;
border: 1px solid var(--line);
border-radius: 4px;
background: var(--paper-2);
font-family: var(--mono);
font-size: 0.82rem;
line-height: 1.7;
}
.term__egg {
position: absolute;
top: 0.5rem;
right: 0.6rem;
z-index: 5;
line-height: 1;
}
.term__egg :global(.egg) {
margin-left: 0;
}
.term__cmd {
color: var(--muted);
margin-bottom: 0.85rem;
}
.term__sigil {
color: var(--accent);
margin-right: 0.6ch;
}
.term__caret {
display: inline-block;
width: 0.6ch;
height: 1em;
margin-left: 0.3ch;
vertical-align: -0.15em;
background: var(--faint);
animation: term-blink 1.1s steps(1) infinite;
}
@keyframes term-blink {
50% {
opacity: 0;
}
}
.finger {
display: grid;
grid-template-columns: auto 1fr;
column-gap: 1.6rem;
row-gap: 0.3rem;
margin: 0;
}
.finger dt {
color: var(--faint);
}
.finger dd {
margin: 0;
color: var(--ink);
min-width: 0;
}
.finger dd.has-tip {
position: relative;
}
.finger__tip {
position: absolute;
left: 0;
top: calc(100% + 0.35rem);
z-index: 6;
width: max-content;
max-width: 16rem;
padding: 0.4rem 0.55rem;
border: 1px solid var(--faint);
border-radius: 5px;
background: var(--paper);
color: var(--muted);
font-size: 0.7rem;
line-height: 1.45;
letter-spacing: 0.01em;
white-space: normal;
box-shadow: 0 10px 22px -12px rgba(0, 0, 0, 0.7);
opacity: 0;
transform: translateY(-3px);
pointer-events: none;
transition:
opacity 0.14s ease,
transform 0.18s ease;
}
.finger dd.has-tip:hover .finger__tip,
.finger dd.has-tip:focus-within .finger__tip {
opacity: 1;
transform: none;
}
@media (prefers-reduced-motion: reduce) {
.finger__tip {
transition: none;
}
}
.finger a {
color: var(--ink);
text-decoration: none;
border-bottom: 1px solid var(--line);
transition:
color 0.16s ease,
border-color 0.16s ease;
}
.finger a:hover {
color: var(--accent-2);
border-color: var(--accent-2);
}
.term__out {
margin-left: 0.4ch;
color: var(--faint);
}
.finger a:hover .term__out {
color: var(--accent-2);
}
.finger dd :global(.copy) {
font-size: 1em;
}
@media (prefers-reduced-motion: reduce) {
.term__caret {
animation: none;
}
}
</style>

View File

@ -0,0 +1,143 @@
---
import { socials } from "../data/site";
const ICONS: Record<string, string> = {
email:
"M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4-8 5-8-5V6l8 5 8-5v2z",
github:
"M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12",
signal:
"M12 0q-.934 0-1.83.139l.17 1.111a11 11 0 0 1 3.32 0l.172-1.111A12 12 0 0 0 12 0M9.152.34A12 12 0 0 0 5.77 1.742l.584.961a10.8 10.8 0 0 1 3.066-1.27zm5.696 0-.268 1.094a10.8 10.8 0 0 1 3.066 1.27l.584-.962A12 12 0 0 0 14.848.34M12 2.25a9.75 9.75 0 0 0-8.539 14.459c.074.134.1.292.064.441l-1.013 4.338 4.338-1.013a.62.62 0 0 1 .441.064A9.7 9.7 0 0 0 12 21.75c5.385 0 9.75-4.365 9.75-9.75S17.385 2.25 12 2.25m-7.092.068a12 12 0 0 0-2.59 2.59l.909.664a11 11 0 0 1 2.345-2.345zm14.184 0-.664.909a11 11 0 0 1 2.345 2.345l.909-.664a12 12 0 0 0-2.59-2.59M1.742 5.77A12 12 0 0 0 .34 9.152l1.094.268a10.8 10.8 0 0 1 1.269-3.066zm20.516 0-.961.584a10.8 10.8 0 0 1 1.27 3.066l1.093-.268a12 12 0 0 0-1.402-3.383M.138 10.168A12 12 0 0 0 0 12q0 .934.139 1.83l1.111-.17A11 11 0 0 1 1.125 12q0-.848.125-1.66zm23.723.002-1.111.17q.125.812.125 1.66c0 .848-.042 1.12-.125 1.66l1.111.172a12.1 12.1 0 0 0 0-3.662M1.434 14.58l-1.094.268a12 12 0 0 0 .96 2.591l-.265 1.14 1.096.255.36-1.539-.188-.365a10.8 10.8 0 0 1-.87-2.35m21.133 0a10.8 10.8 0 0 1-1.27 3.067l.962.584a12 12 0 0 0 1.402-3.383zm-1.793 3.848a11 11 0 0 1-2.345 2.345l.664.909a12 12 0 0 0 2.59-2.59zm-19.959 1.1L.357 21.48a1.8 1.8 0 0 0 2.162 2.161l1.954-.455-.256-1.095-1.953.455a.675.675 0 0 1-.81-.81l.454-1.954zm16.832 1.769a10.8 10.8 0 0 1-3.066 1.27l.268 1.093a12 12 0 0 0 3.382-1.402zm-10.94.213-1.54.36.256 1.095 1.139-.266c.814.415 1.683.74 2.591.961l.268-1.094a10.8 10.8 0 0 1-2.35-.869zm3.634 1.24-.172 1.111a12.1 12.1 0 0 0 3.662 0l-.17-1.111q-.812.125-1.66.125a11 11 0 0 1-1.66-.125",
matrix:
"M.632.55v22.9H2.28V24H0V0h2.28v.55zm7.043 7.26v1.157h.033c.309-.443.683-.784 1.117-1.024.433-.245.936-.365 1.5-.365.54 0 1.033.107 1.481.314.448.208.785.582 1.02 1.108.254-.374.6-.706 1.034-.992.434-.287.95-.43 1.546-.43.453 0 .872.056 1.26.167.388.11.716.286.993.53.276.245.489.559.646.951.152.392.23.863.23 1.417v5.728h-2.349V11.52c0-.286-.01-.559-.032-.812a1.755 1.755 0 0 0-.18-.66 1.106 1.106 0 0 0-.438-.448c-.194-.11-.457-.166-.785-.166-.332 0-.6.064-.803.189a1.38 1.38 0 0 0-.48.499 1.946 1.946 0 0 0-.231.696 5.56 5.56 0 0 0-.06.785v4.768h-2.35v-4.8c0-.254-.004-.503-.018-.752a2.074 2.074 0 0 0-.143-.688 1.052 1.052 0 0 0-.415-.503c-.194-.125-.476-.19-.854-.19-.111 0-.259.024-.439.074-.18.051-.36.143-.53.282-.171.138-.319.337-.439.595-.12.259-.18.6-.18 1.02v4.966H5.46V7.81zm15.693 15.64V.55H21.72V0H24v24h-2.28v-.55z",
};
const isExt = (href: string) => href.startsWith("http");
---
<ul class="methods">
{
socials.map((s) => {
const ext = isExt(s.href);
return (
<li>
<a
class="method"
href={s.href}
target={ext ? "_blank" : undefined}
rel={ext ? "me noopener" : undefined}
data-umami-event="contact"
data-umami-event-via={s.label}
>
<svg class="method__icon" viewBox="0 0 24 24" aria-hidden="true">
<path d={ICONS[s.icon]} />
</svg>
<span class="method__top">
<span class="method__name">{s.label}</span>
<span class="method__handle">{s.handle}</span>
</span>
<span
class:list={["method__arw", { "method__arw--out": ext }]}
aria-hidden="true"
>
{ext ? "↗" : "→"}
</span>
<span class="method__blurb">{s.blurb}</span>
</a>
</li>
);
})
}
</ul>
<style>
.methods {
list-style: none;
padding: 0;
margin: 1.6rem 0 0;
}
.method {
display: grid;
grid-template-columns: auto 1fr auto;
column-gap: 0.95rem;
row-gap: 0.15rem;
padding: 1rem 0;
border-top: 1px solid var(--line);
text-decoration: none;
color: var(--ink);
}
.methods li:last-child .method {
border-bottom: 1px solid var(--line);
}
.method__icon {
grid-column: 1;
grid-row: 1 / 3;
align-self: start;
width: 1.15rem;
height: 1.15rem;
margin-top: 0.15rem;
fill: var(--muted);
transition: fill 0.16s ease;
}
.method__top {
grid-column: 2;
grid-row: 1;
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 0.2rem 0.6rem;
}
.method__name {
font-family: var(--serif);
font-size: 1.02rem;
color: var(--ink);
}
.method__handle {
font-family: var(--mono);
font-size: 0.78rem;
letter-spacing: 0.02em;
color: var(--faint);
transition: color 0.16s ease;
}
.method__arw {
grid-column: 3;
grid-row: 1;
align-self: baseline;
font-family: var(--mono);
font-size: 0.85rem;
color: var(--muted);
transition:
transform 0.22s cubic-bezier(0.16, 1, 0.3, 1),
color 0.16s ease;
}
.method__blurb {
grid-column: 2;
grid-row: 2;
font-size: 0.86rem;
color: var(--muted);
text-wrap: pretty;
}
.method:hover .method__icon,
.method:focus-visible .method__icon {
fill: var(--ink);
}
.method:hover .method__handle,
.method:focus-visible .method__handle {
color: var(--muted);
}
.method:hover .method__arw,
.method:focus-visible .method__arw {
color: var(--accent-2);
transform: translateX(0.25em);
}
.method:hover .method__arw--out,
.method:focus-visible .method__arw--out {
transform: translate(0.22em, -0.22em);
}
.method:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 4px;
border-radius: 3px;
}
</style>

View File

@ -0,0 +1,9 @@
---
import CopyText from "./CopyText.astro";
interface Props {
email: string;
}
const { email } = Astro.props;
---
<CopyText value={email} label={`copy ${email}`} event="copy-email" />

View File

@ -0,0 +1,53 @@
---
interface Props {
value: string;
display?: string;
label?: string;
event?: string;
}
const { value, display = value, label = `copy ${value}`, event } = Astro.props;
---
<button
class="copy"
type="button"
data-copy={value}
aria-label={label}
data-umami-event={event}
>
<span class="copy__text">{display}</span>
<svg class="copy__icon" viewBox="0 0 24 24" aria-hidden="true">
<rect x="9" y="9" width="11" height="11" rx="2"></rect>
<path d="M5 15V5a2 2 0 0 1 2-2h10"></path>
</svg>
<svg class="copy__check" viewBox="0 0 24 24" aria-hidden="true">
<path d="M20 6 9 17l-5-5"></path>
</svg>
<span class="tip" aria-hidden="true">copied</span>
</button>
<script>
document.querySelectorAll<HTMLButtonElement>("[data-copy]").forEach((btn) => {
if (btn.dataset.copyBound) return;
btn.dataset.copyBound = "1";
btn.addEventListener("click", async () => {
const val = btn.dataset.copy ?? "";
try {
await navigator.clipboard.writeText(val);
} catch {
const t = btn.querySelector(".copy__text");
if (t) {
const r = document.createRange();
r.selectNodeContents(t);
const sel = getSelection();
sel?.removeAllRanges();
sel?.addRange(r);
document.execCommand?.("copy");
}
}
btn.classList.add("copied");
setTimeout(() => btn.classList.remove("copied"), 1600);
});
});
</script>

97
src/components/Egg.astro Normal file
View File

@ -0,0 +1,97 @@
---
interface Props {
note: string;
align?: "left" | "right";
}
const { note, align = "left" } = Astro.props;
---
<span
class:list={["egg", { "egg--right": align === "right" }]}
tabindex="0"
role="note"
aria-label={`about this: ${note}`}
>
<span class="egg__mark" aria-hidden="true">i</span>
<span class="egg__tip" role="tooltip">{note}</span>
</span>
<style>
.egg {
position: relative;
display: inline-flex;
align-items: center;
margin-left: 0.4em;
vertical-align: middle;
cursor: help;
outline: none;
}
.egg__mark {
display: grid;
place-items: center;
width: 14px;
height: 14px;
font-family: var(--serif);
font-style: italic;
font-weight: 600;
font-size: 11px;
line-height: 1;
letter-spacing: 0;
color: var(--faint);
border: 1px solid var(--line);
border-radius: 50%;
transition:
color 0.15s ease,
border-color 0.15s ease;
}
.egg:hover .egg__mark,
.egg:focus-visible .egg__mark {
color: var(--accent);
border-color: var(--accent);
}
.egg__tip {
position: absolute;
top: calc(100% + 0.4rem);
left: 0;
z-index: 30;
width: max-content;
max-width: 17rem;
padding: 0.45rem 0.6rem;
border: 1px solid var(--faint);
border-radius: 5px;
background: var(--paper);
color: var(--muted);
font-family: var(--mono);
font-size: 0.68rem;
font-weight: 400;
letter-spacing: 0.01em;
line-height: 1.5;
text-transform: none;
white-space: normal;
box-shadow: 0 12px 26px -12px rgba(0, 0, 0, 0.75);
opacity: 0;
transform: translateY(-3px);
pointer-events: none;
transition:
opacity 0.14s ease,
transform 0.18s ease;
}
.egg--right .egg__tip {
left: auto;
right: 0;
}
.egg:hover .egg__tip,
.egg:focus-within .egg__tip {
opacity: 1;
transform: none;
}
@media (prefers-reduced-motion: reduce) {
.egg__tip {
transition: none;
}
}
</style>

272
src/components/Music.astro Normal file
View File

@ -0,0 +1,272 @@
---
---
<section class="section" id="np-section" hidden>
<h2 class="label">now playing</h2>
<div class="np" id="np">
<div class="np__body">
<img class="np__art np__art--none" id="np-art" alt="" width="64" height="64" decoding="async" />
<div class="np__meta">
<div class="np__title" id="np-title">&mdash;</div>
<div class="np__artist" id="np-artist">&mdash;</div>
<div class="np__foot">
<span class="np__tx" aria-hidden="true">
<svg viewBox="0 0 24 24" width="11" height="11" fill="currentColor"
><path d="M6 5h2v14H6zM20 5v14L9 12z"></path></svg
>
<svg viewBox="0 0 24 24" width="12" height="12" fill="currentColor"
><path d="M6 4l14 8-14 8z"></path></svg
>
<svg viewBox="0 0 24 24" width="11" height="11" fill="currentColor"
><path d="M16 5h2v14h-2zM4 5l11 7-11 7z"></path></svg
>
</span>
<span class="np__dur" id="np-dur"></span>
<span class="np__ago" id="np-ago"></span>
<a class="np__src" id="np-src" href="#" target="_blank" rel="noopener">
<svg class="np__logo" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"
><path
d="M12 0C5.4 0 0 5.4 0 12s5.4 12 12 12 12-5.4 12-12S18.66 0 12 0zm5.521 17.34c-.24.359-.66.48-1.021.24-2.82-1.74-6.36-2.101-10.561-1.141-.418.122-.779-.179-.899-.539-.12-.421.18-.78.54-.9 4.56-1.021 8.52-.6 11.64 1.32.42.18.479.659.301 1.02zm1.44-3.3c-.301.42-.841.6-1.262.3-3.239-1.98-8.159-2.58-11.939-1.38-.479.12-1.02-.12-1.14-.6-.12-.48.12-1.021.6-1.141C9.6 9.9 15 10.561 18.72 12.84c.361.181.54.78.241 1.2zm.12-3.36C15.24 8.4 8.82 8.16 5.16 9.301c-.6.179-1.2-.16-1.38-.72-.18-.601.18-1.2.72-1.381 4.26-1.26 11.28-1.02 15.721 1.621.539.3.719 1.02.419 1.56-.299.421-1.02.599-1.559.3z"
></path></svg
><span>spotify</span>
</a>
</div>
</div>
<div class="np__prog" id="np-prog" hidden><div class="np__prog-fill" id="np-prog-fill"></div></div>
</div>
</div>
</section>
<section class="section top" id="top-section" hidden>
<h2 class="label">top tracks <span class="label__sub">last 4 weeks</span></h2>
<div class="mosaic" id="mosaic"></div>
<div class="mosaic__caps" id="mosaic-caps"></div>
</section>
<style is:global>
.np {
width: 100%;
max-width: 22rem;
border: 1px solid var(--faint);
background: #151412;
}
.np__body {
display: flex;
gap: 0.7rem;
padding: 0.65rem;
}
.np__art {
width: 64px;
height: 64px;
flex-shrink: 0;
object-fit: cover;
border: 1px solid var(--faint);
border-radius: 4px;
background: var(--faint);
}
.np__art--none {
background: linear-gradient(145deg, #2c2a26, #171614);
}
.np__meta {
min-width: 0;
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
gap: 0.05rem;
font-family: var(--mono);
}
.np__title {
color: #fff;
font-size: 0.8rem;
line-height: 1.35;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.np__artist {
color: var(--muted);
font-size: 0.74rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.np__foot {
display: flex;
align-items: center;
gap: 0.6rem;
margin-top: 0.3rem;
font-size: 0.68rem;
}
.np__tx {
display: inline-flex;
align-items: center;
gap: 0.32rem;
color: var(--muted);
flex-shrink: 0;
}
.np__tx svg {
display: block;
}
.np__dur {
color: var(--muted);
white-space: nowrap;
}
.np__ago {
color: var(--faint);
white-space: nowrap;
}
.np__src {
margin-left: auto;
display: inline-flex;
align-items: center;
gap: 0.3rem;
color: var(--muted);
text-decoration: none;
white-space: nowrap;
}
.np__src:hover {
color: #fff;
}
.np__logo {
width: 0.95em;
height: 0.95em;
fill: #1ed760;
flex-shrink: 0;
display: block;
}
.np__prog {
height: 2px;
background: #2b2825;
}
.np__prog-fill {
height: 2px;
width: 0%;
background: linear-gradient(90deg, rgb(255, 224, 138), rgb(255, 171, 94), rgb(255, 95, 109));
transition: width 1s linear;
}
.mosaic {
display: grid;
grid-template-columns: repeat(4, 1fr);
grid-template-rows: repeat(3, 1fr);
grid-auto-flow: dense;
gap: 3px;
aspect-ratio: 4 / 3;
width: 100%;
max-width: 268px;
}
.mosaic__cell {
position: relative;
display: block;
overflow: hidden;
min-width: 0;
background: rgba(255, 255, 255, 0.04);
}
.mosaic__cell--xl {
grid-column: span 2;
grid-row: span 2;
}
.mosaic__cell img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
filter: saturate(0.7) brightness(0.82);
transition:
filter 0.18s ease,
transform 0.3s cubic-bezier(0.16, 1, 0.3, 1);
}
.mosaic__cell:hover img,
.mosaic__cell:focus-visible img {
filter: none;
transform: scale(1.08);
}
.mosaic__caps {
position: relative;
margin-top: 0.5rem;
min-height: 1.35em;
font-family: var(--mono);
font-size: 0.74rem;
}
.mosaic__cap {
position: absolute;
inset: 0;
display: flex;
align-items: baseline;
gap: 0.55rem;
opacity: 0;
transition: opacity 0.14s ease;
pointer-events: none;
white-space: nowrap;
}
.mosaic__rank {
color: var(--faint);
font-variant-numeric: tabular-nums;
flex-shrink: 0;
}
.mosaic__title {
color: var(--ink);
overflow: hidden;
text-overflow: ellipsis;
flex-shrink: 1;
min-width: 0;
}
.mosaic__artist {
color: var(--faint);
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
min-width: 0;
}
.mosaic__cap:first-child {
opacity: 1;
}
@supports selector(:has(*)) {
.top:has(.mosaic__cell:hover) .mosaic__cap,
.top:has(.mosaic__cell:focus-visible) .mosaic__cap {
opacity: 0;
}
.top:has(.mosaic__cell:nth-child(1):hover) .mosaic__cap:nth-child(1),
.top:has(.mosaic__cell:nth-child(1):focus-visible) .mosaic__cap:nth-child(1),
.top:has(.mosaic__cell:nth-child(2):hover) .mosaic__cap:nth-child(2),
.top:has(.mosaic__cell:nth-child(2):focus-visible) .mosaic__cap:nth-child(2),
.top:has(.mosaic__cell:nth-child(3):hover) .mosaic__cap:nth-child(3),
.top:has(.mosaic__cell:nth-child(3):focus-visible) .mosaic__cap:nth-child(3),
.top:has(.mosaic__cell:nth-child(4):hover) .mosaic__cap:nth-child(4),
.top:has(.mosaic__cell:nth-child(4):focus-visible) .mosaic__cap:nth-child(4),
.top:has(.mosaic__cell:nth-child(5):hover) .mosaic__cap:nth-child(5),
.top:has(.mosaic__cell:nth-child(5):focus-visible) .mosaic__cap:nth-child(5),
.top:has(.mosaic__cell:nth-child(6):hover) .mosaic__cap:nth-child(6),
.top:has(.mosaic__cell:nth-child(6):focus-visible) .mosaic__cap:nth-child(6),
.top:has(.mosaic__cell:nth-child(7):hover) .mosaic__cap:nth-child(7),
.top:has(.mosaic__cell:nth-child(7):focus-visible) .mosaic__cap:nth-child(7),
.top:has(.mosaic__cell:nth-child(8):hover) .mosaic__cap:nth-child(8),
.top:has(.mosaic__cell:nth-child(8):focus-visible) .mosaic__cap:nth-child(8),
.top:has(.mosaic__cell:nth-child(9):hover) .mosaic__cap:nth-child(9),
.top:has(.mosaic__cell:nth-child(9):focus-visible) .mosaic__cap:nth-child(9) {
opacity: 1;
}
}
@media (max-width: 560px) {
.np {
max-width: none;
}
.mosaic__cap {
white-space: normal;
}
}
</style>
<script>
import "../scripts/music.ts";
</script>

View File

@ -0,0 +1,183 @@
---
import { webrings } from "../data/site";
const { hotline, online, snug } = webrings;
type Ring = { name: string; home: string } & Record<string, string>;
const rings: { key: string; data: Ring; links: [string, string][] }[] = [
{
key: "hotline",
data: hotline as Ring,
links: [
["prev", " prev"],
["next", "next "],
],
},
{
key: "online",
data: online as Ring,
links: [
["prev", " prev"],
["random", "rand"],
["next", "next "],
],
},
{
key: "snug",
data: snug as Ring,
links: [
["prev", " prev"],
["random", "rand"],
["next", "next "],
],
},
];
const aria: Record<string, string> = {
prev: "previous site",
next: "next site",
random: "random site",
};
---
<section class="section">
<h2 class="label">webrings</h2>
<div class="rings">
{
rings.map((r) => (
<nav class="ring" aria-label={r.data.name}>
<span class="ring__name">
<a
href={r.data.home}
target="_blank"
rel="noopener"
aria-label={`open ${r.data.name}`}
data-umami-event="webring"
data-umami-event-ring={r.key}
data-umami-event-dir="home"
>
{r.data.name}
</a>
</span>
<span class="ring__nav">
{r.links.map(([dir, glyph]) => (
<a
class={`ring__link ring__link--${dir}`}
href={r.data[dir]}
rel="noopener"
aria-label={aria[dir]}
data-umami-event="webring"
data-umami-event-ring={r.key}
data-umami-event-dir={dir}
>
{dir === "random" && (
<svg class="ring__die" viewBox="0 0 20 20" aria-hidden="true">
<rect
x="1.5"
y="1.5"
width="17"
height="17"
rx="4"
fill="none"
stroke="currentColor"
stroke-width="2"
/>
<circle cx="6" cy="6" r="2" />
<circle cx="10" cy="10" r="2" />
<circle cx="14" cy="14" r="2" />
</svg>
)}
{glyph}
</a>
))}
</span>
</nav>
))
}
</div>
</section>
<style>
.rings {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 1.1rem;
margin-top: 0.3rem;
}
.ring {
position: relative;
display: block;
box-sizing: border-box;
width: fit-content;
min-width: 18rem;
max-width: 100%;
padding: 1rem 1.1rem 0.85rem;
border: 1px solid var(--line);
border-radius: 3px;
background: var(--paper-2);
font-family: var(--mono);
font-size: 0.82rem;
}
.ring__name {
position: absolute;
top: 0;
left: 0.85rem;
transform: translateY(-50%);
padding: 0 0.45rem;
background: var(--paper);
color: var(--muted);
letter-spacing: 0.05em;
white-space: nowrap;
}
.ring__name a {
color: inherit;
text-decoration: none;
transition: color 0.18s ease;
}
.ring__name a::after {
content: " \2197";
color: var(--accent);
transition: transform 0.22s cubic-bezier(0.16, 1, 0.3, 1);
display: inline-block;
}
.ring__name a:hover {
color: var(--ink);
}
.ring__name a:hover::after {
transform: translate(0.15em, -0.15em);
}
.ring__nav {
display: flex;
gap: 1.4rem;
}
.ring__link {
color: var(--muted);
text-decoration: none;
transition:
transform 0.22s cubic-bezier(0.16, 1, 0.3, 1),
color 0.18s ease;
}
.ring__link:hover {
color: var(--ink);
}
.ring__link--prev:hover {
transform: translateX(-0.25em);
}
.ring__link--next:hover {
transform: translateX(0.25em);
}
.ring__link--random {
display: inline-flex;
align-items: center;
gap: 0.4em;
}
.ring__link--random:hover {
color: var(--accent);
}
.ring__die {
width: 1.05em;
height: 1.05em;
fill: currentColor;
flex: none;
}
</style>

94
src/data/site.ts Normal file
View File

@ -0,0 +1,94 @@
export const site = {
name: "zach",
wordmark: "ZACH",
domain: "zachy.cc",
version: "v3.0.1",
tagline: "cool things for the web",
description:
"i make cool things sometimes.",
bio: "call me zoop, i like to make cool projects, and my website is one of them :).",
email: "hi@zachy.cc",
availability: "poking at new projects for early 2026.",
} as const;
export const elsewhere = [
{ label: "status", href: "https://status.zachy.cc", external: true },
] as const;
export const socials = [
{
label: "email",
icon: "email",
href: `mailto:${site.email}`,
handle: site.email,
blurb: "best for anything substantial",
},
{
label: "github",
icon: "github",
href: "https://github.com/zoop-dev",
handle: "@zoop-dev",
blurb: "code, issues, pull requests",
},
{
label: "signal",
icon: "signal",
href: "https://signal.me/#eu/34335-HJyGBA-3-N_f6QY115AJwX3Tt8wIAxzNt5InseqA4SBh9CirG3lLRqacxo",
handle: "signal.me link",
blurb: "encrypted, usually the fastest reply",
},
{
label: "matrix",
icon: "matrix",
href: "https://matrix.to/#/@imzach:tchncs.de",
handle: "@imzach:tchncs.de",
blurb: "federated chat, fine for anything",
},
];
export const webrings = {
hotline: {
name: "hotline webring",
home: "https://hotlinewebring.club",
prev: "https://hotlinewebring.club/zach/previous",
next: "https://hotlinewebring.club/zach/next",
},
online: {
name: "online webring",
home: "https://webring.ghostk.id/online/",
prev: "https://webring.ghostk.id/online/zach/previous",
random: "https://webring.ghostk.id/online/random",
next: "https://webring.ghostk.id/online/zach/next",
},
snug: {
name: "snug ring",
home: "https://snugnook.org/ring",
prev: "https://snugnook.org/ring/zachy.cc/previous",
random: "https://snugnook.org/ring/random",
next: "https://snugnook.org/ring/zachy.cc/next",
},
};
export const buttons = [
{ label: "zachy.cc", img: "/badges/zachy.gif", still: "/badges/zachy.png", href: null },
{ label: "powered by fedora", img: "/badges/fedora.gif", still: null, href: "https://fedoraproject.org/" },
{ label: "matrix", img: "/badges/matrix.gif", still: "/badges/matrix.png", href: "https://matrix.org/" },
{ label: "get brave", img: "/badges/brave.gif", still: null, href: "https://brave.com/" },
{ label: "say no to web3", img: "/badges/saynotoweb3.gif", still: "/badges/saynotoweb3.png", href: "https://yesterweb.org/no-to-web3/" },
{ label: "codestorm", img: "/badges/sky.gif", still: "/badges/sky.png", href: "https://codestorm.net/" },
{ label: "daudix", img: "/badges/daudix.gif", still: null, href: "https://daudix.one/" },
{ label: "mambuco", img: "/badges/mambuco.gif", still: null, href: "https://mambuco.dev" },
{ label: "anins1der", img: "/badges/anins1der.gif", still: null, href: "https://anins1der.is-a.dev" },
{ label: "xip", img: "/badges/pixl_xip.gif", still: null, href: "https://xip.neocities.org/" },
{ label: "snug nook", img: "/badges/snug-nook.gif", still: null, href: "https://snugnook.org" },
{ label: "ghostk.id", img: "/badges/ghostk.gif", still: null, href: "https://ghostk.id/" },
{ label: "increment", iframe: "//incr.easrng.net/badge?key=zachy.cc-2026", bg: "//incr.easrng.net/bg.gif", href: null },
];

168
src/layouts/Base.astro Normal file
View File

@ -0,0 +1,168 @@
---
import "../styles/global.css";
import { site } from "../data/site";
import Egg from "../components/Egg.astro";
interface Props {
title?: string;
description?: string;
nav?: boolean;
}
const {
title = `${site.name} — ${site.tagline}`,
description = site.description,
nav = true,
} = Astro.props;
const path = Astro.url.pathname;
const canonical = new URL(path, `https://${site.domain}`).href;
const KAO_DEADLINE = "2027-01-01T00:00:00";
const kaoMs = new Date(KAO_DEADLINE).getTime() - Date.now();
const kaoCount = kaoMs > 0 ? `${Math.floor(kaoMs / 86_400_000)}d` : "in effect";
---
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{title}</title>
<meta name="description" content={description} />
<link rel="canonical" href={canonical} />
{path.startsWith("/admin") && <meta name="robots" content="noindex, nofollow" />}
<link rel="icon" href="/favicon.ico" sizes="32x32" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="color-scheme" content="dark" />
<meta name="theme-color" content="#1b1a18" />
<script is:inline>
try {
if (localStorage.getItem("kao-ribbon") === "off")
document.documentElement.dataset.ribbon = "off";
} catch (e) {}
</script>
<meta property="og:type" content="website" />
<meta property="og:site_name" content={site.domain} />
<meta property="og:url" content={canonical} />
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:image" content={`https://${site.domain}/og-image.png`} />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={title} />
<meta name="twitter:description" content={description} />
<meta name="twitter:image" content={`https://${site.domain}/og-image.png`} />
</head>
<body>
<a class="skip" href="#main">skip to content</a>
<div class="ribbon">
<a
class="ribbon__link"
href="https://keepandroidopen.org/"
target="_blank"
rel="noopener"
data-umami-event="ribbon"
data-umami-event-to="keepandroidopen"
>
<span class="ribbon__dot" aria-hidden="true"></span>
<span class="ribbon__text">keep android open</span>
<span class="ribbon__sub"
>&#8212;&nbsp;<span
class="ribbon__count"
data-kao-count
data-kao-deadline={KAO_DEADLINE}>{kaoCount}</span
></span
>
<span class="ribbon__arw" aria-hidden="true">&#8599;</span>
</a>
<button
class="ribbon__x"
type="button"
data-ribbon-dismiss
aria-label="dismiss"
>&#10005;</button>
</div>
{
nav && (
<header class="topbar">
<a class="mark" href="/">{site.wordmark}</a>
<nav class="topnav" aria-label="primary">
<a href="/projects" aria-current={path.startsWith("/projects") ? "page" : undefined}>
projects
</a>
<a href="/notes" aria-current={path.startsWith("/notes") ? "page" : undefined}>
notes
</a>
<a href="/contact" aria-current={path.startsWith("/contact") ? "page" : undefined}>
contact
</a>
</nav>
</header>
)
}
<main id="main">
<slot />
<p class="colophon">
<span class="version">{site.version}</span>
<Egg
note="third full version of the site, this ones a complete ui rebuild (still on cf workers, always)."
/>
</p>
</main>
<script>
const mark = document.querySelector(".mark");
const still = window.matchMedia("(prefers-reduced-motion: reduce)");
if (mark && !still.matches) {
const sweep = () => {
mark.classList.add("is-sweeping");
setTimeout(() => mark.classList.remove("is-sweeping"), 1200);
setTimeout(sweep, 9000 + Math.random() * 13000);
};
setTimeout(sweep, 4000 + Math.random() * 4000);
}
document
.querySelector("[data-ribbon-dismiss]")
?.addEventListener("click", () => {
document.querySelector(".ribbon")?.remove();
try {
localStorage.setItem("kao-ribbon", "off");
} catch (e) {}
});
const kaoEl = document.querySelector("[data-kao-count]");
if (kaoEl instanceof HTMLElement && kaoEl.dataset.kaoDeadline) {
const target = new Date(kaoEl.dataset.kaoDeadline).getTime();
const tick = () => {
const d = target - Date.now();
if (d <= 0) {
kaoEl.textContent = "in effect";
return true;
}
const days = Math.floor(d / 86400000);
const hrs = Math.floor((d % 86400000) / 3600000);
const min = Math.floor((d % 3600000) / 60000);
const sec = Math.floor((d % 60000) / 1000);
kaoEl.textContent = `${days}d ${hrs}h ${min}m ${sec}s`;
return false;
};
if (!tick()) {
const iv = setInterval(() => {
if (tick()) clearInterval(iv);
}, 1000);
}
}
</script>
</body>
</html>

212
src/lib/content.ts Normal file
View File

@ -0,0 +1,212 @@
import { env as cfEnv } from "cloudflare:workers";
export interface Note {
body: string;
date: string;
}
export interface Project {
id: string;
title: string;
blurb: string;
year: string;
href?: string;
repo?: string;
tags: string[];
featured: boolean;
draft: boolean;
}
export const DEFAULT_NOTE: Note = {
body: "rebuilt the site from scratch this weekend. corkboard of badges, a rolling note, the usual webrings. more soon.",
date: "2026-09-06T00:00:00.000Z",
};
export const DEFAULT_PROJECTS: Project[] = [
{
id: "drop",
title: "drop",
blurb: "encrypted, no-account file transfer",
year: "2025",
href: "https://drop.zachy.cc",
repo: "https://github.com/zoop-dev/drop",
tags: ["workers", "d1", "r2"],
featured: false,
draft: false,
},
{
id: "b3am",
title: "b3am",
blurb: "end-to-end encrypted messenger",
year: "2026",
href: "https://b3am.zo0p.dev",
tags: ["workers", "d1", "websocket"],
featured: false,
draft: false,
},
{
id: "pulse",
title: "pulse",
blurb: "Garmin companion app",
year: "2026",
href: "https://fdroid.zachy.cc",
tags: ["kotlin", "java"],
featured: false,
draft: false,
},
{
id: "foyer",
title: "foyer",
blurb: "personal website framework + CMS",
year: "2026",
href: "https://foyer.zo0p.dev",
tags: ["node", "cloudflare"],
featured: false,
draft: false,
},
{
id: "my-portfolio",
title: "my portfolio",
blurb: "codes",
year: "2026",
href: "https://portfolio.zo0p.dev",
tags: ["supabase", "js"],
featured: false,
draft: false,
},
{
id: "sp33d",
title: "sp33d",
blurb: "network speed tester",
year: "2026",
href: "https://sp33d.zo0p.dev",
tags: [],
featured: false,
draft: false,
},
{
id: "pr0xy",
title: "pr0xy",
blurb: "web proxy",
year: "2026",
href: "https://pr0xy.zo0p.dev",
tags: [],
featured: false,
draft: false,
},
{
id: "m0rph",
title: "m0rph",
blurb: "",
year: "2026",
href: "https://m0rph.zo0p.dev",
tags: [],
featured: false,
draft: false,
},
{
id: "shortl3y",
title: "shortl3y",
blurb: "url shortener",
year: "2026",
href: "https://go.zo0p.dev",
tags: [],
featured: false,
draft: false,
},
{
id: "backly",
title: "backly",
blurb: "a little code backup tool",
year: "",
repo: "https://github.com/zoop-dev/backly",
tags: ["cli", "javascript"],
featured: false,
draft: false,
},
{
id: "pwa-stuff",
title: "pwa stuff",
blurb: "cool apps",
year: "",
href: "https://pwa.zachy.cc",
tags: [],
featured: false,
draft: false,
},
{
id: "cm",
title: "cm",
blurb: "compression",
year: "",
href: "https://cm.loop82.com",
tags: [],
featured: false,
draft: false,
},
];
function kv(): any {
try {
return (cfEnv as any)?.CONTENT ?? null;
} catch {
return null;
}
}
export async function getNote(): Promise<Note> {
const store = kv();
if (!store) return DEFAULT_NOTE;
let raw: string | null = null;
try {
raw = await store.get("note");
} catch {
return DEFAULT_NOTE;
}
if (!raw) return DEFAULT_NOTE;
try {
const n = JSON.parse(raw);
if (n && typeof n.body === "string") {
return { body: n.body, date: n.date ?? new Date().toISOString() };
}
} catch {
return { body: raw, date: new Date().toISOString() };
}
return DEFAULT_NOTE;
}
export async function setNote(body: string): Promise<void> {
const store = kv();
if (!store) throw new Error("CONTENT KV namespace is not bound");
await store.put("note", JSON.stringify({ body, date: new Date().toISOString() }));
}
export async function getProjects(): Promise<Project[]> {
const store = kv();
if (!store) return DEFAULT_PROJECTS;
let raw: string | null = null;
try {
raw = await store.get("projects");
} catch {
return DEFAULT_PROJECTS;
}
if (!raw) return DEFAULT_PROJECTS;
try {
const list = JSON.parse(raw);
return Array.isArray(list) ? (list as Project[]) : DEFAULT_PROJECTS;
} catch {
return DEFAULT_PROJECTS;
}
}
export async function setProjects(list: Project[]): Promise<void> {
const store = kv();
if (!store) throw new Error("CONTENT KV namespace is not bound");
await store.put("projects", JSON.stringify(list));
}

82
src/lib/md.ts Normal file
View File

@ -0,0 +1,82 @@
const esc = (s: string) =>
s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
function inline(s: string): string {
return esc(s)
.replace(/`([^`]+)`/g, (_, c) => `<code>${c}</code>`)
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
.replace(/(^|[\s(])_([^_]+)_/g, "$1<em>$2</em>")
.replace(
/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g,
'<a href="$2" target="_blank" rel="noopener">$1</a>',
);
}
export function renderMarkdown(src: string): string {
const lines = src.replace(/\r\n/g, "\n").split("\n");
const out: string[] = [];
let i = 0;
while (i < lines.length) {
const line = lines[i];
if (line.trim() === "") {
i++;
continue;
}
if (line.trim().startsWith("```")) {
const buf: string[] = [];
i++;
while (i < lines.length && !lines[i].trim().startsWith("```")) {
buf.push(esc(lines[i]));
i++;
}
i++;
out.push(`<pre><code>${buf.join("\n")}</code></pre>`);
continue;
}
const h = line.match(/^(#{2,4})\s+(.*)$/);
if (h) {
const level = h[1].length;
out.push(`<h${level}>${inline(h[2])}</h${level}>`);
i++;
continue;
}
if (/^\s*[-*]\s+/.test(line)) {
const items: string[] = [];
while (i < lines.length && /^\s*[-*]\s+/.test(lines[i])) {
items.push(`<li>${inline(lines[i].replace(/^\s*[-*]\s+/, ""))}</li>`);
i++;
}
out.push(`<ul>${items.join("")}</ul>`);
continue;
}
const para: string[] = [];
while (
i < lines.length &&
lines[i].trim() !== "" &&
!lines[i].trim().startsWith("```") &&
!/^(#{2,4})\s+/.test(lines[i]) &&
!/^\s*[-*]\s+/.test(lines[i])
) {
para.push(lines[i]);
i++;
}
out.push(`<p>${inline(para.join(" "))}</p>`);
}
return out.join("\n");
}

88
src/lib/session.ts Normal file
View File

@ -0,0 +1,88 @@
import { env as cfEnv } from "cloudflare:workers";
const COOKIE = "admin_session";
const TTL = 60 * 60 * 24 * 7;
function secrets(): Record<string, string | undefined> {
const node = typeof process !== "undefined" ? process.env : {};
let worker: Record<string, string | undefined> = {};
try {
worker = cfEnv as any;
} catch {
}
return { ...node, ...worker };
}
export function adminPasswordSet(): boolean {
return !!secrets().ADMIN_PASSWORD;
}
function sessionSecret(): string {
return secrets().SESSION_SECRET || "insecure-dev-secret-change-me";
}
async function hmacHex(msg: string): Promise<string> {
const enc = new TextEncoder();
const key = await crypto.subtle.importKey(
"raw",
enc.encode(sessionSecret()),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
);
const sig = await crypto.subtle.sign("HMAC", key, enc.encode(msg));
return [...new Uint8Array(sig)]
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}
function timingSafeEqual(a: string, b: string): boolean {
if (a.length !== b.length) return false;
let out = 0;
for (let i = 0; i < a.length; i++) out |= a.charCodeAt(i) ^ b.charCodeAt(i);
return out === 0;
}
function readCookie(request: Request, name: string): string | null {
const header = request.headers.get("cookie");
if (!header) return null;
for (const part of header.split(";")) {
const eq = part.indexOf("=");
if (eq < 0) continue;
if (part.slice(0, eq).trim() === name) return part.slice(eq + 1).trim();
}
return null;
}
export async function checkPassword(input: string): Promise<boolean> {
const pw = secrets().ADMIN_PASSWORD;
if (!pw) return false;
return timingSafeEqual(input, pw);
}
export async function makeSessionCookie(url: URL): Promise<string> {
const exp = Math.floor(Date.now() / 1000) + TTL;
const value = `${exp}.${await hmacHex(String(exp))}`;
const secure = url.protocol === "https:" ? "; Secure" : "";
return `${COOKIE}=${value}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${TTL}${secure}`;
}
export function clearSessionCookie(url: URL): string {
const secure = url.protocol === "https:" ? "; Secure" : "";
return `${COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0${secure}`;
}
export async function isAuthed(request: Request): Promise<boolean> {
const raw = readCookie(request, COOKIE);
if (!raw) return false;
const dot = raw.indexOf(".");
if (dot < 0) return false;
const exp = raw.slice(0, dot);
const sig = raw.slice(dot + 1);
if (!/^\d+$/.test(exp)) return false;
if (Number(exp) < Math.floor(Date.now() / 1000)) return false;
return timingSafeEqual(sig, await hmacHex(exp));
}

121
src/pages/404.astro Normal file
View File

@ -0,0 +1,121 @@
---
import Base from "../layouts/Base.astro";
const path = Astro.url.pathname;
const file = `zach${path}.md`;
const routes = ["projects", "notes", "contact"];
---
<Base title="404 — not found" description="that page doesn't exist.">
<section class="fof" aria-labelledby="fof-heading">
<h1 id="fof-heading" class="sr-only">404 — page not found</h1>
<p class="fof__cmd">
<span class="fof__sig">$</span> cat {file}<span
class="fof__caret"
aria-hidden="true"></span>
</p>
<p class="fof__err">cat: {file}: No such file or directory</p>
<p class="fof__cmd fof__cmd--2"><span class="fof__sig">$</span> ls zach/</p>
<nav class="fof__ls" aria-label="pages">
<a href="/">~/</a>
{routes.map((r) => <a href={`/${r}`}>{r}/</a>)}
</nav>
</section>
</Base>
<script>
const fof = document.querySelector<HTMLElement>(".fof");
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)");
if (fof && !reduce.matches) {
fof.classList.add("fof--running");
window.setTimeout(() => fof.classList.remove("fof--running"), 550);
}
</script>
<style>
.fof {
min-height: 44vh;
display: grid;
align-content: center;
gap: 0.2rem;
}
.fof__cmd {
margin: 0;
font-family: var(--mono);
font-size: clamp(1.05rem, 4.4vw, 1.55rem);
line-height: 1.4;
color: var(--ink);
overflow-wrap: anywhere;
}
.fof__sig {
color: var(--accent);
margin-right: 0.7ch;
}
.fof__caret {
display: inline-block;
width: 0.5ch;
height: 1.05em;
margin-left: 0.12ch;
vertical-align: -0.16em;
background: var(--faint);
animation: fof-blink 1.1s steps(1) infinite;
}
@keyframes fof-blink {
50% {
opacity: 0;
}
}
.fof__err {
margin: 0.6rem 0 0;
font-family: var(--mono);
font-size: clamp(0.82rem, 3vw, 1.05rem);
line-height: 1.5;
color: var(--faint);
overflow-wrap: anywhere;
}
.fof__cmd--2 {
margin-top: 1.7rem;
}
.fof__ls {
display: flex;
flex-wrap: wrap;
gap: 0.35rem 1.7rem;
margin: 0.55rem 0 0;
font-family: var(--mono);
font-size: clamp(0.85rem, 3vw, 1.05rem);
}
.fof__ls a {
color: var(--muted);
text-decoration: none;
transition: color 0.16s ease;
}
.fof__ls a:hover {
color: var(--accent);
}
.fof__err,
.fof__cmd--2,
.fof__ls {
transition:
opacity 0.25s ease,
transform 0.3s ease;
}
.fof--running .fof__err,
.fof--running .fof__cmd--2,
.fof--running .fof__ls {
opacity: 0;
transform: translateY(-3px);
}
@media (prefers-reduced-motion: reduce) {
.fof__caret {
animation: none;
}
}
</style>

368
src/pages/admin/index.astro Normal file
View File

@ -0,0 +1,368 @@
---
import Base from "../../layouts/Base.astro";
import { isAuthed, adminPasswordSet } from "../../lib/session";
import { getNote, getProjects } from "../../lib/content";
const authed = await isAuthed(Astro.request);
const loginError = Astro.url.searchParams.has("e");
const noPassword = !adminPasswordSet();
const note = authed ? await getNote() : null;
const projects = authed ? await getProjects() : [];
const thisYear = String(new Date().getFullYear());
---
<Base title="admin" description="edit site content.">
<section class="intro">
<h1>admin</h1>
</section>
{
!authed && (
<form class="card login" method="POST" action="/api/admin/login">
<label class="fld">
<span>password</span>
<input
type="password"
name="password"
autocomplete="current-password"
required
autofocus
/>
</label>
{loginError && <p class="msg msg--err">nope. try again.</p>}
{noPassword && (
<p class="msg msg--err">
ADMIN_PASSWORD isn't set — add it to .dev.vars (local) or as a worker
secret (prod).
</p>
)}
<button type="submit">enter</button>
</form>
)
}
{
authed && note && (
<>
<form class="card" method="POST" action="/api/admin/save">
<input type="hidden" name="action" value="note" />
<h2 class="label">note</h2>
<textarea name="body" rows="5" spellcheck="true" set:text={note.body} />
<p class="hint">markdown ok. saving stamps today's date.</p>
<div class="row">
<button type="submit">save note</button>
</div>
</form>
<section class="card">
<h2 class="label">projects &mdash; {projects.length}</h2>
{projects.map((p, i) => (
<details class="pj">
<summary>
{p.draft && <span class="tag">draft</span>}
{p.featured && <span class="tag tag--star">&#9733;</span>}
<span class="pj__name">{p.title || "(untitled)"}</span>
<span class="pj__year">{p.year}</span>
</summary>
<form method="POST" action="/api/admin/save" class="pj__form">
<input type="hidden" name="action" value="project-save" />
<input type="hidden" name="id" value={p.id} />
<label class="fld">
<span>title</span>
<input name="title" value={p.title} required />
</label>
<label class="fld fld--sm">
<span>year</span>
<input name="year" value={p.year} />
</label>
<label class="fld fld--wide">
<span>blurb</span>
<textarea name="blurb" rows="2" set:text={p.blurb} />
</label>
<label class="fld">
<span>live url</span>
<input name="href" value={p.href ?? ""} placeholder="https://" />
</label>
<label class="fld">
<span>repo url</span>
<input name="repo" value={p.repo ?? ""} placeholder="https://" />
</label>
<label class="fld fld--wide">
<span>tags (comma-separated)</span>
<input name="tags" value={p.tags.join(", ")} />
</label>
<label class="chk">
<input type="checkbox" name="featured" checked={p.featured} />{" "}
featured
</label>
<label class="chk">
<input type="checkbox" name="draft" checked={p.draft} /> draft
</label>
<div class="row fld--wide">
<button type="submit">save</button>
</div>
</form>
<div class="row pj__ops">
<form method="POST" action="/api/admin/save">
<input type="hidden" name="action" value="project-move" />
<input type="hidden" name="id" value={p.id} />
<input type="hidden" name="dir" value="up" />
<button type="submit" disabled={i === 0} aria-label="move up">
&#8593;
</button>
</form>
<form method="POST" action="/api/admin/save">
<input type="hidden" name="action" value="project-move" />
<input type="hidden" name="id" value={p.id} />
<input type="hidden" name="dir" value="down" />
<button
type="submit"
disabled={i === projects.length - 1}
aria-label="move down"
>
&#8595;
</button>
</form>
<form
method="POST"
action="/api/admin/save"
onsubmit="return confirm('delete this project?')"
>
<input type="hidden" name="action" value="project-delete" />
<input type="hidden" name="id" value={p.id} />
<button type="submit" class="danger">
delete
</button>
</form>
</div>
</details>
))}
<details class="pj pj--add">
<summary>+ add project</summary>
<form method="POST" action="/api/admin/save" class="pj__form">
<input type="hidden" name="action" value="project-save" />
<label class="fld">
<span>title</span>
<input name="title" required />
</label>
<label class="fld fld--sm">
<span>year</span>
<input name="year" value={thisYear} />
</label>
<label class="fld fld--wide">
<span>blurb</span>
<textarea name="blurb" rows="2" />
</label>
<label class="fld">
<span>live url</span>
<input name="href" placeholder="https://" />
</label>
<label class="fld">
<span>repo url</span>
<input name="repo" placeholder="https://" />
</label>
<label class="fld fld--wide">
<span>tags (comma-separated)</span>
<input name="tags" />
</label>
<label class="chk">
<input type="checkbox" name="featured" /> featured
</label>
<label class="chk">
<input type="checkbox" name="draft" /> draft
</label>
<div class="row fld--wide">
<button type="submit">add</button>
</div>
</form>
</details>
</section>
<form method="POST" action="/api/admin/logout" class="logout">
<button type="submit">log out</button>
</form>
</>
)
}
</Base>
<style>
.card {
margin-top: 1.6rem;
padding: 1.1rem 1.2rem 1.3rem;
border: 1px solid var(--line);
border-radius: 6px;
background: var(--paper-2);
}
.login {
max-width: 22rem;
}
.label {
margin-bottom: 0.9rem;
}
.fld {
display: flex;
flex-direction: column;
gap: 0.3rem;
margin-bottom: 0.9rem;
}
.fld > span {
font-family: var(--mono);
font-size: 0.68rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--faint);
}
input[type="text"],
input:not([type]),
input[type="password"],
textarea {
width: 100%;
box-sizing: border-box;
font: inherit;
font-size: 0.9rem;
color: var(--ink);
background: var(--paper);
border: 1px solid var(--line);
border-radius: 4px;
padding: 0.5rem 0.6rem;
resize: vertical;
}
input:focus,
textarea:focus {
outline: none;
border-color: var(--accent-2);
}
textarea {
font-family: var(--mono);
font-size: 0.82rem;
line-height: 1.55;
}
button {
font: inherit;
font-family: var(--mono);
font-size: 0.76rem;
letter-spacing: 0.03em;
color: var(--ink);
background: var(--paper);
border: 1px solid var(--line);
border-radius: 4px;
padding: 0.4rem 0.9rem;
cursor: pointer;
transition:
border-color 0.15s ease,
color 0.15s ease;
}
button:hover:not(:disabled) {
border-color: var(--accent-2);
color: var(--accent-2);
}
button:disabled {
opacity: 0.4;
cursor: default;
}
button.danger:hover {
border-color: #d64545;
color: #ff9a9a;
}
.row {
display: flex;
gap: 0.5rem;
align-items: center;
}
.hint,
.msg {
font-family: var(--mono);
font-size: 0.7rem;
color: var(--faint);
margin: 0.5rem 0 0.8rem;
}
.msg--err {
color: #ff9a9a;
}
.pj {
border-top: 1px solid var(--line);
padding: 0.7rem 0;
}
.pj > summary {
cursor: pointer;
display: flex;
align-items: baseline;
gap: 0.6rem;
font-family: var(--serif);
font-size: 1rem;
}
.pj__year {
margin-left: auto;
font-family: var(--mono);
font-size: 0.72rem;
color: var(--faint);
}
.tag {
font-family: var(--mono);
font-size: 0.6rem;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--faint);
border: 1px solid var(--line);
border-radius: 3px;
padding: 0.05rem 0.3rem;
}
.tag--star {
color: var(--accent);
border-color: transparent;
padding: 0;
font-size: 0.75rem;
}
.pj--add > summary {
font-family: var(--mono);
font-size: 0.8rem;
color: var(--muted);
}
.pj__form {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0 1rem;
margin-top: 0.9rem;
}
.fld--wide {
grid-column: 1 / -1;
}
.fld--sm {
max-width: 8rem;
}
.chk {
display: flex;
align-items: center;
gap: 0.4rem;
font-family: var(--mono);
font-size: 0.78rem;
color: var(--muted);
margin-bottom: 0.9rem;
}
.chk input {
width: auto;
}
.pj__ops {
margin-top: 0.4rem;
}
.pj__ops form {
display: inline;
}
.logout {
margin-top: 1.5rem;
}
</style>

View File

@ -0,0 +1,21 @@
import type { APIRoute } from "astro";
import { checkPassword, makeSessionCookie } from "../../../lib/session";
export const prerender = false;
export const POST: APIRoute = async ({ request }) => {
const url = new URL(request.url);
const form = await request.formData();
const pw = String(form.get("password") ?? "");
const headers = new Headers({ Location: "/admin" });
if (await checkPassword(pw)) {
headers.append("Set-Cookie", await makeSessionCookie(url));
} else {
headers.set("Location", "/admin?e=1");
}
return new Response(null, { status: 303, headers });
};
export const GET: APIRoute = () =>
new Response(null, { status: 303, headers: { Location: "/admin" } });

View File

@ -0,0 +1,12 @@
import type { APIRoute } from "astro";
import { clearSessionCookie } from "../../../lib/session";
export const prerender = false;
export const POST: APIRoute = async ({ request }) => {
const url = new URL(request.url);
return new Response(null, {
status: 303,
headers: { Location: "/admin", "Set-Cookie": clearSessionCookie(url) },
});
};

View File

@ -0,0 +1,70 @@
import type { APIRoute } from "astro";
import { isAuthed } from "../../../lib/session";
import {
getProjects,
setProjects,
setNote,
type Project,
} from "../../../lib/content";
export const prerender = false;
const back = () =>
new Response(null, { status: 303, headers: { Location: "/admin" } });
function projectFromForm(form: FormData, id: string): Project {
const s = (k: string) => String(form.get(k) ?? "").trim();
return {
id,
title: s("title"),
blurb: s("blurb"),
year: s("year"),
href: s("href") || undefined,
repo: s("repo") || undefined,
tags: s("tags")
.split(",")
.map((t) => t.trim().replace(/^#/, ""))
.filter(Boolean),
featured: form.get("featured") != null,
draft: form.get("draft") != null,
};
}
export const POST: APIRoute = async ({ request }) => {
if (!(await isAuthed(request))) return back();
const form = await request.formData();
const action = String(form.get("action") ?? "");
if (action === "note") {
await setNote(String(form.get("body") ?? ""));
return back();
}
const list = await getProjects();
if (action === "project-save") {
const id =
String(form.get("id") ?? "").trim() || crypto.randomUUID().slice(0, 8);
const p = projectFromForm(form, id);
if (!p.title) return back();
const i = list.findIndex((x) => x.id === id);
if (i >= 0) list[i] = p;
else list.push(p);
await setProjects(list);
} else if (action === "project-delete") {
const id = String(form.get("id") ?? "");
await setProjects(list.filter((x) => x.id !== id));
} else if (action === "project-move") {
const id = String(form.get("id") ?? "");
const dir = String(form.get("dir") ?? "");
const i = list.findIndex((x) => x.id === id);
const j = dir === "up" ? i - 1 : i + 1;
if (i >= 0 && j >= 0 && j < list.length) {
[list[i], list[j]] = [list[j], list[i]];
await setProjects(list);
}
}
return back();
};

View File

@ -0,0 +1,142 @@
import type { APIRoute } from "astro";
import { env as cfEnv } from "cloudflare:workers";
export const prerender = false;
type Env = Record<string, string | undefined>;
interface Track {
title: string;
artist: string;
url: string;
art: string | null;
}
interface NowPlaying extends Track {
isPlaying: boolean;
progressMs: number | null;
durationMs: number | null;
playedAt: string | null;
}
const TOKEN_URL = "https://accounts.spotify.com/api/token";
const API = "https://api.spotify.com/v1";
function getEnv(): Env {
const nodeEnv = typeof process !== "undefined" ? process.env : {};
let workerEnv: Env = {};
try {
workerEnv = cfEnv as unknown as Env;
} catch {
}
return { ...nodeEnv, ...workerEnv };
}
async function accessToken(env: Env): Promise<string | null> {
const { SPOTIFY_CLIENT_ID, SPOTIFY_CLIENT_SECRET, SPOTIFY_REFRESH_TOKEN } = env;
if (!SPOTIFY_CLIENT_ID || !SPOTIFY_CLIENT_SECRET || !SPOTIFY_REFRESH_TOKEN) return null;
const basic = btoa(`${SPOTIFY_CLIENT_ID}:${SPOTIFY_CLIENT_SECRET}`);
const res = await fetch(TOKEN_URL, {
method: "POST",
headers: {
Authorization: `Basic ${basic}`,
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
grant_type: "refresh_token",
refresh_token: SPOTIFY_REFRESH_TOKEN,
}),
});
if (!res.ok) return null;
const json = (await res.json()) as { access_token?: string };
return json.access_token ?? null;
}
function mapTrack(t: any): Track {
return {
title: t?.name ?? "",
artist: (t?.artists ?? []).map((a: any) => a.name).join(", "),
url: t?.external_urls?.spotify ?? "",
art: t?.album?.images?.[1]?.url ?? t?.album?.images?.[0]?.url ?? null,
};
}
async function nowPlaying(token: string): Promise<NowPlaying | null> {
let res = await fetch(`${API}/me/player/currently-playing`, {
headers: { Authorization: `Bearer ${token}` },
});
if (res.status === 200) {
const json = (await res.json()) as any;
if (json?.item) {
return {
...mapTrack(json.item),
isPlaying: !!json.is_playing,
progressMs: json.progress_ms ?? null,
durationMs: json.item.duration_ms ?? null,
playedAt: null,
};
}
}
res = await fetch(`${API}/me/player/recently-played?limit=1`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) return null;
const json = (await res.json()) as any;
const item = json?.items?.[0];
if (!item?.track) return null;
return {
...mapTrack(item.track),
isPlaying: false,
progressMs: null,
durationMs: item.track.duration_ms ?? null,
playedAt: item.played_at ?? null,
};
}
async function topTracks(token: string): Promise<Track[]> {
const res = await fetch(`${API}/me/top/tracks?time_range=short_term&limit=9`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) return [];
const json = (await res.json()) as any;
return (json?.items ?? []).map(mapTrack);
}
export const GET: APIRoute = async () => {
const env = getEnv();
const headers = {
"Content-Type": "application/json",
"Cache-Control": "public, max-age=30, s-maxage=60, stale-while-revalidate=300",
};
try {
const token = await accessToken(env);
if (!token) {
return new Response(
JSON.stringify({ configured: false, nowPlaying: null, top: [] }),
{ headers },
);
}
const [np, top] = await Promise.all([nowPlaying(token), topTracks(token)]);
return new Response(
JSON.stringify({ configured: true, nowPlaying: np, top }),
{ headers },
);
} catch (err) {
return new Response(
JSON.stringify({ configured: false, error: String(err), nowPlaying: null, top: [] }),
{ headers, status: 200 },
);
}
};

25
src/pages/contact.astro Normal file
View File

@ -0,0 +1,25 @@
---
import Base from "../layouts/Base.astro";
import ContactFinger from "../components/ContactFinger.astro";
import { site } from "../data/site";
---
<Base title="contact — zach" description="get in touch with zach.">
<section class="intro">
<h1>contact</h1>
<p class="lede">
pick whatever youd like, ill probably respond fast ;).
</p>
</section>
<ContactFinger />
</Base>
<style>
.lede {
color: var(--muted);
max-width: 52ch;
margin-top: 0.9rem;
text-wrap: pretty;
}
</style>

38
src/pages/index.astro Normal file
View File

@ -0,0 +1,38 @@
---
import Base from "../layouts/Base.astro";
import Music from "../components/Music.astro";
import Webrings from "../components/Webrings.astro";
import Buttons from "../components/Buttons.astro";
import { site, elsewhere } from "../data/site";
---
<Base>
<section class="intro">
<h1 class="sr-only">{site.name} — {site.tagline}</h1>
<p class="bio">{site.bio}</p>
</section>
<Music />
<section class="section">
<h2 class="label">elsewhere</h2>
<nav class="pagenav" aria-label="more">
{
elsewhere.map((l) => (
<a
href={l.href}
target={l.external ? "_blank" : undefined}
rel={l.external ? "noopener" : undefined}
data-umami-event="elsewhere"
data-umami-event-to={l.label}
>
{l.label} <span class="arw">{l.external ? "↗" : "→"}</span>
</a>
))
}
</nav>
</section>
<Webrings />
<Buttons />
</Base>

93
src/pages/notes.astro Normal file
View File

@ -0,0 +1,93 @@
---
import Base from "../layouts/Base.astro";
import { getNote } from "../lib/content";
import { renderMarkdown } from "../lib/md";
const note = await getNote();
const html = note.body.trim() ? renderMarkdown(note.body) : "";
const fmt = (d: string) =>
new Date(d)
.toLocaleDateString("en-GB", { day: "numeric", month: "long", year: "numeric" })
.toUpperCase();
---
<Base title="notes — zach" description="a note from zach.">
<section class="intro">
<h1>notes</h1>
</section>
{
html ? (
<article class="notepage">
<p class="notepage__date">{fmt(note.date)}</p>
<div class="notepage__body" set:html={html} />
</article>
) : (
<p class="notepage__empty">nothing noted yet.</p>
)
}
</Base>
<style>
.notepage__date {
font-family: var(--mono);
font-size: 0.78rem;
letter-spacing: 0.16em;
color: var(--muted);
margin-bottom: 1.3rem;
}
.notepage__empty {
color: var(--faint);
}
.notepage__body {
max-width: 62ch;
}
.notepage__body :global(p) {
margin: 0 0 1.1rem;
}
.notepage__body :global(p:last-child) {
margin-bottom: 0;
}
.notepage__body :global(a) {
text-decoration-color: var(--accent);
}
.notepage__body :global(ul) {
margin: 0 0 1.1rem;
padding-left: 1.2rem;
}
.notepage__body :global(li) {
margin: 0 0 0.35rem;
}
.notepage__body :global(code) {
font-family: var(--mono);
font-size: 0.88em;
color: var(--muted);
background: var(--paper-2);
padding: 0.05em 0.3em;
border-radius: 3px;
}
.notepage__body :global(h2),
.notepage__body :global(h3),
.notepage__body :global(h4) {
font-family: var(--mono);
font-size: 0.8rem;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--muted);
margin: 1.8rem 0 0.7rem;
}
.notepage__body :global(pre) {
background: var(--paper-2);
padding: 1rem;
border-radius: 6px;
overflow-x: auto;
font-size: 0.85rem;
}
.notepage__body :global(pre code) {
background: none;
padding: 0;
color: var(--ink);
}
</style>

156
src/pages/projects.astro Normal file
View File

@ -0,0 +1,156 @@
---
import Base from "../layouts/Base.astro";
import { getProjects } from "../lib/content";
const projects = (await getProjects()).filter((p) => !p.draft);
---
<Base title="projects — zach" description="things zach has built.">
<section class="intro">
<h1>projects</h1>
<p class="bio">
a running list of things i've built{
projects.length ? ` — ${projects.length} so far` : ""
}. most are open source.
</p>
</section>
<section class="section">
{
projects.length === 0 ? (
<p class="projs__empty">nothing here yet.</p>
) : (
<ul class="projs">
{projects.map((p) => {
const to = p.href ?? p.repo;
return (
<li class="proj">
<a
class="proj__title"
href={to ?? "#"}
target={to ? "_blank" : undefined}
rel={to ? "noopener" : undefined}
data-umami-event="project"
data-umami-event-name={p.title}
>
{p.featured && (
<span class="proj__star" aria-hidden="true">
</span>
)}
<span>{p.title}</span>
{to && (
<span class="proj__arw" aria-hidden="true">
</span>
)}
</a>
<p class="proj__blurb">{p.blurb}</p>
<p class="proj__meta">
{p.href && (
<a href={p.href} target="_blank" rel="noopener">
live ↗
</a>
)}
{p.repo && (
<a href={p.repo} target="_blank" rel="noopener">
code ↗
</a>
)}
{p.tags.map((t) => (
<span class="proj__tag">#{t}</span>
))}
<span class="proj__year">{p.year}</span>
</p>
</li>
);
})}
</ul>
)
}
</section>
<style>
.projs {
list-style: none;
padding: 0;
margin: 2rem 0 0;
}
.proj {
padding: 1.5rem 0;
border-top: 1px solid var(--line);
}
.projs .proj:last-child {
border-bottom: 1px solid var(--line);
}
.proj__year {
margin-left: auto;
color: var(--faint);
white-space: nowrap;
}
.proj__title {
display: inline-flex;
align-items: baseline;
gap: 0.4rem;
font-family: var(--serif);
font-size: 1.22rem;
line-height: 1.25;
color: var(--ink);
text-decoration: none;
transition: color 0.16s ease;
}
.proj__title:hover {
color: var(--accent);
}
.proj__star {
color: var(--accent);
font-size: 0.62em;
transform: translateY(-0.15em);
}
.proj__arw {
font-family: var(--mono);
font-size: 0.68rem;
color: var(--faint);
transition:
transform 0.22s cubic-bezier(0.16, 1, 0.3, 1),
color 0.16s ease;
}
.proj__title:hover .proj__arw {
color: var(--accent);
transform: translate(0.22em, -0.22em);
}
.proj__blurb {
color: var(--muted);
margin-top: 0.45rem;
max-width: 58ch;
text-wrap: pretty;
}
.proj__meta {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0.35rem 1.1rem;
margin-top: 0.7rem;
font-family: var(--mono);
font-size: 0.72rem;
letter-spacing: 0.02em;
}
.proj__meta a {
color: var(--muted);
text-decoration: none;
transition: color 0.16s ease;
}
.proj__meta a:hover {
color: var(--accent-2);
}
.proj__tag {
color: var(--faint);
}
.projs__empty {
color: var(--faint);
}
</style>
</Base>

147
src/scripts/music.ts Normal file
View File

@ -0,0 +1,147 @@
interface Track {
title: string;
artist: string;
url: string;
art: string | null;
}
interface NP extends Track {
isPlaying: boolean;
progressMs: number | null;
durationMs: number | null;
playedAt: string | null;
}
function ago(iso: string): string {
const s = Math.max(0, (Date.now() - new Date(iso).getTime()) / 1000);
if (s < 90) return "just now";
const m = s / 60;
if (m < 60) return `${Math.round(m)}m ago`;
const h = m / 60;
if (h < 24) return `${Math.round(h)}h ago`;
return `${Math.round(h / 24)}d ago`;
}
const mmss = (ms: number): string => {
const t = Math.round(ms / 1000);
return `${Math.floor(t / 60)}:${String(t % 60).padStart(2, "0")}`;
};
const $ = (id: string) => document.getElementById(id);
const esc = (s: string): string =>
s.replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]!);
function renderNowPlaying(np: NP): void {
const section = $("np-section");
if (!section) return;
const art = $("np-art") as HTMLImageElement | null;
if (art) {
if (np.art) {
art.src = np.art;
art.classList.remove("np__art--none");
} else {
art.removeAttribute("src");
art.classList.add("np__art--none");
}
}
const title = $("np-title");
const artist = $("np-artist");
if (title) title.textContent = np.title;
if (artist) artist.textContent = np.artist;
const src = $("np-src") as HTMLAnchorElement | null;
if (src && np.url) src.href = np.url;
const dur = $("np-dur");
const agoEl = $("np-ago");
const prog = $("np-prog");
const fill = $("np-prog-fill") as HTMLElement | null;
const total = np.durationMs;
if (np.isPlaying && np.progressMs != null && total) {
if (dur) dur.textContent = `${mmss(np.progressMs)} / ${mmss(total)}`;
if (agoEl) agoEl.textContent = "";
if (prog) prog.hidden = false;
const paint = (p: number): void => {
if (fill) fill.style.width = Math.min(100, (p / total) * 100) + "%";
};
paint(np.progressMs);
let p = np.progressMs;
const iv = window.setInterval(() => {
p += 1000;
if (p >= total) {
window.clearInterval(iv);
return;
}
paint(p);
}, 1000);
} else {
if (prog) prog.hidden = true;
if (dur) dur.textContent = total ? mmss(total) : "";
if (agoEl) agoEl.textContent = np.playedAt ? ago(np.playedAt) : "";
}
section.hidden = false;
}
function renderTop(tracks: Track[]): void {
const section = $("top-section");
const mosaic = $("mosaic");
const caps = $("mosaic-caps");
if (!section || !mosaic || !caps || !tracks.length) return;
mosaic.innerHTML = tracks
.map((t, i) => {
const cls = i === 0 ? "mosaic__cell mosaic__cell--xl" : "mosaic__cell";
const label = esc(`${t.title}${t.artist}`);
const img = t.art
? `<img src="${esc(t.art)}" alt="${label}" loading="lazy" decoding="async">`
: "";
return `<a class="${cls}" href="${esc(t.url)}" target="_blank" rel="noopener" title="${label}">${img}</a>`;
})
.join("");
caps.innerHTML = tracks
.map((t, i) => {
const rank = String(i + 1).padStart(2, "0");
return `<div class="mosaic__cap"><span class="mosaic__rank">${rank}</span><span class="mosaic__title">${esc(
t.title,
)}</span><span class="mosaic__artist">${esc(t.artist)}</span></div>`;
})
.join("");
section.hidden = false;
}
function mockData() {
const art = (n: number) => `https://picsum.photos/seed/np${n}/200`;
const mk = (n: number): Track => ({
title: ["After Party", "Gemstone", "Heartless", "Breathe", "ONE CALL", "Alright", "Nights", "Ivy", "Self Control"][n],
artist: ["Don Toliver", "Don Toliver", "Kanye West", "Yeat", "Rich Amiri", "Kendrick Lamar", "Frank Ocean", "Frank Ocean", "Frank Ocean"][n],
url: "https://open.spotify.com",
art: art(n),
});
return {
configured: true,
nowPlaying: { ...mk(0), isPlaying: true, progressMs: 78000, durationMs: 175000, playedAt: null },
top: Array.from({ length: 9 }, (_, i) => mk(i)),
};
}
async function load(): Promise<void> {
let data: any;
try {
const res = await fetch("/api/spotify.json", { headers: { accept: "application/json" } });
data = await res.json();
} catch {
return;
}
if ((!data || !data.configured) && location.search.includes("mockmusic")) data = mockData();
if (!data || !data.configured) return;
if (data.nowPlaying) renderNowPlaying(data.nowPlaying as NP);
if (Array.isArray(data.top)) renderTop(data.top as Track[]);
}
load();

470
src/styles/global.css Normal file
View File

@ -0,0 +1,470 @@
@import "@fontsource-variable/newsreader";
@import "@fontsource-variable/newsreader/wght-italic.css";
@import "@fontsource-variable/jetbrains-mono";
@import "@fontsource/unbounded/900.css";
:root {
color-scheme: dark;
--paper: #1b1a18;
--paper-2: #232220;
--ink: #e7e3dc;
--muted: #9a948a;
--faint: #6b655c;
--line: #322f2b;
--accent: #ffab5e;
--accent-2: #5ea0ff;
--star: #ffab5e;
--grad: linear-gradient(
90deg,
rgb(255, 224, 138),
rgb(255, 171, 94),
rgb(255, 95, 109),
rgb(200, 75, 214),
rgb(255, 95, 109),
rgb(255, 171, 94),
rgb(255, 224, 138)
);
--serif: "Newsreader Variable", Charter, "Iowan Old Style", Georgia, serif;
--mono: "JetBrains Mono Variable", ui-monospace, "SF Mono", Menlo, monospace;
--display: "Unbounded", var(--mono);
--col: 40rem;
--step: clamp(1.05rem, 0.92rem + 0.6vw, 1.2rem);
--gap: 2.5rem;
}
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
}
html {
-webkit-text-size-adjust: 100%;
scrollbar-gutter: stable;
background: var(--paper);
}
body {
background: var(--paper);
color: var(--ink);
font-family: var(--serif);
font-size: var(--step);
line-height: 1.6;
font-optical-sizing: auto;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
max-width: var(--col);
margin-inline: auto;
padding: clamp(2.5rem, 9vw, 4.5rem) clamp(1.5rem, 6vw, 2rem) clamp(5rem, 12vw, 7rem);
}
::selection {
background: rgba(255, 171, 94, 0.22);
}
a {
color: inherit;
text-underline-offset: 0.18em;
text-decoration-color: var(--faint);
text-decoration-thickness: from-font;
}
a:hover {
text-decoration-color: var(--accent);
}
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 3px;
border-radius: 2px;
}
img {
max-width: 100%;
display: block;
}
[hidden] {
display: none !important;
}
.skip {
position: absolute;
left: -9999px;
}
.skip:focus {
left: 1rem;
top: 1rem;
background: var(--ink);
color: var(--paper);
padding: 0.5rem 0.75rem;
z-index: 10;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.ribbon {
display: flex;
align-items: stretch;
margin: 0 0 clamp(1.6rem, 4.5vw, 2.4rem);
border: 1px solid var(--line);
border-radius: 4px;
background: var(--paper-2);
font-family: var(--mono);
font-size: 0.72rem;
letter-spacing: 0.015em;
transition: border-color 0.18s ease;
}
.ribbon:hover {
border-color: var(--faint);
}
:root[data-ribbon="off"] .ribbon {
display: none;
}
.ribbon__link {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 0.55rem;
padding: 0.5rem 0.45rem 0.5rem 0.7rem;
color: var(--muted);
text-decoration: none;
transition: color 0.18s ease;
}
.ribbon__link:hover {
color: var(--ink);
}
.ribbon__dot {
flex: none;
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--accent);
}
.ribbon__text {
color: var(--ink);
white-space: nowrap;
}
.ribbon__sub {
color: var(--faint);
white-space: nowrap;
}
.ribbon__count {
font-variant-numeric: tabular-nums;
}
.ribbon__arw {
margin-left: auto;
padding-left: 0.5rem;
color: var(--faint);
transition:
transform 0.22s cubic-bezier(0.16, 1, 0.3, 1),
color 0.18s ease;
}
.ribbon__link:hover .ribbon__arw {
color: var(--accent);
transform: translate(0.18em, -0.18em);
}
.ribbon__x {
flex: none;
padding: 0 0.7rem;
background: none;
border: 0;
border-left: 1px solid var(--line);
color: var(--faint);
font: inherit;
font-size: 0.7rem;
line-height: 1;
cursor: pointer;
transition:
color 0.15s ease,
background 0.15s ease;
}
.ribbon__x:hover {
color: var(--ink);
background: rgba(255, 255, 255, 0.03);
}
.topbar {
margin: 0 0 clamp(2rem, 5vw, 3rem);
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 1rem;
}
.mark {
font-family: var(--display);
font-weight: 900;
font-size: clamp(1.6rem, 1.2rem + 2vw, 2.1rem);
line-height: 1;
letter-spacing: -0.03em;
text-decoration: none;
display: inline-block;
padding-right: 0.06em;
color: #fff;
background: var(--grad);
background-size: 220% 100%;
background-position: 0% 50%;
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: currentColor;
transition:
-webkit-text-fill-color 0.4s ease,
background-position 0.9s ease;
user-select: none;
}
.mark:hover,
.mark:focus-visible,
.mark.is-sweeping {
-webkit-text-fill-color: transparent;
background-position: 100% 50%;
}
.topnav {
font-family: var(--mono);
font-size: 0.78rem;
letter-spacing: 0.04em;
display: flex;
gap: 1.1rem;
}
.topnav a {
color: #fff;
text-decoration: none;
}
.topnav a:hover {
color: var(--accent-2);
}
.topnav a[aria-current="page"] {
color: var(--muted);
}
.section {
margin-top: var(--gap);
}
.label {
font-family: var(--mono);
font-size: 0.72rem;
font-weight: 400;
letter-spacing: 0.1em;
color: var(--faint);
margin-bottom: 0.7rem;
}
.label__sub {
color: var(--faint);
letter-spacing: 0.06em;
}
.intro {
margin-bottom: var(--gap);
}
.intro h1 {
font-family: var(--display);
font-weight: 900;
font-size: clamp(1.7rem, 1.2rem + 2.4vw, 2.2rem);
line-height: 1;
letter-spacing: -0.03em;
margin-bottom: 0.9rem;
}
.intro .bio {
color: var(--muted);
max-width: 46ch;
text-wrap: pretty;
}
.pagenav {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0.4rem 1.5rem;
font-family: var(--mono);
font-size: 0.82rem;
letter-spacing: 0.02em;
}
.pagenav a {
text-decoration: none;
color: var(--ink);
transition: color 0.18s ease;
}
.pagenav a:hover {
color: var(--accent-2);
}
.pagenav .arw {
display: inline-block;
color: var(--muted);
transition: transform 0.28s cubic-bezier(0.16, 1, 0.3, 1);
}
.pagenav a:hover .arw {
transform: translateX(0.3em);
color: var(--accent-2);
}
.pagenav a:hover .arw--out {
transform: translate(0.22em, -0.22em);
}
.contact .avail {
color: var(--muted);
font-size: 0.95rem;
margin-top: 0.7rem;
}
.contact-links {
display: flex;
gap: 1.2rem;
align-items: center;
margin-top: 1rem;
}
.contact-links a {
display: inline-flex;
color: var(--muted);
transition: color 0.15s ease;
padding: 0.25rem;
margin: -0.25rem;
}
.contact-links a:hover {
color: var(--ink);
}
.contact-links svg {
width: 1.2rem;
height: 1.2rem;
fill: currentColor;
display: block;
}
.copy {
font-family: var(--mono);
font-size: 0.9em;
border: 0;
background: none;
color: var(--ink);
padding: 0.05em 0.15em;
cursor: pointer;
border-bottom: 1px solid var(--line);
position: relative;
display: inline-flex;
align-items: center;
gap: 0.35em;
vertical-align: baseline;
max-width: 100%;
}
.copy__text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.copy__icon,
.copy__check {
flex: none;
width: 0.95em;
height: 0.95em;
fill: none;
stroke: var(--faint);
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
.copy__check {
display: none;
stroke: var(--accent-2);
}
.copy:hover {
border-color: var(--accent-2);
}
.copy:hover .copy__icon {
stroke: var(--accent-2);
}
.copy.copied .copy__icon {
display: none;
}
.copy.copied .copy__check {
display: block;
animation: copy-pop 0.28s cubic-bezier(0.17, 0.89, 0.32, 1.28);
}
@keyframes copy-pop {
from {
transform: scale(0.5);
opacity: 0;
}
}
.copy .tip {
position: absolute;
bottom: 130%;
left: 50%;
transform: translateX(-50%);
background: var(--ink);
color: var(--paper);
font-size: 0.66rem;
letter-spacing: 0.04em;
padding: 0.3rem 0.5rem;
border-radius: 5px;
white-space: nowrap;
opacity: 0;
transition: opacity 0.15s;
pointer-events: none;
}
.copy.copied .tip {
opacity: 1;
}
.colophon {
display: flex;
align-items: center;
margin: 3rem 0 0;
}
.version {
font-family: var(--mono);
font-size: 0.72rem;
letter-spacing: 0.06em;
color: var(--faint);
user-select: none;
}
.backlink {
font-family: var(--mono);
font-size: 0.78rem;
color: var(--muted);
text-decoration: none;
display: inline-block;
margin-bottom: 2rem;
}
.backlink:hover {
color: var(--accent);
}
@media (prefers-reduced-motion: reduce) {
* {
animation-duration: 0.001ms !important;
transition-duration: 0.001ms !important;
}
}

11
tsconfig.json Normal file
View File

@ -0,0 +1,11 @@
{
"extends": "astro/tsconfigs/strict",
"include": [
".astro/types.d.ts",
"**/*",
"./worker-configuration.d.ts"
],
"exclude": [
"dist"
]
}

43
wrangler.jsonc Normal file
View File

@ -0,0 +1,43 @@
{
"$schema": "./node_modules/wrangler/config-schema.json",
"compatibility_date": "2026-09-03",
"compatibility_flags": [
"global_fetch_strictly_public"
],
"name": "website-v3",
"account_id": "5745d698d83c15e655924b25248a3029",
"main": "@astrojs/cloudflare/entrypoints/server",
"assets": {
"directory": "./dist",
"binding": "ASSETS"
},
"observability": {
"enabled": true
},
"routes": [
{
"pattern": "zachy.cc",
"zone_name": "zachy.cc",
"custom_domain": true,
"enabled": true,
"previews_enabled": false
}
],
"kv_namespaces": [
{
"binding": "CONTENT",
"id": "96b661e1818f4773acd2a3a1b39c58b0"
},
{
"binding": "SESSION",
"id": "8d6df673f9934c1e9d15cfb4de09daac"
}
]
}