Refactor code structure for improved readability and maintainability
All checks were successful
Build and Push Docker Image / docker (push) Successful in 21s
All checks were successful
Build and Push Docker Image / docker (push) Successful in 21s
This commit is contained in:
@@ -5,53 +5,118 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Hitstar Web</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="/socket.io/socket.io.js"></script>
|
||||
<style>
|
||||
@keyframes record-spin { from { transform: rotate(0deg);} to { transform: rotate(360deg);} }
|
||||
.spin-record { animation: record-spin 3.2s linear infinite; }
|
||||
@keyframes record-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
.spin-record {
|
||||
animation: record-spin 3.2s linear infinite;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="min-h-screen bg-slate-50 text-slate-900 dark:bg-slate-950 dark:text-slate-100">
|
||||
<div id="app" class="max-w-5xl mx-auto p-4 md:p-6">
|
||||
<header class="mb-6 md:mb-8">
|
||||
<h1 class="text-3xl md:text-4xl font-bold tracking-tight">Hitstar</h1>
|
||||
<p class="text-slate-500 dark:text-slate-400 mt-1">Lokales Multiplayer-Spiel mit deiner eigenen Musik</p>
|
||||
<p class="text-slate-500 dark:text-slate-400 mt-1">
|
||||
Lokales Multiplayer-Spiel mit deiner eigenen Musik
|
||||
</p>
|
||||
</header>
|
||||
<!-- Toast Notification -->
|
||||
<div id="toast" class="fixed top-6 left-1/2 -translate-x-1/2 z-50 px-4 py-2 rounded-lg bg-indigo-600 text-white text-sm font-medium shadow-lg opacity-0 pointer-events-none transition-opacity duration-500">Code kopiert!</div>
|
||||
<!-- Toast Notification -->
|
||||
<div
|
||||
id="toast"
|
||||
class="fixed top-6 left-1/2 -translate-x-1/2 z-50 px-4 py-2 rounded-lg bg-indigo-600 text-white text-sm font-medium shadow-lg opacity-0 pointer-events-none transition-opacity duration-500"
|
||||
>
|
||||
Code kopiert!
|
||||
</div>
|
||||
|
||||
<!-- Lobby Card -->
|
||||
<div id="lobby" class="rounded-xl border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 shadow-sm p-4 md:p-6 space-y-4">
|
||||
<div
|
||||
id="lobby"
|
||||
class="rounded-xl border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 shadow-sm p-4 md:p-6 space-y-4"
|
||||
>
|
||||
<div class="flex flex-col sm:flex-row gap-3 sm:items-end">
|
||||
<label class="flex-1 text-sm font-medium text-slate-600 dark:text-slate-300">
|
||||
Dein Name
|
||||
<input id="name" placeholder="Name" class="mt-1 w-full rounded-lg border border-slate-300 dark:border-slate-700 bg-white/80 dark:bg-slate-800 px-3 py-2 h-11 outline-none focus:ring-2 focus:ring-indigo-500"/>
|
||||
<input
|
||||
id="name"
|
||||
placeholder="Name"
|
||||
class="mt-1 w-full rounded-lg border border-slate-300 dark:border-slate-700 bg-white/80 dark:bg-slate-800 px-3 py-2 h-11 outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
/>
|
||||
</label>
|
||||
<button id="setName" class="h-11 px-4 rounded-lg bg-indigo-600 hover:bg-indigo-700 text-white font-medium">Setzen</button>
|
||||
<button
|
||||
id="setName"
|
||||
class="h-11 px-4 rounded-lg bg-indigo-600 hover:bg-indigo-700 text-white font-medium"
|
||||
>
|
||||
Setzen
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex flex-col sm:flex-row gap-3">
|
||||
<button id="createRoom" class="h-11 px-4 rounded-lg bg-emerald-600 hover:bg-emerald-700 text-white font-medium">Raum erstellen</button>
|
||||
<input id="roomCode" placeholder="Code" class="flex-1 h-11 rounded-lg border border-slate-300 dark:border-slate-700 bg-white/80 dark:bg-slate-800 px-3 outline-none focus:ring-2 focus:ring-indigo-500"/>
|
||||
<button id="joinRoom" class="h-11 px-4 rounded-lg bg-slate-900 hover:bg-slate-700 text-white font-medium dark:bg-slate-700 dark:hover:bg-slate-600">Beitreten</button>
|
||||
<button
|
||||
id="createRoom"
|
||||
class="h-11 px-4 rounded-lg bg-emerald-600 hover:bg-emerald-700 text-white font-medium"
|
||||
>
|
||||
Raum erstellen
|
||||
</button>
|
||||
<input
|
||||
id="roomCode"
|
||||
placeholder="Code"
|
||||
class="flex-1 h-11 rounded-lg border border-slate-300 dark:border-slate-700 bg-white/80 dark:bg-slate-800 px-3 outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
/>
|
||||
<button
|
||||
id="joinRoom"
|
||||
class="h-11 px-4 rounded-lg bg-slate-900 hover:bg-slate-700 text-white font-medium dark:bg-slate-700 dark:hover:bg-slate-600"
|
||||
>
|
||||
Beitreten
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Room Card -->
|
||||
<div id="room" class="hidden rounded-xl border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 shadow-sm p-4 md:p-6 space-y-4">
|
||||
<div
|
||||
id="room"
|
||||
class="hidden rounded-xl border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 shadow-sm p-4 md:p-6 space-y-4"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<h2 class="text-xl md:text-2xl font-semibold flex items-center gap-2">Raum <span id="roomId" class="font-mono tracking-wider cursor-pointer" title="Klicken zum Kopieren"></span>
|
||||
<h2 class="text-xl md:text-2xl font-semibold flex items-center gap-2">
|
||||
Raum
|
||||
<span
|
||||
id="roomId"
|
||||
class="font-mono tracking-wider cursor-pointer"
|
||||
title="Klicken zum Kopieren"
|
||||
></span>
|
||||
</h2>
|
||||
<button id="leaveRoom" class="h-10 px-4 rounded-lg border border-slate-300 dark:border-slate-700 hover:bg-slate-50 dark:hover:bg-slate-800">Verlassen</button>
|
||||
<button
|
||||
id="leaveRoom"
|
||||
class="h-10 px-4 rounded-lg border border-slate-300 dark:border-slate-700 hover:bg-slate-50 dark:hover:bg-slate-800"
|
||||
>
|
||||
Verlassen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="text-slate-700 dark:text-slate-300">Dein Name: <strong id="nameDisplay" class="font-semibold"></strong></div>
|
||||
|
||||
|
||||
<div class="text-slate-700 dark:text-slate-300">
|
||||
Dein Name: <strong id="nameDisplay" class="font-semibold"></strong>
|
||||
</div>
|
||||
|
||||
<!-- Expandable Dashboard: player statuses and scores -->
|
||||
<div>
|
||||
<details id="dashboard" class="rounded-lg border border-slate-200 dark:border-slate-800 bg-white/60 dark:bg-slate-900/40 p-3">
|
||||
<summary class="cursor-pointer select-none text-sm font-semibold text-slate-700 dark:text-slate-300 flex items-center gap-2">
|
||||
<span class="inline-flex h-5 w-5 items-center justify-center rounded-full bg-indigo-600 text-white text-[11px] font-bold">i</span>
|
||||
<details
|
||||
id="dashboard"
|
||||
class="rounded-lg border border-slate-200 dark:border-slate-800 bg-white/60 dark:bg-slate-900/40 p-3"
|
||||
>
|
||||
<summary
|
||||
class="cursor-pointer select-none text-sm font-semibold text-slate-700 dark:text-slate-300 flex items-center gap-2"
|
||||
>
|
||||
<span
|
||||
class="inline-flex h-5 w-5 items-center justify-center rounded-full bg-indigo-600 text-white text-[11px] font-bold"
|
||||
>i</span
|
||||
>
|
||||
Dashboard: Spielerstatus & Punkte
|
||||
</summary>
|
||||
<div class="mt-3 overflow-x-auto">
|
||||
@@ -64,7 +129,10 @@
|
||||
<th class="py-2 pr-3">Score</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="dashboardList" class="divide-y divide-slate-200 dark:divide-slate-800"></tbody>
|
||||
<tbody
|
||||
id="dashboardList"
|
||||
class="divide-y divide-slate-200 dark:divide-slate-800"
|
||||
></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</details>
|
||||
@@ -72,23 +140,45 @@
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 items-start">
|
||||
<div class="space-y-1">
|
||||
<div class="text-slate-700 dark:text-slate-300">Status: <span id="status" class="font-medium"></span></div>
|
||||
<div class="text-slate-700 dark:text-slate-300">Am Zug: <span id="guesser" class="font-medium"></span></div>
|
||||
<div class="text-slate-700 dark:text-slate-300">
|
||||
Status: <span id="status" class="font-medium"></span>
|
||||
</div>
|
||||
<div class="text-slate-700 dark:text-slate-300">
|
||||
Am Zug: <span id="guesser" class="font-medium"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-3 justify-start md:justify-end">
|
||||
<label class="inline-flex items-center gap-3 text-slate-700 dark:text-slate-300 select-none">
|
||||
<input type="checkbox" id="readyChk" class="peer sr-only" aria-label="Bereit um zu starten" />
|
||||
<span class="relative inline-flex h-6 w-10 shrink-0 cursor-pointer rounded-full bg-slate-300 transition-colors duration-200 dark:bg-slate-700 peer-checked:bg-emerald-500 peer-focus-visible:outline-none peer-focus-visible:ring-2 peer-focus-visible:ring-indigo-500 before:absolute before:top-1 before:left-1 before:h-4 before:w-4 before:rounded-full before:bg-white before:shadow before:transition-transform before:duration-200 peer-checked:before:translate-x-4"></span>
|
||||
<label
|
||||
class="inline-flex items-center gap-3 text-slate-700 dark:text-slate-300 select-none"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
id="readyChk"
|
||||
class="peer sr-only"
|
||||
aria-label="Bereit um zu starten"
|
||||
/>
|
||||
<span
|
||||
class="relative inline-flex h-6 w-10 shrink-0 cursor-pointer rounded-full bg-slate-300 transition-colors duration-200 dark:bg-slate-700 peer-checked:bg-emerald-500 peer-focus-visible:outline-none peer-focus-visible:ring-2 peer-focus-visible:ring-indigo-500 before:absolute before:top-1 before:left-1 before:h-4 before:w-4 before:rounded-full before:bg-white before:shadow before:transition-transform before:duration-200 peer-checked:before:translate-x-4"
|
||||
></span>
|
||||
<span>Bereit</span>
|
||||
</label>
|
||||
<button id="startGame" class="hidden h-10 px-4 rounded-lg bg-indigo-600 hover:bg-indigo-700 text-white font-medium">Spiel starten (Host)</button>
|
||||
<button
|
||||
id="startGame"
|
||||
class="hidden h-10 px-4 rounded-lg bg-indigo-600 hover:bg-indigo-700 text-white font-medium"
|
||||
>
|
||||
Spiel starten (Host)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="nowPlaying" class="hidden rounded-lg border border-slate-200 dark:border-slate-800 p-4 bg-slate-50/60 dark:bg-slate-800/60">
|
||||
<div
|
||||
id="nowPlaying"
|
||||
class="hidden rounded-lg border border-slate-200 dark:border-slate-800 p-4 bg-slate-50/60 dark:bg-slate-800/60"
|
||||
>
|
||||
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-3">
|
||||
<div class="text-lg font-semibold">
|
||||
<strong id="npTitle"> </strong><span id="npArtist"></span><span id="npYear" class="text-slate-500"></span>
|
||||
<strong id="npTitle"> </strong><span id="npArtist"></span
|
||||
><span id="npYear" class="text-slate-500"></span>
|
||||
</div>
|
||||
<div id="revealBanner" class="hidden"></div>
|
||||
</div>
|
||||
@@ -96,66 +186,149 @@
|
||||
<audio id="audio" preload="none" class="hidden"></audio>
|
||||
<div class="flex flex-col items-center">
|
||||
<!-- Record Disc -->
|
||||
<div class="relative" style="width: 200px; height: 200px;">
|
||||
<img id="recordDisc" src="/hitstar.png" alt="Record" class="w-full h-full rounded-full object-cover shadow-lg ring-2 ring-slate-300 dark:ring-slate-700" />
|
||||
<div class="relative" style="width: 200px; height: 200px">
|
||||
<img
|
||||
id="recordDisc"
|
||||
src="/hitstar.png"
|
||||
alt="Record"
|
||||
class="w-full h-full rounded-full object-cover shadow-lg ring-2 ring-slate-300 dark:ring-slate-700"
|
||||
/>
|
||||
<!-- center hole overlay -->
|
||||
<div class="pointer-events-none absolute inset-0 rounded-full" style="background: radial-gradient(circle at center, transparent 0 14px, rgba(0,0,0,0.22) 14px, transparent 16px);"></div>
|
||||
<div
|
||||
class="pointer-events-none absolute inset-0 rounded-full"
|
||||
style="
|
||||
background: radial-gradient(
|
||||
circle at center,
|
||||
transparent 0 14px,
|
||||
rgba(0, 0, 0, 0.22) 14px,
|
||||
transparent 16px
|
||||
);
|
||||
"
|
||||
></div>
|
||||
<!-- buffering badge -->
|
||||
<div id="bufferBadge" class="absolute bottom-2 left-1/2 -translate-x-1/2 rounded bg-slate-900/80 text-white text-xs px-2 py-1 hidden">Buffering…</div>
|
||||
<div
|
||||
id="bufferBadge"
|
||||
class="absolute bottom-2 left-1/2 -translate-x-1/2 rounded bg-slate-900/80 text-white text-xs px-2 py-1 hidden"
|
||||
>
|
||||
Buffering…
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Progress bar -->
|
||||
<div class="mt-4 w-full">
|
||||
<div class="relative h-2 rounded-full bg-slate-200 dark:bg-slate-700 overflow-hidden">
|
||||
<div id="progressFill" class="absolute left-0 top-0 h-full bg-indigo-600" style="width:0%"></div>
|
||||
<div
|
||||
class="relative h-2 rounded-full bg-slate-200 dark:bg-slate-700 overflow-hidden"
|
||||
>
|
||||
<div
|
||||
id="progressFill"
|
||||
class="absolute left-0 top-0 h-full bg-indigo-600"
|
||||
style="width: 0%"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Controls (Play/Pause restricted to guesser) -->
|
||||
<div id="mediaControls" class="hidden mt-4 flex items-center gap-3">
|
||||
<button id="playBtn" class="h-10 px-4 rounded-lg bg-emerald-600 hover:bg-emerald-700 text-white font-medium">Play</button>
|
||||
<button id="pauseBtn" class="h-10 px-4 rounded-lg bg-rose-600 hover:bg-rose-700 text-white font-medium">Pause</button>
|
||||
<button
|
||||
id="playBtn"
|
||||
class="h-10 px-4 rounded-lg bg-emerald-600 hover:bg-emerald-700 text-white font-medium"
|
||||
>
|
||||
Play
|
||||
</button>
|
||||
<button
|
||||
id="pauseBtn"
|
||||
class="h-10 px-4 rounded-lg bg-rose-600 hover:bg-rose-700 text-white font-medium"
|
||||
>
|
||||
Pause
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Volume (available to all players) -->
|
||||
<div class="mt-3">
|
||||
<label class="inline-flex items-center gap-2 text-sm text-slate-700 dark:text-slate-300">
|
||||
<label
|
||||
class="inline-flex items-center gap-2 text-sm text-slate-700 dark:text-slate-300"
|
||||
>
|
||||
Lautstärke
|
||||
<input id="volumeSlider" type="range" min="0" max="1" step="0.01" class="w-40 accent-indigo-600" />
|
||||
<input
|
||||
id="volumeSlider"
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
class="w-40 accent-indigo-600"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Answer form: everyone can try to guess title & artist for a coin -->
|
||||
<form id="answerForm" class="mt-4 w-full flex flex-col md:flex-row gap-2 md:items-end">
|
||||
<form
|
||||
id="answerForm"
|
||||
class="mt-4 w-full flex flex-col md:flex-row gap-2 md:items-end"
|
||||
>
|
||||
<label class="flex-1 text-sm">
|
||||
<span class="text-slate-700 dark:text-slate-300">Titel</span>
|
||||
<input id="guessTitle" name="title" placeholder="Songtitel" autocomplete="off" class="mt-1 w-full h-10 rounded-lg border border-slate-300 dark:border-slate-700 bg-white/80 dark:bg-slate-800 px-3" />
|
||||
<input
|
||||
id="guessTitle"
|
||||
name="title"
|
||||
placeholder="Songtitel"
|
||||
autocomplete="off"
|
||||
class="mt-1 w-full h-10 rounded-lg border border-slate-300 dark:border-slate-700 bg-white/80 dark:bg-slate-800 px-3"
|
||||
/>
|
||||
</label>
|
||||
<label class="flex-1 text-sm">
|
||||
<span class="text-slate-700 dark:text-slate-300">Künstler</span>
|
||||
<input id="guessArtist" name="artist" placeholder="Künstler" autocomplete="off" class="mt-1 w-full h-10 rounded-lg border border-slate-300 dark:border-slate-700 bg-white/80 dark:bg-slate-800 px-3" />
|
||||
<input
|
||||
id="guessArtist"
|
||||
name="artist"
|
||||
placeholder="Künstler"
|
||||
autocomplete="off"
|
||||
class="mt-1 w-full h-10 rounded-lg border border-slate-300 dark:border-slate-700 bg-white/80 dark:bg-slate-800 px-3"
|
||||
/>
|
||||
</label>
|
||||
<button id="submitAnswer" type="submit" class="h-10 px-4 rounded-lg bg-emerald-600 hover:bg-emerald-700 text-white font-medium">Abschicken</button>
|
||||
<button
|
||||
id="submitAnswer"
|
||||
type="submit"
|
||||
class="h-10 px-4 rounded-lg bg-emerald-600 hover:bg-emerald-700 text-white font-medium"
|
||||
>
|
||||
Abschicken
|
||||
</button>
|
||||
</form>
|
||||
<div id="answerResult" class="mt-1 text-sm"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3 flex flex-wrap items-center gap-3">
|
||||
<div id="placeArea" class="hidden flex items-center gap-2">
|
||||
<label class="text-sm text-slate-600 dark:text-slate-300">Position:
|
||||
<select id="slotSelect" class="ml-2 h-10 rounded-lg border border-slate-300 dark:border-slate-700 bg-white/80 dark:bg-slate-800 px-3"></select>
|
||||
<label class="text-sm text-slate-600 dark:text-slate-300"
|
||||
>Position:
|
||||
<select
|
||||
id="slotSelect"
|
||||
class="ml-2 h-10 rounded-lg border border-slate-300 dark:border-slate-700 bg-white/80 dark:bg-slate-800 px-3"
|
||||
></select>
|
||||
</label>
|
||||
<button id="placeBtn" class="h-10 px-4 rounded-lg bg-slate-900 hover:bg-slate-700 text-white font-medium dark:bg-slate-700 dark:hover:bg-slate-600">Platzieren</button>
|
||||
<button
|
||||
id="placeBtn"
|
||||
class="h-10 px-4 rounded-lg bg-slate-900 hover:bg-slate-700 text-white font-medium dark:bg-slate-700 dark:hover:bg-slate-600"
|
||||
>
|
||||
Platzieren
|
||||
</button>
|
||||
</div>
|
||||
<div id="nextArea" class="hidden">
|
||||
<button id="nextBtn" class="h-10 px-4 rounded-lg bg-indigo-600 hover:bg-indigo-700 text-white font-medium">Next</button>
|
||||
<button
|
||||
id="nextBtn"
|
||||
class="h-10 px-4 rounded-lg bg-indigo-600 hover:bg-indigo-700 text-white font-medium"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold mt-2">Deine Zeitleiste</h3>
|
||||
<div id="timeline" class="mt-2 flex flex-wrap gap-3 p-3 rounded-lg border border-slate-200 dark:border-slate-800 bg-white/60 dark:bg-slate-900/40 min-h-[64px]"></div>
|
||||
<div
|
||||
id="timeline"
|
||||
class="mt-2 flex flex-wrap gap-3 p-3 rounded-lg border border-slate-200 dark:border-slate-800 bg-white/60 dark:bg-slate-900/40 min-h-[64px]"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3 text-slate-700 dark:text-slate-300">
|
||||
@@ -164,6 +337,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/js/main.js" type="module"></script>
|
||||
<script src="/js/main.js" type="module"></script>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
||||
@@ -8,7 +8,8 @@ export function initAudioUI() {
|
||||
if ('webkitPreservesPitch' in $audio) $audio.webkitPreservesPitch = true;
|
||||
} catch {}
|
||||
$audio.addEventListener('timeupdate', () => {
|
||||
const dur = $audio.duration || 0; if (!dur || !$progressFill) return;
|
||||
const dur = $audio.duration || 0;
|
||||
if (!dur || !$progressFill) return;
|
||||
const pct = Math.min(100, Math.max(0, ($audio.currentTime / dur) * 100));
|
||||
$progressFill.style.width = pct + '%';
|
||||
});
|
||||
@@ -21,7 +22,10 @@ export function initAudioUI() {
|
||||
$audio.addEventListener('stalled', () => showBuffer(true));
|
||||
$audio.addEventListener('canplay', () => showBuffer(false));
|
||||
$audio.addEventListener('playing', () => showBuffer(false));
|
||||
$audio.addEventListener('ended', () => { if ($recordDisc) $recordDisc.classList.remove('spin-record'); $audio.playbackRate = 1.0; });
|
||||
$audio.addEventListener('ended', () => {
|
||||
if ($recordDisc) $recordDisc.classList.remove('spin-record');
|
||||
$audio.playbackRate = 1.0;
|
||||
});
|
||||
}
|
||||
|
||||
export function applySync(startAt, serverNow) {
|
||||
@@ -34,7 +38,7 @@ export function applySync(startAt, serverNow) {
|
||||
const abs = Math.abs(drift);
|
||||
if (abs > 1.0) {
|
||||
$audio.currentTime = Math.max(0, elapsed);
|
||||
if ($audio.paused) $audio.play().catch(()=>{});
|
||||
if ($audio.paused) $audio.play().catch(() => {});
|
||||
$audio.playbackRate = 1.0;
|
||||
} else if (abs > 0.12) {
|
||||
const maxNudge = 0.03;
|
||||
@@ -42,16 +46,32 @@ export function applySync(startAt, serverNow) {
|
||||
const rate = 1 + sign * Math.min(maxNudge, abs * 0.5);
|
||||
$audio.playbackRate = Math.max(0.8, Math.min(1.2, rate));
|
||||
} else {
|
||||
if (Math.abs($audio.playbackRate - 1) > 0.001) { $audio.playbackRate = 1.0; }
|
||||
if (Math.abs($audio.playbackRate - 1) > 0.001) {
|
||||
$audio.playbackRate = 1.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function stopAudioPlayback() {
|
||||
try { $audio.pause(); } catch {}
|
||||
try { $audio.currentTime = 0; } catch {}
|
||||
try { $audio.src = ''; } catch {}
|
||||
try { $audio.playbackRate = 1.0; } catch {}
|
||||
try { if ($recordDisc) $recordDisc.classList.remove('spin-record'); } catch {}
|
||||
try { if ($progressFill) $progressFill.style.width = '0%'; } catch {}
|
||||
try { if ($bufferBadge) $bufferBadge.classList.add('hidden'); } catch {}
|
||||
try {
|
||||
$audio.pause();
|
||||
} catch {}
|
||||
try {
|
||||
$audio.currentTime = 0;
|
||||
} catch {}
|
||||
try {
|
||||
$audio.src = '';
|
||||
} catch {}
|
||||
try {
|
||||
$audio.playbackRate = 1.0;
|
||||
} catch {}
|
||||
try {
|
||||
if ($recordDisc) $recordDisc.classList.remove('spin-record');
|
||||
} catch {}
|
||||
try {
|
||||
if ($progressFill) $progressFill.style.width = '0%';
|
||||
} catch {}
|
||||
try {
|
||||
if ($bufferBadge) $bufferBadge.classList.add('hidden');
|
||||
} catch {}
|
||||
}
|
||||
|
||||
@@ -44,5 +44,11 @@ export const $guessTitle = el('guessTitle');
|
||||
export const $guessArtist = el('guessArtist');
|
||||
export const $answerResult = el('answerResult');
|
||||
|
||||
export function showLobby() { $lobby.classList.remove('hidden'); $room.classList.add('hidden'); }
|
||||
export function showRoom() { $lobby.classList.add('hidden'); $room.classList.remove('hidden'); }
|
||||
export function showLobby() {
|
||||
$lobby.classList.remove('hidden');
|
||||
$room.classList.add('hidden');
|
||||
}
|
||||
export function showRoom() {
|
||||
$lobby.classList.add('hidden');
|
||||
$room.classList.remove('hidden');
|
||||
}
|
||||
|
||||
@@ -1,4 +1,31 @@
|
||||
import { $audio, $copyRoomCode, $leaveRoom, $nameDisplay, $nameLobby, $npArtist, $npTitle, $npYear, $pauseBtn, $placeBtn, $readyChk, $roomId, $roomCode, $slotSelect, $startGame, $volumeSlider, $playBtn, $nextBtn, $createRoom, $joinRoom, $lobby, $room, $setNameLobby, $guessTitle, $guessArtist, $answerResult } from './dom.js';
|
||||
import {
|
||||
$audio,
|
||||
$copyRoomCode,
|
||||
$leaveRoom,
|
||||
$nameDisplay,
|
||||
$nameLobby,
|
||||
$npArtist,
|
||||
$npTitle,
|
||||
$npYear,
|
||||
$pauseBtn,
|
||||
$placeBtn,
|
||||
$readyChk,
|
||||
$roomId,
|
||||
$roomCode,
|
||||
$slotSelect,
|
||||
$startGame,
|
||||
$volumeSlider,
|
||||
$playBtn,
|
||||
$nextBtn,
|
||||
$createRoom,
|
||||
$joinRoom,
|
||||
$lobby,
|
||||
$room,
|
||||
$setNameLobby,
|
||||
$guessTitle,
|
||||
$guessArtist,
|
||||
$answerResult,
|
||||
} from './dom.js';
|
||||
import { state } from './state.js';
|
||||
import { connectWS, sendMsg, cacheSessionId, cacheLastRoomId } from './ws.js';
|
||||
import { renderRoom } from './render.js';
|
||||
@@ -56,8 +83,13 @@ function handlePlayTrack(msg) {
|
||||
// reset answer UI
|
||||
if ($guessTitle) $guessTitle.value = '';
|
||||
if ($guessArtist) $guessArtist.value = '';
|
||||
if ($answerResult) { $answerResult.textContent=''; $answerResult.className='mt-1 text-sm'; }
|
||||
try { $audio.preload = 'auto'; } catch {}
|
||||
if ($answerResult) {
|
||||
$answerResult.textContent = '';
|
||||
$answerResult.className = 'mt-1 text-sm';
|
||||
}
|
||||
try {
|
||||
$audio.preload = 'auto';
|
||||
} catch {}
|
||||
$audio.src = t.url;
|
||||
const pf = document.getElementById('progressFill');
|
||||
if (pf) {
|
||||
@@ -124,10 +156,12 @@ function handleReveal(msg) {
|
||||
if ($rb) {
|
||||
if (result.correct) {
|
||||
$rb.textContent = 'Richtig!';
|
||||
$rb.className = 'inline-block rounded-md bg-emerald-600 text-white px-3 py-1 text-sm font-medium';
|
||||
$rb.className =
|
||||
'inline-block rounded-md bg-emerald-600 text-white px-3 py-1 text-sm font-medium';
|
||||
} else {
|
||||
$rb.textContent = 'Falsch!';
|
||||
$rb.className = 'inline-block rounded-md bg-rose-600 text-white px-3 py-1 text-sm font-medium';
|
||||
$rb.className =
|
||||
'inline-block rounded-md bg-rose-600 text-white px-3 py-1 text-sm font-medium';
|
||||
}
|
||||
}
|
||||
const $placeArea = document.getElementById('placeArea');
|
||||
@@ -139,8 +173,12 @@ function handleReveal(msg) {
|
||||
if (rd && track?.id) {
|
||||
const coverUrl = `/cover/${encodeURIComponent(track.id)}`;
|
||||
const img = new Image();
|
||||
img.onload = () => { rd.src = coverUrl; };
|
||||
img.onerror = () => { /* keep default logo */ };
|
||||
img.onload = () => {
|
||||
rd.src = coverUrl;
|
||||
};
|
||||
img.onerror = () => {
|
||||
/* keep default logo */
|
||||
};
|
||||
img.src = coverUrl + `?t=${Date.now()}`; // bypass cache just in case
|
||||
}
|
||||
}
|
||||
@@ -197,7 +235,9 @@ function onMessage(ev) {
|
||||
if (msg.awarded) coin = ' +1 Token';
|
||||
else if (msg.alreadyAwarded) coin = ' (bereits erhalten)';
|
||||
$answerResult.textContent = `${parts.join(' · ')}${coin}`;
|
||||
$answerResult.className = okBoth ? 'mt-1 text-sm text-emerald-600' : 'mt-1 text-sm text-amber-600';
|
||||
$answerResult.className = okBoth
|
||||
? 'mt-1 text-sm text-emerald-600'
|
||||
: 'mt-1 text-sm text-amber-600';
|
||||
}
|
||||
}
|
||||
break;
|
||||
@@ -239,14 +279,26 @@ function wireUi() {
|
||||
});
|
||||
wire($leaveRoom, 'click', () => {
|
||||
sendMsg({ type: 'leave_room' });
|
||||
// Clear all local storage entries on leave
|
||||
try { localStorage.clear(); } catch {}
|
||||
stopAudioPlayback();
|
||||
// Clear all local storage entries on leave
|
||||
try {
|
||||
localStorage.clear();
|
||||
} catch {}
|
||||
stopAudioPlayback();
|
||||
state.room = null;
|
||||
// Reset visible name inputs/labels
|
||||
if ($nameLobby) { try { $nameLobby.value = ''; } catch {} }
|
||||
if ($nameDisplay) { $nameDisplay.textContent = ''; }
|
||||
if ($readyChk) { try { $readyChk.checked = false; } catch {} }
|
||||
// Reset visible name inputs/labels
|
||||
if ($nameLobby) {
|
||||
try {
|
||||
$nameLobby.value = '';
|
||||
} catch {}
|
||||
}
|
||||
if ($nameDisplay) {
|
||||
$nameDisplay.textContent = '';
|
||||
}
|
||||
if ($readyChk) {
|
||||
try {
|
||||
$readyChk.checked = false;
|
||||
} catch {}
|
||||
}
|
||||
$lobby.classList.remove('hidden');
|
||||
$room.classList.add('hidden');
|
||||
});
|
||||
@@ -307,7 +359,10 @@ function wireUi() {
|
||||
const title = ($guessTitle?.value || '').trim();
|
||||
const artist = ($guessArtist?.value || '').trim();
|
||||
if (!title || !artist) {
|
||||
if ($answerResult) { $answerResult.textContent = 'Bitte Titel und Künstler eingeben'; $answerResult.className = 'mt-1 text-sm text-amber-600'; }
|
||||
if ($answerResult) {
|
||||
$answerResult.textContent = 'Bitte Titel und Künstler eingeben';
|
||||
$answerResult.className = 'mt-1 text-sm text-amber-600';
|
||||
}
|
||||
return;
|
||||
}
|
||||
sendMsg({ type: 'submit_answer', guess: { title, artist } });
|
||||
|
||||
@@ -1,28 +1,62 @@
|
||||
import { state } from './state.js';
|
||||
import { badgeColorForYear } from '../utils/colors.js';
|
||||
import { $answerForm, $answerResult, $dashboardList, $guesser, $lobby, $mediaControls, $nameDisplay, $nextArea, $np, $placeArea, $readyChk, $revealBanner, $room, $roomId, $slotSelect, $startGame, $status, $timeline, $tokens } from './dom.js';
|
||||
import {
|
||||
$answerForm,
|
||||
$answerResult,
|
||||
$dashboardList,
|
||||
$guesser,
|
||||
$lobby,
|
||||
$mediaControls,
|
||||
$nameDisplay,
|
||||
$nextArea,
|
||||
$np,
|
||||
$placeArea,
|
||||
$readyChk,
|
||||
$revealBanner,
|
||||
$room,
|
||||
$roomId,
|
||||
$slotSelect,
|
||||
$startGame,
|
||||
$status,
|
||||
$timeline,
|
||||
$tokens,
|
||||
} from './dom.js';
|
||||
|
||||
export function renderRoom(room) {
|
||||
state.room = room; if (!room) { $lobby.classList.remove('hidden'); $room.classList.add('hidden'); return; }
|
||||
try { localStorage.setItem('lastRoomId', room.id); } catch {}
|
||||
$lobby.classList.add('hidden'); $room.classList.remove('hidden');
|
||||
state.room = room;
|
||||
if (!room) {
|
||||
$lobby.classList.remove('hidden');
|
||||
$room.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
localStorage.setItem('lastRoomId', room.id);
|
||||
} catch {}
|
||||
$lobby.classList.add('hidden');
|
||||
$room.classList.remove('hidden');
|
||||
$roomId.textContent = room.id;
|
||||
$status.textContent = room.state.status;
|
||||
$guesser.textContent = shortName(room.state.currentGuesser);
|
||||
const me = room.players.find(p=>p.id===state.playerId);
|
||||
if ($nameDisplay) $nameDisplay.textContent = (me?.name || localStorage.getItem('playerName') || '-');
|
||||
const me = room.players.find((p) => p.id === state.playerId);
|
||||
if ($nameDisplay)
|
||||
$nameDisplay.textContent = me?.name || localStorage.getItem('playerName') || '-';
|
||||
if ($dashboardList) {
|
||||
$dashboardList.innerHTML = room.players.map(p => {
|
||||
const connected = p.connected ? '<span class="text-emerald-600">online</span>' : '<span class="text-rose-600">offline</span>';
|
||||
const ready = p.ready ? '<span class="text-emerald-600">bereit</span>' : '<span class="text-slate-400">-</span>';
|
||||
const score = (room.state.timeline?.[p.id]?.length) ?? 0;
|
||||
const isMe = p.id === state.playerId;
|
||||
return `
|
||||
$dashboardList.innerHTML = room.players
|
||||
.map((p) => {
|
||||
const connected = p.connected
|
||||
? '<span class="text-emerald-600">online</span>'
|
||||
: '<span class="text-rose-600">offline</span>';
|
||||
const ready = p.ready
|
||||
? '<span class="text-emerald-600">bereit</span>'
|
||||
: '<span class="text-slate-400">-</span>';
|
||||
const score = room.state.timeline?.[p.id]?.length ?? 0;
|
||||
const isMe = p.id === state.playerId;
|
||||
return `
|
||||
<tr class="align-top">
|
||||
<td class="py-2 pr-3">
|
||||
<div class="inline-flex items-center gap-1">
|
||||
<span>${escapeHtml(p.name)}</span>${p.spectator ? ' <span title="Zuschauer">👻</span>' : ''}
|
||||
${p.id===room.hostId ? '<span title="Host" class="text-amber-600">\u2B50</span>' : ''}
|
||||
${p.id === room.hostId ? '<span title="Host" class="text-amber-600">\u2B50</span>' : ''}
|
||||
${isMe ? '<span title="Du" class="text-indigo-600">(du)</span>' : ''}
|
||||
</div>
|
||||
</td>
|
||||
@@ -30,16 +64,18 @@ export function renderRoom(room) {
|
||||
<td class="py-2 pr-3">${ready}</td>
|
||||
<td class="py-2 pr-3 font-semibold tabular-nums">${score}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
})
|
||||
.join('');
|
||||
}
|
||||
const myTl = room.state.timeline?.[state.playerId] || [];
|
||||
$timeline.innerHTML = myTl.map(t => {
|
||||
const title = escapeHtml(t.title || t.trackId || 'Unbekannt');
|
||||
const artist = t.artist ? escapeHtml(t.artist) : '';
|
||||
const year = (t.year ?? '?');
|
||||
const badgeStyle = badgeColorForYear(year);
|
||||
return `
|
||||
<div class="flex items-center gap-2 border border-slate-200 dark:border-slate-800 rounded-lg px-3 py-2 bg-white text-slate-900 dark:bg-slate-800 dark:text-slate-100 shadow-sm" title="${title}${artist? ' — '+artist : ''} (${year})">
|
||||
$timeline.innerHTML = myTl
|
||||
.map((t) => {
|
||||
const title = escapeHtml(t.title || t.trackId || 'Unbekannt');
|
||||
const artist = t.artist ? escapeHtml(t.artist) : '';
|
||||
const year = t.year ?? '?';
|
||||
const badgeStyle = badgeColorForYear(year);
|
||||
return `
|
||||
<div class="flex items-center gap-2 border border-slate-200 dark:border-slate-800 rounded-lg px-3 py-2 bg-white text-slate-900 dark:bg-slate-800 dark:text-slate-100 shadow-sm" title="${title}${artist ? ' — ' + artist : ''} (${year})">
|
||||
<div class="font-bold tabular-nums text-white rounded-md px-2 py-0.5 min-w-[3ch] text-center" style="${badgeStyle}">${year}</div>
|
||||
<div class="leading-tight">
|
||||
<div class="font-semibold">${title}</div>
|
||||
@@ -47,20 +83,29 @@ export function renderRoom(room) {
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
})
|
||||
.join('');
|
||||
$tokens.textContent = room.state.tokens?.[state.playerId] ?? 0;
|
||||
if ($readyChk) {
|
||||
const serverReady = !!me?.ready;
|
||||
if (state.pendingReady === null || state.pendingReady === undefined) { $readyChk.checked = serverReady; }
|
||||
else { $readyChk.checked = !!state.pendingReady; if (serverReady === state.pendingReady) state.pendingReady = null; }
|
||||
if (state.pendingReady === null || state.pendingReady === undefined) {
|
||||
$readyChk.checked = serverReady;
|
||||
} else {
|
||||
$readyChk.checked = !!state.pendingReady;
|
||||
if (serverReady === state.pendingReady) state.pendingReady = null;
|
||||
}
|
||||
$readyChk.parentElement.classList.toggle('hidden', room.state.status !== 'lobby');
|
||||
}
|
||||
const isHost = state.playerId === room.hostId;
|
||||
const activePlayers = room.players.filter(p => !p.spectator && p.connected);
|
||||
const allReady = activePlayers.length>0 && activePlayers.every(p=>p.ready);
|
||||
const canStart = room.state.status==='lobby' && isHost && allReady;
|
||||
const activePlayers = room.players.filter((p) => !p.spectator && p.connected);
|
||||
const allReady = activePlayers.length > 0 && activePlayers.every((p) => p.ready);
|
||||
const canStart = room.state.status === 'lobby' && isHost && allReady;
|
||||
if ($startGame) $startGame.classList.toggle('hidden', !canStart);
|
||||
const isMyTurn = room.state.status==='playing' && room.state.phase==='guess' && room.state.currentGuesser===state.playerId && room.state.currentTrack;
|
||||
const isMyTurn =
|
||||
room.state.status === 'playing' &&
|
||||
room.state.phase === 'guess' &&
|
||||
room.state.currentGuesser === state.playerId &&
|
||||
room.state.currentTrack;
|
||||
const canGuess = isMyTurn;
|
||||
// Media controls (play/pause) only for current guesser while guessing and a track is active
|
||||
if ($mediaControls) $mediaControls.classList.toggle('hidden', !isMyTurn);
|
||||
@@ -89,19 +134,37 @@ export function renderRoom(room) {
|
||||
$placeArea.classList.toggle('hidden', !canGuess);
|
||||
}
|
||||
$np.classList.toggle('hidden', !room.state.currentTrack);
|
||||
if ($revealBanner) { const inReveal = room.state.phase === 'reveal'; if (!inReveal) { $revealBanner.className = 'hidden'; $revealBanner.textContent=''; } }
|
||||
const canNext = room.state.status==='playing' && room.state.phase==='reveal' && (isHost || room.state.currentGuesser===state.playerId);
|
||||
if ($revealBanner) {
|
||||
const inReveal = room.state.phase === 'reveal';
|
||||
if (!inReveal) {
|
||||
$revealBanner.className = 'hidden';
|
||||
$revealBanner.textContent = '';
|
||||
}
|
||||
}
|
||||
const canNext =
|
||||
room.state.status === 'playing' &&
|
||||
room.state.phase === 'reveal' &&
|
||||
(isHost || room.state.currentGuesser === state.playerId);
|
||||
if ($nextArea) $nextArea.classList.toggle('hidden', !canNext);
|
||||
// Answer form visible during guess phase while a track is active
|
||||
const showAnswer = room.state.status==='playing' && room.state.phase==='guess' && !!room.state.currentTrack;
|
||||
const showAnswer =
|
||||
room.state.status === 'playing' && room.state.phase === 'guess' && !!room.state.currentTrack;
|
||||
if ($answerForm) $answerForm.classList.toggle('hidden', !showAnswer);
|
||||
if ($answerResult && !showAnswer) { $answerResult.textContent=''; $answerResult.className='mt-1 text-sm'; }
|
||||
if ($answerResult && !showAnswer) {
|
||||
$answerResult.textContent = '';
|
||||
$answerResult.className = 'mt-1 text-sm';
|
||||
}
|
||||
}
|
||||
|
||||
export function shortName(id) {
|
||||
if (!id) return '-';
|
||||
const p = state.room?.players.find(x=>x.id===id);
|
||||
return p ? p.name : id.slice(0,4);
|
||||
const p = state.room?.players.find((x) => x.id === id);
|
||||
return p ? p.name : id.slice(0, 4);
|
||||
}
|
||||
|
||||
export function escapeHtml(s) { return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); }
|
||||
export function escapeHtml(s) {
|
||||
return String(s).replace(
|
||||
/[&<>"']/g,
|
||||
(c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,53 +1,58 @@
|
||||
import { state } from './state.js';
|
||||
|
||||
let ws;
|
||||
let reconnectAttempts = 0;
|
||||
let reconnectTimer = null;
|
||||
// Assumes socket.io client library is loaded globally as io
|
||||
let socket;
|
||||
const outbox = [];
|
||||
let sessionId = localStorage.getItem('sessionId') || null;
|
||||
let lastRoomId = localStorage.getItem('lastRoomId') || null;
|
||||
let _lastRoomId = localStorage.getItem('lastRoomId') || null;
|
||||
|
||||
export function wsIsOpen() { return ws && ws.readyState === WebSocket.OPEN; }
|
||||
export function sendMsg(obj) { if (wsIsOpen()) ws.send(JSON.stringify(obj)); else outbox.push(obj); }
|
||||
|
||||
function scheduleReconnect(connect) {
|
||||
if (reconnectTimer) return;
|
||||
const delay = Math.min(30000, 1000 * Math.pow(2, reconnectAttempts));
|
||||
reconnectAttempts++;
|
||||
reconnectTimer = setTimeout(() => { reconnectTimer = null; connect(); }, delay);
|
||||
export function wsIsOpen() {
|
||||
return !!socket?.connected;
|
||||
}
|
||||
export function sendMsg(obj) {
|
||||
if (wsIsOpen()) socket.emit('message', obj);
|
||||
else outbox.push(obj);
|
||||
}
|
||||
|
||||
export function connectWS(onMessage) {
|
||||
const url = (location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host;
|
||||
ws = new WebSocket(url);
|
||||
ws.addEventListener('open', () => {
|
||||
reconnectAttempts = 0;
|
||||
// Establish Socket.IO connection in websocket-only mode
|
||||
socket = window.io({ transports: ['websocket'] });
|
||||
socket.on('connect', () => {
|
||||
// Try to resume session immediately on (re)connect
|
||||
if (sessionId) {
|
||||
try { ws.send(JSON.stringify({ type: 'resume', sessionId })); } catch {}
|
||||
try {
|
||||
socket.emit('message', { type: 'resume', sessionId });
|
||||
} catch {}
|
||||
}
|
||||
setTimeout(() => { while (outbox.length && wsIsOpen()) { try { ws.send(JSON.stringify(outbox.shift())); } catch { break; } } }, 100);
|
||||
// flush queued
|
||||
setTimeout(() => {
|
||||
while (outbox.length && wsIsOpen()) {
|
||||
try {
|
||||
socket.emit('message', outbox.shift());
|
||||
} catch {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}, 50);
|
||||
});
|
||||
ws.addEventListener('message', (ev) => onMessage(ev));
|
||||
ws.addEventListener('close', () => { scheduleReconnect(() => connectWS(onMessage)); });
|
||||
ws.addEventListener('error', () => { try { ws.close(); } catch {} });
|
||||
socket.on('message', (msg) => {
|
||||
// Adapt to previous onmessage(ev) signature used by main.js
|
||||
const ev = { data: JSON.stringify(msg) };
|
||||
onMessage(ev);
|
||||
});
|
||||
// Socket.IO handles reconnection internally; no manual timers required
|
||||
}
|
||||
|
||||
window.addEventListener('online', () => {
|
||||
if (!wsIsOpen()) {
|
||||
if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
|
||||
// Kick off a reconnect by calling connectWS from app main again
|
||||
}
|
||||
});
|
||||
|
||||
// Helpers to update cached ids from other modules
|
||||
export function cacheSessionId(id) {
|
||||
if (!id) return;
|
||||
sessionId = id;
|
||||
try { localStorage.setItem('sessionId', id); } catch {}
|
||||
try {
|
||||
localStorage.setItem('sessionId', id);
|
||||
} catch {}
|
||||
}
|
||||
export function cacheLastRoomId(id) {
|
||||
if (!id) return;
|
||||
lastRoomId = id;
|
||||
try { localStorage.setItem('lastRoomId', id); } catch {}
|
||||
_lastRoomId = id;
|
||||
try {
|
||||
localStorage.setItem('lastRoomId', id);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
206
public/style.css
206
public/style.css
@@ -1,37 +1,177 @@
|
||||
:root { color-scheme: light dark; }
|
||||
html { -webkit-text-size-adjust: 100%; touch-action: manipulation; }
|
||||
body { font-family: system-ui, sans-serif; margin: 0 auto; padding: 1rem; padding-bottom: calc(1rem + env(safe-area-inset-bottom)); max-width: 960px; }
|
||||
h1 { margin-top: 0; }
|
||||
.card { border: 1px solid #8884; padding: 1rem; border-radius: 12px; margin-bottom: 1rem; }
|
||||
.row { display: flex; gap: .5rem; align-items: center; flex-wrap: wrap; }
|
||||
.row.space { justify-content: space-between; }
|
||||
.hidden { display: none; }
|
||||
.muted { opacity: .7; font-size: .9em; }
|
||||
button, input, select { padding: .7rem 1rem; min-height: 44px; font-size: 1rem; border-radius: 10px; }
|
||||
button { cursor: pointer; }
|
||||
input, select { border: 1px solid #8884; background: inherit; color: inherit; }
|
||||
.timeline { display: flex; gap: .75rem; flex-wrap: wrap; padding: .75rem; border: 1px dashed #8886; min-height: 64px; border-radius: 12px; }
|
||||
.chip { padding: .25rem .5rem; border-radius: 999px; border: 1px solid #8886; }
|
||||
.np { display: grid; grid-template-columns: 1fr; gap: .5rem; align-items: center; margin: .5rem 0; }
|
||||
.track-card { display: flex; align-items: center; gap: .5rem; border: 1px solid #8885; border-radius: 8px; padding: .4rem .6rem; background: #fff; color: #000; box-shadow: 0 1px 2px #0001; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.track-card { background: #1b1b1b; color: #eee; border-color: #ffffff22; box-shadow: 0 1px 2px #0005; }
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
}
|
||||
html {
|
||||
-webkit-text-size-adjust: 100%;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
body {
|
||||
font-family: system-ui, sans-serif;
|
||||
margin: 0 auto;
|
||||
padding: 1rem;
|
||||
padding-bottom: calc(1rem + env(safe-area-inset-bottom));
|
||||
max-width: 960px;
|
||||
}
|
||||
h1 {
|
||||
margin-top: 0;
|
||||
}
|
||||
.card {
|
||||
border: 1px solid #8884;
|
||||
padding: 1rem;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.row.space {
|
||||
justify-content: space-between;
|
||||
}
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
.muted {
|
||||
opacity: 0.7;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
button,
|
||||
input,
|
||||
select {
|
||||
padding: 0.7rem 1rem;
|
||||
min-height: 44px;
|
||||
font-size: 1rem;
|
||||
border-radius: 10px;
|
||||
}
|
||||
button {
|
||||
cursor: pointer;
|
||||
}
|
||||
input,
|
||||
select {
|
||||
border: 1px solid #8884;
|
||||
background: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
.timeline {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
padding: 0.75rem;
|
||||
border: 1px dashed #8886;
|
||||
min-height: 64px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
.chip {
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid #8886;
|
||||
}
|
||||
.np {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
.track-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
border: 1px solid #8885;
|
||||
border-radius: 8px;
|
||||
padding: 0.4rem 0.6rem;
|
||||
background: #fff;
|
||||
color: #000;
|
||||
box-shadow: 0 1px 2px #0001;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.track-card {
|
||||
background: #1b1b1b;
|
||||
color: #eee;
|
||||
border-color: #ffffff22;
|
||||
box-shadow: 0 1px 2px #0005;
|
||||
}
|
||||
}
|
||||
.year-badge {
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
background: #6200ee;
|
||||
color: white;
|
||||
border-radius: 6px;
|
||||
padding: 0.15rem 0.4rem;
|
||||
min-width: 3ch;
|
||||
text-align: center;
|
||||
}
|
||||
.track-info {
|
||||
display: grid;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.track-title {
|
||||
font-weight: 600;
|
||||
}
|
||||
.track-artist {
|
||||
opacity: 0.8;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.year-badge { font-weight: 700; font-variant-numeric: tabular-nums; background: #6200ee; color: white; border-radius: 6px; padding: .15rem .4rem; min-width: 3ch; text-align: center; }
|
||||
.track-info { display: grid; line-height: 1.2; }
|
||||
.track-title { font-weight: 600; }
|
||||
.track-artist { opacity: .8; font-size: .9em; }
|
||||
|
||||
@media (max-width: 800px) {
|
||||
body { padding: .75rem; }
|
||||
h1 { font-size: 1.5rem; }
|
||||
.row { gap: .5rem; }
|
||||
.timeline { flex-wrap: nowrap; overflow-x: auto; scroll-snap-type: x mandatory; -webkit-overflow-scrolling: touch; }
|
||||
.track-card { flex: 0 0 auto; scroll-snap-align: start; padding: .6rem .8rem; min-width: 220px; }
|
||||
.year-badge { padding: .2rem .5rem; }
|
||||
#placeArea { position: sticky; bottom: 0; left: 0; right: 0; padding: .5rem; gap: .5rem; background: color-mix(in srgb, Canvas 92%, transparent); backdrop-filter: blur(6px); border: 1px solid #8883; border-radius: 12px; box-shadow: 0 -4px 12px #0002; z-index: 10; }
|
||||
#placeArea button { flex: 1 1 auto; }
|
||||
#placeArea select { flex: 2 1 auto; min-width: 40vw; }
|
||||
body {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
.row {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.timeline {
|
||||
flex-wrap: nowrap;
|
||||
overflow-x: auto;
|
||||
scroll-snap-type: x mandatory;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
.track-card {
|
||||
flex: 0 0 auto;
|
||||
scroll-snap-align: start;
|
||||
padding: 0.6rem 0.8rem;
|
||||
min-width: 220px;
|
||||
}
|
||||
.year-badge {
|
||||
padding: 0.2rem 0.5rem;
|
||||
}
|
||||
#placeArea {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 0.5rem;
|
||||
gap: 0.5rem;
|
||||
background: color-mix(in srgb, Canvas 92%, transparent);
|
||||
backdrop-filter: blur(6px);
|
||||
border: 1px solid #8883;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 -4px 12px #0002;
|
||||
z-index: 10;
|
||||
}
|
||||
#placeArea button {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
#placeArea select {
|
||||
flex: 2 1 auto;
|
||||
min-width: 40vw;
|
||||
}
|
||||
}
|
||||
.banner-ok {
|
||||
background: #1b5e20;
|
||||
color: white;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.banner-bad {
|
||||
background: #b71c1c;
|
||||
color: white;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.banner-ok { background: #1b5e20; color: white; padding: .5rem .75rem; border-radius: 6px; }
|
||||
.banner-bad { background: #b71c1c; color: white; padding: .5rem .75rem; border-radius: 6px; }
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
// Stable distinct color per year for the year badge
|
||||
export function badgeColorForYear(y) {
|
||||
const val = (y === undefined || y === null) ? '?' : y;
|
||||
const val = y === undefined || y === null ? '?' : y;
|
||||
if (val === '?' || Number.isNaN(Number(val))) {
|
||||
// Neutral slate for unknown years
|
||||
return 'background-color: hsl(215 16% 34%);';
|
||||
}
|
||||
const n = Number(val);
|
||||
const hue = ((n * 23) % 360 + 360) % 360; // spread hues deterministically
|
||||
const hue = (((n * 23) % 360) + 360) % 360; // spread hues deterministically
|
||||
return `background-color: hsl(${hue} 70% 42%);`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user