tons o changes
This commit is contained in:
parent
2a0f022e3f
commit
011c123f8f
@ -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=
|
||||
|
||||
@ -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
116
package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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
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
BIN
public/badges/ro-er.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
@ -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) => {
|
||||
|
||||
91
src/components/Clock.astro
Normal file
91
src/components/Clock.astro
Normal 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>
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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">→</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">—</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();
|
||||
|
||||
383
src/components/Reading.astro
Normal file
383
src/components/Reading.astro
Normal 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">▢</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">→</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>✕</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 ↗</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>
|
||||
@ -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>
|
||||
))}
|
||||
|
||||
@ -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
1
src/icons/README.md
Normal 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
|
||||
@ -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 ↗
|
||||
</a>
|
||||
</p>
|
||||
|
||||
130
src/pages/api/reading.json.ts
Normal file
130
src/pages/api/reading.json.ts
Normal 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 });
|
||||
};
|
||||
@ -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>
|
||||
|
||||
46
src/scripts/hash-dialog.ts
Normal file
46
src/scripts/hash-dialog.ts
Normal 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;
|
||||
}
|
||||
@ -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
112
src/scripts/reading.ts
Normal 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) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[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();
|
||||
@ -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);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user