website v3
14
.dev.vars.example
Normal 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
@ -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
@ -0,0 +1,4 @@
|
||||
{
|
||||
"recommendations": ["astro-build.astro-vscode"],
|
||||
"unwantedRecommendations": []
|
||||
}
|
||||
11
.vscode/launch.json
vendored
Normal 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
@ -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/)
|
||||
50
README.md
Normal 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
@ -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
@ -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
28
package.json
Normal 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
|
After Width: | Height: | Size: 571 B |
BIN
public/badges/brave.gif
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
public/badges/daudix.gif
Normal file
|
After Width: | Height: | Size: 518 B |
BIN
public/badges/fedora.gif
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
public/badges/ghostk.gif
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
public/badges/mambuco.gif
Normal file
|
After Width: | Height: | Size: 582 B |
BIN
public/badges/matrix.gif
Normal file
|
After Width: | Height: | Size: 6.3 KiB |
BIN
public/badges/matrix.png
Normal file
|
After Width: | Height: | Size: 638 B |
BIN
public/badges/pixl_xip.gif
Normal file
|
After Width: | Height: | Size: 794 B |
BIN
public/badges/saynotoweb3.gif
Normal file
|
After Width: | Height: | Size: 483 B |
BIN
public/badges/saynotoweb3.png
Normal file
|
After Width: | Height: | Size: 444 B |
BIN
public/badges/sky.gif
Normal file
|
After Width: | Height: | Size: 3.2 KiB |
BIN
public/badges/sky.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
public/badges/snug-nook.gif
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
public/badges/zachy.gif
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
public/badges/zachy.png
Normal file
|
After Width: | Height: | Size: 693 B |
BIN
public/cork.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
public/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
12
public/favicon.svg
Normal 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
|
After Width: | Height: | Size: 131 KiB |
478
scripts/commit.mjs
Normal 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
@ -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
@ -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 });
|
||||
}
|
||||
410
src/components/Buttons.astro
Normal 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} →</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>✕</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 · <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>
|
||||
269
src/components/ContactAccordion.astro
Normal 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>
|
||||
192
src/components/ContactFinger.astro
Normal 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>
|
||||
143
src/components/ContactLinks.astro
Normal 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>
|
||||
9
src/components/CopyEmail.astro
Normal 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" />
|
||||
53
src/components/CopyText.astro
Normal 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
@ -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
@ -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">—</div>
|
||||
<div class="np__artist" id="np-artist">—</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>
|
||||
183
src/components/Webrings.astro
Normal 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
@ -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
@ -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"
|
||||
>— <span
|
||||
class="ribbon__count"
|
||||
data-kao-count
|
||||
data-kao-deadline={KAO_DEADLINE}>{kaoCount}</span
|
||||
></span
|
||||
>
|
||||
<span class="ribbon__arw" aria-hidden="true">↗</span>
|
||||
</a>
|
||||
<button
|
||||
class="ribbon__x"
|
||||
type="button"
|
||||
data-ribbon-dismiss
|
||||
aria-label="dismiss"
|
||||
>✕</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
@ -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
@ -0,0 +1,82 @@
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
const esc = (s: string) =>
|
||||
s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
|
||||
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
@ -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
@ -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
@ -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 — {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">★</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">
|
||||
↑
|
||||
</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"
|
||||
>
|
||||
↓
|
||||
</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>
|
||||
21
src/pages/api/admin/login.ts
Normal 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" } });
|
||||
12
src/pages/api/admin/logout.ts
Normal 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) },
|
||||
});
|
||||
};
|
||||
70
src/pages/api/admin/save.ts
Normal 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();
|
||||
};
|
||||
142
src/pages/api/spotify.json.ts
Normal 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
@ -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
@ -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
@ -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
@ -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
@ -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) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[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
@ -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
@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"include": [
|
||||
".astro/types.d.ts",
|
||||
"**/*",
|
||||
"./worker-configuration.d.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"dist"
|
||||
]
|
||||
}
|
||||
43
wrangler.jsonc
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||