Updatezz
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
const USERNAME_KEY = 'scrumPoker.username';
|
||||
const PRESETS_KEY = 'scrumPoker.deckPresets.v1';
|
||||
|
||||
const SCALE_PRESETS = {
|
||||
fibonacci: ['0', '1', '2', '3', '5', '8', '13', '21', '?'],
|
||||
@@ -6,6 +7,12 @@ const SCALE_PRESETS = {
|
||||
'powers-of-two': ['1', '2', '4', '8', '16', '32', '?'],
|
||||
};
|
||||
|
||||
const SPECIAL_CARD_ORDER = {
|
||||
'?': 1,
|
||||
'∞': 2,
|
||||
'☕': 3,
|
||||
};
|
||||
|
||||
const themeToggleBtn = document.getElementById('theme-toggle');
|
||||
const roomConfigForm = document.getElementById('room-config-form');
|
||||
const statusLine = document.getElementById('config-status');
|
||||
@@ -16,12 +23,25 @@ const previewMaxPeople = document.getElementById('preview-max-people');
|
||||
const previewCards = document.getElementById('preview-cards');
|
||||
const customCardInput = document.getElementById('custom-card');
|
||||
const addCardButton = document.getElementById('add-card');
|
||||
const autoSortButton = document.getElementById('auto-sort');
|
||||
const usernameInput = document.getElementById('username');
|
||||
|
||||
const savePresetButton = document.getElementById('save-preset');
|
||||
const pickerToggleButton = document.getElementById('preset-picker-toggle');
|
||||
const shareDeckButton = document.getElementById('share-deck');
|
||||
const presetPicker = document.getElementById('preset-picker');
|
||||
const presetList = document.getElementById('preset-list');
|
||||
const importToggleButton = document.getElementById('import-toggle');
|
||||
const importPane = document.getElementById('import-pane');
|
||||
const importInput = document.getElementById('import-b64');
|
||||
const importApplyButton = document.getElementById('import-apply');
|
||||
|
||||
let isDarkMode = false;
|
||||
let nextCardID = 1;
|
||||
let currentCards = [];
|
||||
let draggingCardID = '';
|
||||
let draggedElement = null;
|
||||
let deckPresets = loadPresets();
|
||||
|
||||
const savedUsername = localStorage.getItem(USERNAME_KEY);
|
||||
if (savedUsername && !usernameInput.value) {
|
||||
@@ -40,6 +60,36 @@ themeToggleBtn.addEventListener('click', () => {
|
||||
themeToggleBtn.textContent = 'Dark Mode';
|
||||
});
|
||||
|
||||
function parseNumericCard(value) {
|
||||
if (!/^-?\d+(\.\d+)?$/.test(value)) {
|
||||
return null;
|
||||
}
|
||||
return Number(value);
|
||||
}
|
||||
|
||||
function cardSortComparator(a, b) {
|
||||
const aNum = parseNumericCard(a.value);
|
||||
const bNum = parseNumericCard(b.value);
|
||||
|
||||
if (aNum !== null && bNum !== null) {
|
||||
return aNum - bNum;
|
||||
}
|
||||
if (aNum !== null) {
|
||||
return -1;
|
||||
}
|
||||
if (bNum !== null) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
const aSpecial = SPECIAL_CARD_ORDER[a.value] || 99;
|
||||
const bSpecial = SPECIAL_CARD_ORDER[b.value] || 99;
|
||||
if (aSpecial !== bSpecial) {
|
||||
return aSpecial - bSpecial;
|
||||
}
|
||||
|
||||
return a.value.localeCompare(b.value);
|
||||
}
|
||||
|
||||
function createCard(value) {
|
||||
return { id: String(nextCardID++), value: value.toString() };
|
||||
}
|
||||
@@ -91,21 +141,28 @@ function removeCard(cardID) {
|
||||
}, { once: true });
|
||||
}
|
||||
|
||||
function reorderCard(fromID, toID) {
|
||||
if (fromID === toID) {
|
||||
return;
|
||||
}
|
||||
function syncCardsFromDOM() {
|
||||
const order = Array.from(previewCards.querySelectorAll('.preview-card')).map((el) => el.dataset.cardId);
|
||||
const map = new Map(currentCards.map((card) => [card.id, card]));
|
||||
currentCards = order.map((id) => map.get(id)).filter(Boolean);
|
||||
}
|
||||
|
||||
const fromIndex = currentCards.findIndex((card) => card.id === fromID);
|
||||
const toIndex = currentCards.findIndex((card) => card.id === toID);
|
||||
if (fromIndex < 0 || toIndex < 0) {
|
||||
return;
|
||||
}
|
||||
function moveDraggedCardNearCursor(clientX, clientY) {
|
||||
const cards = Array.from(previewCards.querySelectorAll('.preview-card:not(.dragging)'));
|
||||
const target = cards.find((cardEl) => {
|
||||
const rect = cardEl.getBoundingClientRect();
|
||||
return clientX < rect.left + rect.width / 2 && clientY < rect.bottom;
|
||||
});
|
||||
|
||||
const previousPositions = captureCardPositions();
|
||||
const [moved] = currentCards.splice(fromIndex, 1);
|
||||
currentCards.splice(toIndex, 0, moved);
|
||||
renderCards(previousPositions);
|
||||
if (!target) {
|
||||
previewCards.appendChild(draggedElement);
|
||||
} else {
|
||||
previewCards.insertBefore(draggedElement, target);
|
||||
}
|
||||
|
||||
syncCardsFromDOM();
|
||||
animateReflow(previousPositions);
|
||||
}
|
||||
|
||||
function buildCardElement(card) {
|
||||
@@ -127,24 +184,14 @@ function buildCardElement(card) {
|
||||
|
||||
cardEl.addEventListener('dragstart', () => {
|
||||
draggingCardID = card.id;
|
||||
cardEl.classList.add('dragging');
|
||||
draggedElement = cardEl;
|
||||
cardEl.classList.add('dragging', 'wiggle');
|
||||
});
|
||||
|
||||
cardEl.addEventListener('dragend', () => {
|
||||
draggingCardID = '';
|
||||
cardEl.classList.remove('dragging');
|
||||
});
|
||||
|
||||
cardEl.addEventListener('dragover', (event) => {
|
||||
event.preventDefault();
|
||||
});
|
||||
|
||||
cardEl.addEventListener('drop', (event) => {
|
||||
event.preventDefault();
|
||||
if (!draggingCardID) {
|
||||
return;
|
||||
}
|
||||
reorderCard(draggingCardID, card.id);
|
||||
draggedElement = null;
|
||||
cardEl.classList.remove('dragging', 'wiggle');
|
||||
});
|
||||
|
||||
cardEl.appendChild(removeBtn);
|
||||
@@ -160,10 +207,18 @@ function renderCards(previousPositions = new Map()) {
|
||||
animateReflow(previousPositions);
|
||||
}
|
||||
|
||||
function resetCardsForScale() {
|
||||
function setDeck(values, message) {
|
||||
const previousPositions = captureCardPositions();
|
||||
currentCards = getCardsForScale(scaleSelect.value);
|
||||
currentCards = values.map((value) => createCard(value));
|
||||
renderCards(previousPositions);
|
||||
if (message) {
|
||||
statusLine.textContent = message;
|
||||
}
|
||||
}
|
||||
|
||||
function resetCardsForScale() {
|
||||
const values = (SCALE_PRESETS[scaleSelect.value] || SCALE_PRESETS.fibonacci).slice();
|
||||
setDeck(values);
|
||||
}
|
||||
|
||||
function updatePreviewMeta() {
|
||||
@@ -171,6 +226,139 @@ function updatePreviewMeta() {
|
||||
previewMaxPeople.textContent = `Max: ${maxPeopleInput.value || 0}`;
|
||||
}
|
||||
|
||||
function encodeDeckBase64(values) {
|
||||
const json = JSON.stringify(values);
|
||||
const bytes = new TextEncoder().encode(json);
|
||||
let binary = '';
|
||||
bytes.forEach((byte) => {
|
||||
binary += String.fromCharCode(byte);
|
||||
});
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
function decodeDeckBase64(input) {
|
||||
const binary = atob(input.trim());
|
||||
const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0));
|
||||
const json = new TextDecoder().decode(bytes);
|
||||
const parsed = JSON.parse(json);
|
||||
if (!Array.isArray(parsed)) {
|
||||
throw new Error('Invalid payload');
|
||||
}
|
||||
const clean = parsed
|
||||
.map((value) => value.toString().trim().slice(0, 8))
|
||||
.filter((value) => value !== '');
|
||||
if (clean.length === 0) {
|
||||
throw new Error('No valid cards');
|
||||
}
|
||||
return clean;
|
||||
}
|
||||
|
||||
function loadPresets() {
|
||||
try {
|
||||
const raw = localStorage.getItem(PRESETS_KEY);
|
||||
if (!raw) {
|
||||
return [];
|
||||
}
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return parsed
|
||||
.filter((item) => item && typeof item.name === 'string' && Array.isArray(item.cards))
|
||||
.map((item) => ({
|
||||
name: item.name.slice(0, 40),
|
||||
cards: item.cards.map((card) => card.toString().slice(0, 8)).filter(Boolean),
|
||||
}))
|
||||
.filter((item) => item.cards.length > 0);
|
||||
} catch (_err) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function persistPresets() {
|
||||
localStorage.setItem(PRESETS_KEY, JSON.stringify(deckPresets));
|
||||
}
|
||||
|
||||
function applyPreset(cards, sourceLabel) {
|
||||
setDeck(cards, `${sourceLabel} preset applied.`);
|
||||
}
|
||||
|
||||
function renderPresetList() {
|
||||
presetList.innerHTML = '';
|
||||
|
||||
if (deckPresets.length === 0) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'preset-item';
|
||||
empty.textContent = 'No presets saved yet.';
|
||||
presetList.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
deckPresets.forEach((preset, index) => {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'preset-item';
|
||||
|
||||
const left = document.createElement('div');
|
||||
const title = document.createElement('div');
|
||||
title.textContent = preset.name;
|
||||
const meta = document.createElement('div');
|
||||
meta.className = 'preset-meta';
|
||||
meta.textContent = preset.cards.join(', ');
|
||||
left.appendChild(title);
|
||||
left.appendChild(meta);
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'preset-actions';
|
||||
|
||||
const useBtn = document.createElement('button');
|
||||
useBtn.type = 'button';
|
||||
useBtn.className = 'btn';
|
||||
useBtn.textContent = 'Use';
|
||||
useBtn.addEventListener('click', () => {
|
||||
applyPreset(preset.cards, preset.name);
|
||||
hidePresetPicker();
|
||||
});
|
||||
|
||||
const deleteBtn = document.createElement('button');
|
||||
deleteBtn.type = 'button';
|
||||
deleteBtn.className = 'btn';
|
||||
deleteBtn.textContent = 'Del';
|
||||
deleteBtn.addEventListener('click', () => {
|
||||
deckPresets.splice(index, 1);
|
||||
persistPresets();
|
||||
renderPresetList();
|
||||
statusLine.textContent = 'Preset deleted.';
|
||||
});
|
||||
|
||||
actions.appendChild(useBtn);
|
||||
actions.appendChild(deleteBtn);
|
||||
|
||||
wrapper.appendChild(left);
|
||||
wrapper.appendChild(actions);
|
||||
presetList.appendChild(wrapper);
|
||||
});
|
||||
}
|
||||
|
||||
function showPresetPicker() {
|
||||
presetPicker.classList.remove('hidden');
|
||||
pickerToggleButton.setAttribute('aria-expanded', 'true');
|
||||
renderPresetList();
|
||||
}
|
||||
|
||||
function hidePresetPicker() {
|
||||
presetPicker.classList.add('hidden');
|
||||
pickerToggleButton.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
|
||||
previewCards.addEventListener('dragover', (event) => {
|
||||
if (!draggingCardID || !draggedElement) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
moveDraggedCardNearCursor(event.clientX, event.clientY);
|
||||
});
|
||||
|
||||
addCardButton.addEventListener('click', () => {
|
||||
const value = customCardInput.value.trim();
|
||||
if (!value) {
|
||||
@@ -184,6 +372,112 @@ addCardButton.addEventListener('click', () => {
|
||||
customCardInput.focus();
|
||||
});
|
||||
|
||||
autoSortButton.addEventListener('click', () => {
|
||||
const previousPositions = captureCardPositions();
|
||||
currentCards = [...currentCards].sort(cardSortComparator);
|
||||
renderCards(previousPositions);
|
||||
statusLine.textContent = 'Deck auto-sorted.';
|
||||
});
|
||||
|
||||
savePresetButton.addEventListener('click', () => {
|
||||
const cards = currentCards.map((card) => card.value);
|
||||
if (cards.length === 0) {
|
||||
statusLine.textContent = 'Cannot save an empty deck.';
|
||||
return;
|
||||
}
|
||||
|
||||
const defaultName = `Preset ${deckPresets.length + 1}`;
|
||||
const name = window.prompt('Preset name:', defaultName);
|
||||
if (!name) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cleanName = name.trim().slice(0, 40);
|
||||
if (!cleanName) {
|
||||
statusLine.textContent = 'Preset name is required.';
|
||||
return;
|
||||
}
|
||||
|
||||
const existingIndex = deckPresets.findIndex((preset) => preset.name === cleanName);
|
||||
const newPreset = { name: cleanName, cards };
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
deckPresets[existingIndex] = newPreset;
|
||||
} else {
|
||||
deckPresets.unshift(newPreset);
|
||||
}
|
||||
|
||||
deckPresets = deckPresets.slice(0, 20);
|
||||
persistPresets();
|
||||
renderPresetList();
|
||||
statusLine.textContent = `Preset "${cleanName}" saved.`;
|
||||
});
|
||||
|
||||
pickerToggleButton.addEventListener('click', () => {
|
||||
if (presetPicker.classList.contains('hidden')) {
|
||||
showPresetPicker();
|
||||
return;
|
||||
}
|
||||
hidePresetPicker();
|
||||
});
|
||||
|
||||
shareDeckButton.addEventListener('click', async () => {
|
||||
const cards = currentCards.map((card) => card.value);
|
||||
if (cards.length === 0) {
|
||||
statusLine.textContent = 'Cannot share an empty deck.';
|
||||
return;
|
||||
}
|
||||
|
||||
const encoded = encodeDeckBase64(cards);
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(encoded);
|
||||
statusLine.textContent = 'Shared deck copied as base64.';
|
||||
} catch (_err) {
|
||||
window.prompt('Copy this base64 deck string:', encoded);
|
||||
statusLine.textContent = 'Clipboard unavailable. Base64 shown for manual copy.';
|
||||
}
|
||||
});
|
||||
|
||||
importToggleButton.addEventListener('click', () => {
|
||||
importPane.classList.toggle('hidden');
|
||||
if (!importPane.classList.contains('hidden')) {
|
||||
importInput.focus();
|
||||
}
|
||||
});
|
||||
|
||||
importApplyButton.addEventListener('click', () => {
|
||||
const raw = importInput.value.trim();
|
||||
if (!raw) {
|
||||
statusLine.textContent = 'Paste a base64 deck first.';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const decodedCards = decodeDeckBase64(raw);
|
||||
applyPreset(decodedCards, 'Imported');
|
||||
importInput.value = '';
|
||||
importPane.classList.add('hidden');
|
||||
hidePresetPicker();
|
||||
} catch (_err) {
|
||||
statusLine.textContent = 'Invalid base64 deck input.';
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('click', (event) => {
|
||||
const target = event.target;
|
||||
if (!(target instanceof Element)) {
|
||||
return;
|
||||
}
|
||||
if (presetPicker.classList.contains('hidden')) {
|
||||
return;
|
||||
}
|
||||
if (presetPicker.contains(target) || pickerToggleButton.contains(target)) {
|
||||
return;
|
||||
}
|
||||
hidePresetPicker();
|
||||
});
|
||||
|
||||
customCardInput.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
@@ -243,7 +537,7 @@ roomConfigForm.addEventListener('submit', async (event) => {
|
||||
|
||||
const target = `/room/${encodeURIComponent(data.roomId)}?participantId=${encodeURIComponent(data.creatorParticipantId)}&adminToken=${encodeURIComponent(data.adminToken)}`;
|
||||
window.location.assign(target);
|
||||
} catch (err) {
|
||||
} catch (_err) {
|
||||
statusLine.textContent = 'Network error while creating room.';
|
||||
}
|
||||
});
|
||||
@@ -258,3 +552,4 @@ roomConfigForm.addEventListener('reset', () => {
|
||||
|
||||
updatePreviewMeta();
|
||||
resetCardsForScale();
|
||||
renderPresetList();
|
||||
|
||||
@@ -4,10 +4,15 @@ const roomID = document.body.dataset.roomId;
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
|
||||
const themeToggleBtn = document.getElementById('theme-toggle');
|
||||
const roomSkeleton = document.getElementById('room-skeleton');
|
||||
const roomGrid = document.getElementById('room-grid');
|
||||
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 summaryBody = document.getElementById('summary-body');
|
||||
const summaryAverage = document.getElementById('summary-average');
|
||||
const summaryRecommended = document.getElementById('summary-recommended');
|
||||
const participantList = document.getElementById('participant-list');
|
||||
const adminControls = document.getElementById('admin-controls');
|
||||
const revealBtn = document.getElementById('reveal-btn');
|
||||
@@ -28,7 +33,10 @@ let isDarkMode = false;
|
||||
let participantID = params.get('participantId') || '';
|
||||
let adminToken = params.get('adminToken') || '';
|
||||
let eventSource = null;
|
||||
let latestState = null;
|
||||
|
||||
const savedUsername = localStorage.getItem(USERNAME_KEY) || '';
|
||||
joinUsernameInput.value = savedUsername;
|
||||
joinAdminTokenInput.value = adminToken;
|
||||
|
||||
themeToggleBtn.addEventListener('click', () => {
|
||||
isDarkMode = !isDarkMode;
|
||||
@@ -42,10 +50,6 @@ themeToggleBtn.addEventListener('click', () => {
|
||||
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');
|
||||
@@ -64,14 +68,23 @@ function updateURL() {
|
||||
} else {
|
||||
next.searchParams.delete('participantId');
|
||||
}
|
||||
|
||||
if (adminToken) {
|
||||
next.searchParams.set('adminToken', adminToken);
|
||||
} else {
|
||||
next.searchParams.delete('adminToken');
|
||||
}
|
||||
|
||||
window.history.replaceState({}, '', next.toString());
|
||||
}
|
||||
|
||||
function activateRoomView() {
|
||||
document.body.classList.remove('prejoin');
|
||||
roomSkeleton.classList.add('hidden');
|
||||
roomGrid.classList.remove('hidden');
|
||||
joinPanel.classList.add('hidden');
|
||||
}
|
||||
|
||||
async function joinRoom({ username, role, password, participantIdOverride }) {
|
||||
const response = await fetch(`/api/rooms/${encodeURIComponent(roomID)}/join`, {
|
||||
method: 'POST',
|
||||
@@ -91,18 +104,14 @@ async function joinRoom({ username, role, password, participantIdOverride }) {
|
||||
}
|
||||
|
||||
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';
|
||||
@@ -134,12 +143,93 @@ function renderParticipants(participants, isRevealed) {
|
||||
});
|
||||
}
|
||||
|
||||
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) => {
|
||||
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}`;
|
||||
}
|
||||
|
||||
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';
|
||||
@@ -151,19 +241,25 @@ function renderCards(cards, participants, isRevealed) {
|
||||
}
|
||||
|
||||
card.disabled = !canVote;
|
||||
card.addEventListener('click', () => castVote(value));
|
||||
card.addEventListener('click', () => {
|
||||
card.classList.remove('impact');
|
||||
void card.offsetWidth;
|
||||
card.classList.add('impact');
|
||||
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);
|
||||
renderSummary(state);
|
||||
|
||||
participantLinkInput.value = `${window.location.origin}${state.links.participantLink}`;
|
||||
adminLinkInput.value = state.links.adminLink ? `${window.location.origin}${state.links.adminLink}` : '';
|
||||
@@ -189,6 +285,7 @@ function connectSSE() {
|
||||
try {
|
||||
const payload = JSON.parse(event.data);
|
||||
renderState(payload);
|
||||
activateRoomView();
|
||||
} catch (_err) {
|
||||
roomStatus.textContent = 'Failed to parse room update.';
|
||||
}
|
||||
@@ -260,9 +357,14 @@ joinForm.addEventListener('submit', async (event) => {
|
||||
username,
|
||||
role: joinRoleInput.value,
|
||||
password: joinPasswordInput.value,
|
||||
participantIdOverride: participantID,
|
||||
});
|
||||
connectSSE();
|
||||
} catch (err) {
|
||||
if (participantID) {
|
||||
participantID = '';
|
||||
updateURL();
|
||||
}
|
||||
setJoinError(err.message);
|
||||
}
|
||||
});
|
||||
@@ -275,32 +377,3 @@ window.addEventListener('pagehide', () => {
|
||||
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();
|
||||
|
||||
Reference in New Issue
Block a user