word-waltz / commit
adding support for multiple games
src/firebase/gameService.ts | 7 +- src/main.ts | 234 ++++++++++++++++++++++++++++++++++++-------- src/styles/main.css | 115 ++++++++++++++++++++++ src/utils/gameRegistry.ts | 49 ++++++++++ 4 files changed, 362 insertions(+), 43 deletions(-) diff --git a/src/firebase/gameService.ts b/src/firebase/gameService.ts index 888ab6c..e987d6f 100644 --- a/src/firebase/gameService.ts +++ b/src/firebase/gameService.ts @@ -13,7 +13,7 @@ * responsibility of the layer above (main.ts / route handlers). */ -import { doc, getDoc, setDoc, updateDoc, onSnapshot } from 'firebase/firestore'; +import { doc, getDoc, setDoc, updateDoc, deleteDoc, onSnapshot } from 'firebase/firestore'; import { db } from './config.js'; import type { Encrypted, EncryptedEnvelope } from '../types.js'; @@ -48,3 +48,8 @@ export function subscribeToGame( }); } +/** Permanently delete a game document from Firestore. */ +export async function deleteGame(gameId: string): Promise<void> { + await deleteDoc(doc(db, 'games', gameId)); +} + diff --git a/src/main.ts b/src/main.ts index 90d6af8..8f2fc35 100644 --- a/src/main.ts +++ b/src/main.ts @@ -8,9 +8,11 @@ * - Subscribe to real-time Firestore updates and re-render on change */ -import { parseLink, buildLink } from './utils/shareLink.js'; +import { parseLink } from './utils/shareLink.js'; import { generateKey, exportKey, importKey, encrypt, decrypt } from './crypto/encryption.js'; -import { createGame, getGame, updateGame, subscribeToGame } from './firebase/gameService.js'; +import { createGame, getGame, updateGame, subscribeToGame, deleteGame } from './firebase/gameService.js'; +import { getStoredGames, upsertStoredGame, removeStoredGame, clearStoredGames } from './utils/gameRegistry.js'; +import { buildLink } from './utils/shareLink.js'; import { mountWordPicker } from './components/WordPicker.js'; import { mountWordleBoard } from './components/WordleBoard.js'; import { mountScoreboard } from './components/Scoreboard.js'; @@ -79,6 +81,21 @@ function isMyTurn(state: GameState, role: PlayerId): boolean { return currentSolver(state) === role; } +/** Human-readable status shown on the home screen game list. */ +function computeStatus(state: GameState, role: PlayerId): string { + if (state.players.b.name === '') return 'Waiting for opponent to join'; + if (isMyTurn(state, role)) { + return state.turn === 'pick' ? 'Your turn to pick a word' : 'Your turn to solve'; + } + return state.turn === 'pick' ? 'Opponent is picking a word' : 'Opponent is solving'; +} + +/** Navigate to a game by setting the URL fragment (triggers hashchange). */ +function navigateToGame(gameId: string, key: string): void { + const fragment = buildLink(gameId, key).split('#')[1]; + if (fragment) window.location.hash = fragment; +} + // --------------------------------------------------------------------------- // Firestore write helper // --------------------------------------------------------------------------- @@ -101,12 +118,26 @@ function render( myRole: PlayerId, gameId: string, key: CryptoKey, + base64urlKey: string, shareUrl: string, ): void { cleanupView?.(); cleanupView = null; app.innerHTML = ''; + // Keep the home-screen game list up to date with the latest state. + upsertStoredGame({ + gameId, + key: base64urlKey, + role: myRole, + myName: state.players[myRole].name, + opponentName: (myRole === 'a' ? state.players.b : state.players.a).name, + status: computeStatus(state, myRole), + myTurn: isMyTurn(state, myRole), + createdAt: state.createdAt, + lastUpdated: Date.now(), + }); + // Scoreboard is always visible const scoreboardEl = document.createElement('div'); app.appendChild(scoreboardEl); @@ -213,31 +244,128 @@ function render( } // --------------------------------------------------------------------------- -// New game creation screen +// Home screen — game list + new game form // --------------------------------------------------------------------------- -function showNewGameScreen(app: HTMLElement): void { - app.innerHTML = ` - <div class="new-game"> - <p>Start a new game and share the link with a friend.</p> - <form id="new-game-form"> - <label>Your name - <input type="text" id="player-name" maxlength="20" required autocomplete="off" value="${getSavedName()}" /> - </label> - <button type="submit">Create game</button> - </form> - </div> - `; - - app.querySelector('#new-game-form')!.addEventListener('submit', async (e) => { +function showHomeScreen(app: HTMLElement): void { + app.innerHTML = ''; + + const root = document.createElement('div'); + root.className = 'home-screen'; + app.appendChild(root); + + // --- Existing game list --- + const games = getStoredGames(); + if (games.length > 0) { + const listSection = document.createElement('div'); + listSection.className = 'game-list'; + + const header = document.createElement('div'); + header.className = 'game-list__header'; + + const h2 = document.createElement('h2'); + h2.textContent = 'Your Games'; + header.appendChild(h2); + + const deleteAllBtn = document.createElement('button'); + deleteAllBtn.className = 'game-list__delete-all'; + deleteAllBtn.textContent = 'Delete all'; + deleteAllBtn.addEventListener('click', () => { + if (!confirm('Delete all games? This cannot be undone.')) return; + const all = getStoredGames(); + clearStoredGames(); + for (const g of all) { + deleteGame(g.gameId).catch((err: unknown) => + console.warn('Firestore delete failed for', g.gameId, err)); + } + showHomeScreen(app); + }); + header.appendChild(deleteAllBtn); + listSection.appendChild(header); + + const ul = document.createElement('ul'); + ul.className = 'game-list__items'; + + for (const game of games) { + const li = document.createElement('li'); + li.className = 'game-card'; + + const body = document.createElement('div'); + body.className = 'game-card__body'; + body.setAttribute('role', 'button'); + body.tabIndex = 0; + body.addEventListener('click', () => navigateToGame(game.gameId, game.key)); + body.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') navigateToGame(game.gameId, game.key); + }); + + const opponentEl = document.createElement('span'); + opponentEl.className = 'game-card__opponent'; + opponentEl.textContent = game.opponentName ? `vs. ${game.opponentName}` : 'Waiting for opponent'; + body.appendChild(opponentEl); + + const statusEl = document.createElement('span'); + statusEl.className = 'game-card__status' + (game.myTurn ? ' game-card__status--my-turn' : ''); + statusEl.textContent = game.status; + body.appendChild(statusEl); + + const delBtn = document.createElement('button'); + delBtn.className = 'game-card__delete'; + delBtn.type = 'button'; + delBtn.setAttribute('aria-label', 'Delete game'); + delBtn.textContent = '×'; + delBtn.addEventListener('click', () => { + removeStoredGame(game.gameId); + deleteGame(game.gameId).catch((err: unknown) => + console.warn('Firestore delete failed for', game.gameId, err)); + li.remove(); + if (getStoredGames().length === 0) showHomeScreen(app); + }); + + li.appendChild(body); + li.appendChild(delBtn); + ul.appendChild(li); + } + + listSection.appendChild(ul); + root.appendChild(listSection); + } + + // --- New game form --- + const newGameSection = document.createElement('div'); + newGameSection.className = 'new-game'; + + const desc = document.createElement('p'); + desc.textContent = 'Start a new game and share the link with a friend.'; + newGameSection.appendChild(desc); + + const form = document.createElement('form'); + + const label = document.createElement('label'); + label.appendChild(document.createTextNode('Your name')); + const nameInput = document.createElement('input'); + nameInput.type = 'text'; + nameInput.maxLength = 20; + nameInput.required = true; + nameInput.autocomplete = 'off'; + nameInput.value = getSavedName(); + label.appendChild(nameInput); + form.appendChild(label); + + const submitBtn = document.createElement('button'); + submitBtn.type = 'submit'; + submitBtn.textContent = 'Create game'; + form.appendChild(submitBtn); + newGameSection.appendChild(form); + root.appendChild(newGameSection); + + form.addEventListener('submit', async (e) => { e.preventDefault(); - const nameInput = app.querySelector<HTMLInputElement>('#player-name')!; const name = nameInput.value.trim(); if (!name) return; - const btn = app.querySelector<HTMLButtonElement>('button')!; - btn.disabled = true; - btn.textContent = 'Creating…'; + submitBtn.disabled = true; + submitBtn.textContent = 'Creating…'; saveName(name); try { @@ -268,14 +396,24 @@ function showNewGameScreen(app: HTMLElement): void { await Promise.race([createGame(gameId, envelope), timeout]); setRole(gameId, 'a'); + upsertStoredGame({ + gameId, + key: base64urlKey, + role: 'a', + myName: name, + opponentName: '', + status: 'Waiting for opponent to join', + myTurn: false, + createdAt: initialState.createdAt, + lastUpdated: Date.now(), + }); // Navigate — key is in the fragment and is never sent to the server - window.location.href = buildLink(gameId, base64urlKey); + navigateToGame(gameId, base64urlKey); } catch (err) { console.error('Game creation failed:', err); - btn.disabled = false; - btn.textContent = 'Create game'; - // Surface Firestore permission-denied errors clearly + submitBtn.disabled = false; + submitBtn.textContent = 'Create game'; const raw = err instanceof Error ? err.message : String(err); const msg = raw.toLowerCase().includes('permission') ? 'Permission denied by Firestore. Open the Firebase console → Firestore → Rules and set: allow read, write: if true; then publish.' @@ -293,20 +431,32 @@ function promptPlayerBName( app: HTMLElement, onName: (name: string) => void, ): void { - app.innerHTML = ` - <div class="new-game"> - <p>You've been invited to play! Enter your name to join.</p> - <form id="join-form"> - <label>Your name - <input type="text" id="player-name" maxlength="20" required autocomplete="off" value="${getSavedName()}" /> - </label> - <button type="submit">Join game</button> - </form> - </div> - `; - app.querySelector('#join-form')!.addEventListener('submit', (e) => { + app.innerHTML = ''; + const wrap = document.createElement('div'); + wrap.className = 'new-game'; + const p = document.createElement('p'); + p.textContent = "You've been invited to play! Enter your name to join."; + wrap.appendChild(p); + const form = document.createElement('form'); + const label = document.createElement('label'); + label.appendChild(document.createTextNode('Your name')); + const nameInput = document.createElement('input'); + nameInput.type = 'text'; + nameInput.maxLength = 20; + nameInput.required = true; + nameInput.autocomplete = 'off'; + nameInput.value = getSavedName(); + label.appendChild(nameInput); + form.appendChild(label); + const btn = document.createElement('button'); + btn.type = 'submit'; + btn.textContent = 'Join game'; + form.appendChild(btn); + wrap.appendChild(form); + app.appendChild(wrap); + form.addEventListener('submit', (e) => { e.preventDefault(); - const name = app.querySelector<HTMLInputElement>('#player-name')!.value.trim(); + const name = nameInput.value.trim(); if (name) { saveName(name); onName(name); } }); } @@ -334,7 +484,7 @@ async function bootstrap(): Promise<void> { const { gameId, key } = parseLink(); if (!gameId || !key) { - showNewGameScreen(app); + showHomeScreen(app); return; } @@ -391,7 +541,7 @@ async function bootstrap(): Promise<void> { const role = myRole as PlayerId; const shareUrl = buildLink(gameId, key); - render(app, state, role, gameId, cryptoKey, shareUrl); + render(app, state, role, gameId, cryptoKey, key, shareUrl); // Track whether it was already my turn when we first loaded so we only // notify on *transitions* (opponent finished), not on initial page open. @@ -407,7 +557,7 @@ async function bootstrap(): Promise<void> { notifyMyTurn(msg); } wasMyTurn = myTurnNow; - render(app, newState, role, gameId, cryptoKey, shareUrl); + render(app, newState, role, gameId, cryptoKey, key, shareUrl); } catch (err) { console.error('Failed to decrypt game update:', err); showError(app, 'Could not decrypt the latest game update — the link may be corrupted.'); @@ -426,7 +576,7 @@ document.getElementById('home-link')?.addEventListener('click', (e) => { clearNotification(); history.replaceState(null, '', window.location.pathname); const app = document.getElementById('app'); - if (app) showNewGameScreen(app); + if (app) showHomeScreen(app); }); // Clear the alert title as soon as the user focuses the tab diff --git a/src/styles/main.css b/src/styles/main.css index e19f3d2..346e0a7 100644 --- a/src/styles/main.css +++ b/src/styles/main.css @@ -594,6 +594,121 @@ body:has(.word-picker) .main-view { padding-top: 12px; } +/* Home screen is the only scrollable view. */ +body:has(.home-screen) #app { + overflow-y: auto; +} + +/* --- Home screen ------------------------------------------ */ +.home-screen { + display: flex; + flex-direction: column; + gap: 20px; + width: 100%; + padding-bottom: 24px; +} + +.game-list { + display: flex; + flex-direction: column; + gap: 8px; + width: 100%; +} + +.game-list__header { + display: flex; + justify-content: space-between; + align-items: center; +} + +.game-list__header h2 { + font-size: 0.78rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--color-muted); +} + +.game-list__delete-all { + font-size: 0.8rem; + color: var(--color-muted); + background: none; + border: none; + cursor: pointer; + padding: 4px 0; +} + +.game-list__delete-all:hover { + color: var(--color-error); +} + +.game-list__items { + list-style: none; + display: flex; + flex-direction: column; + gap: 6px; +} + +.game-card { + display: flex; + align-items: stretch; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius); + overflow: hidden; +} + +.game-card__body { + flex: 1; + padding: 12px 16px; + cursor: pointer; + display: flex; + flex-direction: column; + gap: 3px; + min-width: 0; + outline: none; +} + +.game-card__body:hover, +.game-card__body:focus-visible { + background: rgba(0, 0, 0, 0.03); +} + +.game-card__opponent { + font-weight: 600; + font-size: 0.95rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.game-card__status { + font-size: 0.8rem; + color: var(--color-muted); +} + +.game-card__status--my-turn { + color: var(--color-correct); + font-weight: 600; +} + +.game-card__delete { + padding: 0 16px; + background: none; + border: none; + border-left: 1px solid var(--color-border); + cursor: pointer; + color: var(--color-muted); + font-size: 1.3rem; + line-height: 1; + flex-shrink: 0; +} + +.game-card__delete:hover { + color: var(--color-error); + background: rgba(192, 57, 43, 0.05); +} + .site-footer a:hover { text-decoration: underline; } \ No newline at end of file diff --git a/src/utils/gameRegistry.ts b/src/utils/gameRegistry.ts new file mode 100644 index 0000000..540524f --- /dev/null +++ b/src/utils/gameRegistry.ts @@ -0,0 +1,49 @@ +/** + * gameRegistry.ts — persists the list of games this device has participated in. + * + * Each entry stores enough to reconstruct the share URL (gameId + key) and to + * display a summary on the home screen without a round-trip to Firestore. + * The encryption key is stored in localStorage (local to this device only) — + * it is never sent to any server, preserving the same threat model as the URL + * fragment approach. + */ + +import type { PlayerId } from '../types.js'; + +export interface StoredGame { + gameId: string; + key: string; // base64url AES-GCM key + role: PlayerId; + myName: string; + opponentName: string; // '' if opponent hasn't joined yet + status: string; // last-known human-readable status, e.g. "Your turn to solve" + myTurn: boolean; // whether it is currently this player's turn + createdAt: number; + lastUpdated: number; +} + +const REGISTRY_KEY = 'word-waltz:games'; + +export function getStoredGames(): StoredGame[] { + try { + return JSON.parse(localStorage.getItem(REGISTRY_KEY) ?? '[]') as StoredGame[]; + } catch { + return []; + } +} + +/** Insert or update a game entry (matched by gameId). Most-recent first. */ +export function upsertStoredGame(game: StoredGame): void { + const existing = getStoredGames().filter(g => g.gameId !== game.gameId); + existing.unshift(game); + localStorage.setItem(REGISTRY_KEY, JSON.stringify(existing)); +} + +export function removeStoredGame(gameId: string): void { + const games = getStoredGames().filter(g => g.gameId !== gameId); + localStorage.setItem(REGISTRY_KEY, JSON.stringify(games)); +} + +export function clearStoredGames(): void { + localStorage.removeItem(REGISTRY_KEY); +}