diff --git a/.dev.vars.example b/.dev.vars.example
index b320bfe..d5e8a1f 100644
--- a/.dev.vars.example
+++ b/.dev.vars.example
@@ -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=
diff --git a/astro.config.mjs b/astro.config.mjs
index 87eea06..8ff9479 100644
--- a/astro.config.mjs
+++ b/astro.config.mjs
@@ -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 },
}),
diff --git a/package-lock.json b/package-lock.json
index 16f3394..94cd699 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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",
diff --git a/package.json b/package.json
index 3008dec..5f75058 100644
--- a/package.json
+++ b/package.json
@@ -24,5 +24,9 @@
"allowScripts": {
"esbuild": true,
"workerd@1.20260903.1": true
+ },
+ "devDependencies": {
+ "@iconify-json/ph": "^1.2.2",
+ "astro-icon": "^1.2.0"
}
}
diff --git a/public/badges/kevadesu.gif b/public/badges/kevadesu.gif
new file mode 100644
index 0000000..045aee5
Binary files /dev/null and b/public/badges/kevadesu.gif differ
diff --git a/public/badges/ro-er.gif b/public/badges/ro-er.gif
new file mode 100644
index 0000000..9f0181f
Binary files /dev/null and b/public/badges/ro-er.gif differ
diff --git a/src/components/Buttons.astro b/src/components/Buttons.astro
index 42a604e..a171c25 100644
--- a/src/components/Buttons.astro
+++ b/src/components/Buttons.astro
@@ -372,6 +372,8 @@ const badgeUrl = `https://${site.domain}/badges/zachy.gif`;
diff --git a/src/components/CopyText.astro b/src/components/CopyText.astro
index d072bd4..76381a1 100644
--- a/src/components/CopyText.astro
+++ b/src/components/CopyText.astro
@@ -6,6 +6,8 @@ interface Props {
event?: string;
}
const { value, display = value, label = `copy ${value}`, event } = Astro.props;
+
+import { Icon } from "astro-icon/components";
---
diff --git a/src/components/Music.astro b/src/components/Music.astro
index 327e7f0..f47a838 100644
--- a/src/components/Music.astro
+++ b/src/components/Music.astro
@@ -1,8 +1,8 @@
---
+import { Icon } from "astro-icon/components";
+---
----
-
-
+
-
now playing
+
+ now playing
+
+
→
+
@@ -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 @@
diff --git a/src/components/Webrings.astro b/src/components/Webrings.astro
index a586bfe..ad42c7d 100644
--- a/src/components/Webrings.astro
+++ b/src/components/Webrings.astro
@@ -37,6 +37,8 @@ const aria: Record
= {
next: "next site",
random: "random site",
};
+
+import { Icon } from "astro-icon/components";
---
@@ -69,23 +71,7 @@ const aria: Record = {
data-umami-event-ring={r.key}
data-umami-event-dir={dir}
>
- {dir === "random" && (
-
- )}
+ {dir === "random" && }
{glyph}
))}
diff --git a/src/data/site.ts b/src/data/site.ts
index 3389d86..61ff0b4 100644
--- a/src/data/site.ts
+++ b/src/data/site.ts
@@ -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/" },
];
diff --git a/src/icons/README.md b/src/icons/README.md
new file mode 100644
index 0000000..be5dc20
--- /dev/null
+++ b/src/icons/README.md
@@ -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
diff --git a/src/layouts/Base.astro b/src/layouts/Base.astro
index 2cc976d..0744001 100644
--- a/src/layouts/Base.astro
+++ b/src/layouts/Base.astro
@@ -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"
>
+
source ↗
diff --git a/src/pages/api/reading.json.ts b/src/pages/api/reading.json.ts
new file mode 100644
index 0000000..3d13726
--- /dev/null
+++ b/src/pages/api/reading.json.ts
@@ -0,0 +1,130 @@
+import type { APIRoute } from "astro";
+import { env as cfEnv } from "cloudflare:workers";
+
+export const prerender = false;
+
+
+type Env = Record;
+
+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 });
+};
diff --git a/src/pages/index.astro b/src/pages/index.astro
index 7a011c4..80bea11 100644
--- a/src/pages/index.astro
+++ b/src/pages/index.astro
@@ -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";
-
+
+
diff --git a/src/scripts/hash-dialog.ts b/src/scripts/hash-dialog.ts
new file mode 100644
index 0000000..1f9877c
--- /dev/null
+++ b/src/scripts/hash-dialog.ts
@@ -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;
+}
diff --git a/src/scripts/music.ts b/src/scripts/music.ts
index c421651..f0f95de 100644
--- a/src/scripts/music.ts
+++ b/src/scripts/music.ts
@@ -154,14 +154,26 @@ async function load(): Promise {
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";
}
}
}
diff --git a/src/scripts/reading.ts b/src/scripts/reading.ts
new file mode 100644
index 0000000..3aafbc0
--- /dev/null
+++ b/src/scripts/reading.ts
@@ -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) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]!);
+
+function bookCard(b: Book, mini = false): string {
+ const inner = b.cover
+ ? `
`
+ : `${esc(b.title)}`;
+ const prog =
+ !mini && b.progress != null
+ ? ``
+ : "";
+ const shortTitle = b.title.split(/\s*[:(]/)[0].trim() || b.title;
+ const meta = mini
+ ? ""
+ : `${esc(shortTitle)}${esc(b.author)}`;
+ return `${inner}${prog}${meta}`;
+}
+
+async function load(): Promise {
+ 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(".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();
diff --git a/src/styles/global.css b/src/styles/global.css
index 214a117..e95a49d 100644
--- a/src/styles/global.css
+++ b/src/styles/global.css
@@ -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);