skeleton loader

This commit is contained in:
zoop 2026-09-09 20:09:07 -04:00
parent 276a266977
commit 6d38676a1b
No known key found for this signature in database
GPG Key ID: 15C0D336C9E08F3F
12 changed files with 206 additions and 56 deletions

View File

@ -55,6 +55,10 @@ const zone = fmt({ timeZoneName: "short" }).find((p) => p.type === "timeZoneName
letter-spacing: 0.1em; letter-spacing: 0.1em;
color: var(--faint); color: var(--faint);
} }
.clock__mer {
margin-left: auto;
padding-left: 0.8ch;
}
</style> </style>
<script> <script>

View File

@ -2,7 +2,8 @@
import { Icon } from "astro-icon/components"; import { Icon } from "astro-icon/components";
--- ---
<div class="music" id="music"> <div class="music" id="music" data-state="loading">
<span class="chip-skel chip-skel--music" aria-hidden="true"></span>
<button <button
class="music__trigger" class="music__trigger"
type="button" type="button"
@ -21,6 +22,7 @@ import { Icon } from "astro-icon/components";
hidden hidden
/> />
<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>
<Icon class="music__logo" name="ph:spotify-logo" aria-hidden="true" />
<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">now playing</span>
<span class="music__sub" id="music-sub" aria-hidden="true"></span> <span class="music__sub" id="music-sub" aria-hidden="true"></span>
@ -78,9 +80,6 @@ import { Icon } from "astro-icon/components";
<style is:global> <style is:global>
.music {
margin-top: var(--gap);
}
.music__trigger { .music__trigger {
position: relative; position: relative;
display: inline-flex; display: inline-flex;
@ -135,6 +134,31 @@ import { Icon } from "astro-icon/components";
flex: none; flex: none;
padding: 0 0.1rem; padding: 0 0.1rem;
} }
.music__logo {
display: none;
width: 1em;
height: 1em;
flex: none;
color: var(--faint);
}
[data-state="idle"] .music__logo {
display: inline-flex;
}
[data-state="idle"] .music__art,
[data-state="idle"] .music__eq,
[data-state="idle"] .music__arw,
[data-state="idle"] .music__sub,
[data-state="idle"] .music__bar {
display: none;
}
[data-state="idle"] .music__trigger {
cursor: default;
color: var(--faint);
}
[data-state="idle"] .music__trigger:hover {
border-color: var(--line);
}
.music__eq i { .music__eq i {
width: 2.5px; width: 2.5px;
height: 28%; height: 28%;
@ -216,6 +240,8 @@ import { Icon } from "astro-icon/components";
white-space: nowrap; white-space: nowrap;
} }
.music__arw { .music__arw {
margin-left: auto;
padding-left: 0.8ch;
color: var(--faint); color: var(--faint);
transition: transform 0.22s cubic-bezier(0.16, 1, 0.3, 1); transition: transform 0.22s cubic-bezier(0.16, 1, 0.3, 1);
} }
@ -508,7 +534,7 @@ import { Icon } from "astro-icon/components";
const openWall = hashDialog({ const openWall = hashDialog({
name: "music", name: "music",
dialog, dialog,
canOpen: () => !!open && !open.hidden, canOpen: () => !!open && !open.hidden && !open.disabled,
}); });
open?.addEventListener("click", () => (openWall ? openWall() : dialog?.showModal())); open?.addEventListener("click", () => (openWall ? openWall() : dialog?.showModal()));

View File

@ -2,7 +2,8 @@
--- ---
<div class="reading" id="books"> <div class="reading" id="books" data-state="loading">
<span class="chip-skel chip-skel--reading" aria-hidden="true"></span>
<button <button
class="reading__trigger" class="reading__trigger"
type="button" type="button"
@ -173,6 +174,8 @@
opacity: 1; opacity: 1;
} }
.reading__arw { .reading__arw {
margin-left: auto;
padding-left: 0.8ch;
color: var(--faint); color: var(--faint);
transition: transform 0.22s cubic-bezier(0.16, 1, 0.3, 1); transition: transform 0.22s cubic-bezier(0.16, 1, 0.3, 1);
} }

View File

@ -2,7 +2,8 @@
import { Icon } from "astro-icon/components"; import { Icon } from "astro-icon/components";
--- ---
<div class="stats" id="stats"> <div class="stats" id="stats" data-state="loading">
<span class="chip-skel chip-skel--stats" aria-hidden="true"></span>
<button <button
class="stats__trigger" class="stats__trigger"
type="button" type="button"
@ -102,6 +103,8 @@ import { Icon } from "astro-icon/components";
color: var(--ink); color: var(--ink);
} }
.stats__unit { .stats__unit {
margin-left: auto;
padding-left: 0.8ch;
font-size: 0.62em; font-size: 0.62em;
letter-spacing: 0.1em; letter-spacing: 0.1em;
color: var(--faint); color: var(--faint);
@ -284,13 +287,17 @@ import { Icon } from "astro-icon/components";
.join(""); .join("");
} }
async function load(): Promise<void> { async function load(): Promise<boolean> {
let d: any; let d: any;
try { try {
const r = await fetch("/api/stats.json", { headers: { accept: "application/json" } }); const mm = (window as any).__mm?.stats as Promise<any> | undefined;
d = await r.json(); d = mm
? await mm
: await fetch("/api/stats.json", { headers: { accept: "application/json" } }).then((r) =>
r.json(),
);
} catch { } catch {
return; return false;
} }
if ( if (
(!d || !d.configured || (!d.pageviews && !d.visitors)) && (!d || !d.configured || (!d.pageviews && !d.visitors)) &&
@ -311,11 +318,11 @@ import { Icon } from "astro-icon/components";
{ ref: "news.ycombinator.com", count: 880 }, { ref: "news.ycombinator.com", count: 880 },
], ],
}; };
if (!d || !d.configured || (!d.pageviews && !d.visitors)) return; if (!d || !d.configured || (!d.pageviews && !d.visitors)) return false;
const trigger = document.querySelector<HTMLButtonElement>(".stats__trigger"); const trigger = document.querySelector<HTMLButtonElement>(".stats__trigger");
const reel = $("stats-reel"); const reel = $("stats-reel");
if (!trigger || !reel) return; if (!trigger || !reel) return false;
const pv = Math.max(0, Math.round(Number(d.pageviews) || 0)); const pv = Math.max(0, Math.round(Number(d.pageviews) || 0));
reel.textContent = fmt(pv); reel.textContent = fmt(pv);
@ -344,6 +351,7 @@ import { Icon } from "astro-icon/components";
); );
$("sb-refs-block")!.hidden = false; $("sb-refs-block")!.hidden = false;
} }
return true;
} }
const dialog = document.querySelector<HTMLDialogElement>("[data-stats-dialog]"); const dialog = document.querySelector<HTMLDialogElement>("[data-stats-dialog]");
@ -362,5 +370,8 @@ import { Icon } from "astro-icon/components";
if (e.target === dialog) dialog.close(); if (e.target === dialog) dialog.close();
}); });
load(); const host = document.getElementById("stats");
load()
.then((shown) => host?.setAttribute("data-state", shown ? "ready" : "empty"))
.catch(() => host?.setAttribute("data-state", "empty"));
</script> </script>

View File

@ -37,6 +37,14 @@ const kaoCount = kaoMs > 0 ? `${Math.floor(kaoMs / 86_400_000)}d` : "in effect";
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="color-scheme" content="dark" /> <meta name="color-scheme" content="dark" />
<meta name="theme-color" content="#1b1a18" /> <meta name="theme-color" content="#1b1a18" />
{
path === "/" && (
<>
<link rel="preconnect" href="https://assets.hardcover.app" crossorigin />
<link rel="preconnect" href="https://i.scdn.co" crossorigin />
</>
)
}
<script is:inline> <script is:inline>
try { try {
if (localStorage.getItem("kao-ribbon") === "off") if (localStorage.getItem("kao-ribbon") === "off")

View File

@ -90,7 +90,10 @@ export const GET: APIRoute = async () => {
const json = (data: unknown, status = 200) => const json = (data: unknown, status = 200) =>
new Response(JSON.stringify(data), { new Response(JSON.stringify(data), {
status, status,
headers: { "content-type": "application/json", "cache-control": "public, max-age=300" }, headers: {
"content-type": "application/json",
"cache-control": "public, max-age=300, s-maxage=1800, stale-while-revalidate=86400",
},
}); });
if (!token) return json(empty); if (!token) return json(empty);

View File

@ -64,12 +64,14 @@ function mapTrack(t: any): Track {
} }
async function nowPlaying(token: string): Promise<NowPlaying | null> { async function nowPlaying(token: string): Promise<NowPlaying | null> {
let res = await fetch(`${API}/me/player/currently-playing`, { const h = { Authorization: `Bearer ${token}` };
headers: { Authorization: `Bearer ${token}` }, const [curRes, recRes] = await Promise.all([
}); fetch(`${API}/me/player/currently-playing`, { headers: h }),
fetch(`${API}/me/player/recently-played?limit=1`, { headers: h }),
]);
if (res.status === 200) { if (curRes.status === 200) {
const json = (await res.json()) as any; const json = (await curRes.json()) as any;
if (json?.item) { if (json?.item) {
return { return {
...mapTrack(json.item), ...mapTrack(json.item),
@ -81,11 +83,8 @@ async function nowPlaying(token: string): Promise<NowPlaying | null> {
} }
} }
res = await fetch(`${API}/me/player/recently-played?limit=1`, { if (!recRes.ok) return null;
headers: { Authorization: `Bearer ${token}` }, const json = (await recRes.json()) as any;
});
if (!res.ok) return null;
const json = (await res.json()) as any;
const item = json?.items?.[0]; const item = json?.items?.[0];
if (!item?.track) return null; if (!item?.track) return null;
return { return {

View File

@ -29,7 +29,10 @@ export const GET: APIRoute = async () => {
const json = (data: unknown, status = 200) => const json = (data: unknown, status = 200) =>
new Response(JSON.stringify(data), { new Response(JSON.stringify(data), {
status, status,
headers: { "content-type": "application/json", "cache-control": "public, max-age=300" }, headers: {
"content-type": "application/json",
"cache-control": "public, max-age=600, s-maxage=3600, stale-while-revalidate=86400",
},
}); });
const hidden = { const hidden = {

View File

@ -10,6 +10,19 @@ import { site, elsewhere } from "../data/site";
--- ---
<Base> <Base>
<script is:inline>
window.__mm = {};
for (const k of ["spotify", "reading", "stats"]) {
window.__mm[k] = fetch("/api/" + k + ".json", { headers: { accept: "application/json" } })
.then(function (r) {
return r.json();
})
.catch(function () {
return null;
});
}
</script>
<section class="intro"> <section class="intro">
<h1 class="sr-only">{site.name} — {site.tagline}</h1> <h1 class="sr-only">{site.name} — {site.tagline}</h1>
<p class="bio">{site.bio}</p> <p class="bio">{site.bio}</p>
@ -39,14 +52,10 @@ import { site, elsewhere } from "../data/site";
<section class="moreme" id="more-about-me" aria-label="more about me"> <section class="moreme" id="more-about-me" aria-label="more about me">
<h2 class="label moreme__label">more about me</h2> <h2 class="label moreme__label">more about me</h2>
<div class="foot"> <div class="mm-grid">
<Music /> <Music />
<Reading /> <Reading />
</div>
<div class="foot">
<Stats /> <Stats />
</div>
<div class="foot foot--clock">
<Clock /> <Clock />
</div> </div>
</section> </section>

View File

@ -131,16 +131,22 @@ function mockData() {
}; };
} }
async function load(): Promise<void> { type MusicState = "ready" | "idle" | "off";
async function load(): Promise<MusicState> {
let data: any; let data: any;
try { try {
const res = await fetch("/api/spotify.json", { headers: { accept: "application/json" } }); const mm = (window as any).__mm?.spotify as Promise<any> | undefined;
data = await res.json(); data = mm
? await mm
: await fetch("/api/spotify.json", { headers: { accept: "application/json" } }).then((r) =>
r.json(),
);
} catch { } catch {
return; return "idle";
} }
if ((!data || !data.configured) && location.search.includes("mockmusic")) data = mockData(); if (location.search.includes("mockmusic")) data = mockData();
if (!data || !data.configured) return; if (!data || !data.configured) return "off";
if (data.nowPlaying) renderNowPlaying(data.nowPlaying as NP); if (data.nowPlaying) renderNowPlaying(data.nowPlaying as NP);
if (Array.isArray(data.top)) renderTop(data.top as Track[]); if (Array.isArray(data.top)) renderTop(data.top as Track[]);
@ -180,7 +186,24 @@ async function load(): Promise<void> {
if (sub) sub.textContent = "last 4 weeks"; if (sub) sub.textContent = "last 4 weeks";
} }
} }
return "ready";
} }
return "idle";
} }
load(); const host = document.getElementById("music");
const apply = (state: MusicState) => {
host?.setAttribute("data-state", state);
if (state === "idle") {
const trigger = document.querySelector<HTMLButtonElement>(".music__trigger");
if (trigger) {
trigger.hidden = false;
trigger.disabled = true;
const label = document.getElementById("music-label");
if (label) label.textContent = "not playing";
}
}
};
load()
.then(apply)
.catch(() => apply("idle"));

View File

@ -28,20 +28,24 @@ function bookCard(b: Book, mini = false): string {
)}"><span class="rbook__frame">${inner}${prog}</span>${meta}</a>`; )}"><span class="rbook__frame">${inner}${prog}</span>${meta}</a>`;
} }
async function load(): Promise<void> { async function load(): Promise<boolean> {
let data: any; let data: any;
try { try {
const res = await fetch("/api/reading.json", { headers: { accept: "application/json" } }); const mm = (window as any).__mm?.reading as Promise<any> | undefined;
data = await res.json(); data = mm
? await mm
: await fetch("/api/reading.json", { headers: { accept: "application/json" } }).then((r) =>
r.json(),
);
} catch { } catch {
return; return false;
} }
if (!data || !data.configured) return; if (!data || !data.configured) return false;
const current: Book[] = Array.isArray(data.current) ? data.current : []; const current: Book[] = Array.isArray(data.current) ? data.current : [];
const want: Book[] = Array.isArray(data.want) ? data.want : []; const want: Book[] = Array.isArray(data.want) ? data.want : [];
const recent: Book[] = Array.isArray(data.recent) ? data.recent : []; const recent: Book[] = Array.isArray(data.recent) ? data.recent : [];
if (!current.length && !want.length && !recent.length) return; if (!current.length && !want.length && !recent.length) return false;
if (current.length) { if (current.length) {
const block = $("reading-current"); const block = $("reading-current");
@ -75,7 +79,7 @@ async function load(): Promise<void> {
} }
const trigger = document.querySelector<HTMLButtonElement>(".reading__trigger"); const trigger = document.querySelector<HTMLButtonElement>(".reading__trigger");
if (!trigger) return; if (!trigger) return false;
trigger.hidden = false; trigger.hidden = false;
const lead = current[0] || recent[0] || want[0]; const lead = current[0] || recent[0] || want[0];
@ -107,6 +111,10 @@ async function load(): Promise<void> {
if (sub) sub.textContent = `${lead.title} · ${lead.author}`; if (sub) sub.textContent = `${lead.title} · ${lead.author}`;
} }
} }
return true;
} }
load(); const host = document.getElementById("books");
load()
.then((shown) => host?.setAttribute("data-state", shown ? "ready" : "empty"))
.catch(() => host?.setAttribute("data-state", "empty"));

View File

@ -436,18 +436,71 @@ img {
letter-spacing: 0.05em; letter-spacing: 0.05em;
} }
.foot { .mm-grid {
display: flex; width: min(30rem, 100%);
flex-wrap: wrap; display: grid;
align-items: center; grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.55rem 0.8rem; gap: 0.6rem;
} }
.foot > .music, .mm-grid > * {
.foot > .reading { min-width: 0;
margin-top: 0;
} }
.foot + .foot { .mm-grid > script {
margin-top: 0.7rem; display: none;
}
.mm-grid > *[data-state="empty"],
.mm-grid > *[data-state="off"] {
display: none;
}
.music__trigger,
.reading__trigger,
.stats__trigger,
.clock {
width: 100%;
}
.chip-skel {
display: none;
width: 100%;
height: 2.05rem;
border-radius: 8px;
background: var(--paper-2);
border: 1px solid var(--line);
position: relative;
overflow: hidden;
}
[data-state="loading"] > .chip-skel {
display: block;
}
[data-state="loading"] > .music__trigger,
[data-state="loading"] > .reading__trigger,
[data-state="loading"] > .stats__trigger {
display: none;
}
.chip-skel::after {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(
90deg,
transparent,
rgba(255, 255, 255, 0.045),
transparent
);
transform: translateX(-100%);
animation: chip-skel 1.5s ease-in-out infinite;
}
@keyframes chip-skel {
to {
transform: translateX(100%);
}
}
@media (prefers-reduced-motion: reduce) {
.chip-skel::after {
animation: none;
opacity: 0.4;
}
} }
.colophon { .colophon {