const USERNAME_KEY = 'scrumPoker.username'; const roomID = document.body.dataset.roomId; const params = new URLSearchParams(window.location.search); const themeToggleBtn = document.getElementById('theme-toggle'); const roomTitle = document.getElementById('room-title'); const revealModeLabel = document.getElementById('reveal-mode-label'); const roundStateLabel = document.getElementById('round-state-label'); const votingBoard = document.getElementById('voting-board'); const participantList = document.getElementById('participant-list'); const adminControls = document.getElementById('admin-controls'); const revealBtn = document.getElementById('reveal-btn'); const resetBtn = document.getElementById('reset-btn'); const participantLinkInput = document.getElementById('participant-link'); const adminLinkInput = document.getElementById('admin-link'); const roomStatus = document.getElementById('room-status'); const joinPanel = document.getElementById('join-panel'); const joinForm = document.getElementById('join-form'); const joinUsernameInput = document.getElementById('join-username'); const joinRoleInput = document.getElementById('join-role'); const joinPasswordInput = document.getElementById('join-password'); const joinAdminTokenInput = document.getElementById('join-admin-token'); const joinError = document.getElementById('join-error'); let isDarkMode = false; let participantID = params.get('participantId') || ''; let adminToken = params.get('adminToken') || ''; let eventSource = null; let latestState = null; themeToggleBtn.addEventListener('click', () => { isDarkMode = !isDarkMode; if (isDarkMode) { document.documentElement.setAttribute('data-theme', 'dark'); themeToggleBtn.textContent = 'Light Mode'; return; } document.documentElement.removeAttribute('data-theme'); themeToggleBtn.textContent = 'Dark Mode'; }); const savedUsername = localStorage.getItem(USERNAME_KEY) || ''; joinUsernameInput.value = savedUsername; joinAdminTokenInput.value = adminToken; function setJoinError(message) { if (!message) { joinError.classList.add('hidden'); joinError.textContent = ''; return; } joinError.classList.remove('hidden'); joinError.textContent = message; } function updateURL() { const next = new URL(window.location.href); if (participantID) { next.searchParams.set('participantId', participantID); } else { next.searchParams.delete('participantId'); } if (adminToken) { next.searchParams.set('adminToken', adminToken); } else { next.searchParams.delete('adminToken'); } window.history.replaceState({}, '', next.toString()); } async function joinRoom({ username, role, password, participantIdOverride }) { const response = await fetch(`/api/rooms/${encodeURIComponent(roomID)}/join`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ participantId: participantIdOverride || participantID, username, role, password, adminToken, }), }); const data = await response.json(); if (!response.ok) { throw new Error(data.error || 'Unable to join room.'); } participantID = data.participantId; if (data.isAdmin && !adminToken) { adminToken = joinAdminTokenInput.value.trim(); } localStorage.setItem(USERNAME_KEY, data.username); updateURL(); joinPanel.classList.add('hidden'); setJoinError(''); } function renderParticipants(participants, isRevealed) { participantList.innerHTML = ''; participants.forEach((participant) => { const item = document.createElement('li'); item.className = 'participant-item'; const name = document.createElement('span'); let label = participant.username; if (participant.id === participantID) { label += ' (You)'; } if (participant.isAdmin) { label += ' [Admin]'; } name.textContent = label; const status = document.createElement('span'); if (participant.role === 'viewer') { status.textContent = 'Viewer'; } else if (!participant.hasVoted) { status.textContent = 'Voting...'; } else if (isRevealed) { status.textContent = participant.voteValue || '-'; } else { status.textContent = participant.voteValue ? `Voted (${participant.voteValue})` : 'Voted'; } item.appendChild(name); item.appendChild(status); participantList.appendChild(item); }); } function renderCards(cards, participants, isRevealed) { const self = participants.find((participant) => participant.id === participantID); const canVote = self && self.role === 'participant'; const selfVote = self ? self.voteValue : ''; votingBoard.innerHTML = ''; cards.forEach((value) => { const card = document.createElement('button'); card.type = 'button'; card.className = 'vote-card'; card.textContent = value; if (selfVote === value && !isRevealed) { card.classList.add('is-selected'); } card.disabled = !canVote; card.addEventListener('click', () => castVote(value)); votingBoard.appendChild(card); }); } function renderState(state) { latestState = state; roomTitle.textContent = `${state.roomName} (${state.roomId})`; revealModeLabel.textContent = `Reveal mode: ${state.revealMode}`; roundStateLabel.textContent = state.revealed ? 'Cards revealed' : 'Cards hidden'; renderParticipants(state.participants, state.revealed); renderCards(state.cards, state.participants, state.revealed); participantLinkInput.value = `${window.location.origin}${state.links.participantLink}`; adminLinkInput.value = state.links.adminLink ? `${window.location.origin}${state.links.adminLink}` : ''; if (state.viewerIsAdmin) { adminControls.classList.remove('hidden'); } else { adminControls.classList.add('hidden'); } const votedCount = state.participants.filter((p) => p.role === 'participant' && p.hasVoted).length; const totalParticipants = state.participants.filter((p) => p.role === 'participant').length; roomStatus.textContent = `Votes: ${votedCount}/${totalParticipants}`; } function connectSSE() { if (eventSource) { eventSource.close(); } eventSource = new EventSource(`/api/rooms/${encodeURIComponent(roomID)}/events?participantId=${encodeURIComponent(participantID)}`); eventSource.addEventListener('state', (event) => { try { const payload = JSON.parse(event.data); renderState(payload); } catch (_err) { roomStatus.textContent = 'Failed to parse room update.'; } }); eventSource.onerror = () => { roomStatus.textContent = 'Connection interrupted. Retrying...'; }; } async function castVote(card) { if (!participantID) { return; } try { const response = await fetch(`/api/rooms/${encodeURIComponent(roomID)}/vote`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ participantId: participantID, card }), }); if (!response.ok) { const data = await response.json(); roomStatus.textContent = data.error || 'Vote rejected.'; } } catch (_err) { roomStatus.textContent = 'Network error while casting vote.'; } } async function adminAction(action) { if (!participantID) { return; } try { const response = await fetch(`/api/rooms/${encodeURIComponent(roomID)}/${action}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ participantId: participantID }), }); if (!response.ok) { const data = await response.json(); roomStatus.textContent = data.error || `Unable to ${action}.`; } } catch (_err) { roomStatus.textContent = 'Network error while sending admin action.'; } } revealBtn.addEventListener('click', () => adminAction('reveal')); resetBtn.addEventListener('click', () => adminAction('reset')); joinForm.addEventListener('submit', async (event) => { event.preventDefault(); const username = joinUsernameInput.value.trim(); if (!username) { setJoinError('Username is required.'); return; } adminToken = joinAdminTokenInput.value.trim(); try { await joinRoom({ username, role: joinRoleInput.value, password: joinPasswordInput.value, }); connectSSE(); } catch (err) { setJoinError(err.message); } }); window.addEventListener('pagehide', () => { if (!participantID) { return; } const payload = JSON.stringify({ participantId: participantID }); navigator.sendBeacon(`/api/rooms/${encodeURIComponent(roomID)}/leave`, new Blob([payload], { type: 'application/json' })); }); async function bootstrap() { if (!participantID) { joinPanel.classList.remove('hidden'); return; } if (!savedUsername) { joinPanel.classList.remove('hidden'); return; } try { await joinRoom({ username: savedUsername, role: 'participant', password: '', participantIdOverride: participantID, }); connectSSE(); } catch (_err) { participantID = ''; updateURL(); joinPanel.classList.remove('hidden'); setJoinError('Please join this room to continue.'); } } bootstrap();