2026-03-05 22:08:06 +02:00
|
|
|
const USERNAME_KEY = 'scrumPoker.username';
|
|
|
|
|
|
|
|
|
|
const roomID = document.body.dataset.roomId;
|
|
|
|
|
const params = new URLSearchParams(window.location.search);
|
|
|
|
|
|
2026-03-05 22:21:50 +02:00
|
|
|
const roomSkeleton = document.getElementById('room-skeleton');
|
|
|
|
|
const roomGrid = document.getElementById('room-grid');
|
2026-03-05 22:08:06 +02:00
|
|
|
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');
|
2026-03-05 22:21:50 +02:00
|
|
|
const summaryBody = document.getElementById('summary-body');
|
|
|
|
|
const summaryAverage = document.getElementById('summary-average');
|
|
|
|
|
const summaryRecommended = document.getElementById('summary-recommended');
|
2026-03-05 22:08:06 +02:00
|
|
|
const participantList = document.getElementById('participant-list');
|
|
|
|
|
const adminControls = document.getElementById('admin-controls');
|
|
|
|
|
const revealBtn = document.getElementById('reveal-btn');
|
|
|
|
|
const resetBtn = document.getElementById('reset-btn');
|
2026-03-05 22:41:16 +02:00
|
|
|
const terminalBtn = document.getElementById('terminal-btn');
|
2026-03-05 22:33:06 +02:00
|
|
|
const shareLinkInput = document.getElementById('share-link');
|
|
|
|
|
const shareAdminToggle = document.getElementById('share-admin-toggle');
|
2026-03-06 11:07:13 +02:00
|
|
|
const votesCounter = document.getElementById('votes-counter');
|
|
|
|
|
const roomMessage = document.getElementById('room-message');
|
|
|
|
|
const changeNameBtn = document.getElementById('change-name-btn');
|
2026-03-05 22:41:16 +02:00
|
|
|
const terminalModalOverlay = document.getElementById('terminal-modal-overlay');
|
|
|
|
|
const terminalCloseBtn = document.getElementById('terminal-close-btn');
|
|
|
|
|
const terminalLogOutput = document.getElementById('terminal-log-output');
|
2026-03-05 22:08:06 +02:00
|
|
|
|
|
|
|
|
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 participantID = params.get('participantId') || '';
|
|
|
|
|
let adminToken = params.get('adminToken') || '';
|
2026-03-06 10:57:57 +02:00
|
|
|
const prefillUsername = params.get('username') || '';
|
2026-03-05 22:08:06 +02:00
|
|
|
let eventSource = null;
|
2026-03-05 22:33:06 +02:00
|
|
|
let latestLinks = { participantLink: '', adminLink: '' };
|
2026-03-05 22:41:16 +02:00
|
|
|
let latestAdminLogs = [];
|
2026-03-06 11:07:13 +02:00
|
|
|
let latestRole = joinRoleInput.value || 'participant';
|
2026-03-05 22:21:50 +02:00
|
|
|
|
|
|
|
|
const savedUsername = localStorage.getItem(USERNAME_KEY) || '';
|
2026-03-06 10:57:57 +02:00
|
|
|
joinUsernameInput.value = prefillUsername || savedUsername;
|
2026-03-05 22:21:50 +02:00
|
|
|
joinAdminTokenInput.value = adminToken;
|
2026-03-05 22:08:06 +02:00
|
|
|
|
2026-03-05 22:38:31 +02:00
|
|
|
if (!window.CardUI || typeof window.CardUI.appendFace !== 'function') {
|
|
|
|
|
throw new Error('CardUI is not loaded. Ensure /static/js/cards.js is included before room.js.');
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-05 22:08:06 +02:00
|
|
|
|
|
|
|
|
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);
|
2026-03-06 10:57:57 +02:00
|
|
|
next.searchParams.delete('username');
|
|
|
|
|
|
2026-03-05 22:08:06 +02:00
|
|
|
if (participantID) {
|
|
|
|
|
next.searchParams.set('participantId', participantID);
|
|
|
|
|
} else {
|
|
|
|
|
next.searchParams.delete('participantId');
|
|
|
|
|
}
|
2026-03-05 22:21:50 +02:00
|
|
|
|
2026-03-05 22:08:06 +02:00
|
|
|
if (adminToken) {
|
|
|
|
|
next.searchParams.set('adminToken', adminToken);
|
|
|
|
|
} else {
|
|
|
|
|
next.searchParams.delete('adminToken');
|
|
|
|
|
}
|
2026-03-05 22:21:50 +02:00
|
|
|
|
2026-03-05 22:08:06 +02:00
|
|
|
window.history.replaceState({}, '', next.toString());
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-05 22:21:50 +02:00
|
|
|
function activateRoomView() {
|
|
|
|
|
document.body.classList.remove('prejoin');
|
|
|
|
|
roomSkeleton.classList.add('hidden');
|
|
|
|
|
roomGrid.classList.remove('hidden');
|
|
|
|
|
joinPanel.classList.add('hidden');
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-06 11:07:13 +02:00
|
|
|
function setRoomMessage(message) {
|
|
|
|
|
roomMessage.textContent = message;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-05 22:08:06 +02:00
|
|
|
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;
|
|
|
|
|
localStorage.setItem(USERNAME_KEY, data.username);
|
|
|
|
|
updateURL();
|
|
|
|
|
setJoinError('');
|
2026-03-06 10:57:57 +02:00
|
|
|
return data;
|
2026-03-05 22:08:06 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function renderParticipants(participants, isRevealed) {
|
|
|
|
|
participantList.innerHTML = '';
|
2026-03-05 22:21:50 +02:00
|
|
|
|
2026-03-05 22:38:31 +02:00
|
|
|
const visibleParticipants = participants.filter((participant) => participant.connected);
|
|
|
|
|
visibleParticipants.forEach((participant) => {
|
2026-03-05 22:08:06 +02:00
|
|
|
const item = document.createElement('li');
|
|
|
|
|
item.className = 'participant-item';
|
|
|
|
|
|
|
|
|
|
const name = document.createElement('span');
|
2026-03-06 10:57:57 +02:00
|
|
|
name.className = 'participant-name';
|
2026-03-05 22:08:06 +02:00
|
|
|
let label = participant.username;
|
|
|
|
|
if (participant.id === participantID) {
|
|
|
|
|
label += ' (You)';
|
|
|
|
|
}
|
|
|
|
|
if (participant.isAdmin) {
|
|
|
|
|
label += ' [Admin]';
|
2026-03-06 10:57:57 +02:00
|
|
|
name.classList.add('is-admin');
|
2026-03-05 22:08:06 +02:00
|
|
|
}
|
|
|
|
|
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);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-05 22:21:50 +02:00
|
|
|
function parseNumericVote(value) {
|
|
|
|
|
if (!/^-?\d+(\.\d+)?$/.test(value)) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
return Number(value);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function calculateSummary(state) {
|
|
|
|
|
const rows = new Map();
|
|
|
|
|
const numericVotes = [];
|
|
|
|
|
|
|
|
|
|
state.participants.forEach((participant) => {
|
2026-03-05 22:38:31 +02:00
|
|
|
if (!participant.connected) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-05 22:21:50 +02:00
|
|
|
if (participant.role !== 'participant' || !participant.hasVoted || !participant.voteValue) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!rows.has(participant.voteValue)) {
|
|
|
|
|
rows.set(participant.voteValue, []);
|
|
|
|
|
}
|
|
|
|
|
rows.get(participant.voteValue).push(participant.username);
|
|
|
|
|
|
|
|
|
|
const numeric = parseNumericVote(participant.voteValue);
|
|
|
|
|
if (numeric !== null) {
|
|
|
|
|
numericVotes.push(numeric);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
let average = null;
|
|
|
|
|
if (numericVotes.length > 0) {
|
|
|
|
|
average = numericVotes.reduce((acc, value) => acc + value, 0) / numericVotes.length;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const deckNumeric = state.cards
|
|
|
|
|
.map(parseNumericVote)
|
|
|
|
|
.filter((value) => value !== null)
|
|
|
|
|
.sort((a, b) => a - b);
|
|
|
|
|
|
|
|
|
|
let recommended = null;
|
|
|
|
|
if (average !== null && deckNumeric.length > 0) {
|
|
|
|
|
recommended = deckNumeric.find((value) => value >= average) ?? deckNumeric[deckNumeric.length - 1];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return { rows, average, recommended };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function renderSummary(state) {
|
|
|
|
|
if (!state.revealed) {
|
|
|
|
|
summaryBody.innerHTML = '<tr><td colspan="2">Results hidden until reveal.</td></tr>';
|
|
|
|
|
summaryAverage.textContent = 'Average: -';
|
|
|
|
|
summaryRecommended.textContent = 'Recommended: -';
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const { rows, average, recommended } = calculateSummary(state);
|
|
|
|
|
summaryBody.innerHTML = '';
|
|
|
|
|
|
|
|
|
|
if (rows.size === 0) {
|
|
|
|
|
summaryBody.innerHTML = '<tr><td colspan="2">No votes submitted.</td></tr>';
|
|
|
|
|
} else {
|
|
|
|
|
state.cards.forEach((cardValue) => {
|
|
|
|
|
const users = rows.get(cardValue);
|
|
|
|
|
if (!users || users.length === 0) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const row = document.createElement('tr');
|
|
|
|
|
const left = document.createElement('td');
|
|
|
|
|
const right = document.createElement('td');
|
|
|
|
|
left.textContent = cardValue;
|
|
|
|
|
right.textContent = users.join(', ');
|
|
|
|
|
row.appendChild(left);
|
|
|
|
|
row.appendChild(right);
|
|
|
|
|
summaryBody.appendChild(row);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
summaryAverage.textContent = average === null ? 'Average: -' : `Average: ${average.toFixed(2)}`;
|
|
|
|
|
summaryRecommended.textContent = recommended === null ? 'Recommended: -' : `Recommended: ${recommended}`;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-05 22:08:06 +02:00
|
|
|
function renderCards(cards, participants, isRevealed) {
|
2026-03-05 22:38:31 +02:00
|
|
|
const self = participants.find((participant) => participant.id === participantID && participant.connected);
|
2026-03-05 22:08:06 +02:00
|
|
|
const canVote = self && self.role === 'participant';
|
|
|
|
|
const selfVote = self ? self.voteValue : '';
|
|
|
|
|
|
|
|
|
|
votingBoard.innerHTML = '';
|
2026-03-05 22:21:50 +02:00
|
|
|
|
2026-03-05 22:08:06 +02:00
|
|
|
cards.forEach((value) => {
|
|
|
|
|
const card = document.createElement('button');
|
|
|
|
|
card.type = 'button';
|
|
|
|
|
card.className = 'vote-card';
|
2026-03-05 22:36:16 +02:00
|
|
|
card.setAttribute('aria-label', `Vote ${value}`);
|
2026-03-05 22:38:31 +02:00
|
|
|
window.CardUI.appendFace(card, value);
|
2026-03-05 22:08:06 +02:00
|
|
|
|
|
|
|
|
if (selfVote === value && !isRevealed) {
|
|
|
|
|
card.classList.add('is-selected');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
card.disabled = !canVote;
|
2026-03-05 22:21:50 +02:00
|
|
|
card.addEventListener('click', () => {
|
|
|
|
|
card.classList.remove('impact');
|
|
|
|
|
void card.offsetWidth;
|
|
|
|
|
card.classList.add('impact');
|
2026-03-06 10:57:57 +02:00
|
|
|
spawnVoteParticles(card);
|
2026-03-05 22:21:50 +02:00
|
|
|
castVote(value);
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-05 22:08:06 +02:00
|
|
|
votingBoard.appendChild(card);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-06 10:57:57 +02:00
|
|
|
function spawnVoteParticles(card) {
|
|
|
|
|
const particleCount = 12;
|
|
|
|
|
for (let i = 0; i < particleCount; i += 1) {
|
|
|
|
|
const particle = document.createElement('span');
|
|
|
|
|
particle.className = 'vote-particle';
|
|
|
|
|
|
|
|
|
|
const angle = (Math.PI * 2 * i) / particleCount + (Math.random() * 0.4 - 0.2);
|
|
|
|
|
const distance = 18 + Math.random() * 26;
|
|
|
|
|
const dx = Math.cos(angle) * distance;
|
|
|
|
|
const dy = Math.sin(angle) * distance;
|
|
|
|
|
|
|
|
|
|
particle.style.setProperty('--tx', `${dx.toFixed(2)}px`);
|
|
|
|
|
particle.style.setProperty('--ty', `${dy.toFixed(2)}px`);
|
|
|
|
|
particle.style.setProperty('--hue', `${Math.floor(Math.random() * 80) + 90}`);
|
|
|
|
|
|
|
|
|
|
particle.addEventListener('animationend', () => particle.remove(), { once: true });
|
|
|
|
|
card.appendChild(particle);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-05 22:41:16 +02:00
|
|
|
function formatLogTime(raw) {
|
|
|
|
|
const parsed = new Date(raw);
|
|
|
|
|
if (Number.isNaN(parsed.getTime())) {
|
|
|
|
|
return raw || '-';
|
|
|
|
|
}
|
|
|
|
|
return parsed.toLocaleString();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function renderTerminalLogs(logs) {
|
|
|
|
|
terminalLogOutput.innerHTML = '';
|
|
|
|
|
if (!Array.isArray(logs) || logs.length === 0) {
|
|
|
|
|
const emptyLine = document.createElement('div');
|
|
|
|
|
emptyLine.className = 'terminal-log-line';
|
|
|
|
|
emptyLine.textContent = '[system] no activity recorded yet';
|
|
|
|
|
terminalLogOutput.appendChild(emptyLine);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
logs.forEach((item) => {
|
|
|
|
|
const line = document.createElement('div');
|
|
|
|
|
line.className = 'terminal-log-line';
|
|
|
|
|
line.textContent = `[${formatLogTime(item.at)}] ${item.message}`;
|
|
|
|
|
terminalLogOutput.appendChild(line);
|
|
|
|
|
});
|
|
|
|
|
terminalLogOutput.scrollTop = terminalLogOutput.scrollHeight;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function openTerminal() {
|
|
|
|
|
terminalModalOverlay.classList.remove('hidden');
|
|
|
|
|
renderTerminalLogs(latestAdminLogs);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function closeTerminal() {
|
|
|
|
|
terminalModalOverlay.classList.add('hidden');
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-05 22:08:06 +02:00
|
|
|
function renderState(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);
|
2026-03-05 22:21:50 +02:00
|
|
|
renderSummary(state);
|
2026-03-05 22:08:06 +02:00
|
|
|
|
2026-03-06 11:07:13 +02:00
|
|
|
const self = state.participants.find((participant) => participant.id === participantID && participant.connected);
|
|
|
|
|
if (self && self.role) {
|
|
|
|
|
latestRole = self.role;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-05 22:33:06 +02:00
|
|
|
latestLinks = state.links || { participantLink: '', adminLink: '' };
|
|
|
|
|
updateShareLink();
|
2026-03-05 22:08:06 +02:00
|
|
|
|
|
|
|
|
if (state.viewerIsAdmin) {
|
|
|
|
|
adminControls.classList.remove('hidden');
|
2026-03-05 22:41:16 +02:00
|
|
|
terminalBtn.classList.remove('hidden');
|
2026-03-05 22:08:06 +02:00
|
|
|
} else {
|
|
|
|
|
adminControls.classList.add('hidden');
|
2026-03-05 22:41:16 +02:00
|
|
|
terminalBtn.classList.add('hidden');
|
|
|
|
|
closeTerminal();
|
|
|
|
|
}
|
|
|
|
|
latestAdminLogs = Array.isArray(state.adminLogs) ? state.adminLogs : [];
|
|
|
|
|
if (state.viewerIsAdmin && !terminalModalOverlay.classList.contains('hidden')) {
|
|
|
|
|
renderTerminalLogs(latestAdminLogs);
|
2026-03-05 22:08:06 +02:00
|
|
|
}
|
|
|
|
|
|
2026-03-05 22:38:31 +02:00
|
|
|
const votedCount = state.participants.filter((p) => p.connected && p.role === 'participant' && p.hasVoted).length;
|
|
|
|
|
const totalParticipants = state.participants.filter((p) => p.connected && p.role === 'participant').length;
|
2026-03-06 11:07:13 +02:00
|
|
|
votesCounter.textContent = `Votes: ${votedCount}/${totalParticipants}`;
|
2026-03-05 22:08:06 +02:00
|
|
|
}
|
|
|
|
|
|
2026-03-05 22:33:06 +02:00
|
|
|
function updateShareLink() {
|
|
|
|
|
const useAdmin = shareAdminToggle.checked && latestLinks.adminLink;
|
|
|
|
|
const raw = useAdmin ? latestLinks.adminLink : latestLinks.participantLink;
|
|
|
|
|
shareLinkInput.value = raw ? `${window.location.origin}${raw}` : '';
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-05 22:08:06 +02:00
|
|
|
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);
|
2026-03-05 22:21:50 +02:00
|
|
|
activateRoomView();
|
2026-03-06 11:07:13 +02:00
|
|
|
setRoomMessage('Connected.');
|
2026-03-05 22:08:06 +02:00
|
|
|
} catch (_err) {
|
2026-03-06 11:07:13 +02:00
|
|
|
setRoomMessage('Failed to parse room update.');
|
2026-03-05 22:08:06 +02:00
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
eventSource.onerror = () => {
|
2026-03-06 11:07:13 +02:00
|
|
|
setRoomMessage('Connection interrupted. Retrying...');
|
2026-03-05 22:08:06 +02:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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();
|
2026-03-06 11:07:13 +02:00
|
|
|
setRoomMessage(data.error || 'Vote rejected.');
|
2026-03-05 22:08:06 +02:00
|
|
|
}
|
|
|
|
|
} catch (_err) {
|
2026-03-06 11:07:13 +02:00
|
|
|
setRoomMessage('Network error while casting vote.');
|
2026-03-05 22:08:06 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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();
|
2026-03-06 11:07:13 +02:00
|
|
|
setRoomMessage(data.error || `Unable to ${action}.`);
|
2026-03-05 22:08:06 +02:00
|
|
|
}
|
|
|
|
|
} catch (_err) {
|
2026-03-06 11:07:13 +02:00
|
|
|
setRoomMessage('Network error while sending admin action.');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function changeName() {
|
|
|
|
|
if (!participantID) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const current = joinUsernameInput.value.trim() || localStorage.getItem(USERNAME_KEY) || '';
|
|
|
|
|
const next = window.prompt('Enter your new name:', current);
|
|
|
|
|
if (next === null) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const username = next.trim();
|
|
|
|
|
if (!username) {
|
|
|
|
|
setRoomMessage('Name cannot be empty.');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (username === current) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const result = await joinRoom({
|
|
|
|
|
username,
|
|
|
|
|
role: latestRole || joinRoleInput.value || 'participant',
|
|
|
|
|
password: joinPasswordInput.value,
|
|
|
|
|
participantIdOverride: participantID,
|
|
|
|
|
});
|
|
|
|
|
joinUsernameInput.value = result.username;
|
|
|
|
|
setRoomMessage('Name updated.');
|
|
|
|
|
} catch (err) {
|
|
|
|
|
setRoomMessage(err.message || 'Unable to change name.');
|
2026-03-05 22:08:06 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
revealBtn.addEventListener('click', () => adminAction('reveal'));
|
|
|
|
|
resetBtn.addEventListener('click', () => adminAction('reset'));
|
2026-03-05 22:41:16 +02:00
|
|
|
terminalBtn.addEventListener('click', openTerminal);
|
|
|
|
|
terminalCloseBtn.addEventListener('click', closeTerminal);
|
|
|
|
|
terminalModalOverlay.addEventListener('click', (event) => {
|
|
|
|
|
if (event.target === terminalModalOverlay) {
|
|
|
|
|
closeTerminal();
|
|
|
|
|
}
|
|
|
|
|
});
|
2026-03-05 22:33:06 +02:00
|
|
|
shareAdminToggle.addEventListener('change', updateShareLink);
|
2026-03-06 11:07:13 +02:00
|
|
|
changeNameBtn.addEventListener('click', () => {
|
|
|
|
|
void changeName();
|
|
|
|
|
});
|
2026-03-05 22:41:16 +02:00
|
|
|
window.addEventListener('keydown', (event) => {
|
|
|
|
|
if (event.key === 'Escape') {
|
|
|
|
|
closeTerminal();
|
|
|
|
|
}
|
|
|
|
|
});
|
2026-03-05 22:08:06 +02:00
|
|
|
|
|
|
|
|
joinForm.addEventListener('submit', async (event) => {
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
|
|
|
|
|
const username = joinUsernameInput.value.trim();
|
|
|
|
|
if (!username) {
|
|
|
|
|
setJoinError('Username is required.');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
adminToken = joinAdminTokenInput.value.trim();
|
|
|
|
|
|
|
|
|
|
try {
|
2026-03-06 10:57:57 +02:00
|
|
|
const result = await joinRoom({
|
2026-03-05 22:08:06 +02:00
|
|
|
username,
|
|
|
|
|
role: joinRoleInput.value,
|
|
|
|
|
password: joinPasswordInput.value,
|
2026-03-05 22:21:50 +02:00
|
|
|
participantIdOverride: participantID,
|
2026-03-05 22:08:06 +02:00
|
|
|
});
|
2026-03-06 10:57:57 +02:00
|
|
|
if (result.isAdmin) {
|
|
|
|
|
const adminRoomURL = `/room/${encodeURIComponent(roomID)}?participantId=${encodeURIComponent(participantID)}&adminToken=${encodeURIComponent(adminToken)}`;
|
|
|
|
|
window.location.assign(adminRoomURL);
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-03-05 22:08:06 +02:00
|
|
|
connectSSE();
|
|
|
|
|
} catch (err) {
|
2026-03-05 22:21:50 +02:00
|
|
|
if (participantID) {
|
|
|
|
|
participantID = '';
|
|
|
|
|
updateURL();
|
|
|
|
|
}
|
2026-03-05 22:08:06 +02:00
|
|
|
setJoinError(err.message);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-06 10:57:57 +02:00
|
|
|
async function tryAutoJoinExistingParticipant() {
|
|
|
|
|
if (!participantID) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const username = joinUsernameInput.value.trim() || prefillUsername || 'host';
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
await joinRoom({
|
|
|
|
|
username,
|
|
|
|
|
role: 'participant',
|
|
|
|
|
password: joinPasswordInput.value,
|
|
|
|
|
participantIdOverride: participantID,
|
|
|
|
|
});
|
|
|
|
|
connectSSE();
|
|
|
|
|
} catch (_err) {
|
|
|
|
|
participantID = '';
|
|
|
|
|
updateURL();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-05 22:08:06 +02:00
|
|
|
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' }));
|
|
|
|
|
});
|
2026-03-06 10:57:57 +02:00
|
|
|
|
|
|
|
|
void tryAutoJoinExistingParticipant();
|