expand more about me section
This commit is contained in:
parent
f6daa5b349
commit
276a266977
@ -20,15 +20,7 @@ const zone = fmt({ timeZoneName: "short" }).find((p) => p.type === "timeZoneName
|
|||||||
aria-label={`zach's local time — ${hour}:${minute} ${mer} ${zone}`}
|
aria-label={`zach's local time — ${hour}:${minute} ${mer} ${zone}`}
|
||||||
>
|
>
|
||||||
<Icon class="clock__ico" name="ph:clock" aria-hidden="true" />
|
<Icon class="clock__ico" name="ph:clock" aria-hidden="true" />
|
||||||
<span class="clock__time" id="clock-time">
|
<span class="clock__time" id="clock-time">{hour}:{minute}</span>
|
||||||
<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__mer" id="clock-mer">{mer}</span>
|
||||||
<span class="clock__zone" id="clock-zone">{zone}</span>
|
<span class="clock__zone" id="clock-zone">{zone}</span>
|
||||||
</div>
|
</div>
|
||||||
@ -36,82 +28,33 @@ const zone = fmt({ timeZoneName: "short" }).find((p) => p.type === "timeZoneName
|
|||||||
<style>
|
<style>
|
||||||
.clock {
|
.clock {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: baseline;
|
||||||
gap: 0.6ch;
|
gap: 0.55ch;
|
||||||
padding: 0.42rem 0.8rem;
|
padding: 0.36rem 0.7rem;
|
||||||
background: var(--paper-2);
|
background: var(--paper-2);
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
font-family: var(--mono);
|
font-family: var(--mono);
|
||||||
font-size: 0.95rem;
|
font-size: 0.82rem;
|
||||||
letter-spacing: 0.06em;
|
letter-spacing: 0.04em;
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
.clock__ico {
|
.clock__ico {
|
||||||
width: 1em;
|
width: 0.95em;
|
||||||
height: 1em;
|
height: 0.95em;
|
||||||
flex: none;
|
flex: none;
|
||||||
color: var(--faint);
|
color: var(--faint);
|
||||||
margin-right: 0.1ch;
|
align-self: center;
|
||||||
}
|
}
|
||||||
.clock__time {
|
.clock__time {
|
||||||
display: inline-flex;
|
color: var(--ink);
|
||||||
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.25;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.clock__mer,
|
.clock__mer,
|
||||||
.clock__zone {
|
.clock__zone {
|
||||||
font-size: 0.72em;
|
font-size: 0.82em;
|
||||||
letter-spacing: 0.14em;
|
letter-spacing: 0.1em;
|
||||||
color: var(--faint);
|
color: var(--faint);
|
||||||
}
|
}
|
||||||
.clock__mer {
|
|
||||||
margin-left: 0.1ch;
|
|
||||||
}
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
.clock__sep {
|
|
||||||
animation: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
@ -128,17 +71,13 @@ const zone = fmt({ timeZoneName: "short" }).find((p) => p.type === "timeZoneName
|
|||||||
hour12: true,
|
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 p = timeFmt.formatToParts(d);
|
const p = timeFmt.formatToParts(d);
|
||||||
const h = p.find((x) => x.type === "hour")?.value ?? "--";
|
const h = p.find((x) => x.type === "hour")?.value ?? "--";
|
||||||
const m = p.find((x) => x.type === "minute")?.value ?? "--";
|
const m = p.find((x) => x.type === "minute")?.value ?? "--";
|
||||||
const mer = (p.find((x) => x.type === "dayPeriod")?.value ?? "").toUpperCase();
|
const mer = (p.find((x) => x.type === "dayPeriod")?.value ?? "").toUpperCase();
|
||||||
timeEl.innerHTML = `${tiles(h)}<span class="clock__sep">:</span>${tiles(m)}`;
|
timeEl.textContent = `${h}:${m}`;
|
||||||
if (merEl) merEl.textContent = mer;
|
if (merEl) merEl.textContent = mer;
|
||||||
const z = zoneFmt.formatToParts(d).find((x) => x.type === "timeZoneName")?.value;
|
const z = zoneFmt.formatToParts(d).find((x) => x.type === "timeZoneName")?.value;
|
||||||
if (zoneEl && z) zoneEl.textContent = z;
|
if (zoneEl && z) zoneEl.textContent = z;
|
||||||
|
|||||||
@ -1,57 +1,15 @@
|
|||||||
---
|
---
|
||||||
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:list={["music__trigger", { "music__trigger--live": mLive }]}
|
class="music__trigger"
|
||||||
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"
|
||||||
title={mTitleAttr}
|
hidden
|
||||||
hidden={!hasData}
|
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
class="music__art"
|
class="music__art"
|
||||||
@ -60,18 +18,15 @@ const mTitleAttr = np ? `${np.title} — ${np.artist}` : undefined;
|
|||||||
width="42"
|
width="42"
|
||||||
height="42"
|
height="42"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
src={mArt || undefined}
|
hidden
|
||||||
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">{mLabel}</span>
|
<span class="music__label" id="music-label">now playing</span>
|
||||||
<span class="music__sub" id="music-sub" aria-hidden="true">{mSub}</span>
|
<span class="music__sub" id="music-sub" aria-hidden="true"></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={mPct == null}>
|
<span class="music__bar" id="music-bar" aria-hidden="true" hidden><i></i></span>
|
||||||
<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">
|
||||||
|
|||||||
@ -1,47 +1,5 @@
|
|||||||
---
|
---
|
||||||
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">
|
||||||
@ -51,7 +9,7 @@ const rPct = current.length && lead?.progress != null ? lead.progress : null;
|
|||||||
data-reading-open
|
data-reading-open
|
||||||
aria-haspopup="dialog"
|
aria-haspopup="dialog"
|
||||||
data-umami-event="reading-open"
|
data-umami-event="reading-open"
|
||||||
hidden={!hasData}
|
hidden
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
class="reading__cover"
|
class="reading__cover"
|
||||||
@ -60,23 +18,15 @@ const rPct = current.length && lead?.progress != null ? lead.progress : null;
|
|||||||
width="28"
|
width="28"
|
||||||
height="40"
|
height="40"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
src={rCover || undefined}
|
hidden
|
||||||
hidden={!rCover}
|
|
||||||
/>
|
/>
|
||||||
<span class="reading__ico" aria-hidden="true" hidden={!!rCover}>▢</span>
|
<span class="reading__ico" aria-hidden="true">▢</span>
|
||||||
<span class="reading__main">
|
<span class="reading__main">
|
||||||
<span class="reading__label" id="reading-label">{rLabel}</span>
|
<span class="reading__label" id="reading-label">reading</span>
|
||||||
<span class="reading__sub" id="reading-sub" aria-hidden="true">{rSub}</span>
|
<span class="reading__sub" id="reading-sub" aria-hidden="true"></span>
|
||||||
</span>
|
</span>
|
||||||
<span class="reading__arw" aria-hidden="true">→</span>
|
<span class="reading__arw" aria-hidden="true">→</span>
|
||||||
<span
|
<span class="reading__bar" id="reading-bar" aria-hidden="true" hidden><i></i></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">
|
||||||
|
|||||||
@ -1,135 +1,288 @@
|
|||||||
---
|
---
|
||||||
import { Icon } from "astro-icon/components";
|
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}>
|
<div class="stats" id="stats">
|
||||||
<Icon class="stats__ico" name="ph:eye" aria-hidden="true" />
|
<button
|
||||||
<span class="stats__reel" id="stats-reel">
|
class="stats__trigger"
|
||||||
{cells.map((x) => <span class:list={["stats__d", { "stats__d--lead": x.lead }]}>{x.c}</span>)}
|
type="button"
|
||||||
</span>
|
data-stats-open
|
||||||
<span class="stats__unit">views</span>
|
aria-haspopup="dialog"
|
||||||
<span class="stats__sub" id="stats-sub" aria-hidden="true">{ok ? `${fmt(vis)} visitors` : ""}</span>
|
data-umami-event="stats-open"
|
||||||
|
hidden
|
||||||
|
>
|
||||||
|
<Icon class="stats__ico" name="ph:eye" aria-hidden="true" />
|
||||||
|
<span class="stats__reel" id="stats-reel"></span>
|
||||||
|
<span class="stats__unit">views</span>
|
||||||
|
<span class="stats__arw" aria-hidden="true">→</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<dialog class="stats-wall" data-stats-dialog aria-label="analytics">
|
||||||
|
<div class="stats-wall__bar">
|
||||||
|
<span class="stats-wall__title">analytics</span>
|
||||||
|
<button
|
||||||
|
class="stats-wall__close"
|
||||||
|
type="button"
|
||||||
|
data-stats-close
|
||||||
|
aria-label="close"
|
||||||
|
autofocus>✕</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stats-wall__body">
|
||||||
|
<div class="stat-bigs">
|
||||||
|
<div class="stat-big">
|
||||||
|
<span class="stat-big__n" id="sb-pv">—</span>
|
||||||
|
<span class="stat-big__k">pageviews</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-big">
|
||||||
|
<span class="stat-big__n" id="sb-vis">—</span>
|
||||||
|
<span class="stat-big__k">visitors</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-big">
|
||||||
|
<span class="stat-big__n" id="sb-visits">—</span>
|
||||||
|
<span class="stat-big__k">visits</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat-block" id="sb-pages-block" hidden>
|
||||||
|
<h3 class="label">top pages</h3>
|
||||||
|
<ul class="stat-list" id="sb-pages"></ul>
|
||||||
|
</div>
|
||||||
|
<div class="stat-block" id="sb-refs-block" hidden>
|
||||||
|
<h3 class="label">top referrers</h3>
|
||||||
|
<ul class="stat-list" id="sb-refs"></ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="stats-wall__foot">
|
||||||
|
all‑time · via <a
|
||||||
|
href="https://umami.is"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener">umami ↗</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style is:global>
|
||||||
.stats {
|
|
||||||
position: relative;
|
.stats__trigger {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.6ch;
|
gap: 0.6ch;
|
||||||
padding: 0.3rem 0.62rem;
|
padding: 0.32rem 0.64rem;
|
||||||
background: var(--paper-2);
|
background: var(--paper-2);
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: 7px;
|
border-radius: 8px;
|
||||||
overflow: hidden;
|
cursor: pointer;
|
||||||
font-family: var(--mono);
|
font-family: var(--mono);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--muted);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
transition:
|
transition:
|
||||||
padding 0.22s ease,
|
border-color 0.18s ease,
|
||||||
border-color 0.18s ease;
|
color 0.18s ease;
|
||||||
}
|
}
|
||||||
.stats:hover,
|
.stats__trigger:hover,
|
||||||
.stats:focus-within {
|
.stats__trigger:focus-visible {
|
||||||
padding-bottom: 1rem;
|
|
||||||
border-color: var(--faint);
|
border-color: var(--faint);
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
.stats__trigger:focus-visible {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 3px;
|
||||||
}
|
}
|
||||||
.stats__ico {
|
.stats__ico {
|
||||||
width: 0.95em;
|
width: 1em;
|
||||||
height: 0.95em;
|
height: 1em;
|
||||||
flex: none;
|
flex: none;
|
||||||
color: var(--faint);
|
color: var(--faint);
|
||||||
}
|
}
|
||||||
.stats__reel {
|
.stats__reel {
|
||||||
display: inline-flex;
|
color: var(--ink);
|
||||||
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 {
|
.stats__unit {
|
||||||
font-size: 0.66rem;
|
font-size: 0.62em;
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.1em;
|
||||||
color: var(--faint);
|
color: var(--faint);
|
||||||
}
|
}
|
||||||
.stats__sub {
|
.stats__arw {
|
||||||
position: absolute;
|
|
||||||
left: 0.62rem;
|
|
||||||
right: 0.62rem;
|
|
||||||
bottom: 0.22rem;
|
|
||||||
font-size: 0.62rem;
|
|
||||||
letter-spacing: 0.03em;
|
|
||||||
color: var(--faint);
|
color: var(--faint);
|
||||||
white-space: nowrap;
|
font-size: 0.8em;
|
||||||
|
transition: transform 0.22s cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
}
|
||||||
|
.stats__trigger:hover .stats__arw {
|
||||||
|
transform: translateX(0.25em);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-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;
|
||||||
|
}
|
||||||
|
.stats-wall[open] {
|
||||||
|
animation: stats-in 0.28s cubic-bezier(0.17, 0.89, 0.32, 1.28);
|
||||||
|
}
|
||||||
|
@keyframes stats-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(8px) scale(0.96);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.stats-wall::backdrop {
|
||||||
|
background: rgba(6, 5, 4, 0.68);
|
||||||
|
backdrop-filter: blur(3px);
|
||||||
|
}
|
||||||
|
.stats-wall__bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.6rem 0.85rem;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.stats-wall__title {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
color: var(--faint);
|
||||||
|
}
|
||||||
|
.stats-wall__close {
|
||||||
|
background: none;
|
||||||
|
border: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 0.3rem;
|
||||||
|
}
|
||||||
|
.stats-wall__close:hover {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
.stats-wall__body {
|
||||||
|
padding: 1rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
max-height: calc(min(82vh, 40rem) - 2.6rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-bigs {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.stat-big {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.2rem;
|
||||||
|
padding: 0.7rem 0.6rem;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #151412;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.stat-big__n {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ink);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.stat-big__k {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 0.58rem;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
color: var(--faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-block {
|
||||||
|
margin-top: 1.4rem;
|
||||||
|
}
|
||||||
|
.stat-block .label {
|
||||||
|
margin-bottom: 0.6rem;
|
||||||
|
}
|
||||||
|
.stat-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.3rem;
|
||||||
|
}
|
||||||
|
.stat-list li {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1ch;
|
||||||
|
padding: 0.34rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 0.74rem;
|
||||||
|
}
|
||||||
|
.stat-list li::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0 auto 0 0;
|
||||||
|
width: var(--w, 0%);
|
||||||
|
background: rgba(255, 171, 94, 0.1);
|
||||||
|
border-right: 1px solid rgba(255, 171, 94, 0.25);
|
||||||
|
}
|
||||||
|
.stat-list__k {
|
||||||
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
opacity: 0;
|
white-space: nowrap;
|
||||||
transform: translateY(3px);
|
color: var(--ink);
|
||||||
pointer-events: none;
|
|
||||||
transition:
|
|
||||||
opacity 0.18s ease,
|
|
||||||
transform 0.2s ease;
|
|
||||||
}
|
}
|
||||||
.stats:hover .stats__sub,
|
.stat-list__v {
|
||||||
.stats:focus-within .stats__sub {
|
position: relative;
|
||||||
opacity: 1;
|
flex: none;
|
||||||
transform: none;
|
color: var(--muted);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-wall__foot {
|
||||||
|
margin: 1.5rem 0 0;
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 0.66rem;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
color: var(--faint);
|
||||||
|
}
|
||||||
|
.stats-wall__foot a {
|
||||||
|
color: var(--faint);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.stats-wall__foot a:hover {
|
||||||
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
import { hashDialog } from "../scripts/hash-dialog.ts";
|
||||||
|
|
||||||
const fmt = (n: number) => new Intl.NumberFormat("en-US").format(n);
|
const fmt = (n: number) => new Intl.NumberFormat("en-US").format(n);
|
||||||
|
const $ = (id: string) => document.getElementById(id);
|
||||||
|
const esc = (s: string) =>
|
||||||
|
s.replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]!);
|
||||||
|
|
||||||
|
function rows(el: HTMLElement, items: { k: string; v: number }[]): void {
|
||||||
|
const max = items.reduce((m, i) => Math.max(m, i.v), 0) || 1;
|
||||||
|
el.innerHTML = items
|
||||||
|
.map(
|
||||||
|
(i) =>
|
||||||
|
`<li style="--w:${Math.round((i.v / max) * 100)}%"><span class="stat-list__k">${esc(
|
||||||
|
i.k,
|
||||||
|
)}</span><span class="stat-list__v">${fmt(i.v)}</span></li>`,
|
||||||
|
)
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
|
||||||
async function load(): Promise<void> {
|
async function load(): Promise<void> {
|
||||||
let d: any;
|
let d: any;
|
||||||
@ -143,29 +296,71 @@ const cells = [...digits].map((c, i) => {
|
|||||||
(!d || !d.configured || (!d.pageviews && !d.visitors)) &&
|
(!d || !d.configured || (!d.pageviews && !d.visitors)) &&
|
||||||
location.search.includes("mockstats")
|
location.search.includes("mockstats")
|
||||||
)
|
)
|
||||||
d = { configured: true, pageviews: 128473, visitors: 41902 };
|
d = {
|
||||||
|
configured: true,
|
||||||
|
pageviews: 128473,
|
||||||
|
visitors: 41902,
|
||||||
|
visits: 52310,
|
||||||
|
topPages: [
|
||||||
|
{ path: "/", count: 90112 },
|
||||||
|
{ path: "/projects", count: 21044 },
|
||||||
|
{ path: "/notes", count: 9010 },
|
||||||
|
],
|
||||||
|
topReferrers: [
|
||||||
|
{ ref: "github.com", count: 4102 },
|
||||||
|
{ ref: "news.ycombinator.com", count: 880 },
|
||||||
|
],
|
||||||
|
};
|
||||||
if (!d || !d.configured || (!d.pageviews && !d.visitors)) return;
|
if (!d || !d.configured || (!d.pageviews && !d.visitors)) return;
|
||||||
|
|
||||||
const block = document.getElementById("stats");
|
const trigger = document.querySelector<HTMLButtonElement>(".stats__trigger");
|
||||||
const reel = document.getElementById("stats-reel");
|
const reel = $("stats-reel");
|
||||||
const sub = document.getElementById("stats-sub");
|
if (!trigger || !reel) return;
|
||||||
if (!block || !reel) return;
|
|
||||||
|
|
||||||
const pv = Math.max(0, Math.round(Number(d.pageviews) || 0));
|
const pv = Math.max(0, Math.round(Number(d.pageviews) || 0));
|
||||||
const vis = Math.max(0, Math.round(Number(d.visitors) || 0));
|
reel.textContent = fmt(pv);
|
||||||
const width = Math.max(6, String(pv).length);
|
trigger.hidden = false;
|
||||||
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`;
|
const pvEl = $("sb-pv");
|
||||||
block.hidden = false;
|
const visEl = $("sb-vis");
|
||||||
|
const visitsEl = $("sb-visits");
|
||||||
|
if (pvEl) pvEl.textContent = fmt(pv);
|
||||||
|
if (visEl) visEl.textContent = fmt(Math.max(0, Math.round(Number(d.visitors) || 0)));
|
||||||
|
if (visitsEl) visitsEl.textContent = fmt(Math.max(0, Math.round(Number(d.visits) || 0)));
|
||||||
|
|
||||||
|
const pages = Array.isArray(d.topPages) ? d.topPages : [];
|
||||||
|
const refs = Array.isArray(d.topReferrers) ? d.topReferrers : [];
|
||||||
|
if (pages.length) {
|
||||||
|
rows(
|
||||||
|
$("sb-pages")!,
|
||||||
|
pages.map((p: any) => ({ k: p.path, v: Number(p.count) || 0 })),
|
||||||
|
);
|
||||||
|
$("sb-pages-block")!.hidden = false;
|
||||||
|
}
|
||||||
|
if (refs.length) {
|
||||||
|
rows(
|
||||||
|
$("sb-refs")!,
|
||||||
|
refs.map((r: any) => ({ k: r.ref, v: Number(r.count) || 0 })),
|
||||||
|
);
|
||||||
|
$("sb-refs-block")!.hidden = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const dialog = document.querySelector<HTMLDialogElement>("[data-stats-dialog]");
|
||||||
|
const open = document.querySelector<HTMLButtonElement>("[data-stats-open]");
|
||||||
|
const close = document.querySelector<HTMLButtonElement>("[data-stats-close]");
|
||||||
|
|
||||||
|
const openWall = hashDialog({
|
||||||
|
name: "stats",
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
|
||||||
load();
|
load();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -110,7 +110,7 @@ export const GET: APIRoute = async () => {
|
|||||||
const env = getEnv();
|
const env = getEnv();
|
||||||
const headers = {
|
const headers = {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"Cache-Control": "public, max-age=30, s-maxage=60, stale-while-revalidate=300",
|
"Cache-Control": "public, max-age=120, s-maxage=180, stale-while-revalidate=600",
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -32,7 +32,14 @@ export const GET: APIRoute = async () => {
|
|||||||
headers: { "content-type": "application/json", "cache-control": "public, max-age=300" },
|
headers: { "content-type": "application/json", "cache-control": "public, max-age=300" },
|
||||||
});
|
});
|
||||||
|
|
||||||
const hidden = { configured: false, pageviews: 0, visitors: 0 };
|
const hidden = {
|
||||||
|
configured: false,
|
||||||
|
pageviews: 0,
|
||||||
|
visitors: 0,
|
||||||
|
visits: 0,
|
||||||
|
topPages: [] as { path: string; count: number }[],
|
||||||
|
topReferrers: [] as { ref: string; count: number }[],
|
||||||
|
};
|
||||||
if (!(shareId || (id && key))) return json(hidden);
|
if (!(shareId || (id && key))) return json(hidden);
|
||||||
|
|
||||||
const headers: Record<string, string> = { accept: "application/json" };
|
const headers: Record<string, string> = { accept: "application/json" };
|
||||||
@ -56,15 +63,29 @@ export const GET: APIRoute = async () => {
|
|||||||
|
|
||||||
if (!websiteId) return json({ ...hidden, configured: true });
|
if (!websiteId) return json({ ...hidden, configured: true });
|
||||||
|
|
||||||
const url = `${base}/websites/${websiteId}/stats?startAt=0&endAt=${Date.now()}`;
|
const w = `${base}/websites/${websiteId}`;
|
||||||
const res = await fetch(url, { headers });
|
const range = `startAt=0&endAt=${Date.now()}`;
|
||||||
const data: any = await res.json();
|
const get = (path: string) => fetch(`${w}/${path}`, { headers }).then((r) => r.json());
|
||||||
|
|
||||||
const pageviews = num(data?.pageviews);
|
const [data, pages, refs] = await Promise.all([
|
||||||
const visitors = num(data?.visitors);
|
get(`stats?${range}`),
|
||||||
|
get(`metrics?${range}&type=path&limit=6`).catch(() => []),
|
||||||
|
get(`metrics?${range}&type=referrer&limit=6`).catch(() => []),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const pageviews = num((data as any)?.pageviews);
|
||||||
|
const visitors = num((data as any)?.visitors);
|
||||||
|
const visits = num((data as any)?.visits);
|
||||||
if (!pageviews && !visitors) return json({ ...hidden, configured: true });
|
if (!pageviews && !visitors) return json({ ...hidden, configured: true });
|
||||||
|
|
||||||
return json({ configured: true, pageviews, visitors });
|
const topPages = (Array.isArray(pages) ? pages : [])
|
||||||
|
.map((m: any) => ({ path: String(m?.x ?? ""), count: num(m?.y) }))
|
||||||
|
.filter((p) => p.path);
|
||||||
|
const topReferrers = (Array.isArray(refs) ? refs : [])
|
||||||
|
.map((m: any) => ({ ref: String(m?.x ?? ""), count: num(m?.y) }))
|
||||||
|
.filter((r) => r.ref);
|
||||||
|
|
||||||
|
return json({ configured: true, pageviews, visitors, visits, topPages, topReferrers });
|
||||||
} catch {
|
} catch {
|
||||||
return json({ ...hidden, configured: true });
|
return json({ ...hidden, configured: true });
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user