word-waltz / commit
stylistic changes, as well as requiring a word when starting a game
.vscode/settings.json | 4 + src/assets/url.png | Bin 0 -> 17468 bytes src/components/Scoreboard.ts | 85 ++++++++++-------- src/index.html | 2 + src/main.ts | 204 ++++++++++++++++++++++++++++++------------- src/styles/main.css | 117 ++++++++++++++++++------- src/utils/shareLink.ts | 17 ++-- 7 files changed, 295 insertions(+), 134 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 9a49189..a4d7ac6 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -3,6 +3,10 @@ "/^npx tsc --noEmit 2>&1 && echo \"TS OK\"$/": { "approve": true, "matchCommandLine": true + }, + "/^npx tsc --noEmit 2>&1$/": { + "approve": true, + "matchCommandLine": true } } } \ No newline at end of file diff --git a/src/assets/url.png b/src/assets/url.png new file mode 100644 index 0000000..59fa946 Binary files /dev/null and b/src/assets/url.png differ diff --git a/src/components/Scoreboard.ts b/src/components/Scoreboard.ts index 5fd6ab4..f260ddf 100644 --- a/src/components/Scoreboard.ts +++ b/src/components/Scoreboard.ts @@ -13,18 +13,24 @@ interface ScoreboardProps { players: { a: Player; b: Player }; rounds: Round[]; myRole: PlayerId; + turn: 'pick' | 'solve'; + currentRound: number; onNameChange: (newName: string) => void; } const COLLAPSED_COUNT = 2; export function mountScoreboard(container: HTMLElement, props: ScoreboardProps): () => void { - const { players, rounds, myRole, onNameChange } = props; + const { players, rounds, myRole, turn, currentRound, onNameChange } = props; const root = document.createElement('div'); root.className = 'scoreboard'; - const completedRounds = rounds.filter((r) => r.guesses.length > 0); + // Exclude a live round (currently being solved) from the completed list. + const liveRoundIdx = turn === 'solve' ? currentRound : -1; + const completedRounds = rounds + .map((r, i) => ({ r, i })) + .filter(({ r, i }) => i !== liveRoundIdx && r.guesses.length > 0); let expanded = false; function render(): void { @@ -47,30 +53,20 @@ export function mountScoreboard(container: HTMLElement, props: ScoreboardProps): const card = document.createElement('div'); card.className = 'scoreboard__player'; + const nameEl = document.createElement('span'); + nameEl.className = `scoreboard__name scoreboard__name--${id}`; + nameEl.textContent = displayName; + if (id === myRole) { - // Editable name - const nameRow = document.createElement('div'); - nameRow.className = 'scoreboard__name-row'; - - const nameEl = document.createElement('span'); - nameEl.className = `scoreboard__name scoreboard__name--${id}`; - nameEl.textContent = displayName; - nameRow.appendChild(nameEl); - - const editBtn = document.createElement('button'); - editBtn.className = 'scoreboard__edit-btn'; - editBtn.title = 'Change name'; - editBtn.textContent = '✎'; - editBtn.addEventListener('click', () => { - const row = nameEl.parentElement!; - // Replace with inline input + nameEl.classList.add('scoreboard__name--editable'); + nameEl.title = 'Click to rename'; + nameEl.addEventListener('click', () => { const input = document.createElement('input'); input.type = 'text'; input.className = 'scoreboard__name-input'; input.value = p.name; input.maxLength = 20; - row.innerHTML = ''; - row.appendChild(input); + nameEl.replaceWith(input); input.focus(); input.select(); @@ -85,15 +81,10 @@ export function mountScoreboard(container: HTMLElement, props: ScoreboardProps): if (e.key === 'Escape') { input.removeEventListener('blur', commit); render(); } }); }); - nameRow.appendChild(editBtn); - card.appendChild(nameRow); - } else { - const nameEl = document.createElement('span'); - nameEl.className = `scoreboard__name scoreboard__name--${id}`; - nameEl.textContent = displayName; - card.appendChild(nameEl); } + card.appendChild(nameEl); + const scoreEl = document.createElement('span'); scoreEl.className = `scoreboard__score scoreboard__score--${id}`; scoreEl.textContent = String(p.score); @@ -104,11 +95,7 @@ export function mountScoreboard(container: HTMLElement, props: ScoreboardProps): root.appendChild(playersEl); // --- History --- - if (completedRounds.length === 0) return; - - const visible = expanded - ? completedRounds - : completedRounds.slice(-COLLAPSED_COUNT); + if (completedRounds.length === 0 && liveRoundIdx < 0) return; const historyEl = document.createElement('div'); historyEl.className = 'scoreboard__history-wrap'; @@ -125,8 +112,11 @@ export function mountScoreboard(container: HTMLElement, props: ScoreboardProps): const ol = document.createElement('ol'); ol.className = 'scoreboard__history'; - ol.start = expanded ? 1 : completedRounds.length - visible.length + 1; - visible.forEach((r) => { + const visible = expanded + ? completedRounds + : completedRounds.slice(-COLLAPSED_COUNT); + visible.forEach(({ r, i }) => { + const roundNum = i + 1; const solver: PlayerId = r.challenger === 'a' ? 'b' : 'a'; const solverName = players[solver].name || (solver === 'a' ? 'Player A' : 'Player B'); const result = r.solved @@ -135,9 +125,13 @@ export function mountScoreboard(container: HTMLElement, props: ScoreboardProps): const li = document.createElement('li'); li.className = 'scoreboard__round'; const textEl = document.createElement('span'); + const numSpan = document.createElement('span'); + numSpan.className = 'scoreboard__round-num'; + numSpan.textContent = `${roundNum}.`; const nameSpan = document.createElement('span'); nameSpan.className = `scoreboard__name--${solver}`; nameSpan.textContent = solverName; + textEl.appendChild(numSpan); textEl.appendChild(nameSpan); textEl.appendChild(document.createTextNode(`: ${result}`)); li.appendChild(textEl); @@ -149,6 +143,29 @@ export function mountScoreboard(container: HTMLElement, props: ScoreboardProps): } ol.appendChild(li); }); + + // Live round — show pending entry to the challenger only. + if (liveRoundIdx >= 0) { + const liveRound = rounds[liveRoundIdx]; + if (liveRound && myRole === liveRound.challenger) { + const roundNum = liveRoundIdx + 1; + const solver: PlayerId = liveRound.challenger === 'a' ? 'b' : 'a'; + const solverName = players[solver].name || (solver === 'a' ? 'Player A' : 'Player B'); + const liveLi = document.createElement('li'); + liveLi.className = 'scoreboard__round scoreboard__round--live'; + const numSpan = document.createElement('span'); + numSpan.className = 'scoreboard__round-num'; + numSpan.textContent = `${roundNum}.`; + const nameSpan = document.createElement('span'); + nameSpan.className = `scoreboard__name--${solver}`; + nameSpan.textContent = solverName; + liveLi.appendChild(numSpan); + liveLi.appendChild(nameSpan); + liveLi.appendChild(document.createTextNode(` is trying to solve ${liveRound.word}…`)); + ol.appendChild(liveLi); + } + } + historyEl.appendChild(ol); root.appendChild(historyEl); } diff --git a/src/index.html b/src/index.html index f4b594e..342cbba 100644 --- a/src/index.html +++ b/src/index.html @@ -19,6 +19,8 @@ <span class="header__title">Word Waltz</span> </a> <nav class="header__links"> + <button id="share-btn" class="header__share-btn" type="button" aria-label="Share game link" + title="Share invite link" hidden></button> <button id="notif-btn" class="header__notif-btn" type="button" aria-label="Notification settings" title="Enable notifications"></button> <a href="https://www.flaticon.com/search?author_id=296&style_id=1001&type=standard" target="_blank" diff --git a/src/main.ts b/src/main.ts index c55a878..a7870aa 100644 --- a/src/main.ts +++ b/src/main.ts @@ -21,6 +21,7 @@ import { mountShareDialog } from './components/ShareDialog.js'; import { notifyMyTurn, clearNotification, registerServiceWorker, subscribeToPush, isNotifOptedOut, setNotifOptOut, unsubscribeFromPush } from './utils/notifications.js'; import speakerIconUrl from './assets/speaker.png'; import muteIconUrl from './assets/mute.png'; +import urlIconUrl from './assets/url.png'; import { getRandomWord } from './utils/wordList.js'; import type { GameState, PlayerId, Round, SoloGameState, SoloRound, PendingNotification } from './types.js'; // Waiting status helper @@ -98,6 +99,60 @@ function navigateToGame(gameId: string, key: string): void { if (fragment) window.location.hash = fragment; } +// --------------------------------------------------------------------------- +// Share button — shown in the header while a game is active +// --------------------------------------------------------------------------- + +let shareModal: HTMLElement | null = null; + +function setShareButton(inviteUrl: string | null): void { + const btn = document.getElementById('share-btn') as HTMLButtonElement | null; + if (!btn) return; + + if (!inviteUrl) { + btn.hidden = true; + btn.onclick = null; + return; + } + + btn.textContent = ''; + const shareImg = document.createElement('img'); + shareImg.src = urlIconUrl; + shareImg.alt = 'Share'; + btn.appendChild(shareImg); + btn.hidden = false; + btn.onclick = () => { + if (shareModal) { shareModal.remove(); shareModal = null; return; } + + const overlay = document.createElement('div'); + overlay.className = 'share-modal'; + shareModal = overlay; + + const box = document.createElement('div'); + box.className = 'share-modal__box'; + + const closeBtn = document.createElement('button'); + closeBtn.className = 'share-modal__close'; + closeBtn.textContent = '×'; + closeBtn.setAttribute('aria-label', 'Close'); + closeBtn.onclick = () => { overlay.remove(); shareModal = null; }; + box.appendChild(closeBtn); + + const inner = document.createElement('div'); + box.appendChild(inner); + overlay.appendChild(box); + document.body.appendChild(overlay); + + import('./components/ShareDialog.js').then(({ mountShareDialog }) => { + mountShareDialog(inner, { shareUrl: inviteUrl }); + }).catch(console.error); + + overlay.addEventListener('click', (e) => { + if (e.target === overlay) { overlay.remove(); shareModal = null; } + }); + }; +} + // --------------------------------------------------------------------------- // Firestore write helper // --------------------------------------------------------------------------- @@ -152,6 +207,8 @@ function render( players: state.players, rounds: state.rounds, myRole, + turn: state.turn, + currentRound: state.currentRound, onNameChange: async (newName: string) => { saveName(newName); await saveState(gameId, key, { @@ -177,7 +234,8 @@ function render( if (state.players.b.name === '') { const shareEl = document.createElement('div'); mainEl.appendChild(shareEl); - cleanupMain = mountShareDialog(shareEl, { shareUrl }); + const inviteUrl = buildLink(gameId, base64urlKey, 'b'); + cleanupMain = mountShareDialog(shareEl, { shareUrl: inviteUrl }); mainEl.appendChild(createWaitingEl('Share the link above with your opponent, then wait for them to join…')); } else if (state.turn === 'pick') { const challenger = nextChallenger(state); @@ -532,67 +590,86 @@ function showHomeScreen(app: HTMLElement): void { root.appendChild(newGameSection); - form.addEventListener('submit', async (e) => { + form.addEventListener('submit', (e) => { e.preventDefault(); const name = nameInput.value.trim(); if (!name) return; - submitBtn.disabled = true; - submitBtn.textContent = 'Creating…'; saveName(name); - try { - const gameId = crypto.randomUUID(); - const cryptoKey = await generateKey(); - const base64urlKey = await exportKey(cryptoKey); + // Step 2: pick a word before creating the game + newGameSection.innerHTML = ''; + const pickDesc = document.createElement('p'); + pickDesc.textContent = 'Pick a secret word to challenge your opponent with:'; + newGameSection.appendChild(pickDesc); - const initialState: GameState = { - players: { - a: { name, score: 0 }, - b: { name: '', score: 0 }, - }, - rounds: [], - currentRound: 0, - turn: 'pick', - createdAt: Date.now(), - updatedAt: Date.now(), - }; + const wordPickerEl = document.createElement('div'); + newGameSection.appendChild(wordPickerEl); - const envelope = await encrypt(JSON.stringify(initialState), cryptoKey); + mountWordPicker(wordPickerEl, { + onWordChosen: async (word: string) => { + wordPickerEl.innerHTML = '<p class="status">Creating game…</p>'; - // Race the Firestore write against an 8 s timeout so we surface - // errors (e.g. database not created, network issue) instead of - // hanging on "Creating…" forever. - const timeout = new Promise<never>((_, reject) => - setTimeout(() => reject(new Error('Timed out connecting to Firestore. Make sure your Firestore security rules allow read/write (see the Rules tab in the Firebase console).')), 8_000), - ); - await Promise.race([createGame(gameId, envelope), timeout]); + try { + const gameId = crypto.randomUUID(); + const cryptoKey = await generateKey(); + const base64urlKey = await exportKey(cryptoKey); - 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(), - }); + const now = Date.now(); + const firstRound: Round = { + challenger: 'a', + word, + guesses: [], + solved: false, + score: 0, + }; + const initialState: GameState = { + players: { + a: { name, score: 0 }, + b: { name: '', score: 0 }, + }, + rounds: [firstRound], + currentRound: 0, + turn: 'solve', + createdAt: now, + updatedAt: now, + }; - // Navigate — key is in the fragment and is never sent to the server - navigateToGame(gameId, base64urlKey); - } catch (err) { - console.error('Game creation failed:', err); - 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.' - : raw || 'Failed to create game. Please try again.'; - showError(app, msg); - } + const envelope = await encrypt(JSON.stringify(initialState), cryptoKey); + + // Race the Firestore write against an 8 s timeout so we surface + // errors (e.g. database not created, network issue) instead of + // hanging on "Creating game…" forever. + const timeout = new Promise<never>((_, reject) => + setTimeout(() => reject(new Error('Timed out connecting to Firestore. Make sure your Firestore security rules allow read/write (see the Rules tab in the Firebase console).')), 8_000), + ); + 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 + navigateToGame(gameId, base64urlKey); + } catch (err) { + console.error('Game creation failed:', err); + 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.' + : raw || 'Failed to create game. Please try again.'; + showError(app, msg); + } + }, + }); }); } @@ -654,7 +731,7 @@ async function bootstrap(): Promise<void> { const app = document.getElementById('app'); if (!app) throw new Error('#app element not found'); - const { gameId, key } = parseLink(); + const { gameId, key, role: urlRole } = parseLink(); if (!gameId || !key) { showHomeScreen(app); @@ -684,10 +761,11 @@ async function bootstrap(): Promise<void> { } // Determine player role. - // Heuristic for new devices: if player B's name slot is empty this is B's first visit. + // Priority: localStorage (same device / browser) → role encoded in the URL + // → heuristic (B slot empty means this is B's first visit). let myRole = getRole(gameId); if (!myRole) { - if (state.players.b.name === '') { + if (urlRole === 'b' || (!urlRole && state.players.b.name === '')) { // Player B's first visit — prompt for name then join await new Promise<void>((resolve) => { promptPlayerBName(app, async (name) => { @@ -699,7 +777,7 @@ async function bootstrap(): Promise<void> { const updatedEnvelope = await encrypt(JSON.stringify(updated), cryptoKey); const joinNotify: PendingNotification = { player: 'a', - message: `${name} has joined! Pick a word to challenge them.`, + message: `${name} has joined and is solving your word!`, gameUrl: buildLink(gameId, key).split('#')[0], }; await updateGame(gameId, updatedEnvelope, joinNotify); @@ -710,7 +788,8 @@ async function bootstrap(): Promise<void> { }); }); } else { - // Fallback: treat as player A (e.g. creator on a new device) + // URL says role=a, or no URL role and B already joined → this is player A + // (e.g. creator rejoining on a new device). setRole(gameId, 'a'); myRole = 'a'; } @@ -718,6 +797,8 @@ async function bootstrap(): Promise<void> { const role = myRole as PlayerId; const shareUrl = buildLink(gameId, key); + const inviteUrl = buildLink(gameId, key, 'b'); + setShareButton(inviteUrl); // Register the service worker and subscribe to push notifications. // Fire-and-forget: push is a best-effort enhancement. @@ -764,6 +845,7 @@ document.getElementById('home-link')?.addEventListener('click', (e) => { cleanupView?.(); cleanupView = null; clearNotification(); + setShareButton(null); history.replaceState(null, '', window.location.pathname); const app = document.getElementById('app'); if (app) showHomeScreen(app); @@ -781,6 +863,7 @@ window.addEventListener('hashchange', () => { unsubscribeGame = null; cleanupView?.(); cleanupView = null; + setShareButton(null); bootstrap().catch((err: unknown) => { console.error('Bootstrap failed:', err); const app = document.getElementById('app'); @@ -820,21 +903,20 @@ function initNotifButton(): void { img.src = speakerIconUrl; btn!.title = 'Notifications enabled — click to disable'; btn!.classList.add('header__notif-btn--on'); - btn!.classList.remove('header__notif-btn--off', 'header__notif-btn--blocked'); + btn!.classList.remove('header__notif-btn--blocked'); } else if (perm === 'denied') { img.src = muteIconUrl; btn!.title = 'Notifications blocked by browser'; - btn!.classList.remove('header__notif-btn--on', 'header__notif-btn--off'); + btn!.classList.remove('header__notif-btn--on'); btn!.classList.add('header__notif-btn--blocked'); } else if (perm === 'granted' && optedOut) { img.src = muteIconUrl; btn!.title = 'Notifications disabled — click to enable'; btn!.classList.remove('header__notif-btn--on', 'header__notif-btn--blocked'); - btn!.classList.add('header__notif-btn--off'); } else { - img.src = speakerIconUrl; + img.src = muteIconUrl; btn!.title = 'Enable notifications'; - btn!.classList.remove('header__notif-btn--on', 'header__notif-btn--off', 'header__notif-btn--blocked'); + btn!.classList.remove('header__notif-btn--on', 'header__notif-btn--blocked'); } } diff --git a/src/styles/main.css b/src/styles/main.css index 4286db6..5853ef8 100644 --- a/src/styles/main.css +++ b/src/styles/main.css @@ -118,7 +118,7 @@ header { padding: 2px 4px; border-radius: 4px; line-height: 1; - opacity: 0.35; + opacity: 0.75; transition: opacity 0.15s; display: flex; align-items: center; @@ -131,19 +131,15 @@ header { } .header__notif-btn:hover { - opacity: 0.8; + opacity: 1; } .header__notif-btn--on { opacity: 1; } -.header__notif-btn--off { - opacity: 0.8; -} - .header__notif-btn--blocked { - opacity: 0.4; + opacity: 0.75; cursor: default; } @@ -231,6 +227,7 @@ header { align-items: center; gap: 2px; flex: 1; + min-width: 0; } .scoreboard__vs { @@ -243,7 +240,9 @@ header { font-size: 0.9rem; font-weight: 600; text-align: center; - word-break: break-word; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } .scoreboard__name--a { @@ -269,11 +268,16 @@ header { } .scoreboard__history { - list-style: decimal; - padding-left: 20px; + list-style: none; + padding-left: 0; display: flex; flex-direction: column; - gap: 3px; + gap: 8px; +} + +.scoreboard__round-num { + color: var(--color-muted); + margin-right: 3px; } .scoreboard__history-wrap { @@ -296,25 +300,11 @@ header { color: var(--color-text); } -.scoreboard__name-row { - display: flex; - align-items: center; - gap: 4px; -} - -.scoreboard__edit-btn { - background: none; - border: none; - padding: 0; - font-size: 0.8rem; +.scoreboard__name--editable { cursor: pointer; - color: var(--color-muted); - line-height: 1; - flex-shrink: 0; -} - -.scoreboard__edit-btn:hover { - color: var(--color-text); + text-decoration: underline; + text-decoration-style: dotted; + text-underline-offset: 2px; } .scoreboard__name-input { @@ -335,6 +325,10 @@ header { gap: 3px; } +.scoreboard__round--live { + font-style: italic; +} + .scoreboard__emoji { font-size: 0.7rem; line-height: 1.4; @@ -342,7 +336,70 @@ header { white-space: pre; } -/* --- Wordle board ----------------------------------------- */ +.header__share-btn { + background: none; + border: none; + cursor: pointer; + padding: 2px 4px; + border-radius: 4px; + line-height: 1; + opacity: 0.75; + transition: opacity 0.15s; + display: flex; + align-items: center; +} + +.header__share-btn img { + height: 22px; + width: 22px; + display: block; +} + +.header__share-btn:hover { + opacity: 1; +} + +/* --- Share modal ------------------------------------------ */ +.share-modal { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.4); + display: flex; + align-items: flex-start; + justify-content: center; + padding-top: 80px; + z-index: 100; +} + +.share-modal__box { + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius); + padding: 20px; + max-width: 420px; + width: 90%; + position: relative; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.18); +} + +.share-modal__close { + position: absolute; + top: 8px; + right: 10px; + background: none; + border: none; + font-size: 1.4rem; + line-height: 1; + cursor: pointer; + color: var(--color-muted); + padding: 0 4px; +} + +.share-modal__close:hover { + color: var(--color-text); +} + + .wordle-board { display: flex; flex-direction: column; diff --git a/src/utils/shareLink.ts b/src/utils/shareLink.ts index f45447a..e2a683a 100644 --- a/src/utils/shareLink.ts +++ b/src/utils/shareLink.ts @@ -9,15 +9,8 @@ * The fragment is parsed on the client to extract both values. */ -// Production URL used when generating share links from localhost so that QR -// codes are scannable from devices that can't reach localhost. -// If you deploy to a different host, update this constant. -const PRODUCTION_URL = 'https://pavelsafronov.gitlab.io/word-waltz/'; function shareBase(): string { - if (['localhost', '127.0.0.1'].includes(window.location.hostname)) { - return PRODUCTION_URL; - } return window.location.origin + window.location.pathname; } @@ -27,21 +20,27 @@ function shareBase(): string { export function buildLink( gameId: string, base64urlKey: string, + role?: 'a' | 'b', base: string = shareBase(), ): string { - const fragment = new URLSearchParams({ gameId, key: base64urlKey }).toString(); + const params: Record<string, string> = { gameId, key: base64urlKey }; + if (role) params.role = role; + const fragment = new URLSearchParams(params).toString(); return `${base}#${fragment}`; } -/** Parse a game URL fragment to extract gameId and key. */ +/** Parse a game URL fragment to extract gameId, key, and optional role. */ export function parseLink(url: string = window.location.hash): { gameId: string | null; key: string | null; + role: 'a' | 'b' | null; } { const fragment = url.startsWith('#') ? url.slice(1) : url; const params = new URLSearchParams(fragment); + const role = params.get('role'); return { gameId: params.get('gameId'), key: params.get('key'), + role: role === 'a' || role === 'b' ? role : null, }; }