init commit

This commit is contained in:
2025-09-03 19:34:00 +02:00
commit 4cbf97cc5a
12 changed files with 11751 additions and 0 deletions

428
public/client.js Normal file
View File

@@ -0,0 +1,428 @@
let ws;
let reconnectAttempts = 0;
let reconnectTimer = null;
const outbox = [];
function wsIsOpen() { return ws && ws.readyState === WebSocket.OPEN; }
function sendMsg(obj) {
if (wsIsOpen()) ws.send(JSON.stringify(obj));
else outbox.push(obj);
}
function scheduleReconnect() {
if (reconnectTimer) return;
const delay = Math.min(30000, 1000 * Math.pow(2, reconnectAttempts));
reconnectAttempts++;
reconnectTimer = setTimeout(() => {
reconnectTimer = null;
connectWS();
}, delay);
}
function connectWS() {
const url = (location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host;
ws = new WebSocket(url);
ws.addEventListener('open', () => {
reconnectAttempts = 0;
// Flush queued messages
setTimeout(() => {
while (outbox.length && wsIsOpen()) {
try { ws.send(JSON.stringify(outbox.shift())); } catch { break; }
}
}, 100);
});
ws.addEventListener('message', (ev) => handleMessage(ev));
ws.addEventListener('close', () => { scheduleReconnect(); });
ws.addEventListener('error', () => { try { ws.close(); } catch {} });
}
let state = {
playerId: null,
room: null,
lastTrack: null,
revealed: false,
pendingReady: null,
isBuffering: false,
};
// Elements
const el = (id) => document.getElementById(id);
const $lobby = el('lobby');
const $room = el('room');
const $players = el('players');
const $roomId = el('roomId');
const $status = el('status');
const $guesser = el('guesser');
const $timeline = el('timeline');
const $tokens = el('tokens');
const $audio = el('audio');
if ($audio) { try { $audio.preload = 'none'; } catch {} }
const $np = document.getElementById('nowPlaying');
const $npTitle = el('npTitle');
const $npArtist = el('npArtist');
const $npYear = el('npYear');
const $readyChk = document.getElementById('readyChk');
const $startGame = document.getElementById('startGame');
const $revealBanner = document.getElementById('revealBanner');
const $placeArea = document.getElementById('placeArea');
const $slotSelect = document.getElementById('slotSelect');
const $placeBtn = document.getElementById('placeBtn');
const $mediaControls = document.getElementById('mediaControls');
const $playBtn = document.getElementById('playBtn');
const $pauseBtn = document.getElementById('pauseBtn');
const $nextArea = document.getElementById('nextArea');
const $nextBtn = document.getElementById('nextBtn');
// Custom player UI
const $recordDisc = document.getElementById('recordDisc');
const $progressFill = document.getElementById('progressFill');
const $volumeSlider = document.getElementById('volumeSlider');
const $bufferBadge = document.getElementById('bufferBadge');
// Copy Room Code button
const $copyRoomCode = document.getElementById('copyRoomCode');
// Name (lobby input + room display)
const $nameLobby = document.getElementById('name');
const $setNameLobby = document.getElementById('setName');
const $nameDisplay = document.getElementById('nameDisplay');
function showLobby() { $lobby.classList.remove('hidden'); $room.classList.add('hidden'); }
function showRoom() { $lobby.classList.add('hidden'); $room.classList.remove('hidden'); }
function renderRoom(room) {
state.room = room;
if (!room) { showLobby(); return; }
showRoom();
$roomId.textContent = room.id;
// Ensure copy button is visible and set up
if ($copyRoomCode) {
$copyRoomCode.style.display = 'inline-block';
$copyRoomCode.onclick = function() {
if (room.id) {
navigator.clipboard.writeText(room.id).then(() => {
$copyRoomCode.textContent = '✔️';
showToast('Code kopiert!');
setTimeout(()=>{$copyRoomCode.textContent = '📋';}, 1200);
});
}
};
}
// Also allow clicking the room code itself to copy
if ($roomId) {
$roomId.onclick = function() {
if (room.id) {
navigator.clipboard.writeText(room.id).then(() => {
$roomId.title = 'Kopiert!';
showToast('Code kopiert!');
setTimeout(()=>{$roomId.title = 'Klicken zum Kopieren';}, 1200);
});
}
};
$roomId.style.cursor = 'pointer';
}
const $toast = document.getElementById('toast');
function showToast(msg) {
if (!$toast) return;
$toast.textContent = msg;
$toast.style.opacity = '1';
setTimeout(() => {
$toast.style.opacity = '0';
}, 1200);
}
$status.textContent = room.state.status;
$guesser.textContent = shortName(room.state.currentGuesser);
// Show my current name (from server if available) or fallback to stored value
const me = room.players.find(p=>p.id===state.playerId);
if ($nameDisplay) $nameDisplay.textContent = (me?.name || localStorage.getItem('playerName') || '-');
$players.innerHTML = room.players.map(p => {
const badges = [
p.id===room.hostId ? '<span class="ml-1 text-amber-600">⭐</span>' : '',
p.ready ? '<span class="ml-1 text-emerald-600">✓</span>' : '',
!p.connected ? '<span class="ml-1 text-rose-600">(off)</span>' : '',
].join('');
return `<span class="inline-flex items-center gap-1 px-2 py-1 rounded-full border border-slate-300 dark:border-slate-700 text-sm">${escapeHtml(p.name)}${badges}</span>`;
}).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 ?? '?');
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 bg-indigo-600 text-white rounded-md px-2 py-0.5 min-w-[3ch] text-center">${year}</div>
<div class="leading-tight">
<div class="font-semibold">${title}</div>
<div class="text-sm text-slate-600 dark:text-slate-300">${artist}</div>
</div>
</div>
`;
}).join('');
$tokens.textContent = room.state.tokens?.[state.playerId] ?? 0;
// Ready control visibility
if ($readyChk) {
const serverReady = !!me?.ready;
// If user recently toggled, keep local visual state until server matches
if (state.pendingReady === null || state.pendingReady === undefined) {
$readyChk.checked = serverReady;
} else {
$readyChk.checked = !!state.pendingReady;
// Clear pending once it matches server
if (serverReady === state.pendingReady) state.pendingReady = null;
}
$readyChk.parentElement.classList.toggle('hidden', room.state.status !== 'lobby');
}
// Host start button when all ready
const isHost = state.playerId === room.hostId;
const allReady = room.players.length>0 && room.players.every(p=>p.ready);
if ($startGame) $startGame.classList.toggle('hidden', !(isHost && room.state.status==='lobby' && allReady));
// Show guess buttons only when it's my turn and a track is active
const isMyTurn = room.state.status==='playing' && room.state.phase==='guess' && room.state.currentGuesser===state.playerId && room.state.currentTrack;
const canGuess = isMyTurn;
// Build slot options: 0..n
if ($placeArea && $slotSelect) {
if (canGuess) {
const tl = room.state.timeline?.[state.playerId] || [];
$slotSelect.innerHTML = '';
for (let i = 0; i <= tl.length; i++) {
const left = i>0 ? (tl[i-1]?.year ?? '?') : null;
const right = i<tl.length ? (tl[i]?.year ?? '?') : null;
let label = '';
if (tl.length === 0) label = 'Einsetzen';
else if (i === 0) label = `Vor (${right})`;
else if (i === tl.length) label = `Nach (${left})`;
else label = `Zwischen (${left} / ${right})`;
const opt = document.createElement('option');
opt.value = String(i);
opt.textContent = label;
$slotSelect.appendChild(opt);
}
}
$placeArea.classList.toggle('hidden', !canGuess);
}
// Show now playing if track exists
$np.classList.toggle('hidden', !room.state.currentTrack);
// Keep reveal banner visible during reveal; otherwise hide/reset it
if ($revealBanner) {
const inReveal = room.state.phase === 'reveal';
if (!inReveal) { $revealBanner.className = 'hidden'; $revealBanner.textContent=''; }
}
// Media controls only for current guesser during guess phase
if ($mediaControls) $mediaControls.classList.toggle('hidden', !isMyTurn);
// Next button visible in reveal to host or current guesser
const canNext = room.state.status==='playing' && room.state.phase==='reveal' && (isHost || room.state.currentGuesser===state.playerId);
if ($nextArea) $nextArea.classList.toggle('hidden', !canNext);
}
function shortName(id) {
if (!id) return '-';
const p = state.room?.players.find(x=>x.id===id);
return p ? p.name : id.slice(0,4);
}
function escapeHtml(s) { return String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); }
function handleMessage(ev) {
const msg = JSON.parse(ev.data);
if (msg.type === 'connected') {
state.playerId = msg.playerId;
// Try to auto-apply stored name
const stored = localStorage.getItem('playerName');
if (stored) {
if ($nameLobby && $nameLobby.value !== stored) {
$nameLobby.value = stored;
}
if ($nameDisplay) {
$nameDisplay.textContent = stored;
}
sendMsg({ type: 'set_name', name: stored });
}
// Try to rejoin room if known
if (state.room?.id) sendMsg({ type: 'join_room', code: state.room.id });
}
if (msg.type === 'room_update') {
renderRoom(msg.room);
}
if (msg.type === 'play_track') {
const t = msg.track;
state.lastTrack = t;
state.revealed = false;
// Hide metadata until a guess is placed
$npTitle.textContent = '???';
$npArtist.textContent = '';
$npYear.textContent = '';
try { $audio.preload = 'auto'; } catch {}
$audio.src = t.url;
// Reset custom UI
if ($progressFill) $progressFill.style.width = '0%';
if ($recordDisc) $recordDisc.classList.remove('spin-record');
// Sync start using server-provided timestamp
const { startAt, serverNow } = msg;
if (startAt && serverNow) {
const now = Date.now();
const offsetMs = startAt - serverNow; // server delay until start
const localStart = now + offsetMs;
const delay = Math.max(0, localStart - now);
setTimeout(() => {
$audio.currentTime = 0;
$audio.play().catch(()=>{});
if ($recordDisc) $recordDisc.classList.add('spin-record');
}, delay);
} else {
$audio.play().catch(()=>{});
if ($recordDisc) $recordDisc.classList.add('spin-record');
}
if (state.room) renderRoom(state.room);
}
if (msg.type === 'sync') {
const { startAt, serverNow } = msg;
if (!state.room?.state?.currentTrack || !startAt || !serverNow) return;
if (state.room?.state?.paused) return; // don't auto-resume while paused
if (state.isBuffering) return; // avoid corrections while buffering
const now = Date.now();
const elapsed = (now - startAt) / 1000; // seconds
const drift = ($audio.currentTime || 0) - elapsed;
// Soft sync via playbackRate adjustments; hard seek if way off
const abs = Math.abs(drift);
if (abs > 1.0) {
// Hard correct when over 1s
$audio.currentTime = Math.max(0, elapsed);
if ($audio.paused) $audio.play().catch(()=>{});
$audio.playbackRate = 1.0;
} else if (abs > 0.12) {
// Gently nudge speed up to +/-3%
const maxNudge = 0.03;
const sign = drift > 0 ? -1 : 1; // if ahead (positive drift), slow down
const rate = 1 + sign * Math.min(maxNudge, abs * 0.5);
$audio.playbackRate = Math.max(0.8, Math.min(1.2, rate));
} else {
// Close enough
if (Math.abs($audio.playbackRate - 1) > 0.001) {
$audio.playbackRate = 1.0;
}
}
}
if (msg.type === 'control') {
const { action, startAt, serverNow } = msg;
if (action === 'pause') {
$audio.pause();
if ($recordDisc) $recordDisc.classList.remove('spin-record');
$audio.playbackRate = 1.0;
} else if (action === 'play') {
if (startAt && serverNow) {
const now = Date.now();
const elapsed = (now - startAt) / 1000;
$audio.currentTime = Math.max(0, elapsed);
}
$audio.play().catch(()=>{});
if ($recordDisc) $recordDisc.classList.add('spin-record');
}
}
if (msg.type === 'reveal') {
const { result, track } = msg;
// Reveal metadata
$npTitle.textContent = track.title || track.id || 'Track';
$npArtist.textContent = track.artist ? ` ${track.artist}` : '';
$npYear.textContent = track.year ? ` (${track.year})` : '';
state.revealed = true;
// Show banner
if ($revealBanner) {
if (result.correct) {
$revealBanner.textContent = 'Richtig!';
$revealBanner.className = 'inline-block rounded-md bg-emerald-600 text-white px-3 py-1 text-sm font-medium';
} else {
$revealBanner.textContent = 'Falsch!';
$revealBanner.className = 'inline-block rounded-md bg-rose-600 text-white px-3 py-1 text-sm font-medium';
}
}
// Hide placement during reveal
if ($placeArea) $placeArea.classList.add('hidden');
}
if (msg.type === 'game_ended') {
alert(`Gewinner: ${shortName(msg.winner)}`);
}
}
// Start connection
connectWS();
window.addEventListener('online', () => {
if (!wsIsOpen()) {
if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
connectWS();
}
});
// UI events
el('setName').onclick = () => {
const name = $nameLobby.value.trim();
if (!name) return;
localStorage.setItem('playerName', name);
if ($nameDisplay) $nameDisplay.textContent = name;
sendMsg({ type: 'set_name', name });
};
el('createRoom').onclick = () => { sendMsg({ type: 'create_room' }); };
el('joinRoom').onclick = () => {
const code = el('roomCode').value.trim();
if (code) sendMsg({ type: 'join_room', code });
};
el('leaveRoom').onclick = () => {
sendMsg({ type: 'leave_room' });
state.room = null; showLobby();
};
el('startGame').onclick = () => sendMsg({ type: 'start_game' });
document.getElementById('readyChk').onchange = (e) => {
const val = !!e.target.checked;
state.pendingReady = val;
sendMsg({ type: 'set_ready', ready: val });
};
el('earnToken').onclick = () => sendMsg({ type: 'earn_token' });
if ($placeBtn) {
$placeBtn.onclick = () => {
const slot = parseInt($slotSelect.value, 10);
sendMsg({ type: 'place_guess', slot });
};
}
if ($playBtn) $playBtn.onclick = () => sendMsg({ type: 'player_control', action: 'play' });
if ($pauseBtn) $pauseBtn.onclick = () => sendMsg({ type: 'player_control', action: 'pause' });
if ($nextBtn) $nextBtn.onclick = () => sendMsg({ type: 'next_track' });
// Progress + volume updates
if ($audio) {
// Try to preserve pitch during slight playbackRate changes if supported
try {
if ('preservesPitch' in $audio) $audio.preservesPitch = true;
if ('mozPreservesPitch' in $audio) $audio.mozPreservesPitch = true;
if ('webkitPreservesPitch' in $audio) $audio.webkitPreservesPitch = true;
} catch {}
$audio.addEventListener('timeupdate', () => {
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 + '%';
});
const showBuffer = (v) => {
state.isBuffering = v;
if ($bufferBadge) $bufferBadge.classList.toggle('hidden', !v);
if ($recordDisc) $recordDisc.classList.toggle('spin-record', !v && !$audio.paused);
};
$audio.addEventListener('waiting', () => showBuffer(true));
$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;
});
}
if ($volumeSlider && $audio) {
// Initialize from current volume
try { $volumeSlider.value = String($audio.volume ?? 1); } catch {}
$volumeSlider.addEventListener('input', () => {
$audio.volume = parseFloat($volumeSlider.value);
});
}
// Try to restore room view if server restarts won't preserve sessions; basic behavior only
(() => {
const saved = localStorage.getItem('playerName');
if (saved) {
if ($nameLobby && $nameLobby.value !== saved) $nameLobby.value = saved;
if ($nameDisplay) $nameDisplay.textContent = saved;
}
})();

BIN
public/hitstar.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

137
public/index.html Normal file
View File

@@ -0,0 +1,137 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Hitstar Web</title>
<script src="https://cdn.tailwindcss.com"></script>
<style>
@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>
</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>
<!-- 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 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"/>
</label>
<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>
</div>
<p class="text-sm text-slate-500">MP3-Dateien in den Ordner <code class="px-1 rounded bg-slate-100 dark:bg-slate-800">data/</code> legen und Server starten.</p>
</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 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>
<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>
<h3 class="text-sm font-semibold text-slate-500 uppercase tracking-wide">Spieler</h3>
<div id="players" class="mt-2 flex flex-wrap gap-2"></div>
</div>
<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>
<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>
<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>
</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 class="flex flex-col md:flex-row md:items-center md:justify-between gap-3">
<div class="text-lg font-semibold">
<strong id="npTitle">&nbsp;</strong><span id="npArtist"></span><span id="npYear" class="text-slate-500"></span>
</div>
<div id="revealBanner" class="hidden"></div>
</div>
<div class="mt-3">
<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" />
<!-- 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>
<!-- 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>
<!-- 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>
</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>
</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">
Lautstärke
<input id="volumeSlider" type="range" min="0" max="1" step="0.01" class="w-40 accent-indigo-600" />
</label>
</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>
<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>
</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>
<div class="flex items-center gap-3 text-slate-700 dark:text-slate-300">
Tokens: <span id="tokens" class="font-semibold">0</span>
<button id="earnToken" class="h-9 px-3 rounded-lg border border-slate-300 dark:border-slate-700 hover:bg-slate-50 dark:hover:bg-slate-800 text-sm">+1 (Titel & Künstler richtig)</button>
</div>
</div>
</div>
<script src="/client.js" type="module"></script>
</body>
</html>

37
public/style.css Normal file
View File

@@ -0,0 +1,37 @@
: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; }
}
.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; }
}
.banner-ok { background: #1b5e20; color: white; padding: .5rem .75rem; border-radius: 6px; }
.banner-bad { background: #b71c1c; color: white; padding: .5rem .75rem; border-radius: 6px; }