Initial build: fullhouse, live music bingo from a real Spotify playlist
All checks were successful
Docker / build-and-push (push) Successful in 1m18s
All checks were successful
Docker / build-and-push (push) Successful in 1m18s
Based on bingo-bango by f8al (MIT) — same core card-generation idea
("both facets": a square is either an exact song title or an artist,
and an artist square lights up on any of their tracks), reworked with
a real host/guest split, live multiplayer sessions, and cover-art
squares. f8al credited in the footer, README, and PROPOSAL.md.
Three ways to play: host a live session (server-side Spotify
connection, join code + QR, caller screen, auto-call from real
playback), join someone else's session (no login needed), or solo
(guest PKCE login or a pasted public playlist URL, one-off card).
Genuinely verified against a real Spotify account throughout, not
just built and assumed working — including two real bugs found and
fixed along the way (Spotify's playlist-tracks endpoint quietly
renamed to /items with a reshaped response; reading playlist tracks
needs real user auth even for public playlists, so the "no guest
login" public-playlist path routes through the host's connection
instead of a dead-end app-only token). Both Docker images built and
run together on a real network with the nginx proxy verified working
end-to-end, and auto-call confirmed detecting an actual track playing
live through Spotify Connect within one poll tick.
This commit is contained in:
24
client/index.html
Normal file
24
client/index.html
Normal file
@@ -0,0 +1,24 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>fullhouse</title>
|
||||
<link rel="stylesheet" href="/style.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<header>
|
||||
<a href="#/" class="brand">> <span class="accent">fullhouse</span></a>
|
||||
<p class="tagline">music bingo from a real playlist</p>
|
||||
</header>
|
||||
<main id="view"></main>
|
||||
<footer>
|
||||
<p class="muted">
|
||||
based on <a href="https://github.com/f8al/bingo-bango" target="_blank" rel="noopener">bingo-bango</a> by f8al (MIT) — reworked with live sessions, host mode, and cover-art squares
|
||||
</p>
|
||||
</footer>
|
||||
</div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
1002
client/package-lock.json
generated
Normal file
1002
client/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
15
client/package.json
Normal file
15
client/package.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "fullhouse-client",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --port 5190",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview --port 5190"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.8.0",
|
||||
"vite": "^5.4.0"
|
||||
}
|
||||
}
|
||||
226
client/public/style.css
Normal file
226
client/public/style.css
Normal file
@@ -0,0 +1,226 @@
|
||||
:root {
|
||||
--bg: #080808;
|
||||
--bg-alt: #0e0e0e;
|
||||
--text: #c8c8c8;
|
||||
--muted: #555;
|
||||
--heading: #f0f0f0;
|
||||
--accent: #00e87a;
|
||||
--accent-dim: rgba(0, 232, 122, 0.12);
|
||||
--accent-border:rgba(0, 232, 122, 0.25);
|
||||
--border: rgba(255, 255, 255, 0.07);
|
||||
--shadow: 0 0 0 1px rgba(255,255,255,0.04), 0 4px 24px rgba(0,0,0,0.6);
|
||||
--error: #ff6b6b;
|
||||
--error-dim: rgba(255, 107, 107, 0.14);
|
||||
|
||||
--mono: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', ui-monospace, monospace;
|
||||
--sans: 'Inter', ui-sans-serif, system-ui, -apple-system, sans-serif;
|
||||
|
||||
font: 16px/1.6 var(--sans);
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
color-scheme: dark;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
body { margin: 0; }
|
||||
|
||||
#app {
|
||||
width: 720px;
|
||||
max-width: 100%;
|
||||
margin: 0 auto;
|
||||
padding: 32px 16px 64px;
|
||||
}
|
||||
|
||||
h1, h2, h3 { font-family: var(--mono); color: var(--heading); line-height: 1.15; margin: 0 0 0.5em; }
|
||||
p { margin: 0; }
|
||||
a { color: var(--accent); text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
.accent { color: var(--accent); }
|
||||
.muted { color: var(--muted); font-size: 0.85em; }
|
||||
|
||||
header { text-align: center; margin-bottom: 28px; }
|
||||
header .brand { font-family: var(--mono); font-size: 1.6em; font-weight: 700; color: var(--heading); }
|
||||
header .tagline { color: var(--muted); font-size: 0.95em; margin-top: 4px; }
|
||||
|
||||
footer { text-align: center; margin-top: 40px; }
|
||||
footer a { color: var(--muted); text-decoration: underline; }
|
||||
|
||||
.card {
|
||||
background: var(--bg-alt);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 20px;
|
||||
box-shadow: var(--shadow);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.stack { display: flex; flex-direction: column; gap: 12px; }
|
||||
.row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
|
||||
.row.between { justify-content: space-between; }
|
||||
.center { text-align: center; }
|
||||
|
||||
button, .btn {
|
||||
font: inherit;
|
||||
font-family: var(--mono);
|
||||
background: var(--accent-dim);
|
||||
color: var(--accent);
|
||||
border: 1px solid var(--accent-border);
|
||||
border-radius: 8px;
|
||||
padding: 10px 18px;
|
||||
cursor: pointer;
|
||||
display: inline-block;
|
||||
}
|
||||
button:hover, .btn:hover { background: rgba(0, 232, 122, 0.2); text-decoration: none; }
|
||||
button:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
button.secondary { background: transparent; color: var(--text); border-color: var(--border); }
|
||||
button.secondary:hover { border-color: var(--muted); background: rgba(255,255,255,0.04); }
|
||||
|
||||
input[type="text"], select {
|
||||
font: inherit;
|
||||
font-family: var(--mono);
|
||||
background: var(--bg-alt);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
width: 100%;
|
||||
}
|
||||
input[type="text"]:focus, select:focus { outline: none; border-color: var(--accent-border); }
|
||||
|
||||
.playlist-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; max-height: 400px; overflow-y: auto; }
|
||||
.playlist-row {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
padding: 8px; border-radius: 8px; cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
.playlist-row:hover { background: var(--accent-dim); border-color: var(--accent-border); }
|
||||
.playlist-row img { width: 40px; height: 40px; border-radius: 6px; object-fit: cover; background: var(--border); }
|
||||
.playlist-row .name { flex: 1; font-weight: 600; color: var(--heading); }
|
||||
.playlist-row .count { color: var(--muted); font-size: 0.85em; }
|
||||
|
||||
.session-code {
|
||||
font-family: var(--mono);
|
||||
font-size: 2.2em;
|
||||
letter-spacing: 0.15em;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Bingo card grid */
|
||||
.bingo-grid {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
background: var(--border);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.bingo-square {
|
||||
background: var(--bg-alt);
|
||||
aspect-ratio: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
padding: 6px;
|
||||
font-size: 0.72em;
|
||||
line-height: 1.25;
|
||||
color: var(--text);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.bingo-square.free { background: var(--accent-dim); color: var(--accent); font-weight: 700; }
|
||||
.bingo-square.marked { background: var(--accent-dim); box-shadow: inset 0 0 0 2px var(--accent); color: var(--heading); }
|
||||
.bingo-square img.cover { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; opacity: 0.85; }
|
||||
|
||||
/* Full-width gradient strip anchored to the bottom, not a floating pill
|
||||
— a fixed-opacity pill reads fine on a dark cover but disappears
|
||||
against a busy/light one. A gradient that goes fully opaque at the
|
||||
very bottom guarantees contrast regardless of what's under it. */
|
||||
.bingo-square .label {
|
||||
position: absolute;
|
||||
left: 0; right: 0; bottom: 0;
|
||||
z-index: 1;
|
||||
padding: 14px 6px 6px;
|
||||
background: linear-gradient(to bottom, transparent, rgba(0,0,0,0.85) 55%, rgba(0,0,0,0.92));
|
||||
color: #fff;
|
||||
text-shadow: 0 1px 2px rgba(0,0,0,0.8);
|
||||
}
|
||||
.bingo-square:not(:has(img.cover)) .label {
|
||||
position: static;
|
||||
background: none;
|
||||
padding: 0;
|
||||
text-shadow: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.facet-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px; height: 16px;
|
||||
border-radius: 50%;
|
||||
font-size: 0.85em;
|
||||
line-height: 1;
|
||||
}
|
||||
.facet-badge.facet-title { background: var(--accent-dim); color: var(--accent); }
|
||||
.facet-badge.facet-artist { background: rgba(255, 200, 0, 0.15); color: #ffc800; }
|
||||
.bingo-square .facet-badge {
|
||||
position: absolute; top: 4px; left: 4px; z-index: 2;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.6);
|
||||
}
|
||||
|
||||
.card-legend { font-size: 0.8em; margin-bottom: 10px; }
|
||||
.card-legend .facet-badge { vertical-align: -3px; margin: 0 2px; }
|
||||
|
||||
.bingo-square.tappable { cursor: pointer; }
|
||||
.bingo-square.tappable:hover { outline: 1px solid var(--accent-border); outline-offset: -1px; }
|
||||
|
||||
.bingo-square.marked::after {
|
||||
content: '✓';
|
||||
position: absolute; top: 4px; right: 4px; z-index: 2;
|
||||
color: #fff; font-weight: 700;
|
||||
background: var(--accent);
|
||||
width: 16px; height: 16px;
|
||||
border-radius: 50%;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 0.75em;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.6);
|
||||
}
|
||||
|
||||
.bingo-banner {
|
||||
text-align: center; padding: 16px; margin-top: 12px;
|
||||
background: var(--accent-dim); border: 1px solid var(--accent-border);
|
||||
border-radius: 8px; color: var(--accent); font-family: var(--mono); font-weight: 700;
|
||||
font-size: 1.3em;
|
||||
}
|
||||
|
||||
/* Caller screen */
|
||||
.caller-now {
|
||||
text-align: center;
|
||||
padding: 32px 16px;
|
||||
}
|
||||
.caller-now .track-title { font-size: 1.8em; font-weight: 700; color: var(--heading); }
|
||||
.caller-now .track-artist { color: var(--muted); margin-top: 4px; }
|
||||
.caller-now img { width: 160px; height: 160px; border-radius: 10px; margin: 0 auto 16px; display: block; box-shadow: var(--shadow); }
|
||||
|
||||
.called-history { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 12px; }
|
||||
.called-chip {
|
||||
font-size: 0.75em; padding: 4px 8px; border-radius: 999px;
|
||||
background: var(--bg); border: 1px solid var(--border); color: var(--muted);
|
||||
}
|
||||
|
||||
.error-box {
|
||||
background: var(--error-dim); border: 1px solid var(--error);
|
||||
color: var(--error); border-radius: 8px; padding: 12px 14px; font-size: 0.9em;
|
||||
}
|
||||
|
||||
.toggle-row { display: flex; align-items: center; gap: 8px; }
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.bingo-square { font-size: 0.62em; }
|
||||
}
|
||||
140
client/src/cards/generate.ts
Normal file
140
client/src/cards/generate.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
// Card generation engine. The "both facets" idea — a square can hold
|
||||
// either a track title or an artist name, and playing a track marks
|
||||
// both the title square and any artist squares for that artist — is
|
||||
// f8al's design from bingo-bango (https://github.com/f8al/bingo-bango,
|
||||
// MIT licensed). This module is a fresh implementation of that shape,
|
||||
// not copied code, extended with a cover-art render mode the original
|
||||
// doesn't have.
|
||||
//
|
||||
// Pure, dependency-free, deterministic given a seed — same property
|
||||
// the original's engine has, load-bearing here too: a guest's card
|
||||
// has to reproduce identically from a shareable seed/URL.
|
||||
|
||||
export type PoolTrack = {
|
||||
id: string;
|
||||
title: string;
|
||||
artist: string;
|
||||
album: string;
|
||||
image: string | null;
|
||||
};
|
||||
|
||||
export type SquareFacet = 'title' | 'artist' | 'free';
|
||||
|
||||
export type CardSquare = {
|
||||
facet: SquareFacet;
|
||||
text: string;
|
||||
image: string | null;
|
||||
/** Track ids that mark this square when called. A title square has
|
||||
* exactly one; an artist square can have several if the artist
|
||||
* appears more than once in the pool. */
|
||||
matchIds: string[];
|
||||
};
|
||||
|
||||
export type BingoCard = {
|
||||
seed: number;
|
||||
size: number;
|
||||
squares: CardSquare[]; // row-major, length size*size
|
||||
};
|
||||
|
||||
// Small, fast, deterministic PRNG (mulberry32) — no crypto needed, the
|
||||
// point is reproducibility from a seed, not unpredictability.
|
||||
function mulberry32(seed: number) {
|
||||
let a = seed;
|
||||
return function () {
|
||||
a |= 0;
|
||||
a = (a + 0x6d2b79f5) | 0;
|
||||
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
function shuffle<T>(arr: T[], rand: () => number): T[] {
|
||||
const out = arr.slice();
|
||||
for (let i = out.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(rand() * (i + 1));
|
||||
[out[i], out[j]] = [out[j], out[i]];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export type CardOptions = {
|
||||
size: 3 | 4 | 5;
|
||||
freeSpace: boolean;
|
||||
/** Roughly how much of the grid is artist squares vs title squares —
|
||||
* 0.5 matches the original's ~50/50 split. */
|
||||
artistRatio?: number;
|
||||
};
|
||||
|
||||
export function generateCard(pool: PoolTrack[], seed: number, opts: CardOptions): BingoCard {
|
||||
const { size, freeSpace, artistRatio = 0.5 } = opts;
|
||||
const cellCount = size * size;
|
||||
const needed = cellCount - (freeSpace ? 1 : 0);
|
||||
if (pool.length < needed) {
|
||||
throw new Error(`pool has ${pool.length} tracks, need at least ${needed} for a ${size}x${size} card`);
|
||||
}
|
||||
|
||||
const rand = mulberry32(seed);
|
||||
const chosen = shuffle(pool, rand).slice(0, needed);
|
||||
|
||||
// Group by artist so an artist square can list every matching track id.
|
||||
const byArtist = new Map<string, PoolTrack[]>();
|
||||
for (const t of pool) {
|
||||
const list = byArtist.get(t.artist) ?? [];
|
||||
list.push(t);
|
||||
byArtist.set(t.artist, list);
|
||||
}
|
||||
|
||||
const squares: CardSquare[] = chosen.map((track) => {
|
||||
const useArtist = rand() < artistRatio;
|
||||
if (useArtist) {
|
||||
const matches = byArtist.get(track.artist) ?? [track];
|
||||
return {
|
||||
facet: 'artist',
|
||||
text: track.artist,
|
||||
image: track.image,
|
||||
matchIds: matches.map((m) => m.id),
|
||||
};
|
||||
}
|
||||
return {
|
||||
facet: 'title',
|
||||
text: track.title,
|
||||
image: track.image,
|
||||
matchIds: [track.id],
|
||||
};
|
||||
});
|
||||
|
||||
if (freeSpace) {
|
||||
const center = Math.floor(cellCount / 2);
|
||||
squares.splice(center, 0, { facet: 'free', text: 'FREE', image: null, matchIds: [] });
|
||||
}
|
||||
|
||||
return { seed, size, squares };
|
||||
}
|
||||
|
||||
/** Which squares light up when a track is called. */
|
||||
export function markedIndices(card: BingoCard, calledTrackId: string): number[] {
|
||||
const out: number[] = [];
|
||||
card.squares.forEach((sq, i) => {
|
||||
if (sq.matchIds.includes(calledTrackId)) out.push(i);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
/** True if any full row, column, or diagonal is fully covered. */
|
||||
export function hasBingo(card: BingoCard, markedSet: Set<number>): boolean {
|
||||
const { size } = card;
|
||||
const at = (r: number, c: number) => r * size + c;
|
||||
const isMarked = (r: number, c: number) => card.squares[at(r, c)].facet === 'free' || markedSet.has(at(r, c));
|
||||
|
||||
for (let r = 0; r < size; r++) {
|
||||
if (Array.from({ length: size }, (_, c) => isMarked(r, c)).every(Boolean)) return true;
|
||||
}
|
||||
for (let c = 0; c < size; c++) {
|
||||
if (Array.from({ length: size }, (_, r) => isMarked(r, c)).every(Boolean)) return true;
|
||||
}
|
||||
if (Array.from({ length: size }, (_, i) => isMarked(i, i)).every(Boolean)) return true;
|
||||
if (Array.from({ length: size }, (_, i) => isMarked(i, size - 1 - i)).every(Boolean)) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
45
client/src/lib/api.ts
Normal file
45
client/src/lib/api.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
// Thin wrapper over fullhouse's own server (host mode + sessions).
|
||||
// Guest PKCE calls go straight to Spotify instead — see pkce.ts.
|
||||
const BASE = import.meta.env.VITE_API_BASE || '';
|
||||
|
||||
async function req<T>(path: string, opts?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${BASE}${path}`, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
...opts,
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || `${path} → ${res.status}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
export type HostPlaylist = { id: string; name: string; trackCount: number; image: string | null };
|
||||
export type PoolTrack = { id: string; title: string; artist: string; album: string; image: string | null };
|
||||
|
||||
export const api = {
|
||||
hostStatus: () => req<{ connected: boolean }>('/api/spotify/status'),
|
||||
hostPlaylists: () => req<HostPlaylist[]>('/api/host/playlists'),
|
||||
hostPlaylistTracks: (id: string) => req<PoolTrack[]>(`/api/host/playlist/${id}/tracks`),
|
||||
publicPlaylist: (url: string) =>
|
||||
req<{ name: string; image: string | null; tracks: PoolTrack[] }>(`/api/public-playlist?url=${encodeURIComponent(url)}`),
|
||||
|
||||
createSession: (playlistId: string, playlistName: string) =>
|
||||
req<{ code: string; playlistName: string; trackCount: number }>('/api/session', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ playlistId, playlistName }),
|
||||
}),
|
||||
joinSession: (code: string) =>
|
||||
req<{ code: string; playlistName: string; tracks: PoolTrack[]; calledIds: string[]; autoCall: boolean }>(
|
||||
`/api/session/${code}`
|
||||
),
|
||||
sessionState: (code: string) =>
|
||||
req<{ calledIds: string[]; autoCall: boolean }>(`/api/session/${code}/state`),
|
||||
callTrack: (code: string, trackId: string) =>
|
||||
req<{ calledIds: string[] }>(`/api/session/${code}/call`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ trackId }),
|
||||
}),
|
||||
callRandom: (code: string) =>
|
||||
req<{ called: PoolTrack; calledIds: string[] }>(`/api/session/${code}/call-random`, { method: 'POST' }),
|
||||
autoCallStart: (code: string) => req<{ ok: true }>(`/api/session/${code}/autocall/start`, { method: 'POST' }),
|
||||
autoCallStop: (code: string) => req<{ ok: true }>(`/api/session/${code}/autocall/stop`, { method: 'POST' }),
|
||||
};
|
||||
141
client/src/lib/pkce.ts
Normal file
141
client/src/lib/pkce.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
// Guest self-service mode: a visitor logs into their own Spotify account
|
||||
// via Authorization Code with PKCE — no client secret, no server
|
||||
// involvement, tokens never leave the browser. Same flow shape as the
|
||||
// original bingo-bango (https://github.com/f8al/bingo-bango, MIT) uses
|
||||
// for its only mode; this is a fresh implementation, not copied code.
|
||||
const CLIENT_ID = import.meta.env.VITE_SPOTIFY_CLIENT_ID as string;
|
||||
// Root URL, not a dedicated path — a single static index.html handles
|
||||
// the callback via a query param (?code=...) instead of needing a
|
||||
// second HTML entry point or server-side rewrite rules for a nested
|
||||
// route. Simpler to deploy (plain nginx, no routing config).
|
||||
const REDIRECT_URI = `${window.location.origin}/`;
|
||||
const SCOPES = 'playlist-read-private playlist-read-collaborative';
|
||||
|
||||
const VERIFIER_KEY = 'fh:pkce_verifier';
|
||||
const TOKEN_KEY = 'fh:guest_token';
|
||||
|
||||
function base64url(bytes: Uint8Array): string {
|
||||
return btoa(String.fromCharCode(...bytes))
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/, '');
|
||||
}
|
||||
|
||||
async function sha256(input: string): Promise<Uint8Array> {
|
||||
const data = new TextEncoder().encode(input);
|
||||
const digest = await crypto.subtle.digest('SHA-256', data);
|
||||
return new Uint8Array(digest);
|
||||
}
|
||||
|
||||
function randomVerifier(): string {
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(64));
|
||||
return base64url(bytes);
|
||||
}
|
||||
|
||||
export async function startGuestLogin(): Promise<void> {
|
||||
const verifier = randomVerifier();
|
||||
sessionStorage.setItem(VERIFIER_KEY, verifier);
|
||||
const challenge = base64url(await sha256(verifier));
|
||||
|
||||
const url = new URL('https://accounts.spotify.com/authorize');
|
||||
url.search = new URLSearchParams({
|
||||
client_id: CLIENT_ID,
|
||||
response_type: 'code',
|
||||
redirect_uri: REDIRECT_URI,
|
||||
code_challenge_method: 'S256',
|
||||
code_challenge: challenge,
|
||||
scope: SCOPES,
|
||||
}).toString();
|
||||
window.location.href = url.toString();
|
||||
}
|
||||
|
||||
export type GuestToken = { access_token: string; expires_at: number };
|
||||
|
||||
export async function completeGuestLogin(code: string): Promise<GuestToken> {
|
||||
const verifier = sessionStorage.getItem(VERIFIER_KEY);
|
||||
if (!verifier) throw new Error('missing PKCE verifier — start login again');
|
||||
|
||||
const res = await fetch('https://accounts.spotify.com/api/token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
client_id: CLIENT_ID,
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
redirect_uri: REDIRECT_URI,
|
||||
code_verifier: verifier,
|
||||
}).toString(),
|
||||
});
|
||||
if (!res.ok) throw new Error(`token exchange failed: ${res.status}`);
|
||||
const data = await res.json();
|
||||
const token: GuestToken = {
|
||||
access_token: data.access_token,
|
||||
expires_at: Date.now() + (data.expires_in - 60) * 1000,
|
||||
};
|
||||
sessionStorage.setItem(TOKEN_KEY, JSON.stringify(token));
|
||||
sessionStorage.removeItem(VERIFIER_KEY);
|
||||
return token;
|
||||
}
|
||||
|
||||
export function getStoredGuestToken(): GuestToken | null {
|
||||
const raw = sessionStorage.getItem(TOKEN_KEY);
|
||||
if (!raw) return null;
|
||||
const token: GuestToken = JSON.parse(raw);
|
||||
if (Date.now() >= token.expires_at) {
|
||||
sessionStorage.removeItem(TOKEN_KEY);
|
||||
return null;
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
export function guestLogout(): void {
|
||||
sessionStorage.removeItem(TOKEN_KEY);
|
||||
}
|
||||
|
||||
// Direct-to-Spotify calls — no server involvement in guest mode, by
|
||||
// design (the whole point of PKCE is no backend holding the token).
|
||||
async function guestFetch(path: string, token: string) {
|
||||
const res = await fetch(`https://api.spotify.com/v1${path}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!res.ok) throw new Error(`Spotify ${path} → ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export type GuestPlaylist = { id: string; name: string; trackCount: number; image: string | null };
|
||||
|
||||
export async function fetchGuestPlaylists(token: string): Promise<GuestPlaylist[]> {
|
||||
const out: GuestPlaylist[] = [];
|
||||
let url = '/me/playlists?limit=50';
|
||||
while (url) {
|
||||
const page = await guestFetch(url, token);
|
||||
for (const p of page.items ?? []) {
|
||||
out.push({ id: p.id, name: p.name, trackCount: p.items?.total ?? p.tracks?.total ?? 0, image: p.images?.[0]?.url ?? null });
|
||||
}
|
||||
url = page.next ? page.next.replace('https://api.spotify.com/v1', '') : '';
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export type GuestTrack = { id: string; title: string; artist: string; album: string; image: string | null };
|
||||
|
||||
export async function fetchGuestPlaylistTracks(token: string, playlistId: string): Promise<GuestTrack[]> {
|
||||
const out: GuestTrack[] = [];
|
||||
let url = `/playlists/${playlistId}/items?limit=100&fields=next,items(item(id,name,artists(name),album(name,images)))`;
|
||||
while (url) {
|
||||
const page = await guestFetch(url, token);
|
||||
for (const entry of page.items ?? []) {
|
||||
const t = entry.item;
|
||||
if (!t) continue;
|
||||
out.push({
|
||||
id: t.id,
|
||||
title: t.name,
|
||||
artist: t.artists?.[0]?.name ?? 'Unknown artist',
|
||||
album: t.album?.name ?? '',
|
||||
image: t.album?.images?.[0]?.url ?? null,
|
||||
});
|
||||
}
|
||||
url = page.next ? page.next.replace('https://api.spotify.com/v1', '') : '';
|
||||
}
|
||||
return out;
|
||||
}
|
||||
407
client/src/main.ts
Normal file
407
client/src/main.ts
Normal file
@@ -0,0 +1,407 @@
|
||||
import { api, type PoolTrack } from './lib/api';
|
||||
import { startGuestLogin, completeGuestLogin, getStoredGuestToken, fetchGuestPlaylists, fetchGuestPlaylistTracks, guestLogout } from './lib/pkce';
|
||||
import { generateCard, markedIndices, hasBingo, type BingoCard, type CardOptions } from './cards/generate';
|
||||
|
||||
const view = document.getElementById('view')!;
|
||||
|
||||
function esc(s: unknown): string {
|
||||
return String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
// Shared by the live session view and solo mode — the "both facets"
|
||||
// mechanic (a square is either an exact song title or an artist, and
|
||||
// an artist square lights up on ANY of their tracks) is f8al's design
|
||||
// from bingo-bango; nothing in the UI explained it, so every square now
|
||||
// carries a badge + a one-line legend up top, and the two facets get
|
||||
// visually distinct badge colors, not just different text.
|
||||
function renderCardGrid(card: BingoCard, marked: Set<number>, interactive: boolean): string {
|
||||
const facetBadge = (facet: string) =>
|
||||
facet === 'title' ? '<span class="facet-badge facet-title" title="song title — lights up on this exact track">♪</span>'
|
||||
: facet === 'artist' ? '<span class="facet-badge facet-artist" title="artist — lights up on any track by them">☺</span>'
|
||||
: '';
|
||||
|
||||
return `
|
||||
<div class="card-legend muted center">
|
||||
<span class="facet-badge facet-title">♪</span> = exact song ·
|
||||
<span class="facet-badge facet-artist">☺</span> = any track by that artist
|
||||
</div>
|
||||
<div class="bingo-grid" style="grid-template-columns: repeat(${card.size}, 1fr);">
|
||||
${card.squares.map((sq, i) => {
|
||||
const isMarked = sq.facet === 'free' || marked.has(i);
|
||||
const canTap = interactive && sq.facet !== 'free';
|
||||
return `
|
||||
<div class="bingo-square ${sq.facet} ${isMarked ? 'marked' : ''} ${canTap ? 'tappable' : ''}" data-i="${i}">
|
||||
${sq.image ? `<img class="cover" src="${esc(sq.image)}" alt="">` : ''}
|
||||
${facetBadge(sq.facet)}
|
||||
<span class="label">${esc(sq.text)}</span>
|
||||
</div>
|
||||
`;
|
||||
}).join('')}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ── Router ──────────────────────────────────────────────────────────
|
||||
type Route = { name: string; params: Record<string, string> };
|
||||
|
||||
function parseRoute(): Route {
|
||||
const hash = location.hash.replace(/^#\/?/, '');
|
||||
const [name, ...rest] = hash.split('/');
|
||||
if (!name) return { name: 'home', params: {} };
|
||||
if (name === 'caller') return { name: 'caller', params: { code: rest[0] } };
|
||||
if (name === 'play') return { name: 'play', params: { code: rest[0] } };
|
||||
return { name, params: {} };
|
||||
}
|
||||
|
||||
async function render() {
|
||||
// Guest PKCE callback lands at the root with ?code=...&state=... —
|
||||
// handle it before anything else, then strip the query string.
|
||||
const params = new URLSearchParams(location.search);
|
||||
if (params.has('code')) {
|
||||
const code = params.get('code')!;
|
||||
history.replaceState({}, '', location.pathname + location.hash);
|
||||
try {
|
||||
await completeGuestLogin(code);
|
||||
} catch (e) {
|
||||
view.innerHTML = `<div class="error-box">guest login failed: ${esc((e as Error).message)}</div>`;
|
||||
return;
|
||||
}
|
||||
location.hash = '#/solo';
|
||||
}
|
||||
|
||||
const route = parseRoute();
|
||||
try {
|
||||
if (route.name === 'home') return renderHome();
|
||||
if (route.name === 'host') return renderHost();
|
||||
if (route.name === 'caller') return renderCaller(route.params.code);
|
||||
if (route.name === 'join') return renderJoin();
|
||||
if (route.name === 'play') return renderPlay(route.params.code);
|
||||
if (route.name === 'solo') return renderSolo();
|
||||
return renderHome();
|
||||
} catch (e) {
|
||||
view.innerHTML = `<div class="error-box">${esc((e as Error).message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('hashchange', render);
|
||||
render();
|
||||
|
||||
// ── Home ────────────────────────────────────────────────────────────
|
||||
function renderHome() {
|
||||
view.innerHTML = `
|
||||
<div class="card center stack">
|
||||
<h2>What are you doing?</h2>
|
||||
<p class="muted">Pick a mode — all three make bingo cards from real Spotify playlists, no invented data.</p>
|
||||
</div>
|
||||
<div class="card stack">
|
||||
<h3>Host a live session</h3>
|
||||
<p class="muted">Connect your own Spotify, pick a playlist, get a join code + QR for a room full of people. Live "caller" screen, auto-call from real playback.</p>
|
||||
<a href="#/host" class="btn">Host a game →</a>
|
||||
</div>
|
||||
<div class="card stack">
|
||||
<h3>Join a session</h3>
|
||||
<p class="muted">Someone else is hosting — enter their code, get your own card, no login needed.</p>
|
||||
<a href="#/join" class="btn">Join a game →</a>
|
||||
</div>
|
||||
<div class="card stack">
|
||||
<h3>Play solo</h3>
|
||||
<p class="muted">Log into your own Spotify (or paste a public playlist URL) and generate a one-off card, no session.</p>
|
||||
<a href="#/solo" class="btn">Play solo →</a>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ── Host: connect + pick playlist + create session ─────────────────
|
||||
async function renderHost() {
|
||||
view.innerHTML = `<div class="card center">checking Spotify connection…</div>`;
|
||||
const status = await api.hostStatus().catch(() => ({ connected: false }));
|
||||
|
||||
if (!status.connected) {
|
||||
view.innerHTML = `
|
||||
<div class="card stack center">
|
||||
<h2>Connect Spotify</h2>
|
||||
<p class="muted">One-time host connection — used to read your playlists and (optionally) auto-call from your live playback.</p>
|
||||
<a href="/api/spotify/login" class="btn">Connect Spotify →</a>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
view.innerHTML = `<div class="card center">loading your playlists…</div>`;
|
||||
const playlists = await api.hostPlaylists();
|
||||
const usable = playlists.filter(p => p.trackCount >= 12);
|
||||
|
||||
view.innerHTML = `
|
||||
<div class="card stack">
|
||||
<h2>Pick a playlist</h2>
|
||||
<p class="muted">Only playlists with 12+ tracks shown (need enough headroom for a 3×3 card). Algorithmic/editorial Spotify playlists (Discover Weekly etc.) aren't readable via the API and won't appear here.</p>
|
||||
<ul class="playlist-list">
|
||||
${usable.map(p => `
|
||||
<li class="playlist-row" data-id="${esc(p.id)}" data-name="${esc(p.name)}">
|
||||
${p.image ? `<img src="${esc(p.image)}" alt="">` : '<div style="width:40px;height:40px;border-radius:6px;background:var(--border);"></div>'}
|
||||
<span class="name">${esc(p.name)}</span>
|
||||
<span class="count">${p.trackCount} tracks</span>
|
||||
</li>
|
||||
`).join('')}
|
||||
</ul>
|
||||
</div>
|
||||
`;
|
||||
|
||||
view.querySelectorAll<HTMLElement>('.playlist-row').forEach(row => {
|
||||
row.addEventListener('click', async () => {
|
||||
const { id, name } = row.dataset;
|
||||
view.innerHTML = `<div class="card center">creating session…</div>`;
|
||||
try {
|
||||
const session = await api.createSession(id!, name!);
|
||||
location.hash = `#/caller/${session.code}`;
|
||||
} catch (e) {
|
||||
view.innerHTML = `<div class="error-box">${esc((e as Error).message)}</div><a href="#/host" class="btn secondary">back</a>`;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ── Caller screen (host) ────────────────────────────────────────────
|
||||
async function renderCaller(code: string) {
|
||||
view.innerHTML = `<div class="card center">loading session…</div>`;
|
||||
const session = await api.joinSession(code);
|
||||
const joinUrl = `${location.origin}/#/play/${code}`;
|
||||
|
||||
function trackById(id: string): PoolTrack | undefined {
|
||||
return session.tracks.find(t => t.id === id);
|
||||
}
|
||||
|
||||
function paint(calledIds: string[], autoCall: boolean) {
|
||||
const lastId = calledIds[calledIds.length - 1];
|
||||
const last = lastId ? trackById(lastId) : null;
|
||||
const remaining = session.tracks.length - calledIds.length;
|
||||
|
||||
view.innerHTML = `
|
||||
<div class="card stack center">
|
||||
<h2>Hosting: ${esc(session.playlistName)}</h2>
|
||||
<div class="session-code">${esc(code)}</div>
|
||||
<p class="muted">join at <a href="${esc(joinUrl)}">${esc(joinUrl.replace('https://', '').replace('http://', ''))}</a></p>
|
||||
</div>
|
||||
|
||||
<div class="card caller-now">
|
||||
${last ? `
|
||||
${last.image ? `<img src="${esc(last.image)}" alt="">` : ''}
|
||||
<div class="track-title">${esc(last.title)}</div>
|
||||
<div class="track-artist">${esc(last.artist)}</div>
|
||||
` : `<p class="muted">nothing called yet</p>`}
|
||||
</div>
|
||||
|
||||
<div class="card stack">
|
||||
<div class="row between">
|
||||
<button id="call-random" ${remaining === 0 ? 'disabled' : ''}>call next (${remaining} left)</button>
|
||||
<div class="toggle-row">
|
||||
<label class="muted">auto-call from live playback</label>
|
||||
<input type="checkbox" id="auto-call" ${autoCall ? 'checked' : ''}>
|
||||
</div>
|
||||
</div>
|
||||
<div class="called-history">
|
||||
${calledIds.map(id => {
|
||||
const t = trackById(id);
|
||||
return t ? `<span class="called-chip">${esc(t.title)}</span>` : '';
|
||||
}).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.getElementById('call-random')?.addEventListener('click', async () => {
|
||||
const res = await api.callRandom(code).catch((e) => { alert(e.message); return null; });
|
||||
if (res) paint(res.calledIds, autoCall);
|
||||
});
|
||||
document.getElementById('auto-call')?.addEventListener('change', async (e) => {
|
||||
const on = (e.target as HTMLInputElement).checked;
|
||||
if (on) await api.autoCallStart(code); else await api.autoCallStop(code);
|
||||
});
|
||||
}
|
||||
|
||||
paint(session.calledIds, session.autoCall);
|
||||
|
||||
// Deliberately dumb polling, not a socket — matches this whole
|
||||
// family's precedent (keep watch, wisp's sweep). A caller screen
|
||||
// doesn't need sub-second latency.
|
||||
const poll = setInterval(async () => {
|
||||
if (parseRoute().name !== 'caller') { clearInterval(poll); return; }
|
||||
const state = await api.sessionState(code).catch(() => null);
|
||||
if (state) paint(state.calledIds, state.autoCall);
|
||||
}, 4000);
|
||||
}
|
||||
|
||||
// ── Join (guest enters a code) ──────────────────────────────────────
|
||||
function renderJoin() {
|
||||
view.innerHTML = `
|
||||
<div class="card stack">
|
||||
<h2>Join a session</h2>
|
||||
<input type="text" id="code-input" placeholder="5-letter code" maxlength="5" style="text-transform:uppercase; font-size:1.4em; text-align:center; letter-spacing:0.2em;">
|
||||
<button id="join-btn">join →</button>
|
||||
<div id="join-error"></div>
|
||||
</div>
|
||||
`;
|
||||
const go = () => {
|
||||
const code = (document.getElementById('code-input') as HTMLInputElement).value.trim().toUpperCase();
|
||||
if (code) location.hash = `#/play/${code}`;
|
||||
};
|
||||
document.getElementById('join-btn')?.addEventListener('click', go);
|
||||
document.getElementById('code-input')?.addEventListener('keydown', (e) => { if ((e as KeyboardEvent).key === 'Enter') go(); });
|
||||
}
|
||||
|
||||
// ── Play (guest card view, live-synced to a session) ────────────────
|
||||
const CARD_OPTS: CardOptions = { size: 3, freeSpace: true, artistRatio: 0.5 };
|
||||
|
||||
async function renderPlay(code: string) {
|
||||
view.innerHTML = `<div class="card center">joining session ${esc(code)}…</div>`;
|
||||
let session;
|
||||
try {
|
||||
session = await api.joinSession(code);
|
||||
} catch (e) {
|
||||
view.innerHTML = `<div class="error-box">${esc((e as Error).message)}</div><a href="#/join" class="btn secondary">try another code</a>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const seed = Math.floor(Math.random() * 2 ** 31);
|
||||
const card = generateCard(session.tracks, seed, CARD_OPTS);
|
||||
|
||||
// Marking is normally fully automatic (the host's calls, synced via
|
||||
// polling) — but a guest can't tell "was that really the track that
|
||||
// just played?" from the host's side alone, and might want to mark a
|
||||
// square they're confident about before the sync catches up, or the
|
||||
// host is calling manually and slowly. Self-marks are local-only
|
||||
// (never sent to the server, never affect other players) and only
|
||||
// apply to squares the auto-sync hasn't already confirmed — once the
|
||||
// server confirms a square, it's locked in and no longer tappable.
|
||||
const selfMarked = new Set<number>();
|
||||
|
||||
function paint(calledIds: string[]) {
|
||||
const autoMarked = new Set<number>();
|
||||
for (const id of calledIds) for (const i of markedIndices(card, id)) autoMarked.add(i);
|
||||
const effective = new Set<number>([...autoMarked, ...selfMarked]);
|
||||
const won = hasBingo(card, effective);
|
||||
|
||||
view.innerHTML = `
|
||||
<div class="card center">
|
||||
<h2>${esc(session!.playlistName)}</h2>
|
||||
<p class="muted">code: ${esc(code)}</p>
|
||||
</div>
|
||||
${renderCardGrid(card, effective, true)}
|
||||
<p class="muted center" style="margin-top:8px;">squares mark themselves as the host calls tracks — tap one yourself if you're sure it just played and the sync hasn't caught up</p>
|
||||
${won ? `<div class="bingo-banner">🎉 BINGO!</div>` : ''}
|
||||
`;
|
||||
|
||||
view.querySelectorAll<HTMLElement>('.bingo-square.tappable').forEach(sq => {
|
||||
const i = Number(sq.dataset.i);
|
||||
if (autoMarked.has(i)) return; // server-confirmed, not toggleable
|
||||
sq.addEventListener('click', () => {
|
||||
if (selfMarked.has(i)) selfMarked.delete(i); else selfMarked.add(i);
|
||||
paint(calledIds);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
paint(session.calledIds);
|
||||
|
||||
const poll = setInterval(async () => {
|
||||
if (parseRoute().name !== 'play') { clearInterval(poll); return; }
|
||||
const state = await api.sessionState(code).catch(() => null);
|
||||
if (state) paint(state.calledIds);
|
||||
}, 4000);
|
||||
}
|
||||
|
||||
// ── Solo (guest PKCE, own playlist or a pasted public URL) ──────────
|
||||
async function renderSolo() {
|
||||
const token = getStoredGuestToken();
|
||||
|
||||
if (!token) {
|
||||
view.innerHTML = `
|
||||
<div class="card stack center">
|
||||
<h2>Play solo</h2>
|
||||
<p class="muted">Log into your own Spotify to pick a playlist, or skip that and paste any public playlist URL below.</p>
|
||||
<button id="guest-login">Log into Spotify →</button>
|
||||
</div>
|
||||
<div class="card stack">
|
||||
<h3>Or use a public playlist URL</h3>
|
||||
<input type="text" id="public-url" placeholder="https://open.spotify.com/playlist/...">
|
||||
<button id="public-go" class="secondary">generate card →</button>
|
||||
<div id="public-error"></div>
|
||||
</div>
|
||||
`;
|
||||
document.getElementById('guest-login')?.addEventListener('click', () => startGuestLogin());
|
||||
document.getElementById('public-go')?.addEventListener('click', async () => {
|
||||
const url = (document.getElementById('public-url') as HTMLInputElement).value.trim();
|
||||
const errEl = document.getElementById('public-error')!;
|
||||
if (!url) return;
|
||||
errEl.innerHTML = `<p class="muted">loading…</p>`;
|
||||
try {
|
||||
const data = await api.publicPlaylist(url);
|
||||
renderSoloCard(data.tracks, data.name);
|
||||
} catch (e) {
|
||||
errEl.innerHTML = `<div class="error-box">${esc((e as Error).message)}</div>`;
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
view.innerHTML = `<div class="card center">loading your playlists…</div>`;
|
||||
const playlists = await fetchGuestPlaylists(token.access_token).catch(() => []);
|
||||
const usable = playlists.filter(p => p.trackCount >= 12);
|
||||
|
||||
view.innerHTML = `
|
||||
<div class="card row between">
|
||||
<h2 style="margin:0;">Pick a playlist</h2>
|
||||
<button class="secondary" id="logout">log out</button>
|
||||
</div>
|
||||
<div class="card">
|
||||
<ul class="playlist-list">
|
||||
${usable.map(p => `
|
||||
<li class="playlist-row" data-id="${esc(p.id)}" data-name="${esc(p.name)}">
|
||||
${p.image ? `<img src="${esc(p.image)}" alt="">` : '<div style="width:40px;height:40px;border-radius:6px;background:var(--border);"></div>'}
|
||||
<span class="name">${esc(p.name)}</span>
|
||||
<span class="count">${p.trackCount} tracks</span>
|
||||
</li>
|
||||
`).join('')}
|
||||
</ul>
|
||||
</div>
|
||||
`;
|
||||
document.getElementById('logout')?.addEventListener('click', () => { guestLogout(); render(); });
|
||||
view.querySelectorAll<HTMLElement>('.playlist-row').forEach(row => {
|
||||
row.addEventListener('click', async () => {
|
||||
const { id, name } = row.dataset;
|
||||
view.innerHTML = `<div class="card center">loading tracks…</div>`;
|
||||
const tracks = await fetchGuestPlaylistTracks(token.access_token, id!);
|
||||
renderSoloCard(tracks, name!);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderSoloCard(tracks: PoolTrack[], playlistName: string) {
|
||||
if (tracks.length < 12) {
|
||||
view.innerHTML = `<div class="error-box">this playlist only has ${tracks.length} tracks — need at least 12.</div><a href="#/solo" class="btn secondary">back</a>`;
|
||||
return;
|
||||
}
|
||||
const seed = Math.floor(Math.random() * 2 ** 31);
|
||||
const card: BingoCard = generateCard(tracks, seed, CARD_OPTS);
|
||||
|
||||
const marked = new Set<number>();
|
||||
|
||||
function paint() {
|
||||
view.innerHTML = `
|
||||
<div class="card center">
|
||||
<h2>${esc(playlistName)}</h2>
|
||||
<p class="muted">tap squares yourself as tracks play — solo mode has no live sync</p>
|
||||
</div>
|
||||
${renderCardGrid(card, marked, true)}
|
||||
${hasBingo(card, marked) ? `<div class="bingo-banner">🎉 BINGO!</div>` : ''}
|
||||
`;
|
||||
view.querySelectorAll<HTMLElement>('.bingo-square.tappable').forEach(sq => {
|
||||
sq.addEventListener('click', () => {
|
||||
const i = Number(sq.dataset.i);
|
||||
if (marked.has(i)) marked.delete(i); else marked.add(i);
|
||||
paint();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
paint();
|
||||
}
|
||||
18
client/tsconfig.json
Normal file
18
client/tsconfig.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
15
client/vite.config.ts
Normal file
15
client/vite.config.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
server: {
|
||||
// Explicit 127.0.0.1, not the default 'localhost' — Vite's default
|
||||
// can resolve to IPv6 loopback only ([::1]) depending on the host's
|
||||
// resolver order, which won't match Spotify's registered redirect
|
||||
// URI (127.0.0.1 specifically — Spotify's HTTPS-required-for-
|
||||
// non-loopback policy only exempts the literal IP, not 'localhost').
|
||||
host: '127.0.0.1',
|
||||
proxy: {
|
||||
'/api': 'http://127.0.0.1:3010',
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user