skeleton loader
This commit is contained in:
parent
276a266977
commit
6d38676a1b
@ -55,6 +55,10 @@ const zone = fmt({ timeZoneName: "short" }).find((p) => p.type === "timeZoneName
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--faint);
|
||||
}
|
||||
.clock__mer {
|
||||
margin-left: auto;
|
||||
padding-left: 0.8ch;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
|
||||
@ -2,7 +2,8 @@
|
||||
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
|
||||
class="music__trigger"
|
||||
type="button"
|
||||
@ -21,6 +22,7 @@ import { Icon } from "astro-icon/components";
|
||||
hidden
|
||||
/>
|
||||
<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__label" id="music-label">now playing</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>
|
||||
|
||||
.music {
|
||||
margin-top: var(--gap);
|
||||
}
|
||||
.music__trigger {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
@ -135,6 +134,31 @@ import { Icon } from "astro-icon/components";
|
||||
flex: none;
|
||||
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 {
|
||||
width: 2.5px;
|
||||
height: 28%;
|
||||
@ -216,6 +240,8 @@ import { Icon } from "astro-icon/components";
|
||||
white-space: nowrap;
|
||||
}
|
||||
.music__arw {
|
||||
margin-left: auto;
|
||||
padding-left: 0.8ch;
|
||||
color: var(--faint);
|
||||
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({
|
||||
name: "music",
|
||||
dialog,
|
||||
canOpen: () => !!open && !open.hidden,
|
||||
canOpen: () => !!open && !open.hidden && !open.disabled,
|
||||
});
|
||||
|
||||
open?.addEventListener("click", () => (openWall ? openWall() : dialog?.showModal()));
|
||||
|
||||
@ -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
|
||||
class="reading__trigger"
|
||||
type="button"
|
||||
@ -173,6 +174,8 @@
|
||||
opacity: 1;
|
||||
}
|
||||
.reading__arw {
|
||||
margin-left: auto;
|
||||
padding-left: 0.8ch;
|
||||
color: var(--faint);
|
||||
transition: transform 0.22s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
@ -2,7 +2,8 @@
|
||||
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
|
||||
class="stats__trigger"
|
||||
type="button"
|
||||
@ -102,6 +103,8 @@ import { Icon } from "astro-icon/components";
|
||||
color: var(--ink);
|
||||
}
|
||||
.stats__unit {
|
||||
margin-left: auto;
|
||||
padding-left: 0.8ch;
|
||||
font-size: 0.62em;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--faint);
|
||||
@ -284,13 +287,17 @@ import { Icon } from "astro-icon/components";
|
||||
.join("");
|
||||
}
|
||||
|
||||
async function load(): Promise<void> {
|
||||
async function load(): Promise<boolean> {
|
||||
let d: any;
|
||||
try {
|
||||
const r = await fetch("/api/stats.json", { headers: { accept: "application/json" } });
|
||||
d = await r.json();
|
||||
const mm = (window as any).__mm?.stats as Promise<any> | undefined;
|
||||
d = mm
|
||||
? await mm
|
||||
: await fetch("/api/stats.json", { headers: { accept: "application/json" } }).then((r) =>
|
||||
r.json(),
|
||||
);
|
||||
} catch {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
(!d || !d.configured || (!d.pageviews && !d.visitors)) &&
|
||||
@ -311,11 +318,11 @@ import { Icon } from "astro-icon/components";
|
||||
{ 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 reel = $("stats-reel");
|
||||
if (!trigger || !reel) return;
|
||||
if (!trigger || !reel) return false;
|
||||
|
||||
const pv = Math.max(0, Math.round(Number(d.pageviews) || 0));
|
||||
reel.textContent = fmt(pv);
|
||||
@ -344,6 +351,7 @@ import { Icon } from "astro-icon/components";
|
||||
);
|
||||
$("sb-refs-block")!.hidden = false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const dialog = document.querySelector<HTMLDialogElement>("[data-stats-dialog]");
|
||||
@ -362,5 +370,8 @@ import { Icon } from "astro-icon/components";
|
||||
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>
|
||||
|
||||
@ -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" />
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<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>
|
||||
try {
|
||||
if (localStorage.getItem("kao-ribbon") === "off")
|
||||
|
||||
@ -90,7 +90,10 @@ export const GET: APIRoute = async () => {
|
||||
const json = (data: unknown, status = 200) =>
|
||||
new Response(JSON.stringify(data), {
|
||||
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);
|
||||
|
||||
@ -64,12 +64,14 @@ function mapTrack(t: any): Track {
|
||||
}
|
||||
|
||||
async function nowPlaying(token: string): Promise<NowPlaying | null> {
|
||||
let res = await fetch(`${API}/me/player/currently-playing`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
const h = { 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) {
|
||||
const json = (await res.json()) as any;
|
||||
if (curRes.status === 200) {
|
||||
const json = (await curRes.json()) as any;
|
||||
if (json?.item) {
|
||||
return {
|
||||
...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`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const json = (await res.json()) as any;
|
||||
if (!recRes.ok) return null;
|
||||
const json = (await recRes.json()) as any;
|
||||
const item = json?.items?.[0];
|
||||
if (!item?.track) return null;
|
||||
return {
|
||||
|
||||
@ -29,7 +29,10 @@ export const GET: APIRoute = async () => {
|
||||
const json = (data: unknown, status = 200) =>
|
||||
new Response(JSON.stringify(data), {
|
||||
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 = {
|
||||
|
||||
@ -10,6 +10,19 @@ import { site, elsewhere } from "../data/site";
|
||||
---
|
||||
|
||||
<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">
|
||||
<h1 class="sr-only">{site.name} — {site.tagline}</h1>
|
||||
<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">
|
||||
<h2 class="label moreme__label">more about me</h2>
|
||||
<div class="foot">
|
||||
<div class="mm-grid">
|
||||
<Music />
|
||||
<Reading />
|
||||
</div>
|
||||
<div class="foot">
|
||||
<Stats />
|
||||
</div>
|
||||
<div class="foot foot--clock">
|
||||
<Clock />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@ -131,16 +131,22 @@ function mockData() {
|
||||
};
|
||||
}
|
||||
|
||||
async function load(): Promise<void> {
|
||||
type MusicState = "ready" | "idle" | "off";
|
||||
|
||||
async function load(): Promise<MusicState> {
|
||||
let data: any;
|
||||
try {
|
||||
const res = await fetch("/api/spotify.json", { headers: { accept: "application/json" } });
|
||||
data = await res.json();
|
||||
const mm = (window as any).__mm?.spotify as Promise<any> | undefined;
|
||||
data = mm
|
||||
? await mm
|
||||
: await fetch("/api/spotify.json", { headers: { accept: "application/json" } }).then((r) =>
|
||||
r.json(),
|
||||
);
|
||||
} catch {
|
||||
return;
|
||||
return "idle";
|
||||
}
|
||||
if ((!data || !data.configured) && location.search.includes("mockmusic")) data = mockData();
|
||||
if (!data || !data.configured) return;
|
||||
if (location.search.includes("mockmusic")) data = mockData();
|
||||
if (!data || !data.configured) return "off";
|
||||
if (data.nowPlaying) renderNowPlaying(data.nowPlaying as NP);
|
||||
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";
|
||||
}
|
||||
}
|
||||
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"));
|
||||
|
||||
@ -28,20 +28,24 @@ function bookCard(b: Book, mini = false): string {
|
||||
)}"><span class="rbook__frame">${inner}${prog}</span>${meta}</a>`;
|
||||
}
|
||||
|
||||
async function load(): Promise<void> {
|
||||
async function load(): Promise<boolean> {
|
||||
let data: any;
|
||||
try {
|
||||
const res = await fetch("/api/reading.json", { headers: { accept: "application/json" } });
|
||||
data = await res.json();
|
||||
const mm = (window as any).__mm?.reading as Promise<any> | undefined;
|
||||
data = mm
|
||||
? await mm
|
||||
: await fetch("/api/reading.json", { headers: { accept: "application/json" } }).then((r) =>
|
||||
r.json(),
|
||||
);
|
||||
} 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 want: Book[] = Array.isArray(data.want) ? data.want : [];
|
||||
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) {
|
||||
const block = $("reading-current");
|
||||
@ -75,7 +79,7 @@ async function load(): Promise<void> {
|
||||
}
|
||||
|
||||
const trigger = document.querySelector<HTMLButtonElement>(".reading__trigger");
|
||||
if (!trigger) return;
|
||||
if (!trigger) return false;
|
||||
trigger.hidden = false;
|
||||
|
||||
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}`;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
load();
|
||||
const host = document.getElementById("books");
|
||||
load()
|
||||
.then((shown) => host?.setAttribute("data-state", shown ? "ready" : "empty"))
|
||||
.catch(() => host?.setAttribute("data-state", "empty"));
|
||||
|
||||
@ -436,18 +436,71 @@ img {
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.foot {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.55rem 0.8rem;
|
||||
.mm-grid {
|
||||
width: min(30rem, 100%);
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.6rem;
|
||||
}
|
||||
.foot > .music,
|
||||
.foot > .reading {
|
||||
margin-top: 0;
|
||||
.mm-grid > * {
|
||||
min-width: 0;
|
||||
}
|
||||
.mm-grid > script {
|
||||
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;
|
||||
}
|
||||
.foot + .foot {
|
||||
margin-top: 0.7rem;
|
||||
}
|
||||
|
||||
.colophon {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user