more about me section
This commit is contained in:
parent
011c123f8f
commit
f6daa5b349
@ -15,3 +15,13 @@ SPOTIFY_REFRESH_TOKEN=
|
|||||||
|
|
||||||
# currently-reading widget — personal token from https://hardcover.app/account/api
|
# currently-reading widget — personal token from https://hardcover.app/account/api
|
||||||
HARDCOVER_TOKEN=
|
HARDCOVER_TOKEN=
|
||||||
|
|
||||||
|
# "the counter" — all-time pageviews + visitors from Umami. needs the website id
|
||||||
|
# plus EITHER an api key OR a share-url id. leave all blank to hide the block.
|
||||||
|
UMAMI_WEBSITE_ID=
|
||||||
|
# api base — default is Umami Cloud; self-hosted: https://<your-host>/api
|
||||||
|
UMAMI_URL=
|
||||||
|
# mode A: an api key (Umami Cloud account → api keys)
|
||||||
|
UMAMI_API_KEY=
|
||||||
|
# mode B (no key): enable "Share URL" on the website, put its id here
|
||||||
|
UMAMI_SHARE_ID=
|
||||||
|
|||||||
@ -1,62 +1,111 @@
|
|||||||
---
|
---
|
||||||
const TZ = "America/New_York";
|
import { Icon } from "astro-icon/components";
|
||||||
|
|
||||||
|
const TZ = "America/New_York";
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const parts = (opts: Intl.DateTimeFormatOptions) =>
|
const fmt = (opts: Intl.DateTimeFormatOptions) =>
|
||||||
new Intl.DateTimeFormat("en-US", { timeZone: TZ, ...opts }).formatToParts(now);
|
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 t = fmt({ hour: "numeric", minute: "2-digit", hour12: true });
|
||||||
const mm = parts({ minute: "2-digit" }).find((p) => p.type === "minute")?.value ?? "--";
|
const hour = t.find((p) => p.type === "hour")?.value ?? "--";
|
||||||
const zone =
|
const minute = t.find((p) => p.type === "minute")?.value ?? "--";
|
||||||
parts({ timeZoneName: "short" }).find((p) => p.type === "timeZoneName")?.value ?? "ET";
|
const mer = (t.find((p) => p.type === "dayPeriod")?.value ?? "").toUpperCase();
|
||||||
|
const zone = fmt({ 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}`}>
|
<div
|
||||||
<span class="clock__scr">
|
class="clock"
|
||||||
<span class="clock__time"
|
data-tz={TZ}
|
||||||
>{hh}<span class="clock__sep">:</span>{mm}</span
|
role="img"
|
||||||
>
|
aria-label={`zach's local time — ${hour}:${minute} ${mer} ${zone}`}
|
||||||
<span class="clock__zone" id="clock-zone">{zone}</span>
|
>
|
||||||
|
<Icon class="clock__ico" name="ph:clock" aria-hidden="true" />
|
||||||
|
<span class="clock__time" id="clock-time">
|
||||||
|
<span class="clock__grp">
|
||||||
|
{[...hour].map((d) => <span class="clock__digit">{d}</span>)}
|
||||||
|
</span>
|
||||||
|
<span class="clock__sep">:</span>
|
||||||
|
<span class="clock__grp">
|
||||||
|
{[...minute].map((d) => <span class="clock__digit">{d}</span>)}
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
|
<span class="clock__mer" id="clock-mer">{mer}</span>
|
||||||
|
<span class="clock__zone" id="clock-zone">{zone}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.clock {
|
.clock {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
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;
|
gap: 0.6ch;
|
||||||
|
padding: 0.42rem 0.8rem;
|
||||||
|
background: var(--paper-2);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
font-family: var(--mono);
|
font-family: var(--mono);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
letter-spacing: 0.14em;
|
}
|
||||||
|
.clock__ico {
|
||||||
|
width: 1em;
|
||||||
|
height: 1em;
|
||||||
|
flex: none;
|
||||||
|
color: var(--faint);
|
||||||
|
margin-right: 0.1ch;
|
||||||
}
|
}
|
||||||
.clock__time {
|
.clock__time {
|
||||||
font-size: 0.9rem;
|
display: inline-flex;
|
||||||
color: #ffb454;
|
align-items: center;
|
||||||
text-shadow: 0 0 6px rgba(255, 180, 84, 0.5);
|
gap: 0.3ch;
|
||||||
|
}
|
||||||
|
.clock__grp {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 0.18ch;
|
||||||
|
}
|
||||||
|
.clock__digit {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 1.05em;
|
||||||
|
padding: 0.14em 0.1em 0.16em;
|
||||||
|
background: linear-gradient(#201e1b, #191613);
|
||||||
|
border: 1px solid #2c2926;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent);
|
||||||
|
text-shadow: 0 0 6px rgba(255, 171, 94, 0.32);
|
||||||
|
}
|
||||||
|
.clock__digit::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
top: 50%;
|
||||||
|
height: 1px;
|
||||||
|
background: rgba(0, 0, 0, 0.55);
|
||||||
|
transform: translateY(-0.5px);
|
||||||
}
|
}
|
||||||
.clock__sep {
|
.clock__sep {
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 600;
|
||||||
|
text-shadow: 0 0 6px rgba(255, 171, 94, 0.32);
|
||||||
animation: clock-blink 1s steps(1, end) infinite;
|
animation: clock-blink 1s steps(1, end) infinite;
|
||||||
}
|
}
|
||||||
@keyframes clock-blink {
|
@keyframes clock-blink {
|
||||||
50% {
|
50% {
|
||||||
opacity: 0.2;
|
opacity: 0.25;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.clock__mer,
|
||||||
.clock__zone {
|
.clock__zone {
|
||||||
font-size: 0.6rem;
|
font-size: 0.72em;
|
||||||
letter-spacing: 0.18em;
|
letter-spacing: 0.14em;
|
||||||
color: #806b4f;
|
color: var(--faint);
|
||||||
|
}
|
||||||
|
.clock__mer {
|
||||||
|
margin-left: 0.1ch;
|
||||||
}
|
}
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.clock__sep {
|
.clock__sep {
|
||||||
@ -67,22 +116,31 @@ const zone =
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
const root = document.querySelector<HTMLElement>(".clock");
|
const root = document.querySelector<HTMLElement>(".clock");
|
||||||
const timeEl = root?.querySelector<HTMLElement>(".clock__time");
|
const timeEl = document.getElementById("clock-time");
|
||||||
|
const merEl = document.getElementById("clock-mer");
|
||||||
const zoneEl = document.getElementById("clock-zone");
|
const zoneEl = document.getElementById("clock-zone");
|
||||||
if (root && timeEl) {
|
if (root && timeEl) {
|
||||||
const tz = root.dataset.tz || "America/New_York";
|
const tz = root.dataset.tz || "America/New_York";
|
||||||
const timeFmt = new Intl.DateTimeFormat("en-US", {
|
const timeFmt = new Intl.DateTimeFormat("en-US", {
|
||||||
timeZone: tz,
|
timeZone: tz,
|
||||||
hour: "2-digit",
|
hour: "numeric",
|
||||||
minute: "2-digit",
|
minute: "2-digit",
|
||||||
hour12: false,
|
hour12: true,
|
||||||
});
|
});
|
||||||
const zoneFmt = new Intl.DateTimeFormat("en-US", { timeZone: tz, timeZoneName: "short" });
|
const zoneFmt = new Intl.DateTimeFormat("en-US", { timeZone: tz, timeZoneName: "short" });
|
||||||
|
const tiles = (s: string) =>
|
||||||
|
`<span class="clock__grp">${[...s]
|
||||||
|
.map((d) => `<span class="clock__digit">${d}</span>`)
|
||||||
|
.join("")}</span>`;
|
||||||
const tick = () => {
|
const tick = () => {
|
||||||
const d = new Date();
|
const d = new Date();
|
||||||
const [h, m] = timeFmt.format(d).split(":");
|
const p = timeFmt.formatToParts(d);
|
||||||
timeEl.innerHTML = `${h}<span class="clock__sep">:</span>${m}`;
|
const h = p.find((x) => x.type === "hour")?.value ?? "--";
|
||||||
const z = zoneFmt.formatToParts(d).find((p) => p.type === "timeZoneName")?.value;
|
const m = p.find((x) => x.type === "minute")?.value ?? "--";
|
||||||
|
const mer = (p.find((x) => x.type === "dayPeriod")?.value ?? "").toUpperCase();
|
||||||
|
timeEl.innerHTML = `${tiles(h)}<span class="clock__sep">:</span>${tiles(m)}`;
|
||||||
|
if (merEl) merEl.textContent = mer;
|
||||||
|
const z = zoneFmt.formatToParts(d).find((x) => x.type === "timeZoneName")?.value;
|
||||||
if (zoneEl && z) zoneEl.textContent = z;
|
if (zoneEl && z) zoneEl.textContent = z;
|
||||||
};
|
};
|
||||||
tick();
|
tick();
|
||||||
|
|||||||
@ -1,15 +1,57 @@
|
|||||||
---
|
---
|
||||||
import { Icon } from "astro-icon/components";
|
import { Icon } from "astro-icon/components";
|
||||||
|
|
||||||
|
const clip = (s: string, n = 22): string =>
|
||||||
|
s && s.length > n ? s.slice(0, n - 1).trimEnd() + "…" : (s ?? "");
|
||||||
|
|
||||||
|
let np: any = null;
|
||||||
|
let top: any[] = [];
|
||||||
|
let configured = false;
|
||||||
|
try {
|
||||||
|
const r = await fetch(new URL("/api/spotify.json", Astro.url), {
|
||||||
|
headers: { accept: "application/json" },
|
||||||
|
});
|
||||||
|
const d: any = await r.json();
|
||||||
|
configured = !!d?.configured;
|
||||||
|
np = d?.nowPlaying ?? null;
|
||||||
|
top = Array.isArray(d?.top) ? d.top : [];
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasData = configured && (!!np || top.length > 0);
|
||||||
|
let mLabel = "now playing";
|
||||||
|
let mSub = "";
|
||||||
|
let mArt = "";
|
||||||
|
let mLive = false;
|
||||||
|
let mPct: number | null = null;
|
||||||
|
if (np?.isPlaying) {
|
||||||
|
mLive = true;
|
||||||
|
mLabel = clip(np.title);
|
||||||
|
mSub = np.artist ?? "";
|
||||||
|
mArt = np.art ?? "";
|
||||||
|
if (np.progressMs != null && np.durationMs)
|
||||||
|
mPct = Math.min(100, (np.progressMs / np.durationMs) * 100);
|
||||||
|
} else if (np?.playedAt) {
|
||||||
|
mLabel = "last played";
|
||||||
|
mSub = `${clip(np.title, 26)} · ${np.artist}`;
|
||||||
|
mArt = np.art ?? "";
|
||||||
|
} else if (top.length) {
|
||||||
|
mLabel = "top tracks";
|
||||||
|
mSub = "last 4 weeks";
|
||||||
|
mArt = top[0]?.art ?? "";
|
||||||
|
}
|
||||||
|
const mTitleAttr = np ? `${np.title} — ${np.artist}` : undefined;
|
||||||
---
|
---
|
||||||
|
|
||||||
<div class="music" id="music">
|
<div class="music" id="music">
|
||||||
<button
|
<button
|
||||||
class="music__trigger"
|
class:list={["music__trigger", { "music__trigger--live": mLive }]}
|
||||||
type="button"
|
type="button"
|
||||||
data-music-open
|
data-music-open
|
||||||
aria-haspopup="dialog"
|
aria-haspopup="dialog"
|
||||||
data-umami-event="music-open"
|
data-umami-event="music-open"
|
||||||
hidden
|
title={mTitleAttr}
|
||||||
|
hidden={!hasData}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
class="music__art"
|
class="music__art"
|
||||||
@ -18,15 +60,18 @@ import { Icon } from "astro-icon/components";
|
|||||||
width="42"
|
width="42"
|
||||||
height="42"
|
height="42"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
hidden
|
src={mArt || undefined}
|
||||||
|
hidden={!mArt}
|
||||||
/>
|
/>
|
||||||
<span class="music__eq" aria-hidden="true"><i></i><i></i><i></i><i></i></span>
|
<span class="music__eq" aria-hidden="true"><i></i><i></i><i></i><i></i></span>
|
||||||
<span class="music__main">
|
<span class="music__main">
|
||||||
<span class="music__label" id="music-label">now playing</span>
|
<span class="music__label" id="music-label">{mLabel}</span>
|
||||||
<span class="music__sub" id="music-sub" aria-hidden="true"></span>
|
<span class="music__sub" id="music-sub" aria-hidden="true">{mSub}</span>
|
||||||
</span>
|
</span>
|
||||||
<span class="music__arw" aria-hidden="true">→</span>
|
<span class="music__arw" aria-hidden="true">→</span>
|
||||||
<span class="music__bar" id="music-bar" aria-hidden="true" hidden><i></i></span>
|
<span class="music__bar" id="music-bar" aria-hidden="true" hidden={mPct == null}>
|
||||||
|
<i style={mPct == null ? undefined : `--p:${mPct}%`}></i>
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<dialog class="music-wall" data-music-dialog aria-label="music">
|
<dialog class="music-wall" data-music-dialog aria-label="music">
|
||||||
@ -105,7 +150,7 @@ import { Icon } from "astro-icon/components";
|
|||||||
.music__trigger:focus-visible {
|
.music__trigger:focus-visible {
|
||||||
border-color: var(--faint);
|
border-color: var(--faint);
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
padding-bottom: 0.45rem;
|
padding-bottom: 1.15rem;
|
||||||
}
|
}
|
||||||
.music__trigger:focus-visible {
|
.music__trigger:focus-visible {
|
||||||
outline: 2px solid var(--accent);
|
outline: 2px solid var(--accent);
|
||||||
@ -148,21 +193,26 @@ import { Icon } from "astro-icon/components";
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
.music__sub {
|
.music__sub {
|
||||||
|
position: absolute;
|
||||||
|
left: 0.35rem;
|
||||||
|
right: 0.6rem;
|
||||||
|
bottom: 0.26rem;
|
||||||
font-size: 0.66rem;
|
font-size: 0.66rem;
|
||||||
color: var(--faint);
|
color: var(--faint);
|
||||||
max-height: 0;
|
|
||||||
opacity: 0;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(3px);
|
||||||
|
pointer-events: none;
|
||||||
transition:
|
transition:
|
||||||
max-height 0.22s ease,
|
opacity 0.18s ease,
|
||||||
opacity 0.18s ease;
|
transform 0.2s ease;
|
||||||
}
|
}
|
||||||
.music__trigger:hover .music__sub,
|
.music__trigger:hover .music__sub,
|
||||||
.music__trigger:focus-visible .music__sub {
|
.music__trigger:focus-visible .music__sub {
|
||||||
max-height: 1.4em;
|
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
|
transform: none;
|
||||||
}
|
}
|
||||||
.music__bar {
|
.music__bar {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|||||||
@ -1,5 +1,47 @@
|
|||||||
---
|
---
|
||||||
|
interface RBook {
|
||||||
|
title: string;
|
||||||
|
author: string;
|
||||||
|
cover: string | null;
|
||||||
|
url: string;
|
||||||
|
progress: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let current: RBook[] = [];
|
||||||
|
let want: RBook[] = [];
|
||||||
|
let recent: RBook[] = [];
|
||||||
|
try {
|
||||||
|
const r = await fetch(new URL("/api/reading.json", Astro.url), {
|
||||||
|
headers: { accept: "application/json" },
|
||||||
|
});
|
||||||
|
const d: any = await r.json();
|
||||||
|
if (d?.configured) {
|
||||||
|
current = Array.isArray(d.current) ? d.current : [];
|
||||||
|
want = Array.isArray(d.want) ? d.want : [];
|
||||||
|
recent = Array.isArray(d.recent) ? d.recent : [];
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasData = current.length > 0 || want.length > 0 || recent.length > 0;
|
||||||
|
const lead: RBook | null = current[0] ?? recent[0] ?? want[0] ?? null;
|
||||||
|
|
||||||
|
let rLabel = "reading";
|
||||||
|
let rSub = "";
|
||||||
|
if (lead) {
|
||||||
|
if (current.length) {
|
||||||
|
rLabel = lead.title;
|
||||||
|
rSub = lead.author;
|
||||||
|
} else if (recent.length) {
|
||||||
|
rLabel = "last read";
|
||||||
|
rSub = `${lead.title} · ${lead.author}`;
|
||||||
|
} else {
|
||||||
|
rLabel = "want to read";
|
||||||
|
rSub = `${lead.title} · ${lead.author}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const rCover = lead?.cover ?? "";
|
||||||
|
const rPct = current.length && lead?.progress != null ? lead.progress : null;
|
||||||
---
|
---
|
||||||
|
|
||||||
<div class="reading" id="books">
|
<div class="reading" id="books">
|
||||||
@ -9,7 +51,7 @@
|
|||||||
data-reading-open
|
data-reading-open
|
||||||
aria-haspopup="dialog"
|
aria-haspopup="dialog"
|
||||||
data-umami-event="reading-open"
|
data-umami-event="reading-open"
|
||||||
hidden
|
hidden={!hasData}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
class="reading__cover"
|
class="reading__cover"
|
||||||
@ -18,15 +60,23 @@
|
|||||||
width="28"
|
width="28"
|
||||||
height="40"
|
height="40"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
hidden
|
src={rCover || undefined}
|
||||||
|
hidden={!rCover}
|
||||||
/>
|
/>
|
||||||
<span class="reading__ico" aria-hidden="true">▢</span>
|
<span class="reading__ico" aria-hidden="true" hidden={!!rCover}>▢</span>
|
||||||
<span class="reading__main">
|
<span class="reading__main">
|
||||||
<span class="reading__label" id="reading-label">reading</span>
|
<span class="reading__label" id="reading-label">{rLabel}</span>
|
||||||
<span class="reading__sub" id="reading-sub" aria-hidden="true"></span>
|
<span class="reading__sub" id="reading-sub" aria-hidden="true">{rSub}</span>
|
||||||
</span>
|
</span>
|
||||||
<span class="reading__arw" aria-hidden="true">→</span>
|
<span class="reading__arw" aria-hidden="true">→</span>
|
||||||
<span class="reading__bar" id="reading-bar" aria-hidden="true" hidden><i></i></span>
|
<span
|
||||||
|
class="reading__bar"
|
||||||
|
id="reading-bar"
|
||||||
|
aria-hidden="true"
|
||||||
|
hidden={rPct == null}
|
||||||
|
>
|
||||||
|
<i style={rPct == null ? undefined : `--p:${rPct}%`}></i>
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<dialog class="reading-wall" data-reading-dialog aria-label="reading">
|
<dialog class="reading-wall" data-reading-dialog aria-label="reading">
|
||||||
@ -92,7 +142,7 @@
|
|||||||
.reading__trigger:focus-visible {
|
.reading__trigger:focus-visible {
|
||||||
border-color: var(--faint);
|
border-color: var(--faint);
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
padding-bottom: 0.45rem;
|
padding-bottom: 1.15rem;
|
||||||
}
|
}
|
||||||
.reading__trigger:focus-visible {
|
.reading__trigger:focus-visible {
|
||||||
outline: 2px solid var(--accent);
|
outline: 2px solid var(--accent);
|
||||||
@ -131,21 +181,26 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.reading__sub {
|
.reading__sub {
|
||||||
|
position: absolute;
|
||||||
|
left: 0.4rem;
|
||||||
|
right: 0.6rem;
|
||||||
|
bottom: 0.26rem;
|
||||||
font-size: 0.66rem;
|
font-size: 0.66rem;
|
||||||
color: var(--faint);
|
color: var(--faint);
|
||||||
max-height: 0;
|
|
||||||
opacity: 0;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(3px);
|
||||||
|
pointer-events: none;
|
||||||
transition:
|
transition:
|
||||||
max-height 0.22s ease,
|
opacity 0.18s ease,
|
||||||
opacity 0.18s ease;
|
transform 0.2s ease;
|
||||||
}
|
}
|
||||||
.reading__trigger:hover .reading__sub,
|
.reading__trigger:hover .reading__sub,
|
||||||
.reading__trigger:focus-visible .reading__sub {
|
.reading__trigger:focus-visible .reading__sub {
|
||||||
max-height: 1.4em;
|
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
|
transform: none;
|
||||||
}
|
}
|
||||||
.reading__bar {
|
.reading__bar {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|||||||
171
src/components/Stats.astro
Normal file
171
src/components/Stats.astro
Normal file
@ -0,0 +1,171 @@
|
|||||||
|
---
|
||||||
|
import { Icon } from "astro-icon/components";
|
||||||
|
|
||||||
|
const fmt = (n: number) => new Intl.NumberFormat("en-US").format(n);
|
||||||
|
|
||||||
|
let pv = 0;
|
||||||
|
let vis = 0;
|
||||||
|
let ok = false;
|
||||||
|
try {
|
||||||
|
const r = await fetch(new URL("/api/stats.json", Astro.url), {
|
||||||
|
headers: { accept: "application/json" },
|
||||||
|
});
|
||||||
|
const d: any = await r.json();
|
||||||
|
if (d?.configured && (d.pageviews || d.visitors)) {
|
||||||
|
pv = Math.max(0, Math.round(Number(d.pageviews) || 0));
|
||||||
|
vis = Math.max(0, Math.round(Number(d.visitors) || 0));
|
||||||
|
ok = true;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
|
||||||
|
const width = Math.max(6, String(pv).length);
|
||||||
|
const digits = ok ? String(pv).padStart(width, "0") : "";
|
||||||
|
let lead = true;
|
||||||
|
const cells = [...digits].map((c, i) => {
|
||||||
|
if (c !== "0" || i === digits.length - 1) lead = false;
|
||||||
|
return { c, lead };
|
||||||
|
});
|
||||||
|
---
|
||||||
|
|
||||||
|
<div class="stats" id="stats" hidden={!ok}>
|
||||||
|
<Icon class="stats__ico" name="ph:eye" aria-hidden="true" />
|
||||||
|
<span class="stats__reel" id="stats-reel">
|
||||||
|
{cells.map((x) => <span class:list={["stats__d", { "stats__d--lead": x.lead }]}>{x.c}</span>)}
|
||||||
|
</span>
|
||||||
|
<span class="stats__unit">views</span>
|
||||||
|
<span class="stats__sub" id="stats-sub" aria-hidden="true">{ok ? `${fmt(vis)} visitors` : ""}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.stats {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6ch;
|
||||||
|
padding: 0.3rem 0.62rem;
|
||||||
|
background: var(--paper-2);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 7px;
|
||||||
|
overflow: hidden;
|
||||||
|
font-family: var(--mono);
|
||||||
|
transition:
|
||||||
|
padding 0.22s ease,
|
||||||
|
border-color 0.18s ease;
|
||||||
|
}
|
||||||
|
.stats:hover,
|
||||||
|
.stats:focus-within {
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
border-color: var(--faint);
|
||||||
|
}
|
||||||
|
.stats__ico {
|
||||||
|
width: 0.95em;
|
||||||
|
height: 0.95em;
|
||||||
|
flex: none;
|
||||||
|
color: var(--faint);
|
||||||
|
}
|
||||||
|
.stats__reel {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 0.16ch;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.stats__d {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 0.92em;
|
||||||
|
padding: 0.09em 0.06em 0.11em;
|
||||||
|
background: linear-gradient(#201e1b, #191613);
|
||||||
|
border: 1px solid #2c2926;
|
||||||
|
border-radius: 2px;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent);
|
||||||
|
text-shadow: 0 0 4px rgba(255, 171, 94, 0.3);
|
||||||
|
}
|
||||||
|
.stats__d::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
top: 50%;
|
||||||
|
height: 1px;
|
||||||
|
background: rgba(0, 0, 0, 0.55);
|
||||||
|
transform: translateY(-0.5px);
|
||||||
|
}
|
||||||
|
.stats__d--lead {
|
||||||
|
color: var(--faint);
|
||||||
|
text-shadow: none;
|
||||||
|
}
|
||||||
|
.stats__unit {
|
||||||
|
font-size: 0.66rem;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--faint);
|
||||||
|
}
|
||||||
|
.stats__sub {
|
||||||
|
position: absolute;
|
||||||
|
left: 0.62rem;
|
||||||
|
right: 0.62rem;
|
||||||
|
bottom: 0.22rem;
|
||||||
|
font-size: 0.62rem;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
color: var(--faint);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(3px);
|
||||||
|
pointer-events: none;
|
||||||
|
transition:
|
||||||
|
opacity 0.18s ease,
|
||||||
|
transform 0.2s ease;
|
||||||
|
}
|
||||||
|
.stats:hover .stats__sub,
|
||||||
|
.stats:focus-within .stats__sub {
|
||||||
|
opacity: 1;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const fmt = (n: number) => new Intl.NumberFormat("en-US").format(n);
|
||||||
|
|
||||||
|
async function load(): Promise<void> {
|
||||||
|
let d: any;
|
||||||
|
try {
|
||||||
|
const r = await fetch("/api/stats.json", { headers: { accept: "application/json" } });
|
||||||
|
d = await r.json();
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
(!d || !d.configured || (!d.pageviews && !d.visitors)) &&
|
||||||
|
location.search.includes("mockstats")
|
||||||
|
)
|
||||||
|
d = { configured: true, pageviews: 128473, visitors: 41902 };
|
||||||
|
if (!d || !d.configured || (!d.pageviews && !d.visitors)) return;
|
||||||
|
|
||||||
|
const block = document.getElementById("stats");
|
||||||
|
const reel = document.getElementById("stats-reel");
|
||||||
|
const sub = document.getElementById("stats-sub");
|
||||||
|
if (!block || !reel) return;
|
||||||
|
|
||||||
|
const pv = Math.max(0, Math.round(Number(d.pageviews) || 0));
|
||||||
|
const vis = Math.max(0, Math.round(Number(d.visitors) || 0));
|
||||||
|
const width = Math.max(6, String(pv).length);
|
||||||
|
const digits = String(pv).padStart(width, "0");
|
||||||
|
let lead = true;
|
||||||
|
reel.innerHTML = [...digits]
|
||||||
|
.map((c, i) => {
|
||||||
|
if (c !== "0" || i === digits.length - 1) lead = false;
|
||||||
|
return `<span class="stats__d${lead ? " stats__d--lead" : ""}">${c}</span>`;
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
if (sub) sub.textContent = `${fmt(vis)} visitors`;
|
||||||
|
block.hidden = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
load();
|
||||||
|
</script>
|
||||||
@ -3,7 +3,7 @@ export const site = {
|
|||||||
name: "zach",
|
name: "zach",
|
||||||
wordmark: "ZACH",
|
wordmark: "ZACH",
|
||||||
domain: "zachy.cc",
|
domain: "zachy.cc",
|
||||||
version: "v3.0.1",
|
version: "v3.1.2",
|
||||||
tagline: "i make cool stuff",
|
tagline: "i make cool stuff",
|
||||||
description:
|
description:
|
||||||
"i make cool things sometimes.",
|
"i make cool things sometimes.",
|
||||||
|
|||||||
71
src/pages/api/stats.json.ts
Normal file
71
src/pages/api/stats.json.ts
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
import type { APIRoute } from "astro";
|
||||||
|
import { env as cfEnv } from "cloudflare:workers";
|
||||||
|
|
||||||
|
export const prerender = false;
|
||||||
|
|
||||||
|
|
||||||
|
type Env = Record<string, string | undefined>;
|
||||||
|
|
||||||
|
function getEnv(): Env {
|
||||||
|
const nodeEnv = typeof process !== "undefined" ? process.env : {};
|
||||||
|
let workerEnv: Env = {};
|
||||||
|
try {
|
||||||
|
workerEnv = cfEnv as unknown as Env;
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
return { ...nodeEnv, ...workerEnv };
|
||||||
|
}
|
||||||
|
|
||||||
|
const num = (v: any): number =>
|
||||||
|
typeof v === "number" ? v : typeof v?.value === "number" ? v.value : 0;
|
||||||
|
|
||||||
|
export const GET: APIRoute = async () => {
|
||||||
|
const e = getEnv();
|
||||||
|
const base = (e.UMAMI_URL || "https://api.umami.is/v1").replace(/\/+$/, "");
|
||||||
|
const id = e.UMAMI_WEBSITE_ID;
|
||||||
|
const key = e.UMAMI_API_KEY;
|
||||||
|
const shareId = e.UMAMI_SHARE_ID;
|
||||||
|
|
||||||
|
const json = (data: unknown, status = 200) =>
|
||||||
|
new Response(JSON.stringify(data), {
|
||||||
|
status,
|
||||||
|
headers: { "content-type": "application/json", "cache-control": "public, max-age=300" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const hidden = { configured: false, pageviews: 0, visitors: 0 };
|
||||||
|
if (!(shareId || (id && key))) return json(hidden);
|
||||||
|
|
||||||
|
const headers: Record<string, string> = { accept: "application/json" };
|
||||||
|
let websiteId = id;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (key) {
|
||||||
|
headers["x-umami-api-key"] = key;
|
||||||
|
} else if (shareId) {
|
||||||
|
const origin = base.replace(/\/v1$/, "").replace(/\/api$/, "");
|
||||||
|
const r = await fetch(`${origin}/api/share/${shareId}`, {
|
||||||
|
headers: { accept: "application/json" },
|
||||||
|
});
|
||||||
|
const s: any = await r.json();
|
||||||
|
if (s?.token) {
|
||||||
|
headers["x-umami-share-token"] = s.token;
|
||||||
|
headers["x-umami-share-context"] = "1";
|
||||||
|
}
|
||||||
|
if (s?.websiteId) websiteId = s.websiteId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!websiteId) return json({ ...hidden, configured: true });
|
||||||
|
|
||||||
|
const url = `${base}/websites/${websiteId}/stats?startAt=0&endAt=${Date.now()}`;
|
||||||
|
const res = await fetch(url, { headers });
|
||||||
|
const data: any = await res.json();
|
||||||
|
|
||||||
|
const pageviews = num(data?.pageviews);
|
||||||
|
const visitors = num(data?.visitors);
|
||||||
|
if (!pageviews && !visitors) return json({ ...hidden, configured: true });
|
||||||
|
|
||||||
|
return json({ configured: true, pageviews, visitors });
|
||||||
|
} catch {
|
||||||
|
return json({ ...hidden, configured: true });
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -3,6 +3,7 @@ import Base from "../layouts/Base.astro";
|
|||||||
import Music from "../components/Music.astro";
|
import Music from "../components/Music.astro";
|
||||||
import Reading from "../components/Reading.astro";
|
import Reading from "../components/Reading.astro";
|
||||||
import Clock from "../components/Clock.astro";
|
import Clock from "../components/Clock.astro";
|
||||||
|
import Stats from "../components/Stats.astro";
|
||||||
import Webrings from "../components/Webrings.astro";
|
import Webrings from "../components/Webrings.astro";
|
||||||
import Buttons from "../components/Buttons.astro";
|
import Buttons from "../components/Buttons.astro";
|
||||||
import { site, elsewhere } from "../data/site";
|
import { site, elsewhere } from "../data/site";
|
||||||
@ -42,6 +43,9 @@ import { site, elsewhere } from "../data/site";
|
|||||||
<Music />
|
<Music />
|
||||||
<Reading />
|
<Reading />
|
||||||
</div>
|
</div>
|
||||||
|
<div class="foot">
|
||||||
|
<Stats />
|
||||||
|
</div>
|
||||||
<div class="foot foot--clock">
|
<div class="foot foot--clock">
|
||||||
<Clock />
|
<Clock />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -30,6 +30,8 @@ const mmss = (ms: number): string => {
|
|||||||
const $ = (id: string) => document.getElementById(id);
|
const $ = (id: string) => document.getElementById(id);
|
||||||
const esc = (s: string): string =>
|
const esc = (s: string): string =>
|
||||||
s.replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]!);
|
s.replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]!);
|
||||||
|
const clamp = (s: string, n = 22): string =>
|
||||||
|
s.length > n ? s.slice(0, n - 1).trimEnd() + "…" : s;
|
||||||
|
|
||||||
function renderNowPlaying(np: NP): void {
|
function renderNowPlaying(np: NP): void {
|
||||||
const section = $("np-section");
|
const section = $("np-section");
|
||||||
@ -162,7 +164,8 @@ async function load(): Promise<void> {
|
|||||||
if (label) {
|
if (label) {
|
||||||
if (np?.isPlaying) {
|
if (np?.isPlaying) {
|
||||||
trigger.classList.add("music__trigger--live");
|
trigger.classList.add("music__trigger--live");
|
||||||
label.textContent = np.title;
|
label.textContent = clamp(np.title);
|
||||||
|
trigger.title = `${np.title} — ${np.artist}`;
|
||||||
if (sub) sub.textContent = np.artist;
|
if (sub) sub.textContent = np.artist;
|
||||||
if (bar && fill && np.progressMs != null && np.durationMs) {
|
if (bar && fill && np.progressMs != null && np.durationMs) {
|
||||||
fill.style.setProperty("--p", `${Math.min(100, (np.progressMs / np.durationMs) * 100)}%`);
|
fill.style.setProperty("--p", `${Math.min(100, (np.progressMs / np.durationMs) * 100)}%`);
|
||||||
@ -170,7 +173,8 @@ async function load(): Promise<void> {
|
|||||||
}
|
}
|
||||||
} else if (np?.playedAt) {
|
} else if (np?.playedAt) {
|
||||||
label.textContent = "last played";
|
label.textContent = "last played";
|
||||||
if (sub) sub.textContent = `${np.title} · ${np.artist}`;
|
trigger.title = `${np.title} — ${np.artist}`;
|
||||||
|
if (sub) sub.textContent = `${clamp(np.title, 26)} · ${np.artist}`;
|
||||||
} else {
|
} else {
|
||||||
label.textContent = "top tracks";
|
label.textContent = "top tracks";
|
||||||
if (sub) sub.textContent = "last 4 weeks";
|
if (sub) sub.textContent = "last 4 weeks";
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user