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
|
||||
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 parts = (opts: Intl.DateTimeFormatOptions) =>
|
||||
const fmt = (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";
|
||||
const t = fmt({ hour: "numeric", minute: "2-digit", hour12: true });
|
||||
const hour = t.find((p) => p.type === "hour")?.value ?? "--";
|
||||
const minute = t.find((p) => p.type === "minute")?.value ?? "--";
|
||||
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}`}>
|
||||
<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>
|
||||
<div
|
||||
class="clock"
|
||||
data-tz={TZ}
|
||||
role="img"
|
||||
aria-label={`zach's local time — ${hour}:${minute} ${mer} ${zone}`}
|
||||
>
|
||||
<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 class="clock__mer" id="clock-mer">{mer}</span>
|
||||
<span class="clock__zone" id="clock-zone">{zone}</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;
|
||||
padding: 0.42rem 0.8rem;
|
||||
background: var(--paper-2);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
font-family: var(--mono);
|
||||
font-size: 0.95rem;
|
||||
letter-spacing: 0.06em;
|
||||
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 {
|
||||
font-size: 0.9rem;
|
||||
color: #ffb454;
|
||||
text-shadow: 0 0 6px rgba(255, 180, 84, 0.5);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
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 {
|
||||
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;
|
||||
}
|
||||
@keyframes clock-blink {
|
||||
50% {
|
||||
opacity: 0.2;
|
||||
opacity: 0.25;
|
||||
}
|
||||
}
|
||||
.clock__mer,
|
||||
.clock__zone {
|
||||
font-size: 0.6rem;
|
||||
letter-spacing: 0.18em;
|
||||
color: #806b4f;
|
||||
font-size: 0.72em;
|
||||
letter-spacing: 0.14em;
|
||||
color: var(--faint);
|
||||
}
|
||||
.clock__mer {
|
||||
margin-left: 0.1ch;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.clock__sep {
|
||||
@ -67,22 +116,31 @@ const zone =
|
||||
|
||||
<script>
|
||||
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");
|
||||
if (root && timeEl) {
|
||||
const tz = root.dataset.tz || "America/New_York";
|
||||
const timeFmt = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: tz,
|
||||
hour: "2-digit",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
hour12: true,
|
||||
});
|
||||
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 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;
|
||||
const p = timeFmt.formatToParts(d);
|
||||
const h = p.find((x) => x.type === "hour")?.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;
|
||||
};
|
||||
tick();
|
||||
|
||||
@ -1,15 +1,57 @@
|
||||
---
|
||||
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">
|
||||
<button
|
||||
class="music__trigger"
|
||||
class:list={["music__trigger", { "music__trigger--live": mLive }]}
|
||||
type="button"
|
||||
data-music-open
|
||||
aria-haspopup="dialog"
|
||||
data-umami-event="music-open"
|
||||
hidden
|
||||
title={mTitleAttr}
|
||||
hidden={!hasData}
|
||||
>
|
||||
<img
|
||||
class="music__art"
|
||||
@ -18,15 +60,18 @@ import { Icon } from "astro-icon/components";
|
||||
width="42"
|
||||
height="42"
|
||||
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__main">
|
||||
<span class="music__label" id="music-label">now playing</span>
|
||||
<span class="music__sub" id="music-sub" aria-hidden="true"></span>
|
||||
<span class="music__label" id="music-label">{mLabel}</span>
|
||||
<span class="music__sub" id="music-sub" aria-hidden="true">{mSub}</span>
|
||||
</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>
|
||||
|
||||
<dialog class="music-wall" data-music-dialog aria-label="music">
|
||||
@ -105,7 +150,7 @@ import { Icon } from "astro-icon/components";
|
||||
.music__trigger:focus-visible {
|
||||
border-color: var(--faint);
|
||||
color: var(--ink);
|
||||
padding-bottom: 0.45rem;
|
||||
padding-bottom: 1.15rem;
|
||||
}
|
||||
.music__trigger:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
@ -148,21 +193,26 @@ import { Icon } from "astro-icon/components";
|
||||
min-width: 0;
|
||||
}
|
||||
.music__sub {
|
||||
position: absolute;
|
||||
left: 0.35rem;
|
||||
right: 0.6rem;
|
||||
bottom: 0.26rem;
|
||||
font-size: 0.66rem;
|
||||
color: var(--faint);
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
opacity: 0;
|
||||
transform: translateY(3px);
|
||||
pointer-events: none;
|
||||
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:focus-visible .music__sub {
|
||||
max-height: 1.4em;
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
.music__bar {
|
||||
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">
|
||||
@ -9,7 +51,7 @@
|
||||
data-reading-open
|
||||
aria-haspopup="dialog"
|
||||
data-umami-event="reading-open"
|
||||
hidden
|
||||
hidden={!hasData}
|
||||
>
|
||||
<img
|
||||
class="reading__cover"
|
||||
@ -18,15 +60,23 @@
|
||||
width="28"
|
||||
height="40"
|
||||
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__label" id="reading-label">reading</span>
|
||||
<span class="reading__sub" id="reading-sub" aria-hidden="true"></span>
|
||||
<span class="reading__label" id="reading-label">{rLabel}</span>
|
||||
<span class="reading__sub" id="reading-sub" aria-hidden="true">{rSub}</span>
|
||||
</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>
|
||||
|
||||
<dialog class="reading-wall" data-reading-dialog aria-label="reading">
|
||||
@ -92,7 +142,7 @@
|
||||
.reading__trigger:focus-visible {
|
||||
border-color: var(--faint);
|
||||
color: var(--ink);
|
||||
padding-bottom: 0.45rem;
|
||||
padding-bottom: 1.15rem;
|
||||
}
|
||||
.reading__trigger:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
@ -131,21 +181,26 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
.reading__sub {
|
||||
position: absolute;
|
||||
left: 0.4rem;
|
||||
right: 0.6rem;
|
||||
bottom: 0.26rem;
|
||||
font-size: 0.66rem;
|
||||
color: var(--faint);
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
opacity: 0;
|
||||
transform: translateY(3px);
|
||||
pointer-events: none;
|
||||
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:focus-visible .reading__sub {
|
||||
max-height: 1.4em;
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
.reading__bar {
|
||||
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",
|
||||
wordmark: "ZACH",
|
||||
domain: "zachy.cc",
|
||||
version: "v3.0.1",
|
||||
version: "v3.1.2",
|
||||
tagline: "i make cool stuff",
|
||||
description:
|
||||
"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 Reading from "../components/Reading.astro";
|
||||
import Clock from "../components/Clock.astro";
|
||||
import Stats from "../components/Stats.astro";
|
||||
import Webrings from "../components/Webrings.astro";
|
||||
import Buttons from "../components/Buttons.astro";
|
||||
import { site, elsewhere } from "../data/site";
|
||||
@ -42,6 +43,9 @@ import { site, elsewhere } from "../data/site";
|
||||
<Music />
|
||||
<Reading />
|
||||
</div>
|
||||
<div class="foot">
|
||||
<Stats />
|
||||
</div>
|
||||
<div class="foot foot--clock">
|
||||
<Clock />
|
||||
</div>
|
||||
|
||||
@ -30,6 +30,8 @@ const mmss = (ms: number): string => {
|
||||
const $ = (id: string) => document.getElementById(id);
|
||||
const esc = (s: string): string =>
|
||||
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 {
|
||||
const section = $("np-section");
|
||||
@ -162,7 +164,8 @@ async function load(): Promise<void> {
|
||||
if (label) {
|
||||
if (np?.isPlaying) {
|
||||
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 (bar && fill && np.progressMs != null && np.durationMs) {
|
||||
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) {
|
||||
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 {
|
||||
label.textContent = "top tracks";
|
||||
if (sub) sub.textContent = "last 4 weeks";
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user