+ {Array.from({ length: BARS }).map((_, i) => (
+
{
+ if (el) barRefs.current[i] = el;
+ }}
+ />
+ ))}
+
+ );
+}
diff --git a/frontend/src/components/Tape.tsx b/frontend/src/components/Tape.tsx
new file mode 100644
index 0000000..c154e6c
--- /dev/null
+++ b/frontend/src/components/Tape.tsx
@@ -0,0 +1,30 @@
+import type { Station } from '@lib/types';
+
+interface Props {
+ station: Station;
+ index: number;
+ active: boolean;
+ onSelect: (id: string) => void;
+ listeners?: number | null;
+}
+
+export function Tape({ station, index, active, onSelect, listeners }: Props) {
+ return (
+
+ );
+}
diff --git a/frontend/src/components/TopNav.astro b/frontend/src/components/TopNav.astro
new file mode 100644
index 0000000..3e76fb5
--- /dev/null
+++ b/frontend/src/components/TopNav.astro
@@ -0,0 +1,9 @@
+---
+---
+
diff --git a/frontend/src/lib/store.ts b/frontend/src/lib/store.ts
new file mode 100644
index 0000000..09b6ff2
--- /dev/null
+++ b/frontend/src/lib/store.ts
@@ -0,0 +1,18 @@
+let audioRef: HTMLAudioElement | null = null;
+const subs = new Set<() => void>();
+
+export const playerStore = {
+ setAudio(el: HTMLAudioElement | null) {
+ audioRef = el;
+ subs.forEach((fn) => fn());
+ },
+ getAudio(): HTMLAudioElement | null {
+ return audioRef;
+ },
+ subscribe(fn: () => void): () => void {
+ subs.add(fn);
+ return () => {
+ subs.delete(fn);
+ };
+ },
+};
diff --git a/frontend/src/pages/index.astro b/frontend/src/pages/index.astro
new file mode 100644
index 0000000..02ca49e
--- /dev/null
+++ b/frontend/src/pages/index.astro
@@ -0,0 +1,44 @@
+---
+import '@styles/tokens.css';
+import '@styles/global.css';
+import '@styles/components/hero.css';
+import '@styles/components/deck.css';
+import '@styles/components/spectrum.css';
+import '@styles/components/tape.css';
+import '@styles/components/history-card.css';
+import '@styles/components/listen-ways-card.css';
+import '@styles/components/footer.css';
+
+import TopNav from '@components/TopNav.astro';
+import HistoryCard from '@components/HistoryCard.astro';
+import ListenWaysCard from '@components/ListenWaysCard.astro';
+import FooterStrip from '@components/FooterStrip.astro';
+import { HeroPlayer } from '@components/HeroPlayer';
+import { listStations } from '@lib/stations';
+
+export const prerender = false;
+
+const root = process.env.LIBRARY_ROOT ?? '/library';
+const initialStations = await listStations(root);
+const firstStation = initialStations[0]?.id ?? '';
+---
+
+
+
+
+
+
denpa.fm // 電波
+
+
+
+
+
+
+
+ {firstStation ?
:
}
+ {firstStation ?
:
}
+
+
+
+
+
diff --git a/frontend/src/styles/components/deck.css b/frontend/src/styles/components/deck.css
new file mode 100644
index 0000000..79e74df
--- /dev/null
+++ b/frontend/src/styles/components/deck.css
@@ -0,0 +1,95 @@
+.hero-deck {
+ background: linear-gradient(180deg, var(--cream) 0%, #f5e0c4 100%);
+ color: var(--ink);
+ padding: 24px;
+ border: 3px solid var(--ink);
+ box-shadow: 8px 8px 0 var(--pink), 16px 16px 0 var(--cyan);
+ position: relative;
+}
+.hero-deck::before {
+ content: ""; position: absolute; inset: 5px;
+ border: 1px dashed #0a041044; pointer-events: none;
+}
+
+.deck-screen {
+ background: var(--ink); color: var(--green);
+ font-family: var(--f-mono); font-size: 14px;
+ padding: 14px 18px;
+ border: 2px inset var(--ink);
+ position: relative; overflow: hidden;
+}
+.deck-screen::after {
+ content: ""; position: absolute; inset: 0; pointer-events: none;
+ background: repeating-linear-gradient(0deg, rgba(94,255,155,0.08) 0 1px, transparent 1px 3px);
+}
+.deck-screen-row { display: flex; justify-content: space-between; opacity: 0.75; font-size: 12px; }
+.deck-now {
+ font-family: var(--f-pixel); font-size: 28px; color: var(--cream);
+ line-height: 1.1; margin: 6px 0 2px;
+ text-shadow: 0 0 10px #5eff9b80;
+}
+.deck-artist { font-size: 16px; color: var(--cyan); margin-bottom: 8px; }
+.deck-progress-row {
+ display: flex; align-items: center; gap: 10px;
+ margin-top: 12px;
+}
+.deck-time { font-size: 12px; color: var(--cyan); min-width: 36px; }
+.deck-progress { flex: 1; height: 4px; background: #5eff9b22; border: 1px solid #5eff9b55; }
+.deck-progress-fill { height: 100%; background: var(--green); transition: width 200ms linear; }
+
+.deck-reels {
+ display: flex; align-items: center; justify-content: space-around;
+ gap: 14px; margin-top: 18px;
+ padding: 14px; background: var(--ink); border: 2px solid var(--ink);
+}
+.reel {
+ width: 110px; height: 110px; border-radius: 50%;
+ background:
+ radial-gradient(circle, var(--cream) 0 16px, transparent 16px),
+ conic-gradient(from 0deg, var(--plum-2) 0 25%, var(--plum) 25% 50%, var(--plum-2) 50% 75%, var(--plum) 75%);
+ border: 2px solid var(--cream);
+ position: relative;
+}
+.reel::before {
+ content: ""; position: absolute; inset: 7px; border-radius: 50%;
+ border: 1px dashed #fff4e833;
+}
+.reel.spinning { animation: spin 1.6s linear infinite; }
+@keyframes spin { to { transform: rotate(360deg); } }
+.reel-strip {
+ flex: 1; height: 7px;
+ background: linear-gradient(90deg, var(--cream) 0%, #d4a578 50%, var(--cream) 100%);
+ border-top: 1px solid var(--ink); border-bottom: 1px solid var(--ink);
+}
+
+.deck-controls {
+ display: flex; align-items: center; gap: 10px; margin-top: 18px;
+ flex-wrap: wrap;
+}
+.deck-btn {
+ font-family: var(--f-pixel); font-size: 16px;
+ background: var(--pink); color: var(--cream);
+ border: 2px solid var(--ink); padding: 10px 14px;
+ cursor: pointer; box-shadow: 3px 3px 0 var(--ink);
+ letter-spacing: 1px;
+ transition: transform 100ms, box-shadow 100ms;
+}
+.deck-btn:hover { transform: translate(-1px,-1px); box-shadow: 4px 4px 0 var(--ink); }
+.deck-btn:active { transform: translate(2px,2px); box-shadow: 1px 1px 0 var(--ink); }
+.deck-btn.play { font-size: 20px; padding: 16px 24px; background: var(--cyan); color: var(--ink); }
+.deck-btn.format { background: var(--cream); color: var(--ink); }
+.deck-btn.format[data-active=true] { background: var(--lemon); }
+
+.deck-vol {
+ flex: 1; min-width: 200px;
+ display: flex; align-items: center; gap: 10px;
+ background: var(--ink); padding: 10px 14px; border: 2px solid var(--ink);
+}
+.deck-vol label { font-family: var(--f-pixel); font-size: 13px; color: var(--lemon); letter-spacing: 1.5px; }
+.deck-vol input { flex: 1; accent-color: var(--pink); }
+.deck-vol-pct { font-family: var(--f-mono); font-size: 13px; color: var(--cyan); min-width: 38px; text-align: right; }
+
+@media (max-width: 640px) {
+ .reel { width: 80px; height: 80px; }
+ .deck-vol { min-width: 100%; }
+}
diff --git a/frontend/src/styles/components/footer.css b/frontend/src/styles/components/footer.css
new file mode 100644
index 0000000..7579d5e
--- /dev/null
+++ b/frontend/src/styles/components/footer.css
@@ -0,0 +1,9 @@
+.footer {
+ margin-top: 12px; padding: 30px 0 24px;
+ border-top: 2px dashed #ff3ea566;
+}
+.footer-bot {
+ display: flex; justify-content: space-between; gap: 14px;
+ font-family: var(--f-mono); font-size: 12px; color: #fff4e8aa;
+ flex-wrap: wrap;
+}
diff --git a/frontend/src/styles/components/hero.css b/frontend/src/styles/components/hero.css
new file mode 100644
index 0000000..f0502e6
--- /dev/null
+++ b/frontend/src/styles/components/hero.css
@@ -0,0 +1,106 @@
+.topnav {
+ display: flex; justify-content: space-between; align-items: center;
+ font-family: var(--f-pixel); font-size: 13px; letter-spacing: 2px;
+ padding: 6px 0 18px; border-bottom: 1px dashed #ff3ea533;
+ margin-bottom: 24px;
+ flex-wrap: wrap; gap: 10px;
+}
+.topnav-brand { color: var(--pink); }
+.topnav-links { display: flex; gap: 18px; }
+.topnav-links a { color: var(--cream); }
+.topnav-links a:hover { color: var(--lemon); }
+
+.hero {
+ position: relative;
+ padding: 30px 32px 36px;
+ background:
+ radial-gradient(ellipse at 30% 0%, #ff3ea522 0%, transparent 55%),
+ radial-gradient(ellipse at 80% 100%, #5ef7ff1a 0%, transparent 55%),
+ var(--plum);
+ border: 2px solid var(--ink);
+ margin-bottom: 36px;
+ overflow: hidden;
+}
+.hero-header {
+ display: grid;
+ grid-template-columns: auto 1fr auto;
+ align-items: end;
+ gap: 24px; margin-bottom: 22px;
+}
+.hero-wordmark .eyebrow {
+ font-family: var(--f-pixel); font-size: 16px; letter-spacing: 4px; color: var(--pink);
+}
+.hero-wordmark .big {
+ font-family: var(--f-pixel);
+ font-size: clamp(48px, 7vw, 88px);
+ line-height: 0.9; letter-spacing: 0; margin-top: 6px;
+ display: inline-block;
+ padding-right: 0.12em;
+ color: transparent;
+ background: linear-gradient(180deg, var(--cream) 0%, var(--cream) 50%, var(--pink) 50%, var(--pink) 100%);
+ -webkit-background-clip: text; background-clip: text;
+ filter: drop-shadow(0 0 8px #ff3ea580);
+}
+.hero-tagline {
+ font-family: var(--f-hand); font-size: 26px;
+ color: var(--cream); transform: rotate(-2deg); line-height: 1.05;
+ text-align: center;
+}
+.hero-status {
+ font-family: var(--f-mono); font-size: 13px;
+ color: var(--cyan); text-align: right; line-height: 1.55;
+}
+.hero-status .live { color: var(--pink); }
+.hero-status .blink { animation: blink 1s steps(2) infinite; }
+@keyframes blink { 50% { opacity: 0.15; } }
+
+.hero-body {
+ display: grid;
+ grid-template-columns: 260px 1fr;
+ gap: 24px;
+ position: relative;
+}
+.hero-stations { display: flex; flex-direction: column; gap: 10px; }
+.hero-stations-label {
+ font-family: var(--f-pixel); color: var(--lemon);
+ font-size: 14px; letter-spacing: 2px;
+ padding: 5px 10px;
+ background: var(--ink); border: 2px solid var(--lemon);
+ align-self: flex-start; transform: rotate(-2deg);
+ margin-bottom: 4px;
+}
+
+.hero-deck-wrap { display: flex; flex-direction: column; gap: 18px; min-width: 0; }
+.hero-footer { display: flex; gap: 12px; align-items: stretch; }
+.share-btn {
+ font-family: var(--f-mono); font-size: 13px;
+ background: var(--ink); color: var(--cyan);
+ padding: 10px 14px; border: 1.5px dashed #5ef7ff66;
+ cursor: pointer; white-space: nowrap;
+}
+.share-btn:hover { border-style: solid; border-color: var(--cyan); }
+.ticker {
+ flex: 1; overflow: hidden; white-space: nowrap;
+ background: var(--ink); padding: 10px 0;
+ border-top: 1px dashed #ff3ea566; border-bottom: 1px dashed #ff3ea566;
+ display: flex; align-items: center;
+}
+.ticker-inner {
+ display: inline-block; padding-left: 100%;
+ animation: marq 30s linear infinite;
+ color: var(--pink); font-family: var(--f-mono); font-size: 13px; letter-spacing: 1px;
+}
+@keyframes marq { to { transform: translateX(-100%); } }
+
+@media (max-width: 1000px) {
+ .hero-body { grid-template-columns: 1fr; }
+ .hero-stations { display: grid; grid-template-columns: repeat(2, 1fr); }
+}
+@media (max-width: 640px) {
+ .page { padding: 14px; }
+ .hero { padding: 20px 16px 24px; }
+ .hero-header { grid-template-columns: 1fr; gap: 14px; }
+ .hero-tagline { text-align: left; }
+ .hero-status { text-align: left; }
+ .hero-stations { grid-template-columns: 1fr; }
+}
diff --git a/frontend/src/styles/components/history-card.css b/frontend/src/styles/components/history-card.css
new file mode 100644
index 0000000..999115d
--- /dev/null
+++ b/frontend/src/styles/components/history-card.css
@@ -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;
+}
diff --git a/frontend/src/styles/components/listen-ways-card.css b/frontend/src/styles/components/listen-ways-card.css
new file mode 100644
index 0000000..2e0a7ac
--- /dev/null
+++ b/frontend/src/styles/components/listen-ways-card.css
@@ -0,0 +1,20 @@
+.listen-ways-list { display: flex; flex-direction: column; gap: 14px; }
+.listen-way {
+ border: 1.5px dashed #5ef7ff44;
+ padding: 12px;
+ background: #0a04108c;
+}
+.lw-label { font-family: var(--f-pixel); font-size: 12px; color: var(--lemon); letter-spacing: 2px; margin-bottom: 4px; }
+.lw-url {
+ display: block; font-family: var(--f-mono); font-size: 13px;
+ color: var(--cyan); background: var(--ink);
+ padding: 6px 8px; word-break: break-all;
+}
+.lw-hint { font-family: var(--f-mono); font-size: 11px; color: #fff4e866; margin-top: 6px; }
+
+.row-grid { display: grid; gap: 24px; margin-bottom: 36px; }
+.row-grid.two { grid-template-columns: 1.2fr 1fr; }
+
+@media (max-width: 1000px) {
+ .row-grid.two { grid-template-columns: 1fr; }
+}
diff --git a/frontend/src/styles/components/spectrum.css b/frontend/src/styles/components/spectrum.css
new file mode 100644
index 0000000..d9d8f24
--- /dev/null
+++ b/frontend/src/styles/components/spectrum.css
@@ -0,0 +1,10 @@
+.deck-spectrum {
+ display: flex; align-items: flex-end; gap: 2px;
+ height: 80px; margin-top: 18px;
+ padding: 10px; background: var(--ink); border: 2px solid var(--ink);
+}
+.spec-bar {
+ flex: 1; min-height: 3px;
+ background: linear-gradient(180deg, var(--pink) 0%, var(--lemon) 50%, var(--green) 100%);
+ transition: height 80ms linear;
+}
diff --git a/frontend/src/styles/components/tape.css b/frontend/src/styles/components/tape.css
new file mode 100644
index 0000000..db5cdcb
--- /dev/null
+++ b/frontend/src/styles/components/tape.css
@@ -0,0 +1,23 @@
+.tape {
+ font-family: var(--f-mono);
+ background: var(--cream); color: var(--ink);
+ padding: 10px 12px 12px;
+ border: 2px solid var(--ink);
+ box-shadow: 3px 3px 0 var(--ink);
+ cursor: pointer; text-align: left;
+ transition: transform 120ms ease-out, box-shadow 120ms ease-out;
+ width: 100%;
+}
+.tape:hover { transform: translate(-2px,-2px); box-shadow: 5px 5px 0 var(--ink); }
+.tape.active {
+ background: var(--pink); color: var(--cream);
+ transform: translate(-3px,-3px) rotate(-1deg);
+ box-shadow: 6px 6px 0 var(--lemon);
+}
+.tape-num { font-family: var(--f-mono); font-size: 10px; opacity: 0.65; letter-spacing: 1.5px; }
+.tape-name { font-family: var(--f-pixel); font-size: 16px; line-height: 1.1; margin-top: 2px; }
+.tape-jp { font-family: var(--f-pixel); font-size: 11px; opacity: 0.85; letter-spacing: 1.5px; margin-top: 2px; }
+.tape-meta { font-family: var(--f-mono); font-size: 10px; margin-top: 6px; display: flex; justify-content: space-between; opacity: 0.75; }
+.tape-holes { display: flex; gap: 4px; margin-top: 6px; }
+.tape-holes span { width: 14px; height: 14px; border-radius: 50%; background: var(--ink); box-shadow: inset 0 0 0 3px var(--cream); }
+.tape.active .tape-holes span { box-shadow: inset 0 0 0 3px var(--pink); }