tons o changes

This commit is contained in:
zoop 2026-09-08 21:37:02 -04:00
parent 2a0f022e3f
commit 011c123f8f
No known key found for this signature in database
GPG Key ID: 15C0D336C9E08F3F
21 changed files with 1061 additions and 65 deletions

View File

@ -12,3 +12,6 @@ SESSION_SECRET=
SPOTIFY_CLIENT_ID=
SPOTIFY_CLIENT_SECRET=
SPOTIFY_REFRESH_TOKEN=
# currently-reading widget — personal token from https://hardcover.app/account/api
HARDCOVER_TOKEN=

View File

@ -1,9 +1,11 @@
import { defineConfig } from 'astro/config';
import cloudflare from '@astrojs/cloudflare';
import icon from 'astro-icon';
export default defineConfig({
output: 'server',
integrations: [icon()],
adapter: cloudflare({
platformProxy: { enabled: true },
}),

116
package-lock.json generated
View File

@ -15,10 +15,28 @@
"astro": "^7.3.1",
"wrangler": "^4.129.0"
},
"devDependencies": {
"@iconify-json/ph": "^1.2.2",
"astro-icon": "^1.2.0"
},
"engines": {
"node": ">=22.12.0"
}
},
"node_modules/@antfu/install-pkg": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@antfu/install-pkg/-/install-pkg-2.0.1.tgz",
"integrity": "sha512-iCKVQcIC0e3oDxEfs3SHQGW+ovhBMZmS1TE+bTk50rVyMCBmCfClv7Qi3HQKlumYwvjb/iIMeWCW2i67q6kFfQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"package-manager-detector": "^1.7.0",
"tinyexec": "^1.2.4"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/@astrojs/cloudflare": {
"version": "14.3.0",
"resolved": "https://registry.npmjs.org/@astrojs/cloudflare/-/cloudflare-14.3.0.tgz",
@ -673,6 +691,16 @@
"node": ">=12"
}
},
"node_modules/@cyberalien/svg-utils": {
"version": "1.2.19",
"resolved": "https://registry.npmjs.org/@cyberalien/svg-utils/-/svg-utils-1.2.19.tgz",
"integrity": "sha512-paDJoDu+LhuH5Ma1q0BDqBpg6d16Xk22cZZrlg/s/J9oB8KgnnpU/snaEETJu2G1pwYcWQINUz6WUqu0Wm9k6A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@iconify/types": "^2.0.0"
}
},
"node_modules/@emnapi/core": {
"version": "1.11.3",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.3.tgz",
@ -1149,6 +1177,51 @@
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@iconify-json/ph": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@iconify-json/ph/-/ph-1.2.2.tgz",
"integrity": "sha512-PgkEZNtqa8hBGjHXQa4pMwZa93hmfu8FUSjs/nv4oUU6yLsgv+gh9nu28Kqi8Fz9CCVu4hj1MZs9/60J57IzFw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@iconify/types": "*"
}
},
"node_modules/@iconify/tools": {
"version": "5.0.14",
"resolved": "https://registry.npmjs.org/@iconify/tools/-/tools-5.0.14.tgz",
"integrity": "sha512-wJ4ZE3/HZh2iBqLwG+8FOMHe7CMJAJqwSXZSpPwMdj1gZM3Z4t2hbQMVw4Vm45oQ985qn0g89PUIcDEdKTheBA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@cyberalien/svg-utils": "^1.2.19",
"@iconify/types": "^2.0.0",
"@iconify/utils": "^3.1.4",
"fflate": "^0.8.3",
"modern-tar": "^0.8.4",
"pathe": "^2.0.3",
"svgo": "^4.1.0"
}
},
"node_modules/@iconify/types": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz",
"integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==",
"dev": true,
"license": "MIT"
},
"node_modules/@iconify/utils": {
"version": "3.1.7",
"resolved": "https://registry.npmjs.org/@iconify/utils/-/utils-3.1.7.tgz",
"integrity": "sha512-JZHlwdID+dy+lTgbYC8NEC4zeugqeYsc6jewvzb4c58kHauJn+X7rNwQjxz5p2qSjqaEeQoLkCIQ9v/H4PK0/w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@antfu/install-pkg": "^2.0.1",
"@iconify/types": "^2.0.0",
"import-meta-resolve": "^4.2.0"
}
},
"node_modules/@img/colour": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
@ -2366,6 +2439,21 @@
}
}
},
"node_modules/astro-icon": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/astro-icon/-/astro-icon-1.2.0.tgz",
"integrity": "sha512-0848V2WZuTPhqGSqLeZimLJZ5wmamMtWYzvA2kvg8s2DrUPZ3/nrTcHJWN0eJHt62jy7uvNxgTNiUJGIr7JNxg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@iconify/tools": "^5.0.12",
"@iconify/types": "^2.0.0",
"@iconify/utils": "^3.1.4"
},
"engines": {
"node": ">=22.12.0"
}
},
"node_modules/axobject-query": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
@ -2839,6 +2927,13 @@
}
}
},
"node_modules/fflate": {
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
"dev": true,
"license": "MIT"
},
"node_modules/find-proc": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/find-proc/-/find-proc-0.1.0.tgz",
@ -2988,6 +3083,17 @@
"integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==",
"license": "BSD-2-Clause"
},
"node_modules/import-meta-resolve": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz",
"integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==",
"dev": true,
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/iron-webcrypto": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/iron-webcrypto/-/iron-webcrypto-1.2.1.tgz",
@ -4076,6 +4182,16 @@
"node": ">=20.18.1"
}
},
"node_modules/modern-tar": {
"version": "0.8.5",
"resolved": "https://registry.npmjs.org/modern-tar/-/modern-tar-0.8.5.tgz",
"integrity": "sha512-snEhs+6G5Tjd4I7tLCDOaoln2RgE0bD19RzEKgvgK2hZ5VKy3MpLhLTZ2fWpXSTg4K2cyPwp+VHATFJhxfnOeA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/mrmime": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",

View File

@ -24,5 +24,9 @@
"allowScripts": {
"esbuild": true,
"workerd@1.20260903.1": true
},
"devDependencies": {
"@iconify-json/ph": "^1.2.2",
"astro-icon": "^1.2.0"
}
}

BIN
public/badges/kevadesu.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 961 B

BIN
public/badges/ro-er.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -372,6 +372,8 @@ const badgeUrl = `https://${site.domain}/badges/zachy.gif`;
</style>
<script>
import { hashDialog } from "../scripts/hash-dialog.ts";
const dialog = document.querySelector<HTMLDialogElement>("[data-badges-dialog]");
const open = document.querySelector<HTMLButtonElement>("[data-badges-open]");
const close = document.querySelector<HTMLButtonElement>("[data-badges-close]");
@ -386,10 +388,9 @@ const badgeUrl = `https://${site.domain}/badges/zachy.gif`;
});
};
open?.addEventListener("click", () => {
loadEmbeds();
dialog?.showModal();
});
const openWall = hashDialog({ name: "badges", dialog, onOpen: loadEmbeds });
open?.addEventListener("click", () => (openWall ? openWall() : dialog?.showModal()));
close?.addEventListener("click", () => dialog?.close());
dialog?.addEventListener("click", (e) => {

View File

@ -0,0 +1,91 @@
---
const TZ = "America/New_York";
const now = new Date();
const parts = (opts: Intl.DateTimeFormatOptions) =>
new Intl.DateTimeFormat("en-US", { timeZone: TZ, ...opts }).formatToParts(now);
const hh = parts({ hour: "2-digit", hour12: false }).find((p) => p.type === "hour")?.value ?? "--";
const mm = parts({ minute: "2-digit" }).find((p) => p.type === "minute")?.value ?? "--";
const zone =
parts({ timeZoneName: "short" }).find((p) => p.type === "timeZoneName")?.value ?? "ET";
---
<div class="clock" data-tz={TZ} role="img" aria-label={`zach's local time — ${hh}:${mm} ${zone}`}>
<span class="clock__scr">
<span class="clock__time"
>{hh}<span class="clock__sep">:</span>{mm}</span
>
<span class="clock__zone" id="clock-zone">{zone}</span>
</span>
</div>
<style>
.clock {
display: inline-flex;
align-items: center;
padding: 0.34rem 0.66rem;
background: #0e0d0b;
border: 1px solid var(--line);
border-radius: 7px;
box-shadow:
inset 0 1px 3px rgba(0, 0, 0, 0.65),
inset 0 0 0 1px rgba(255, 180, 84, 0.04);
}
.clock__scr {
display: inline-flex;
align-items: baseline;
gap: 0.6ch;
font-family: var(--mono);
font-variant-numeric: tabular-nums;
letter-spacing: 0.14em;
}
.clock__time {
font-size: 0.9rem;
color: #ffb454;
text-shadow: 0 0 6px rgba(255, 180, 84, 0.5);
}
.clock__sep {
animation: clock-blink 1s steps(1, end) infinite;
}
@keyframes clock-blink {
50% {
opacity: 0.2;
}
}
.clock__zone {
font-size: 0.6rem;
letter-spacing: 0.18em;
color: #806b4f;
}
@media (prefers-reduced-motion: reduce) {
.clock__sep {
animation: none;
}
}
</style>
<script>
const root = document.querySelector<HTMLElement>(".clock");
const timeEl = root?.querySelector<HTMLElement>(".clock__time");
const zoneEl = document.getElementById("clock-zone");
if (root && timeEl) {
const tz = root.dataset.tz || "America/New_York";
const timeFmt = new Intl.DateTimeFormat("en-US", {
timeZone: tz,
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
const zoneFmt = new Intl.DateTimeFormat("en-US", { timeZone: tz, timeZoneName: "short" });
const tick = () => {
const d = new Date();
const [h, m] = timeFmt.format(d).split(":");
timeEl.innerHTML = `${h}<span class="clock__sep">:</span>${m}`;
const z = zoneFmt.formatToParts(d).find((p) => p.type === "timeZoneName")?.value;
if (zoneEl && z) zoneEl.textContent = z;
};
tick();
setInterval(tick, 15_000);
}
</script>

View File

@ -6,6 +6,8 @@ interface Props {
event?: string;
}
const { value, display = value, label = `copy ${value}`, event } = Astro.props;
import { Icon } from "astro-icon/components";
---
<button
@ -16,13 +18,8 @@ const { value, display = value, label = `copy ${value}`, event } = Astro.props;
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>
<Icon class="copy__icon" name="ph:copy" aria-hidden="true" />
<Icon class="copy__check" name="ph:check-bold" aria-hidden="true" />
<span class="tip" aria-hidden="true">copied</span>
</button>

View File

@ -1,8 +1,8 @@
---
import { Icon } from "astro-icon/components";
---
---
<div class="music">
<div class="music" id="music">
<button
class="music__trigger"
type="button"
@ -15,14 +15,18 @@
class="music__art"
id="music-art"
alt=""
width="30"
height="30"
width="42"
height="42"
decoding="async"
hidden
/>
<span class="music__eq" aria-hidden="true"><i></i><i></i><i></i><i></i></span>
<span class="music__main">
<span class="music__label" id="music-label">now playing</span>
<span class="music__sub" id="music-sub" aria-hidden="true"></span>
</span>
<span class="music__arw" aria-hidden="true">&#8594;</span>
<span class="music__bar" id="music-bar" aria-hidden="true" hidden><i></i></span>
</button>
<dialog class="music-wall" data-music-dialog aria-label="music">
@ -47,24 +51,14 @@
<div class="np__artist" id="np-artist">&mdash;</div>
<div class="np__foot">
<span class="np__tx" aria-hidden="true">
<svg viewBox="0 0 24 24" width="11" height="11" fill="currentColor"
><path d="M6 5h2v14H6zM20 5v14L9 12z"></path></svg
>
<svg viewBox="0 0 24 24" width="12" height="12" fill="currentColor"
><path d="M6 4l14 8-14 8z"></path></svg
>
<svg viewBox="0 0 24 24" width="11" height="11" fill="currentColor"
><path d="M16 5h2v14h-2zM4 5l11 7-11 7z"></path></svg
>
<Icon name="ph:skip-back-fill" />
<Icon name="ph:play-fill" />
<Icon name="ph:skip-forward-fill" />
</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>
<Icon class="np__logo" name="ph:spotify-logo-fill" aria-hidden="true" /><span>spotify</span>
</a>
</div>
</div>
@ -88,6 +82,7 @@
margin-top: var(--gap);
}
.music__trigger {
position: relative;
display: inline-flex;
align-items: center;
gap: 0.6rem;
@ -96,29 +91,41 @@
border: 1px solid var(--line);
border-radius: 7px;
cursor: pointer;
overflow: hidden;
font-family: var(--mono);
font-size: 0.76rem;
letter-spacing: 0.02em;
color: var(--muted);
transition:
border-color 0.18s ease,
color 0.18s ease;
color 0.18s ease,
padding 0.22s ease;
}
.music__trigger:hover {
.music__trigger:hover,
.music__trigger:focus-visible {
border-color: var(--faint);
color: var(--ink);
padding-bottom: 0.45rem;
}
.music__trigger:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 3px;
}
.music__art {
width: 30px;
height: 30px;
width: 26px;
height: 26px;
flex: none;
object-fit: cover;
border: 1px solid var(--line);
border-radius: 3px;
transition:
width 0.22s cubic-bezier(0.16, 1, 0.3, 1),
height 0.22s cubic-bezier(0.16, 1, 0.3, 1);
}
.music__trigger:hover .music__art,
.music__trigger:focus-visible .music__art {
width: 42px;
height: 42px;
}
.music__eq {
display: inline-flex;
@ -134,6 +141,49 @@
background: var(--accent);
border-radius: 1px;
}
.music__main {
display: flex;
flex-direction: column;
gap: 0.05rem;
min-width: 0;
}
.music__sub {
font-size: 0.66rem;
color: var(--faint);
max-height: 0;
opacity: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
transition:
max-height 0.22s ease,
opacity 0.18s ease;
}
.music__trigger:hover .music__sub,
.music__trigger:focus-visible .music__sub {
max-height: 1.4em;
opacity: 1;
}
.music__bar {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 2px;
background: #2b2825;
opacity: 0;
transition: opacity 0.2s ease;
}
.music__bar > i {
display: block;
height: 100%;
width: var(--p, 0%);
background: linear-gradient(90deg, rgb(255, 224, 138), rgb(255, 171, 94), rgb(255, 95, 109));
}
.music__trigger:hover .music__bar,
.music__trigger:focus-visible .music__bar {
opacity: 1;
}
.music__trigger--live .music__eq i {
animation: music-eq 0.95s ease-in-out infinite alternate;
}
@ -316,7 +366,7 @@
.np__logo {
width: 0.95em;
height: 0.95em;
fill: #1ed760;
color: #1ed760;
flex-shrink: 0;
display: block;
}
@ -444,12 +494,19 @@
<script>
import "../scripts/music.ts";
import { hashDialog } from "../scripts/hash-dialog.ts";
const dialog = document.querySelector<HTMLDialogElement>("[data-music-dialog]");
const open = document.querySelector<HTMLButtonElement>("[data-music-open]");
const close = document.querySelector<HTMLButtonElement>("[data-music-close]");
open?.addEventListener("click", () => dialog?.showModal());
const openWall = hashDialog({
name: "music",
dialog,
canOpen: () => !!open && !open.hidden,
});
open?.addEventListener("click", () => (openWall ? openWall() : dialog?.showModal()));
close?.addEventListener("click", () => dialog?.close());
dialog?.addEventListener("click", (e) => {
if (e.target === dialog) dialog.close();

View File

@ -0,0 +1,383 @@
---
---
<div class="reading" id="books">
<button
class="reading__trigger"
type="button"
data-reading-open
aria-haspopup="dialog"
data-umami-event="reading-open"
hidden
>
<img
class="reading__cover"
id="reading-cover"
alt=""
width="28"
height="40"
decoding="async"
hidden
/>
<span class="reading__ico" aria-hidden="true">&#9634;</span>
<span class="reading__main">
<span class="reading__label" id="reading-label">reading</span>
<span class="reading__sub" id="reading-sub" aria-hidden="true"></span>
</span>
<span class="reading__arw" aria-hidden="true">&#8594;</span>
<span class="reading__bar" id="reading-bar" aria-hidden="true" hidden><i></i></span>
</button>
<dialog class="reading-wall" data-reading-dialog aria-label="reading">
<div class="reading-wall__bar">
<span class="reading-wall__title">reading</span>
<button
class="reading-wall__close"
type="button"
data-reading-close
aria-label="close"
autofocus>&#10005;</button>
</div>
<div class="reading-wall__body">
<div class="reading-block" id="reading-current" hidden>
<h2 class="label">currently reading</h2>
<div class="rshelf" id="reading-current-list"></div>
</div>
<div class="reading-block" id="reading-want" hidden>
<h2 class="label">up next</h2>
<div class="rshelf rshelf--recent rshelf--want" id="reading-want-strip"></div>
</div>
<div class="reading-block" id="reading-recent" hidden>
<h2 class="label">recently finished</h2>
<div class="rshelf rshelf--recent" id="reading-recent-strip"></div>
</div>
<p class="reading-wall__foot">
<a
href="https://hardcover.app"
target="_blank"
rel="noopener"
data-umami-event="reading-hardcover">tracked on hardcover &#8599;</a>
</p>
</div>
</dialog>
</div>
<style is:global>
.reading {
}
.reading__trigger {
position: relative;
display: inline-flex;
align-items: center;
gap: 0.55rem;
padding: 0.3rem 0.6rem 0.3rem 0.4rem;
background: var(--paper-2);
border: 1px solid var(--line);
border-radius: 7px;
cursor: pointer;
overflow: hidden;
font-family: var(--mono);
font-size: 0.76rem;
letter-spacing: 0.02em;
color: var(--muted);
transition:
border-color 0.18s ease,
color 0.18s ease,
padding 0.22s ease;
}
.reading__trigger:hover,
.reading__trigger:focus-visible {
border-color: var(--faint);
color: var(--ink);
padding-bottom: 0.45rem;
}
.reading__trigger:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 3px;
}
.reading__cover {
width: 20px;
height: 28px;
flex: none;
object-fit: cover;
border: 1px solid var(--line);
border-radius: 2px;
transition:
width 0.22s cubic-bezier(0.16, 1, 0.3, 1),
height 0.22s cubic-bezier(0.16, 1, 0.3, 1);
}
.reading__trigger:hover .reading__cover,
.reading__trigger:focus-visible .reading__cover {
width: 30px;
height: 44px;
}
.reading__ico {
color: var(--accent);
font-size: 0.9rem;
}
.reading__main {
display: flex;
flex-direction: column;
gap: 0.05rem;
min-width: 0;
}
.reading__label {
max-width: 26ch;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.reading__sub {
font-size: 0.66rem;
color: var(--faint);
max-height: 0;
opacity: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
transition:
max-height 0.22s ease,
opacity 0.18s ease;
}
.reading__trigger:hover .reading__sub,
.reading__trigger:focus-visible .reading__sub {
max-height: 1.4em;
opacity: 1;
}
.reading__bar {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 2px;
background: #2b2825;
opacity: 0;
transition: opacity 0.2s ease;
}
.reading__bar > i {
display: block;
height: 100%;
width: var(--p, 0%);
background: linear-gradient(90deg, rgb(255, 224, 138), rgb(255, 171, 94), rgb(255, 95, 109));
}
.reading__trigger:hover .reading__bar,
.reading__trigger:focus-visible .reading__bar {
opacity: 1;
}
.reading__arw {
color: var(--faint);
transition: transform 0.22s cubic-bezier(0.16, 1, 0.3, 1);
}
.reading__trigger:hover .reading__arw {
transform: translateX(0.25em);
}
.reading-wall {
margin: auto;
width: min(24rem, calc(100vw - 1.5rem));
max-height: min(82vh, 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;
}
.reading-wall[open] {
animation: reading-in 0.28s cubic-bezier(0.17, 0.89, 0.32, 1.28);
}
@keyframes reading-in {
from {
opacity: 0;
transform: translateY(8px) scale(0.96);
}
}
.reading-wall::backdrop {
background: rgba(6, 5, 4, 0.68);
backdrop-filter: blur(3px);
}
.reading-wall__bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.6rem 0.85rem;
border-bottom: 1px solid var(--line);
}
.reading-wall__title {
font-family: var(--mono);
font-size: 0.72rem;
letter-spacing: 0.1em;
color: var(--faint);
}
.reading-wall__close {
background: none;
border: 0;
color: var(--muted);
cursor: pointer;
font-size: 0.85rem;
line-height: 1;
padding: 0.3rem;
}
.reading-wall__close:hover {
color: var(--accent);
}
.reading-wall__body {
padding: 1rem;
overflow-y: auto;
max-height: calc(min(82vh, 40rem) - 2.6rem);
}
.reading-block + .reading-block {
margin-top: 1.5rem;
}
.reading-block .label {
margin-bottom: 0.7rem;
}
.rshelf {
display: flex;
flex-wrap: wrap;
align-content: flex-start;
gap: 1.15rem 1.1rem;
}
.rbook {
display: flex;
flex-direction: column;
gap: 0.45rem;
width: 86px;
text-decoration: none;
color: inherit;
}
.rbook__frame {
position: relative;
width: 100%;
aspect-ratio: 2 / 3;
overflow: hidden;
border: 1px solid var(--faint);
border-radius: 3px;
background: var(--paper);
box-shadow: 0 8px 18px -10px rgba(0, 0, 0, 0.7);
transition:
transform 0.25s cubic-bezier(0.16, 1, 0.3, 1),
box-shadow 0.25s ease;
}
.rbook:hover .rbook__frame,
.rbook:focus-visible .rbook__frame {
transform: translateY(-3px);
box-shadow: 0 16px 26px -12px rgba(0, 0, 0, 0.82);
}
.rbook__cover {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
span.rbook__cover {
display: flex;
align-items: center;
justify-content: center;
padding: 0.35rem;
text-align: center;
background: linear-gradient(145deg, #2c2a26, #171614);
color: var(--muted);
font-family: var(--serif);
font-size: 0.64rem;
line-height: 1.3;
}
.rbook__prog {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 3px;
background: rgba(0, 0, 0, 0.55);
}
.rbook__prog > span {
display: block;
height: 100%;
background: linear-gradient(90deg, rgb(255, 224, 138), rgb(255, 171, 94), rgb(255, 95, 109));
}
.rbook__title {
font-family: var(--serif);
font-size: 0.82rem;
line-height: 1.22;
color: var(--ink);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.rbook__author {
font-family: var(--mono);
font-size: 0.63rem;
letter-spacing: 0.01em;
color: var(--faint);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rshelf--recent {
gap: 0.55rem;
}
.rshelf--recent .rbook {
width: 50px;
}
.rshelf--recent .rbook__frame {
box-shadow: none;
border-color: var(--line);
}
.rshelf--recent .rbook__cover {
filter: saturate(0.68) brightness(0.78);
transition: filter 0.2s ease;
}
.rshelf--recent .rbook:hover .rbook__cover,
.rshelf--recent .rbook:focus-visible .rbook__cover {
filter: none;
}
.rshelf--want .rbook__frame {
border-color: var(--faint);
}
.rshelf--want .rbook__cover {
filter: none;
}
.reading-wall__foot {
margin: 1.5rem 0 0;
font-family: var(--mono);
font-size: 0.66rem;
letter-spacing: 0.02em;
}
.reading-wall__foot a {
color: var(--faint);
text-decoration: none;
}
.reading-wall__foot a:hover {
color: var(--accent);
}
</style>
<script>
import "../scripts/reading.ts";
import { hashDialog } from "../scripts/hash-dialog.ts";
const dialog = document.querySelector<HTMLDialogElement>("[data-reading-dialog]");
const open = document.querySelector<HTMLButtonElement>("[data-reading-open]");
const close = document.querySelector<HTMLButtonElement>("[data-reading-close]");
const openWall = hashDialog({
name: "books",
dialog,
canOpen: () => !!open && !open.hidden,
});
open?.addEventListener("click", () => (openWall ? openWall() : dialog?.showModal()));
close?.addEventListener("click", () => dialog?.close());
dialog?.addEventListener("click", (e) => {
if (e.target === dialog) dialog.close();
});
</script>

View File

@ -37,6 +37,8 @@ const aria: Record<string, string> = {
next: "next site",
random: "random site",
};
import { Icon } from "astro-icon/components";
---
<section class="section">
@ -69,23 +71,7 @@ const aria: Record<string, string> = {
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>
)}
{dir === "random" && <Icon class="ring__die" name="ph:dice-three" aria-hidden="true" />}
{glyph}
</a>
))}

View File

@ -4,7 +4,7 @@ export const site = {
wordmark: "ZACH",
domain: "zachy.cc",
version: "v3.0.1",
tagline: "cool things for the web",
tagline: "i make cool stuff",
description:
"i make cool things sometimes.",
bio: "call me zoop, i like to make cool projects, and my website is one of them :).",
@ -84,4 +84,6 @@ export const buttons = [
{ 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 },
{ label: "ro.er", img: "/badges/ro-er.gif", still: null, href: "https://retrobloxxer.github.io/" },
{ label: "kevadesu", img: "/badges/kevadesu.gif", still: null, href: "https://kevadesu.github.io/" },
];

1
src/icons/README.md Normal file
View File

@ -0,0 +1 @@
# astro-icon scans this dir for local SVG icons; we only use the `ph:` set, but the dir must exist to avoid a build warning

View File

@ -2,6 +2,7 @@
import "../styles/global.css";
import { site } from "../data/site";
import Egg from "../components/Egg.astro";
import { Icon } from "astro-icon/components";
interface Props {
title?: string;
@ -119,6 +120,7 @@ const kaoCount = kaoMs > 0 ? `${Math.floor(kaoMs / 86_400_000)}d` : "in effect";
rel="noopener"
data-umami-event="src"
>
<Icon class="srcline__icon" name="ph:git-branch" aria-hidden="true" />
source &#8599;
</a>
</p>

View File

@ -0,0 +1,130 @@
import type { APIRoute } from "astro";
import { env as cfEnv } from "cloudflare:workers";
export const prerender = false;
type Env = Record<string, string | undefined>;
interface Book {
title: string;
author: string;
cover: string | null;
url: string;
progress: number | null;
}
const GQL = "https://api.hardcover.app/v1/graphql";
const SITE = "https://hardcover.app";
const QUERY = `
query SiteReading {
me {
current: user_books(where: {status_id: {_eq: 2}}, limit: 5) {
id
user_book_reads(order_by: {id: desc}, limit: 1) { progress_pages }
book {
title
slug
pages
image { url }
contributions { author { name } }
}
}
want: user_books(where: {status_id: {_eq: 1}}, order_by: {id: desc}, limit: 12) {
book {
title
slug
image { url }
contributions { author { name } }
}
}
recent: user_books(where: {status_id: {_eq: 3}}, order_by: {id: desc}, limit: 8) {
book {
title
slug
image { url }
contributions { author { name } }
}
}
}
}`;
function getEnv(): Env {
const nodeEnv = typeof process !== "undefined" ? process.env : {};
let workerEnv: Env = {};
try {
workerEnv = cfEnv as unknown as Env;
} catch {
}
return { ...nodeEnv, ...workerEnv };
}
function mapBook(ub: any, withProgress: boolean): Book {
const b = ub?.book ?? {};
const author = (b?.contributions?.[0]?.author?.name ?? "").replace(/\s+/g, " ").trim();
const cover = b?.image?.url ?? null;
let progress: number | null = null;
if (withProgress) {
const read = ub?.user_book_reads?.[0];
const done = read?.progress_pages;
const total = b?.pages;
if (typeof done === "number" && typeof total === "number" && total > 0) {
progress = Math.max(0, Math.min(100, Math.round((done / total) * 100)));
}
}
return {
title: b?.title ?? "",
author,
cover,
url: b?.slug ? `${SITE}/books/${b.slug}` : SITE,
progress,
};
}
export const GET: APIRoute = async () => {
const token = getEnv().HARDCOVER_TOKEN;
const empty = { configured: false, current: [], want: [], recent: [] };
const json = (data: unknown, status = 200) =>
new Response(JSON.stringify(data), {
status,
headers: { "content-type": "application/json", "cache-control": "public, max-age=300" },
});
if (!token) return json(empty);
let payload: any;
try {
const res = await fetch(GQL, {
method: "POST",
headers: {
"content-type": "application/json",
authorization: token.startsWith("Bearer ") ? token : `Bearer ${token}`,
"user-agent": "zachy.cc reading widget",
},
body: JSON.stringify({ query: QUERY }),
});
payload = await res.json();
} catch {
return json({ ...empty, configured: true });
}
const me = Array.isArray(payload?.data?.me) ? payload.data.me[0] : payload?.data?.me;
if (!me || payload.errors) {
return json({ ...empty, configured: true });
}
const current: Book[] = (me.current ?? [])
.map((ub: any) => mapBook(ub, true))
.filter((b: Book) => b.title);
const want: Book[] = (me.want ?? [])
.map((ub: any) => mapBook(ub, false))
.filter((b: Book) => b.title);
const recent: Book[] = (me.recent ?? [])
.map((ub: any) => mapBook(ub, false))
.filter((b: Book) => b.title);
return json({ configured: true, current, want, recent });
};

View File

@ -1,6 +1,8 @@
---
import Base from "../layouts/Base.astro";
import Music from "../components/Music.astro";
import Reading from "../components/Reading.astro";
import Clock from "../components/Clock.astro";
import Webrings from "../components/Webrings.astro";
import Buttons from "../components/Buttons.astro";
import { site, elsewhere } from "../data/site";
@ -33,5 +35,15 @@ import { site, elsewhere } from "../data/site";
<Webrings />
<Buttons />
<section class="moreme" id="more-about-me" aria-label="more about me">
<h2 class="label moreme__label">more about me</h2>
<div class="foot">
<Music />
<Reading />
</div>
<div class="foot foot--clock">
<Clock />
</div>
</section>
</Base>

View File

@ -0,0 +1,46 @@
interface Opts {
name: string;
dialog: HTMLDialogElement | null;
onOpen?: () => void;
canOpen?: () => boolean;
}
export function hashDialog({ name, dialog, onOpen, canOpen }: Opts): (() => void) | undefined {
if (!dialog) return undefined;
const hash = `#${name}`;
const clean = () => location.pathname + location.search;
const open = (): void => {
if (dialog.open || (canOpen && !canOpen())) return;
onOpen?.();
dialog.showModal();
if (location.hash !== hash) history.replaceState(null, "", hash);
};
new MutationObserver(() => {
if (!dialog.open && location.hash === hash) {
history.replaceState(null, "", clean());
}
}).observe(dialog, { attributes: true, attributeFilter: ["open"] });
window.addEventListener("hashchange", () => {
if (location.hash === hash) open();
else if (dialog.open) dialog.close();
});
if (location.hash === hash) {
let tries = 0;
const tick = (): void => {
if (dialog.open) return;
if (!canOpen || canOpen()) {
open();
return;
}
if (tries++ < 40) window.setTimeout(tick, 100);
};
tick();
}
return open;
}

View File

@ -154,14 +154,26 @@ async function load(): Promise<void> {
artEl.src = cover;
artEl.hidden = false;
}
const sub = $("music-sub");
const bar = $("music-bar");
const fill = bar?.firstElementChild as HTMLElement | null;
if (label) {
if (np?.isPlaying) {
trigger.classList.add("music__trigger--live");
label.textContent = `${np.title} · ${np.artist}`;
label.textContent = np.title;
if (sub) sub.textContent = np.artist;
if (bar && fill && np.progressMs != null && np.durationMs) {
fill.style.setProperty("--p", `${Math.min(100, (np.progressMs / np.durationMs) * 100)}%`);
bar.hidden = false;
}
} else if (np?.playedAt) {
label.textContent = `last played · ${np.title}`;
label.textContent = "last played";
if (sub) sub.textContent = `${np.title} · ${np.artist}`;
} else {
label.textContent = "top tracks";
if (sub) sub.textContent = "last 4 weeks";
}
}
}

112
src/scripts/reading.ts Normal file
View File

@ -0,0 +1,112 @@
interface Book {
title: string;
author: string;
cover: string | null;
url: string;
progress: number | null;
}
const $ = (id: string) => document.getElementById(id);
const esc = (s: string): string =>
s.replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]!);
function bookCard(b: Book, mini = false): string {
const inner = b.cover
? `<img class="rbook__cover" src="${esc(b.cover)}" alt="" loading="lazy" decoding="async">`
: `<span class="rbook__cover">${esc(b.title)}</span>`;
const prog =
!mini && b.progress != null
? `<span class="rbook__prog"><span style="width:${b.progress}%"></span></span>`
: "";
const shortTitle = b.title.split(/\s*[:(]/)[0].trim() || b.title;
const meta = mini
? ""
: `<span class="rbook__title">${esc(shortTitle)}</span><span class="rbook__author">${esc(b.author)}</span>`;
return `<a class="rbook" href="${esc(b.url)}" target="_blank" rel="noopener" title="${esc(
`${b.title}${b.author}`,
)}"><span class="rbook__frame">${inner}${prog}</span>${meta}</a>`;
}
async function load(): Promise<void> {
let data: any;
try {
const res = await fetch("/api/reading.json", { headers: { accept: "application/json" } });
data = await res.json();
} catch {
return;
}
if (!data || !data.configured) return;
const current: Book[] = Array.isArray(data.current) ? data.current : [];
const want: Book[] = Array.isArray(data.want) ? data.want : [];
const recent: Book[] = Array.isArray(data.recent) ? data.recent : [];
if (!current.length && !want.length && !recent.length) return;
if (current.length) {
const block = $("reading-current");
const list = $("reading-current-list");
if (block && list) {
list.innerHTML = current.map((b) => bookCard(b)).join("");
block.hidden = false;
}
}
if (want.length) {
const block = $("reading-want");
const strip = $("reading-want-strip");
if (block && strip) {
const withCovers = want.filter((b) => b.cover);
if (withCovers.length) {
strip.innerHTML = withCovers.map((b) => bookCard(b, true)).join("");
block.hidden = false;
}
}
}
if (recent.length) {
const block = $("reading-recent");
const strip = $("reading-recent-strip");
if (block && strip) {
const withCovers = recent.filter((b) => b.cover);
if (withCovers.length) {
strip.innerHTML = withCovers.map((b) => bookCard(b, true)).join("");
block.hidden = false;
}
}
}
const trigger = document.querySelector<HTMLButtonElement>(".reading__trigger");
if (!trigger) return;
trigger.hidden = false;
const lead = current[0] || recent[0] || want[0];
const label = $("reading-label");
const cover = $("reading-cover") as HTMLImageElement | null;
if (lead && cover && lead.cover) {
cover.src = lead.cover;
cover.hidden = false;
const ico = trigger.querySelector(".reading__ico") as HTMLElement | null;
if (ico) ico.hidden = true;
}
const sub = $("reading-sub");
const bar = $("reading-bar");
const fill = bar?.firstElementChild as HTMLElement | null;
if (label && lead) {
if (current.length) {
label.textContent = lead.title;
if (sub) sub.textContent = lead.author;
if (bar && fill && lead.progress != null) {
fill.style.setProperty("--p", `${lead.progress}%`);
bar.hidden = false;
}
} else if (recent.length) {
label.textContent = "last read";
if (sub) sub.textContent = `${lead.title} · ${lead.author}`;
} else {
label.textContent = "want to read";
if (sub) sub.textContent = `${lead.title} · ${lead.author}`;
}
}
}
load();

View File

@ -370,21 +370,17 @@ img {
flex: none;
width: 0.95em;
height: 0.95em;
fill: none;
stroke: var(--faint);
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
color: var(--faint);
}
.copy__check {
display: none;
stroke: var(--accent-2);
color: var(--accent-2);
}
.copy:hover {
border-color: var(--accent-2);
}
.copy:hover .copy__icon {
stroke: var(--accent-2);
color: var(--accent-2);
}
.copy.copied .copy__icon {
display: none;
@ -419,6 +415,41 @@ img {
opacity: 1;
}
.moreme {
position: relative;
box-sizing: border-box;
width: fit-content;
max-width: 100%;
margin-top: var(--gap);
padding: 1.2rem 1.15rem 1.05rem;
border: 1px solid var(--line);
border-radius: 3px;
}
.moreme__label {
position: absolute;
top: 0;
left: 0.85rem;
transform: translateY(-50%);
margin: 0;
padding: 0 0.45rem;
background: var(--paper);
letter-spacing: 0.05em;
}
.foot {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.55rem 0.8rem;
}
.foot > .music,
.foot > .reading {
margin-top: 0;
}
.foot + .foot {
margin-top: 0.7rem;
}
.colophon {
display: flex;
align-items: center;
@ -435,6 +466,9 @@ img {
margin: 0.5rem 0 0;
}
.srcline a {
display: inline-flex;
align-items: center;
gap: 0.4em;
font-family: var(--mono);
font-size: 0.72rem;
letter-spacing: 0.06em;
@ -445,6 +479,11 @@ img {
.srcline a:hover {
color: var(--accent);
}
.srcline__icon {
width: 0.95em;
height: 0.95em;
flex: none;
}
.backlink {
font-family: var(--mono);