change scrobbling to last.fm
This commit is contained in:
parent
6d38676a1b
commit
ae41a40866
@ -6,12 +6,15 @@ ADMIN_PASSWORD=
|
||||
# 32+ random bytes, e.g. `openssl rand -hex 32`
|
||||
SESSION_SECRET=
|
||||
|
||||
# one-time: get a refresh token via the spotify authorization-code flow with
|
||||
# scopes: user-read-currently-playing user-read-recently-played user-top-read
|
||||
# without these the "now playing" / "on repeat" sections just stay hidden.
|
||||
SPOTIFY_CLIENT_ID=
|
||||
SPOTIFY_CLIENT_SECRET=
|
||||
SPOTIFY_REFRESH_TOKEN=
|
||||
# music widget — now playing + weekly scrobble stats from last.fm. reads a PUBLIC
|
||||
# profile, so just a key (last.fm/api/account/create) + your username. no auth.
|
||||
LASTFM_API_KEY=
|
||||
LASTFM_USER=
|
||||
|
||||
# (legacy) spotify — no longer used; the music widget is on last.fm now.
|
||||
# SPOTIFY_CLIENT_ID=
|
||||
# SPOTIFY_CLIENT_SECRET=
|
||||
# SPOTIFY_REFRESH_TOKEN=
|
||||
|
||||
# currently-reading widget — personal token from https://hardcover.app/account/api
|
||||
HARDCOVER_TOKEN=
|
||||
|
||||
BIN
public/badges/brgr.png
Normal file
BIN
public/badges/brgr.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.2 KiB |
@ -314,6 +314,10 @@ const badgeUrl = `https://${site.domain}/badges/zachy.gif`;
|
||||
z-index: 3;
|
||||
transform: translate(var(--x), calc(var(--y) - 5px)) rotate(0deg) scale(1.2);
|
||||
}
|
||||
.badge:hover ~ .badge,
|
||||
.badge:focus-visible ~ .badge {
|
||||
transform: translate(var(--x), calc(var(--y) - 7px)) rotate(var(--t));
|
||||
}
|
||||
.badge:hover::before,
|
||||
.badge:focus-visible::before {
|
||||
top: 3px;
|
||||
|
||||
@ -22,7 +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" />
|
||||
<Icon class="music__logo" name="ph:music-notes" 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>
|
||||
@ -43,37 +43,58 @@ import { Icon } from "astro-icon/components";
|
||||
</div>
|
||||
|
||||
<div class="music-wall__body">
|
||||
<div class="music-block" id="np-section" hidden>
|
||||
<h2 class="label">now playing</h2>
|
||||
<div class="np" id="np">
|
||||
<div class="np__body">
|
||||
<img class="np__art np__art--none" id="np-art" alt="" width="64" height="64" decoding="async" />
|
||||
<div class="np__meta">
|
||||
<div class="np__title" id="np-title">—</div>
|
||||
<div class="np__artist" id="np-artist">—</div>
|
||||
<div class="np__foot">
|
||||
<span class="np__tx" aria-hidden="true">
|
||||
<Icon name="ph:skip-back-fill" />
|
||||
<Icon name="ph:play-fill" />
|
||||
<Icon name="ph:skip-forward-fill" />
|
||||
</span>
|
||||
<span class="np__dur" id="np-dur"></span>
|
||||
<span class="np__ago" id="np-ago"></span>
|
||||
<a class="np__src" id="np-src" href="#" target="_blank" rel="noopener">
|
||||
<Icon class="np__logo" name="ph:spotify-logo-fill" aria-hidden="true" /><span>spotify</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="np__prog" id="np-prog" hidden><div class="np__prog-fill" id="np-prog-fill"></div></div>
|
||||
</div>
|
||||
<a class="np" id="np-src" href="#" target="_blank" rel="noopener" hidden>
|
||||
<img class="np__art np__art--none" id="np-art" alt="" width="60" height="60" decoding="async" />
|
||||
<span class="np__meta">
|
||||
<span class="np__title" id="np-title">—</span>
|
||||
<span class="np__by" id="np-by">—</span>
|
||||
<span class="np__foot">
|
||||
<span class="np__src">
|
||||
<Icon class="np__logo" name="ph:spotify-logo-fill" aria-hidden="true" /><span>spotify</span>
|
||||
</span>
|
||||
<span class="np__ago" id="np-ago"></span>
|
||||
</span>
|
||||
</span>
|
||||
</a>
|
||||
|
||||
<div class="stat-bigs" id="lfm-bigs" hidden>
|
||||
<div class="stat-big">
|
||||
<span class="stat-big__n" id="lfm-week">—</span>
|
||||
<span class="stat-big__k">this week</span>
|
||||
</div>
|
||||
<div class="stat-big">
|
||||
<span class="stat-big__n" id="lfm-all">—</span>
|
||||
<span class="stat-big__k">all‑time</span>
|
||||
</div>
|
||||
<div class="stat-big">
|
||||
<span class="stat-big__n" id="lfm-mins">—</span>
|
||||
<span class="stat-big__k">minutes</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="music-block top" id="top-section" hidden>
|
||||
<h2 class="label">top tracks <span class="label__sub">last 4 weeks</span></h2>
|
||||
<div class="mosaic" id="mosaic"></div>
|
||||
<div class="mosaic__caps" id="mosaic-caps"></div>
|
||||
<p class="lfm-lib" id="lfm-lib" hidden></p>
|
||||
|
||||
<div class="stat-block" id="lfm-top-block" hidden>
|
||||
<h2 class="label">on repeat · this week</h2>
|
||||
<ul class="stat-list" id="lfm-top"></ul>
|
||||
</div>
|
||||
|
||||
<div class="stat-block" id="lfm-artists-block" hidden>
|
||||
<h2 class="label">top artists · this week</h2>
|
||||
<ul class="stat-list" id="lfm-artists"></ul>
|
||||
</div>
|
||||
|
||||
<div class="stat-block" id="lfm-recent-block" hidden>
|
||||
<h2 class="label">recently played</h2>
|
||||
<ul class="stat-list stat-list--plain" id="lfm-recent"></ul>
|
||||
</div>
|
||||
|
||||
<p class="wall-foot">
|
||||
scrobbling <a
|
||||
href="https://www.last.fm/user/zoop-zach"
|
||||
target="_blank"
|
||||
rel="noopener">via last.fm ↗</a>
|
||||
</p>
|
||||
</div>
|
||||
</dialog>
|
||||
</div>
|
||||
@ -304,31 +325,51 @@ import { Icon } from "astro-icon/components";
|
||||
overflow-y: auto;
|
||||
max-height: calc(min(82vh, 40rem) - 2.6rem);
|
||||
}
|
||||
.music-block + .music-block {
|
||||
margin-top: 1.4rem;
|
||||
.music-wall__body .stat-bigs {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.music-block .label {
|
||||
margin-bottom: 0.7rem;
|
||||
.music-wall__body .np[hidden] + .stat-bigs {
|
||||
margin-top: 0;
|
||||
}
|
||||
.lfm-lib {
|
||||
margin: 0.7rem 0 0;
|
||||
font-family: var(--mono);
|
||||
font-size: 0.66rem;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--faint);
|
||||
}
|
||||
.stat-list--plain li::before {
|
||||
display: none;
|
||||
}
|
||||
.stat-list--plain .stat-list__v {
|
||||
color: var(--faint);
|
||||
}
|
||||
|
||||
.np {
|
||||
width: 100%;
|
||||
border: 1px solid var(--faint);
|
||||
background: #151412;
|
||||
}
|
||||
.np__body {
|
||||
display: flex;
|
||||
gap: 0.7rem;
|
||||
padding: 0.65rem;
|
||||
gap: 0.8rem;
|
||||
align-items: center;
|
||||
padding: 0.7rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: #151412;
|
||||
text-decoration: none;
|
||||
transition:
|
||||
border-color 0.18s ease,
|
||||
background 0.18s ease;
|
||||
}
|
||||
.np:hover,
|
||||
.np:focus-visible {
|
||||
border-color: var(--faint);
|
||||
background: #191714;
|
||||
}
|
||||
.np__art {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
flex-shrink: 0;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
flex: none;
|
||||
object-fit: cover;
|
||||
border: 1px solid var(--faint);
|
||||
border-radius: 4px;
|
||||
background: var(--faint);
|
||||
border-radius: 5px;
|
||||
background: var(--line);
|
||||
}
|
||||
.np__art--none {
|
||||
background: linear-gradient(145deg, #2c2a26, #171614);
|
||||
@ -338,21 +379,25 @@ import { Icon } from "astro-icon/components";
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 0.05rem;
|
||||
gap: 0.15rem;
|
||||
font-family: var(--mono);
|
||||
}
|
||||
.np__title {
|
||||
color: #fff;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.35;
|
||||
color: var(--ink);
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.3;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
transition: color 0.18s ease;
|
||||
}
|
||||
.np__artist {
|
||||
.np:hover .np__title,
|
||||
.np:focus-visible .np__title {
|
||||
color: #fff;
|
||||
}
|
||||
.np__by {
|
||||
font-size: 0.72rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.74rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
@ -361,157 +406,41 @@ import { Icon } from "astro-icon/components";
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
margin-top: 0.3rem;
|
||||
margin-top: 0.32rem;
|
||||
font-size: 0.68rem;
|
||||
}
|
||||
.np__tx {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.32rem;
|
||||
color: var(--muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.np__tx svg {
|
||||
display: block;
|
||||
}
|
||||
.np__dur {
|
||||
color: var(--muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.np__ago {
|
||||
color: var(--faint);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.np__src {
|
||||
margin-left: auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
gap: 0.35ch;
|
||||
color: var(--muted);
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.np__src:hover {
|
||||
.np:hover .np__src {
|
||||
color: #fff;
|
||||
}
|
||||
.np__logo {
|
||||
width: 0.95em;
|
||||
height: 0.95em;
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
flex: none;
|
||||
color: #1ed760;
|
||||
flex-shrink: 0;
|
||||
display: block;
|
||||
}
|
||||
.np__prog {
|
||||
height: 2px;
|
||||
background: #2b2825;
|
||||
.np--live .np__logo {
|
||||
animation: np-glow 1.8s ease-in-out infinite;
|
||||
}
|
||||
.np__prog-fill {
|
||||
height: 2px;
|
||||
width: 0%;
|
||||
background: linear-gradient(90deg, rgb(255, 224, 138), rgb(255, 171, 94), rgb(255, 95, 109));
|
||||
transition: width 1s linear;
|
||||
}
|
||||
|
||||
.mosaic {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
grid-template-rows: repeat(3, 1fr);
|
||||
grid-auto-flow: dense;
|
||||
gap: 3px;
|
||||
aspect-ratio: 4 / 3;
|
||||
width: 100%;
|
||||
}
|
||||
.mosaic__cell {
|
||||
position: relative;
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
.mosaic__cell--xl {
|
||||
grid-column: span 2;
|
||||
grid-row: span 2;
|
||||
}
|
||||
.mosaic__cell img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
filter: saturate(0.7) brightness(0.82);
|
||||
transition:
|
||||
filter 0.18s ease,
|
||||
transform 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
.mosaic__cell:hover img,
|
||||
.mosaic__cell:focus-visible img {
|
||||
filter: none;
|
||||
transform: scale(1.08);
|
||||
}
|
||||
.mosaic__caps {
|
||||
position: relative;
|
||||
margin-top: 0.5rem;
|
||||
min-height: 1.35em;
|
||||
font-family: var(--mono);
|
||||
font-size: 0.74rem;
|
||||
}
|
||||
.mosaic__cap {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.55rem;
|
||||
opacity: 0;
|
||||
transition: opacity 0.14s ease;
|
||||
pointer-events: none;
|
||||
white-space: normal;
|
||||
}
|
||||
.mosaic__rank {
|
||||
color: var(--faint);
|
||||
font-variant-numeric: tabular-nums;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.mosaic__title {
|
||||
color: var(--ink);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
flex-shrink: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.mosaic__artist {
|
||||
color: var(--faint);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.mosaic__cap:first-child {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@supports selector(:has(*)) {
|
||||
.top:has(.mosaic__cell:hover) .mosaic__cap,
|
||||
.top:has(.mosaic__cell:focus-visible) .mosaic__cap {
|
||||
opacity: 0;
|
||||
@keyframes np-glow {
|
||||
50% {
|
||||
filter: drop-shadow(0 0 4px rgba(30, 215, 96, 0.8));
|
||||
}
|
||||
.top:has(.mosaic__cell:nth-child(1):hover) .mosaic__cap:nth-child(1),
|
||||
.top:has(.mosaic__cell:nth-child(1):focus-visible) .mosaic__cap:nth-child(1),
|
||||
.top:has(.mosaic__cell:nth-child(2):hover) .mosaic__cap:nth-child(2),
|
||||
.top:has(.mosaic__cell:nth-child(2):focus-visible) .mosaic__cap:nth-child(2),
|
||||
.top:has(.mosaic__cell:nth-child(3):hover) .mosaic__cap:nth-child(3),
|
||||
.top:has(.mosaic__cell:nth-child(3):focus-visible) .mosaic__cap:nth-child(3),
|
||||
.top:has(.mosaic__cell:nth-child(4):hover) .mosaic__cap:nth-child(4),
|
||||
.top:has(.mosaic__cell:nth-child(4):focus-visible) .mosaic__cap:nth-child(4),
|
||||
.top:has(.mosaic__cell:nth-child(5):hover) .mosaic__cap:nth-child(5),
|
||||
.top:has(.mosaic__cell:nth-child(5):focus-visible) .mosaic__cap:nth-child(5),
|
||||
.top:has(.mosaic__cell:nth-child(6):hover) .mosaic__cap:nth-child(6),
|
||||
.top:has(.mosaic__cell:nth-child(6):focus-visible) .mosaic__cap:nth-child(6),
|
||||
.top:has(.mosaic__cell:nth-child(7):hover) .mosaic__cap:nth-child(7),
|
||||
.top:has(.mosaic__cell:nth-child(7):focus-visible) .mosaic__cap:nth-child(7),
|
||||
.top:has(.mosaic__cell:nth-child(8):hover) .mosaic__cap:nth-child(8),
|
||||
.top:has(.mosaic__cell:nth-child(8):focus-visible) .mosaic__cap:nth-child(8),
|
||||
.top:has(.mosaic__cell:nth-child(9):hover) .mosaic__cap:nth-child(9),
|
||||
.top:has(.mosaic__cell:nth-child(9):focus-visible) .mosaic__cap:nth-child(9) {
|
||||
opacity: 1;
|
||||
}
|
||||
.np__ago {
|
||||
margin-left: auto;
|
||||
flex: none;
|
||||
color: var(--faint);
|
||||
white-space: nowrap;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.np--live .np__logo {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -54,7 +54,7 @@ import { Icon } from "astro-icon/components";
|
||||
<ul class="stat-list" id="sb-refs"></ul>
|
||||
</div>
|
||||
|
||||
<p class="stats-wall__foot">
|
||||
<p class="wall-foot">
|
||||
all‑time · via <a
|
||||
href="https://umami.is"
|
||||
target="_blank"
|
||||
@ -174,97 +174,6 @@ import { Icon } from "astro-icon/components";
|
||||
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;
|
||||
white-space: nowrap;
|
||||
color: var(--ink);
|
||||
}
|
||||
.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>
|
||||
|
||||
@ -95,7 +95,7 @@ import { Icon } from "astro-icon/components";
|
||||
display: block;
|
||||
box-sizing: border-box;
|
||||
width: fit-content;
|
||||
min-width: 18rem;
|
||||
min-width: min(18rem, 100%);
|
||||
max-width: 100%;
|
||||
padding: 1rem 1.1rem 0.85rem;
|
||||
border: 1px solid var(--line);
|
||||
|
||||
@ -86,4 +86,5 @@ export const buttons = [
|
||||
{ label: "increment", iframe: "//incr.easrng.net/badge?key=zachy.cc-2026", bg: "//incr.easrng.net/bg.gif", href: null },
|
||||
{ label: "ro.er", img: "/badges/ro-er.gif", still: null, href: "https://retrobloxxer.github.io/" },
|
||||
{ label: "kevadesu", img: "/badges/kevadesu.gif", still: null, href: "https://kevadesu.github.io/" },
|
||||
{ label: "burger", img: "/badges/brgr.png", still: null, href: "https://burgeronthe.net/" },
|
||||
];
|
||||
|
||||
@ -41,7 +41,7 @@ const kaoCount = kaoMs > 0 ? `${Math.floor(kaoMs / 86_400_000)}d` : "in effect";
|
||||
path === "/" && (
|
||||
<>
|
||||
<link rel="preconnect" href="https://assets.hardcover.app" crossorigin />
|
||||
<link rel="preconnect" href="https://i.scdn.co" crossorigin />
|
||||
<link rel="preconnect" href="https://lastfm-img.freetls.fastly.net" crossorigin />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
149
src/pages/api/lastfm.json.ts
Normal file
149
src/pages/api/lastfm.json.ts
Normal file
@ -0,0 +1,149 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import { env as cfEnv } from "cloudflare:workers";
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
|
||||
type Env = Record<string, string | undefined>;
|
||||
|
||||
function getEnv(): Env {
|
||||
const nodeEnv = typeof process !== "undefined" ? process.env : {};
|
||||
let workerEnv: Env = {};
|
||||
try {
|
||||
workerEnv = cfEnv as unknown as Env;
|
||||
} catch {
|
||||
}
|
||||
return { ...nodeEnv, ...workerEnv };
|
||||
}
|
||||
|
||||
const API = "https://ws.audioscrobbler.com/2.0/";
|
||||
const NO_ART = "2a96cbd8b46e442fc41c2b86b821562f";
|
||||
|
||||
function pickArt(images: any): string | null {
|
||||
if (!Array.isArray(images)) return null;
|
||||
const order = ["extralarge", "large", "medium", "small"];
|
||||
for (const size of order) {
|
||||
const u = images.find((x: any) => x?.size === size)?.["#text"];
|
||||
if (u && !u.includes(NO_ART)) return u;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const n = (v: any): number => {
|
||||
const x = Number(v);
|
||||
return Number.isFinite(x) ? x : 0;
|
||||
};
|
||||
|
||||
export const GET: APIRoute = async () => {
|
||||
const e = getEnv();
|
||||
const key = e.LASTFM_API_KEY;
|
||||
const user = e.LASTFM_USER;
|
||||
|
||||
const json = (data: unknown, status = 200) =>
|
||||
new Response(JSON.stringify(data), {
|
||||
status,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"cache-control": "public, max-age=180, s-maxage=300, stale-while-revalidate=1800",
|
||||
},
|
||||
});
|
||||
|
||||
const empty = {
|
||||
configured: false,
|
||||
nowPlaying: null as unknown,
|
||||
top: [] as unknown[],
|
||||
topArtists: [] as unknown[],
|
||||
history: [] as unknown[],
|
||||
weekPlays: 0,
|
||||
allTimePlays: 0,
|
||||
estMinutes: 0,
|
||||
lib: { artists: 0, albums: 0, tracks: 0 },
|
||||
};
|
||||
if (!key || !user) return json(empty);
|
||||
|
||||
const call = (params: string) =>
|
||||
fetch(`${API}?${params}&api_key=${key}&user=${encodeURIComponent(user)}&format=json`, {
|
||||
headers: { "user-agent": "zachy.cc music widget" },
|
||||
}).then((r) => r.json());
|
||||
|
||||
const from = Math.floor(Date.now() / 1000) - 7 * 86400;
|
||||
|
||||
try {
|
||||
const [recent, top, artists, week, info] = await Promise.all([
|
||||
call("method=user.getrecenttracks&limit=8&extended=1"),
|
||||
call("method=user.gettoptracks&period=7day&limit=8"),
|
||||
call("method=user.gettopartists&period=7day&limit=6"),
|
||||
call(`method=user.getrecenttracks&limit=1&from=${from}`),
|
||||
call("method=user.getinfo"),
|
||||
]);
|
||||
|
||||
const rtRaw = (recent as any)?.recenttracks?.track;
|
||||
const rtArr: any[] = Array.isArray(rtRaw) ? rtRaw : rtRaw ? [rtRaw] : [];
|
||||
const rt = rtArr[0];
|
||||
const nowPlaying = rt
|
||||
? {
|
||||
title: rt.name ?? "",
|
||||
artist: rt.artist?.name ?? rt.artist?.["#text"] ?? "",
|
||||
album: rt.album?.["#text"] ?? "",
|
||||
art: pickArt(rt.image),
|
||||
url: rt.url ?? "",
|
||||
isPlaying: rt["@attr"]?.nowplaying === "true",
|
||||
playedAt: rt.date?.uts ? n(rt.date.uts) * 1000 : null,
|
||||
}
|
||||
: null;
|
||||
|
||||
const history = rtArr
|
||||
.filter((t: any) => t["@attr"]?.nowplaying !== "true" && t.date?.uts)
|
||||
.slice(0, 6)
|
||||
.map((t: any) => ({
|
||||
title: t.name ?? "",
|
||||
artist: t.artist?.name ?? t.artist?.["#text"] ?? "",
|
||||
playedAt: n(t.date.uts) * 1000,
|
||||
}))
|
||||
.filter((t: any) => t.title);
|
||||
|
||||
const topTracks = ((top as any)?.toptracks?.track ?? [])
|
||||
.map((t: any) => ({
|
||||
title: t.name ?? "",
|
||||
artist: t.artist?.name ?? "",
|
||||
plays: n(t.playcount),
|
||||
secs: n(t.duration),
|
||||
url: t.url ?? "",
|
||||
}))
|
||||
.filter((t: any) => t.title);
|
||||
|
||||
const topArtists = ((artists as any)?.topartists?.artist ?? [])
|
||||
.map((a: any) => ({
|
||||
name: a.name ?? "",
|
||||
plays: n(a.playcount),
|
||||
url: a.url ?? "",
|
||||
}))
|
||||
.filter((a: any) => a.name);
|
||||
|
||||
const weekPlays = n((week as any)?.recenttracks?.["@attr"]?.total);
|
||||
const u = (info as any)?.user ?? {};
|
||||
const allTimePlays = n(u.playcount);
|
||||
const lib = { artists: n(u.artist_count), albums: n(u.album_count), tracks: n(u.track_count) };
|
||||
|
||||
const durs = topTracks.map((t: any) => t.secs).filter((s: number) => s > 0);
|
||||
const avgSec = durs.length ? durs.reduce((a: number, b: number) => a + b, 0) / durs.length : 210;
|
||||
const estMinutes = Math.round((allTimePlays * avgSec) / 60);
|
||||
|
||||
if (!nowPlaying && !topTracks.length && !weekPlays) {
|
||||
return json({ ...empty, configured: true });
|
||||
}
|
||||
return json({
|
||||
configured: true,
|
||||
nowPlaying,
|
||||
top: topTracks,
|
||||
topArtists,
|
||||
history,
|
||||
weekPlays,
|
||||
allTimePlays,
|
||||
estMinutes,
|
||||
lib,
|
||||
});
|
||||
} catch {
|
||||
return json({ ...empty, configured: true });
|
||||
}
|
||||
};
|
||||
@ -12,7 +12,7 @@ import { site, elsewhere } from "../data/site";
|
||||
<Base>
|
||||
<script is:inline>
|
||||
window.__mm = {};
|
||||
for (const k of ["spotify", "reading", "stats"]) {
|
||||
for (const k of ["lastfm", "reading", "stats"]) {
|
||||
window.__mm[k] = fetch("/api/" + k + ".json", { headers: { accept: "application/json" } })
|
||||
.then(function (r) {
|
||||
return r.json();
|
||||
|
||||
@ -3,17 +3,47 @@ interface Track {
|
||||
title: string;
|
||||
artist: string;
|
||||
url: string;
|
||||
art: string | null;
|
||||
plays?: number;
|
||||
}
|
||||
interface NP extends Track {
|
||||
interface ArtistRow {
|
||||
name: string;
|
||||
plays: number;
|
||||
url: string;
|
||||
}
|
||||
interface HistRow {
|
||||
title: string;
|
||||
artist: string;
|
||||
playedAt: number;
|
||||
}
|
||||
interface NP {
|
||||
title: string;
|
||||
artist: string;
|
||||
album: string;
|
||||
art: string | null;
|
||||
url: string;
|
||||
isPlaying: boolean;
|
||||
progressMs: number | null;
|
||||
durationMs: number | null;
|
||||
playedAt: string | null;
|
||||
playedAt: number | null;
|
||||
}
|
||||
interface Data {
|
||||
configured: boolean;
|
||||
nowPlaying: NP | null;
|
||||
top: Track[];
|
||||
topArtists: ArtistRow[];
|
||||
history: HistRow[];
|
||||
weekPlays: number;
|
||||
allTimePlays: number;
|
||||
estMinutes: number;
|
||||
lib: { artists: number; albums: number; tracks: number };
|
||||
}
|
||||
|
||||
function ago(iso: string): string {
|
||||
const s = Math.max(0, (Date.now() - new Date(iso).getTime()) / 1000);
|
||||
const $ = (id: string) => document.getElementById(id);
|
||||
const esc = (s: string): string =>
|
||||
s.replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]!);
|
||||
const fmt = (n: number): string => new Intl.NumberFormat("en-US").format(n);
|
||||
const clamp = (s: string, n = 22): string => (s.length > n ? s.slice(0, n - 1).trimEnd() + "…" : s);
|
||||
|
||||
function ago(ms: number): string {
|
||||
const s = Math.max(0, (Date.now() - ms) / 1000);
|
||||
if (s < 90) return "just now";
|
||||
const m = s / 60;
|
||||
if (m < 60) return `${Math.round(m)}m ago`;
|
||||
@ -22,20 +52,9 @@ function ago(iso: string): string {
|
||||
return `${Math.round(h / 24)}d ago`;
|
||||
}
|
||||
|
||||
const mmss = (ms: number): string => {
|
||||
const t = Math.round(ms / 1000);
|
||||
return `${Math.floor(t / 60)}:${String(t % 60).padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
const $ = (id: string) => document.getElementById(id);
|
||||
const esc = (s: string): string =>
|
||||
s.replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]!);
|
||||
const clamp = (s: string, n = 22): string =>
|
||||
s.length > n ? s.slice(0, n - 1).trimEnd() + "…" : s;
|
||||
|
||||
function renderNowPlaying(np: NP): void {
|
||||
const section = $("np-section");
|
||||
if (!section) return;
|
||||
const card = $("np-src") as HTMLAnchorElement | null;
|
||||
if (!card) return;
|
||||
|
||||
const art = $("np-art") as HTMLImageElement | null;
|
||||
if (art) {
|
||||
@ -47,99 +66,144 @@ function renderNowPlaying(np: NP): void {
|
||||
art.classList.add("np__art--none");
|
||||
}
|
||||
}
|
||||
|
||||
const title = $("np-title");
|
||||
const artist = $("np-artist");
|
||||
if (title) title.textContent = np.title;
|
||||
if (artist) artist.textContent = np.artist;
|
||||
|
||||
const src = $("np-src") as HTMLAnchorElement | null;
|
||||
if (src && np.url) src.href = np.url;
|
||||
|
||||
const dur = $("np-dur");
|
||||
const by = $("np-by");
|
||||
const agoEl = $("np-ago");
|
||||
const prog = $("np-prog");
|
||||
const fill = $("np-prog-fill") as HTMLElement | null;
|
||||
const total = np.durationMs;
|
||||
if (title) title.textContent = np.title;
|
||||
if (by) by.textContent = np.album ? `${np.artist} · ${np.album}` : np.artist;
|
||||
if (agoEl) agoEl.textContent = np.isPlaying ? "" : np.playedAt ? ago(np.playedAt) : "";
|
||||
|
||||
if (np.isPlaying && np.progressMs != null && total) {
|
||||
if (dur) dur.textContent = `${mmss(np.progressMs)} / ${mmss(total)}`;
|
||||
if (agoEl) agoEl.textContent = "";
|
||||
if (prog) prog.hidden = false;
|
||||
const paint = (p: number): void => {
|
||||
if (fill) fill.style.width = Math.min(100, (p / total) * 100) + "%";
|
||||
};
|
||||
paint(np.progressMs);
|
||||
let p = np.progressMs;
|
||||
const iv = window.setInterval(() => {
|
||||
p += 1000;
|
||||
if (p >= total) {
|
||||
window.clearInterval(iv);
|
||||
return;
|
||||
}
|
||||
paint(p);
|
||||
}, 1000);
|
||||
} else {
|
||||
if (prog) prog.hidden = true;
|
||||
if (dur) dur.textContent = total ? mmss(total) : "";
|
||||
if (agoEl) agoEl.textContent = np.playedAt ? ago(np.playedAt) : "";
|
||||
card.classList.toggle("np--live", np.isPlaying);
|
||||
card.href = `https://open.spotify.com/search/${encodeURIComponent(`${np.title} ${np.artist}`)}`;
|
||||
|
||||
card.hidden = false;
|
||||
}
|
||||
|
||||
interface Row {
|
||||
k: string;
|
||||
sub?: string;
|
||||
v: string;
|
||||
w?: number;
|
||||
}
|
||||
|
||||
function fillList(listId: string, blockId: string, rows: Row[]): void {
|
||||
const list = $(listId);
|
||||
const block = $(blockId);
|
||||
if (!list || !block || !rows.length) return;
|
||||
list.innerHTML = rows
|
||||
.map((r) => {
|
||||
const sub = r.sub ? ` <span class="stat-list__sub">${esc(r.sub)}</span>` : "";
|
||||
return `<li style="--w:${r.w ?? 0}%"><span class="stat-list__k">${esc(
|
||||
r.k,
|
||||
)}${sub}</span><span class="stat-list__v">${r.v}</span></li>`;
|
||||
})
|
||||
.join("");
|
||||
block.hidden = false;
|
||||
}
|
||||
|
||||
function renderStats(d: Data): void {
|
||||
const week = $("lfm-week");
|
||||
const all = $("lfm-all");
|
||||
const mins = $("lfm-mins");
|
||||
const bigs = $("lfm-bigs");
|
||||
if (week) week.textContent = fmt(d.weekPlays);
|
||||
if (all) all.textContent = fmt(d.allTimePlays);
|
||||
if (mins) mins.textContent = fmt(d.estMinutes);
|
||||
if (bigs && (d.weekPlays || d.allTimePlays)) bigs.hidden = false;
|
||||
|
||||
const lib = $("lfm-lib");
|
||||
if (lib && d.lib && (d.lib.artists || d.lib.tracks)) {
|
||||
lib.textContent = `${fmt(d.lib.artists)} artists · ${fmt(d.lib.albums)} albums · ${fmt(
|
||||
d.lib.tracks,
|
||||
)} tracks in the library`;
|
||||
lib.hidden = false;
|
||||
}
|
||||
|
||||
section.hidden = false;
|
||||
const trackMax = d.top.reduce((m, t) => Math.max(m, t.plays ?? 0), 0) || 1;
|
||||
fillList(
|
||||
"lfm-top",
|
||||
"lfm-top-block",
|
||||
d.top.map((t) => ({
|
||||
k: t.title,
|
||||
sub: t.artist,
|
||||
v: `${fmt(t.plays ?? 0)} ×`,
|
||||
w: Math.round(((t.plays ?? 0) / trackMax) * 100),
|
||||
})),
|
||||
);
|
||||
|
||||
const artMax = d.topArtists.reduce((m, a) => Math.max(m, a.plays), 0) || 1;
|
||||
fillList(
|
||||
"lfm-artists",
|
||||
"lfm-artists-block",
|
||||
d.topArtists.map((a) => ({
|
||||
k: a.name,
|
||||
v: `${fmt(a.plays)} ×`,
|
||||
w: Math.round((a.plays / artMax) * 100),
|
||||
})),
|
||||
);
|
||||
|
||||
fillList(
|
||||
"lfm-recent",
|
||||
"lfm-recent-block",
|
||||
d.history.map((h) => ({ k: h.title, sub: h.artist, v: ago(h.playedAt) })),
|
||||
);
|
||||
}
|
||||
|
||||
function renderTop(tracks: Track[]): void {
|
||||
const section = $("top-section");
|
||||
const mosaic = $("mosaic");
|
||||
const caps = $("mosaic-caps");
|
||||
if (!section || !mosaic || !caps || !tracks.length) return;
|
||||
|
||||
mosaic.innerHTML = tracks
|
||||
.map((t, i) => {
|
||||
const cls = i === 0 ? "mosaic__cell mosaic__cell--xl" : "mosaic__cell";
|
||||
const label = esc(`${t.title} — ${t.artist}`);
|
||||
const img = t.art
|
||||
? `<img src="${esc(t.art)}" alt="${label}" loading="lazy" decoding="async">`
|
||||
: "";
|
||||
return `<a class="${cls}" href="${esc(t.url)}" target="_blank" rel="noopener" title="${label}">${img}</a>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
caps.innerHTML = tracks
|
||||
.map((t, i) => {
|
||||
const rank = String(i + 1).padStart(2, "0");
|
||||
return `<div class="mosaic__cap"><span class="mosaic__rank">${rank}</span><span class="mosaic__title">${esc(
|
||||
t.title,
|
||||
)}</span><span class="mosaic__artist">${esc(t.artist)}</span></div>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
section.hidden = false;
|
||||
}
|
||||
|
||||
function mockData() {
|
||||
const art = (n: number) => `https://picsum.photos/seed/np${n}/200`;
|
||||
const mk = (n: number): Track => ({
|
||||
title: ["After Party", "Gemstone", "Heartless", "Breathe", "ONE CALL", "Alright", "Nights", "Ivy", "Self Control"][n],
|
||||
artist: ["Don Toliver", "Don Toliver", "Kanye West", "Yeat", "Rich Amiri", "Kendrick Lamar", "Frank Ocean", "Frank Ocean", "Frank Ocean"][n],
|
||||
url: "https://open.spotify.com",
|
||||
art: art(n),
|
||||
function mockData(): Data {
|
||||
const mk = (title: string, artist: string, plays: number): Track => ({
|
||||
title,
|
||||
artist,
|
||||
url: "https://www.last.fm",
|
||||
plays,
|
||||
});
|
||||
return {
|
||||
configured: true,
|
||||
nowPlaying: { ...mk(0), isPlaying: true, progressMs: 78000, durationMs: 175000, playedAt: null },
|
||||
top: Array.from({ length: 9 }, (_, i) => mk(i)),
|
||||
nowPlaying: {
|
||||
title: "20 Min",
|
||||
artist: "Lil Uzi Vert",
|
||||
album: "Luv Is Rage 2 (Deluxe)",
|
||||
art: "https://picsum.photos/seed/np/200",
|
||||
url: "https://www.last.fm",
|
||||
isPlaying: true,
|
||||
playedAt: null,
|
||||
},
|
||||
top: [
|
||||
mk("After Party", "Don Toliver", 14),
|
||||
mk("NIGHTS LIKE THIS", "Kehlani", 11),
|
||||
mk("20 Min", "Lil Uzi Vert", 9),
|
||||
mk("Gemstones", "Don Toliver", 6),
|
||||
mk("HEARTLESS", "The Weeknd", 4),
|
||||
],
|
||||
topArtists: [
|
||||
{ name: "Don Toliver", plays: 26, url: "" },
|
||||
{ name: "Lil Uzi Vert", plays: 18, url: "" },
|
||||
{ name: "Kehlani", plays: 12, url: "" },
|
||||
{ name: "The Weeknd", plays: 7, url: "" },
|
||||
],
|
||||
history: [
|
||||
{ title: "20 Min", artist: "Lil Uzi Vert", playedAt: Date.now() - 6 * 60_000 },
|
||||
{ title: "After Party", artist: "Don Toliver", playedAt: Date.now() - 12 * 60_000 },
|
||||
{ title: "Gemstones", artist: "Don Toliver", playedAt: Date.now() - 40 * 60_000 },
|
||||
{ title: "NIGHTS LIKE THIS", artist: "Kehlani", playedAt: Date.now() - 3 * 3_600_000 },
|
||||
{ title: "HEARTLESS", artist: "The Weeknd", playedAt: Date.now() - 26 * 3_600_000 },
|
||||
],
|
||||
weekPlays: 128,
|
||||
allTimePlays: 4210,
|
||||
estMinutes: 13892,
|
||||
lib: { artists: 214, albums: 388, tracks: 1102 },
|
||||
};
|
||||
}
|
||||
|
||||
type MusicState = "ready" | "idle" | "off";
|
||||
|
||||
async function load(): Promise<MusicState> {
|
||||
let data: any;
|
||||
let data: Data;
|
||||
try {
|
||||
const mm = (window as any).__mm?.spotify as Promise<any> | undefined;
|
||||
const mm = (window as any).__mm?.lastfm as Promise<Data> | undefined;
|
||||
data = mm
|
||||
? await mm
|
||||
: await fetch("/api/spotify.json", { headers: { accept: "application/json" } }).then((r) =>
|
||||
: await fetch("/api/lastfm.json", { headers: { accept: "application/json" } }).then((r) =>
|
||||
r.json(),
|
||||
);
|
||||
} catch {
|
||||
@ -147,43 +211,42 @@ async function load(): Promise<MusicState> {
|
||||
}
|
||||
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[]);
|
||||
|
||||
const np = data.nowPlaying;
|
||||
const top = Array.isArray(data.top) ? data.top : [];
|
||||
data.top = top;
|
||||
data.topArtists = Array.isArray(data.topArtists) ? data.topArtists : [];
|
||||
data.history = Array.isArray(data.history) ? data.history : [];
|
||||
data.lib = data.lib ?? { artists: 0, albums: 0, tracks: 0 };
|
||||
if (np) renderNowPlaying(np);
|
||||
renderStats(data);
|
||||
|
||||
const trigger = document.querySelector<HTMLButtonElement>(".music__trigger");
|
||||
if (trigger && (data.nowPlaying || (Array.isArray(data.top) && data.top.length))) {
|
||||
const hasAnything = !!np || top.length > 0 || !!data.weekPlays;
|
||||
if (trigger && hasAnything) {
|
||||
trigger.hidden = false;
|
||||
const label = $("music-label");
|
||||
const sub = $("music-sub");
|
||||
const artEl = $("music-art") as HTMLImageElement | null;
|
||||
const np = data.nowPlaying as NP | null;
|
||||
const cover = np?.art || (data.top as Track[] | undefined)?.[0]?.art || null;
|
||||
|
||||
if (artEl && cover) {
|
||||
artEl.src = cover;
|
||||
if (artEl && np?.art) {
|
||||
artEl.src = np.art;
|
||||
artEl.hidden = false;
|
||||
}
|
||||
|
||||
const sub = $("music-sub");
|
||||
const bar = $("music-bar");
|
||||
const fill = bar?.firstElementChild as HTMLElement | null;
|
||||
|
||||
if (label) {
|
||||
if (np?.isPlaying) {
|
||||
trigger.classList.add("music__trigger--live");
|
||||
label.textContent = clamp(np.title);
|
||||
trigger.title = `${np.title} — ${np.artist}`;
|
||||
if (sub) sub.textContent = np.artist;
|
||||
if (bar && fill && np.progressMs != null && np.durationMs) {
|
||||
fill.style.setProperty("--p", `${Math.min(100, (np.progressMs / np.durationMs) * 100)}%`);
|
||||
bar.hidden = false;
|
||||
}
|
||||
} else if (np?.playedAt) {
|
||||
} else if (np) {
|
||||
label.textContent = "last played";
|
||||
trigger.title = `${np.title} — ${np.artist}`;
|
||||
if (sub) sub.textContent = `${clamp(np.title, 26)} · ${np.artist}`;
|
||||
} else {
|
||||
label.textContent = "top tracks";
|
||||
if (sub) sub.textContent = "last 4 weeks";
|
||||
label.textContent = "music";
|
||||
if (sub) sub.textContent = `${fmt(data.weekPlays)} plays this week`;
|
||||
}
|
||||
}
|
||||
return "ready";
|
||||
@ -200,7 +263,7 @@ const apply = (state: MusicState) => {
|
||||
trigger.hidden = false;
|
||||
trigger.disabled = true;
|
||||
const label = document.getElementById("music-label");
|
||||
if (label) label.textContent = "not playing";
|
||||
if (label) label.textContent = "not scrobbling";
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@ -60,7 +60,7 @@ body {
|
||||
text-rendering: optimizeLegibility;
|
||||
max-width: var(--col);
|
||||
margin-inline: auto;
|
||||
padding: clamp(2.5rem, 9vw, 4.5rem) clamp(1.5rem, 6vw, 2rem) clamp(5rem, 12vw, 7rem);
|
||||
padding: clamp(2.5rem, 9vw, 4.5rem) clamp(1.25rem, 5vw, 2rem) clamp(5rem, 12vw, 7rem);
|
||||
}
|
||||
|
||||
::selection {
|
||||
@ -204,9 +204,10 @@ img {
|
||||
.topbar {
|
||||
margin: 0 0 clamp(2rem, 5vw, 3rem);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
gap: 0.6rem 1rem;
|
||||
}
|
||||
.mark {
|
||||
font-family: var(--display);
|
||||
@ -550,6 +551,116 @@ img {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.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;
|
||||
white-space: nowrap;
|
||||
color: var(--ink);
|
||||
}
|
||||
.stat-list__k .stat-list__sub {
|
||||
color: var(--faint);
|
||||
}
|
||||
.stat-list__v {
|
||||
position: relative;
|
||||
flex: none;
|
||||
color: var(--muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.wall-foot {
|
||||
margin: 1.5rem 0 0;
|
||||
font-family: var(--mono);
|
||||
font-size: 0.66rem;
|
||||
letter-spacing: 0.03em;
|
||||
color: var(--faint);
|
||||
}
|
||||
.wall-foot a {
|
||||
color: var(--faint);
|
||||
text-decoration: none;
|
||||
}
|
||||
.wall-foot a:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
@media (max-width: 25rem) {
|
||||
.ribbon__sub {
|
||||
display: none;
|
||||
}
|
||||
.ribbon__link {
|
||||
padding: 0.5rem 0.35rem 0.5rem 0.6rem;
|
||||
}
|
||||
.ribbon__x {
|
||||
padding: 0 0.55rem;
|
||||
}
|
||||
}
|
||||
@media (max-width: 20rem) {
|
||||
.mm-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
* {
|
||||
animation-duration: 0.001ms !important;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user