feat(frontend): add HistoryCard + HistoryList

This commit is contained in:
devilreef 2026-04-30 09:42:16 +06:00
parent 65d88be032
commit 01c281ca80
Signed by: devilreef
SSH key fingerprint: SHA256:UZisRr4iuXx+IhkbZnR655L2RWAT6o2rgbGv5F/6m3Y
3 changed files with 88 additions and 0 deletions

View file

@ -0,0 +1,12 @@
---
import { HistoryList } from './HistoryList';
interface Props { station: string; }
const { station } = Astro.props;
---
<div class="card history">
<div class="full-secthead">
<div class="full-secthead-jp">再生履歴</div>
<div class="full-secthead-en">// recently played</div>
</div>
<HistoryList station={station} client:visible />
</div>

View file

@ -0,0 +1,48 @@
import { useEffect, useState } from 'react';
import type { HistoryEntry } from '@lib/types';
import { fmtRelative } from '@lib/format';
interface Props {
station: string;
}
export function HistoryList({ station }: Props) {
const [entries, setEntries] = useState<HistoryEntry[] | null>(null);
const [now, setNow] = useState(() => new Date());
useEffect(() => {
const ctrl = new AbortController();
const load = async () => {
try {
const r = await fetch(`/now-playing/${station}.history.json`, { signal: ctrl.signal });
if (!r.ok) { setEntries([]); return; }
const arr = await r.json() as HistoryEntry[];
setEntries(Array.isArray(arr) ? arr : []);
} catch (err) {
if ((err as Error).name !== 'AbortError') setEntries([]);
}
};
void load();
const id = setInterval(load, 15_000);
const tick = setInterval(() => setNow(new Date()), 30_000);
return () => { ctrl.abort(); clearInterval(id); clearInterval(tick); };
}, [station]);
if (entries === null) return <div className="history-empty">loading</div>;
if (!entries.length) return <div className="history-empty">no recent tracks</div>;
return (
<div className="history-list">
{entries.slice(0, 10).map((e, i) => {
const start = new Date(parseInt(e.started_at, 10) * 1000);
return (
<div key={`${e.started_at}-${i}`} className="history-row">
<span className="t">{fmtRelative(start, now)}</span>
<span className="title">{e.title || '—'}</span>
<span className="artist">{e.artist || '—'}</span>
</div>
);
})}
</div>
);
}

View file

@ -0,0 +1,28 @@
.full-secthead { margin-bottom: 14px; }
.full-secthead-jp { font-family: var(--f-pixel); font-size: 18px; color: var(--lemon); letter-spacing: 4px; }
.full-secthead-en { font-family: var(--f-pixel); font-size: 22px; color: var(--cream); letter-spacing: 1px; margin-top: 2px; }
.card {
background: var(--plum);
border: 2px solid var(--ink);
padding: 22px 24px;
position: relative;
}
.card::before {
content: ""; position: absolute; inset: 4px; border: 1px dashed #ff3ea522; pointer-events: none;
}
.history-list { display: flex; flex-direction: column; }
.history-row {
display: grid; grid-template-columns: 50px 1fr auto;
gap: 12px; padding: 8px 4px;
border-bottom: 1px dashed #ff3ea522;
font-size: 13px;
}
.history-row:last-child { border-bottom: none; }
.history-row .t { font-family: var(--f-mono); color: var(--cyan); }
.history-row .title { font-family: var(--f-pixel); font-size: 14px; color: var(--cream); }
.history-row .artist { font-family: var(--f-mono); color: #fff4e8aa; font-size: 12px; }
.history-empty {
font-family: var(--f-mono); font-size: 13px; color: #fff4e8aa; padding: 8px 4px;
}