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}`}
|
||||
>
|
||||
<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__time" id="clock-time">{hour}:{minute}</span>
|
||||
<span class="clock__mer" id="clock-mer">{mer}</span>
|
||||
<span class="clock__zone" id="clock-zone">{zone}</span>
|
||||
</div>
|
||||
@ -36,82 +28,33 @@ const zone = fmt({ timeZoneName: "short" }).find((p) => p.type === "timeZoneName
|
||||
<style>
|
||||
.clock {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.6ch;
|
||||
padding: 0.42rem 0.8rem;
|
||||
align-items: baseline;
|
||||
gap: 0.55ch;
|
||||
padding: 0.36rem 0.7rem;
|
||||
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-size: 0.82rem;
|
||||
letter-spacing: 0.04em;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.clock__ico {
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
width: 0.95em;
|
||||
height: 0.95em;
|
||||
flex: none;
|
||||
color: var(--faint);
|
||||
margin-right: 0.1ch;
|
||||
align-self: center;
|
||||
}
|
||||
.clock__time {
|
||||
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.25;
|
||||
}
|
||||
color: var(--ink);
|
||||
}
|
||||
.clock__mer,
|
||||
.clock__zone {
|
||||
font-size: 0.72em;
|
||||
letter-spacing: 0.14em;
|
||||
font-size: 0.82em;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--faint);
|
||||
}
|
||||
.clock__mer {
|
||||
margin-left: 0.1ch;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.clock__sep {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
@ -128,17 +71,13 @@ const zone = fmt({ timeZoneName: "short" }).find((p) => p.type === "timeZoneName
|
||||
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 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)}`;
|
||||
timeEl.textContent = `${h}:${m}`;
|
||||
if (merEl) merEl.textContent = mer;
|
||||
const z = zoneFmt.formatToParts(d).find((x) => x.type === "timeZoneName")?.value;
|
||||
if (zoneEl && z) zoneEl.textContent = z;
|
||||
|
||||
@ -1,57 +1,15 @@
|
||||
---
|
||||
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:list={["music__trigger", { "music__trigger--live": mLive }]}
|
||||
class="music__trigger"
|
||||
type="button"
|
||||
data-music-open
|
||||
aria-haspopup="dialog"
|
||||
data-umami-event="music-open"
|
||||
title={mTitleAttr}
|
||||
hidden={!hasData}
|
||||
hidden
|
||||
>
|
||||
<img
|
||||
class="music__art"
|
||||
@ -60,18 +18,15 @@ const mTitleAttr = np ? `${np.title} — ${np.artist}` : undefined;
|
||||
width="42"
|
||||
height="42"
|
||||
decoding="async"
|
||||
src={mArt || undefined}
|
||||
hidden={!mArt}
|
||||
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">{mLabel}</span>
|
||||
<span class="music__sub" id="music-sub" aria-hidden="true">{mSub}</span>
|
||||
<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={mPct == null}>
|
||||
<i style={mPct == null ? undefined : `--p:${mPct}%`}></i>
|
||||
</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">
|
||||
|
||||
@ -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">
|
||||
@ -51,7 +9,7 @@ const rPct = current.length && lead?.progress != null ? lead.progress : null;
|
||||
data-reading-open
|
||||
aria-haspopup="dialog"
|
||||
data-umami-event="reading-open"
|
||||
hidden={!hasData}
|
||||
hidden
|
||||
>
|
||||
<img
|
||||
class="reading__cover"
|
||||
@ -60,23 +18,15 @@ const rPct = current.length && lead?.progress != null ? lead.progress : null;
|
||||
width="28"
|
||||
height="40"
|
||||
decoding="async"
|
||||
src={rCover || undefined}
|
||||
hidden={!rCover}
|
||||
hidden
|
||||
/>
|
||||
<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__label" id="reading-label">{rLabel}</span>
|
||||
<span class="reading__sub" id="reading-sub" aria-hidden="true">{rSub}</span>
|
||||
<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={rPct == null}
|
||||
>
|
||||
<i style={rPct == null ? undefined : `--p:${rPct}%`}></i>
|
||||
</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">
|
||||
|
||||
@ -1,135 +1,288 @@
|
||||
---
|
||||
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">
|
||||
<button
|
||||
class="stats__trigger"
|
||||
type="button"
|
||||
data-stats-open
|
||||
aria-haspopup="dialog"
|
||||
data-umami-event="stats-open"
|
||||
hidden
|
||||
>
|
||||
<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__reel" id="stats-reel"></span>
|
||||
<span class="stats__unit">views</span>
|
||||
<span class="stats__sub" id="stats-sub" aria-hidden="true">{ok ? `${fmt(vis)} visitors` : ""}</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>
|
||||
|
||||
<style>
|
||||
.stats {
|
||||
position: relative;
|
||||
<style is:global>
|
||||
|
||||
.stats__trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.6ch;
|
||||
padding: 0.3rem 0.62rem;
|
||||
padding: 0.32rem 0.64rem;
|
||||
background: var(--paper-2);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 7px;
|
||||
overflow: hidden;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-family: var(--mono);
|
||||
font-size: 0.95rem;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
transition:
|
||||
padding 0.22s ease,
|
||||
border-color 0.18s ease;
|
||||
border-color 0.18s ease,
|
||||
color 0.18s ease;
|
||||
}
|
||||
.stats:hover,
|
||||
.stats:focus-within {
|
||||
padding-bottom: 1rem;
|
||||
.stats__trigger:hover,
|
||||
.stats__trigger:focus-visible {
|
||||
border-color: var(--faint);
|
||||
color: var(--ink);
|
||||
}
|
||||
.stats__trigger:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
.stats__ico {
|
||||
width: 0.95em;
|
||||
height: 0.95em;
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
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;
|
||||
color: var(--ink);
|
||||
}
|
||||
.stats__unit {
|
||||
font-size: 0.66rem;
|
||||
letter-spacing: 0.04em;
|
||||
font-size: 0.62em;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--faint);
|
||||
}
|
||||
.stats__sub {
|
||||
position: absolute;
|
||||
left: 0.62rem;
|
||||
right: 0.62rem;
|
||||
bottom: 0.22rem;
|
||||
font-size: 0.62rem;
|
||||
letter-spacing: 0.03em;
|
||||
.stats__arw {
|
||||
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;
|
||||
text-overflow: ellipsis;
|
||||
opacity: 0;
|
||||
transform: translateY(3px);
|
||||
pointer-events: none;
|
||||
transition:
|
||||
opacity 0.18s ease,
|
||||
transform 0.2s ease;
|
||||
white-space: nowrap;
|
||||
color: var(--ink);
|
||||
}
|
||||
.stats:hover .stats__sub,
|
||||
.stats:focus-within .stats__sub {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
.stat-list__v {
|
||||
position: relative;
|
||||
flex: 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>
|
||||
|
||||
<script>
|
||||
import { hashDialog } from "../scripts/hash-dialog.ts";
|
||||
|
||||
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> {
|
||||
let d: any;
|
||||
@ -143,29 +296,71 @@ const cells = [...digits].map((c, i) => {
|
||||
(!d || !d.configured || (!d.pageviews && !d.visitors)) &&
|
||||
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;
|
||||
|
||||
const block = document.getElementById("stats");
|
||||
const reel = document.getElementById("stats-reel");
|
||||
const sub = document.getElementById("stats-sub");
|
||||
if (!block || !reel) return;
|
||||
const trigger = document.querySelector<HTMLButtonElement>(".stats__trigger");
|
||||
const reel = $("stats-reel");
|
||||
if (!trigger || !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("");
|
||||
reel.textContent = fmt(pv);
|
||||
trigger.hidden = false;
|
||||
|
||||
if (sub) sub.textContent = `${fmt(vis)} visitors`;
|
||||
block.hidden = false;
|
||||
const pvEl = $("sb-pv");
|
||||
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();
|
||||
</script>
|
||||
|
||||
@ -110,7 +110,7 @@ export const GET: APIRoute = async () => {
|
||||
const env = getEnv();
|
||||
const headers = {
|
||||
"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 {
|
||||
|
||||
@ -32,7 +32,14 @@ export const GET: APIRoute = async () => {
|
||||
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);
|
||||
|
||||
const headers: Record<string, string> = { accept: "application/json" };
|
||||
@ -56,15 +63,29 @@ export const GET: APIRoute = async () => {
|
||||
|
||||
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 w = `${base}/websites/${websiteId}`;
|
||||
const range = `startAt=0&endAt=${Date.now()}`;
|
||||
const get = (path: string) => fetch(`${w}/${path}`, { headers }).then((r) => r.json());
|
||||
|
||||
const pageviews = num(data?.pageviews);
|
||||
const visitors = num(data?.visitors);
|
||||
const [data, pages, refs] = await Promise.all([
|
||||
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 });
|
||||
|
||||
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 {
|
||||
return json({ ...hidden, configured: true });
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user