const USERNAME_KEY = 'scrumPoker.username'; const PRESETS_KEY = 'scrumPoker.deckPresets.v1'; const SCALE_PRESETS = { fibonacci: ['0', '1', '2', '3', '5', '8', '13', '21', '?'], tshirt: ['XS', 'S', 'M', 'L', 'XL', '?'], 'powers-of-two': ['1', '2', '4', '8', '16', '32', '?'], }; const SPECIAL_CARD_ORDER = { '?': 1, '∞': 2, '☕': 3, }; const CARD_ICONS = ['★', '◆', '✦', '☀', '☘', '⚙', '♣', '♠', '♥', '♦', '✚', '⚡', '☾', '✿']; const roomConfigForm = document.getElementById('room-config-form'); const statusLine = document.getElementById('config-status'); const scaleSelect = document.getElementById('estimation-scale'); const maxPeopleInput = document.getElementById('max-people'); const previewScale = document.getElementById('preview-scale'); 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 presetModalOverlay = document.getElementById('preset-modal-overlay'); const presetModalCloseButton = document.getElementById('preset-modal-close'); const presetModalDoneButton = document.getElementById('preset-modal-done'); 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 nextCardID = 1; let currentCards = []; let draggingCardID = ''; let draggedElement = null; let deckPresets = loadPresets(); const savedUsername = localStorage.getItem(USERNAME_KEY); if (savedUsername && !usernameInput.value) { usernameInput.value = savedUsername; } 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() }; } function iconForCard(value) { if (value === '?') return '❓'; if (value === '☕') return '☕'; if (value === '∞') return '∞'; let hash = 0; for (let i = 0; i < value.length; i += 1) { hash = (hash * 31 + value.charCodeAt(i)) >>> 0; } return CARD_ICONS[hash % CARD_ICONS.length]; } function appendCardFace(el, value) { const topLeft = document.createElement('span'); topLeft.className = 'card-corner top-left'; topLeft.textContent = value; const center = document.createElement('span'); center.className = 'card-center-icon'; center.textContent = iconForCard(value); const bottomRight = document.createElement('span'); bottomRight.className = 'card-corner bottom-right'; bottomRight.textContent = value; el.appendChild(topLeft); el.appendChild(center); el.appendChild(bottomRight); } function getCardsForScale(scale) { return (SCALE_PRESETS[scale] || SCALE_PRESETS.fibonacci).map(createCard); } function captureCardPositions() { const positions = new Map(); previewCards.querySelectorAll('.preview-card').forEach((el) => { positions.set(el.dataset.cardId, el.getBoundingClientRect()); }); return positions; } function animateReflow(previousPositions) { previewCards.querySelectorAll('.preview-card').forEach((el) => { const previousRect = previousPositions.get(el.dataset.cardId); if (!previousRect) { return; } const nextRect = el.getBoundingClientRect(); const deltaX = previousRect.left - nextRect.left; const deltaY = previousRect.top - nextRect.top; if (!deltaX && !deltaY) { return; } el.style.transform = `translate(${deltaX}px, ${deltaY}px)`; requestAnimationFrame(() => { el.style.transform = 'translate(0, 0)'; }); }); } function removeCard(cardID) { const cardEl = previewCards.querySelector(`[data-card-id="${cardID}"]`); if (!cardEl) { return; } cardEl.classList.add('is-removing'); cardEl.addEventListener('animationend', () => { const previousPositions = captureCardPositions(); currentCards = currentCards.filter((card) => card.id !== cardID); renderCards(previousPositions); }, { once: true }); } 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); } 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(); if (!target) { previewCards.appendChild(draggedElement); } else { previewCards.insertBefore(draggedElement, target); } syncCardsFromDOM(); animateReflow(previousPositions); } function buildCardElement(card) { const cardEl = document.createElement('div'); cardEl.className = 'preview-card'; cardEl.dataset.cardId = card.id; cardEl.draggable = true; appendCardFace(cardEl, card.value); const removeBtn = document.createElement('button'); removeBtn.type = 'button'; removeBtn.className = 'preview-card-remove'; removeBtn.textContent = 'X'; removeBtn.setAttribute('aria-label', `Remove ${card.value}`); removeBtn.addEventListener('click', (event) => { event.stopPropagation(); removeCard(card.id); }); cardEl.addEventListener('dragstart', () => { draggingCardID = card.id; draggedElement = cardEl; cardEl.classList.add('dragging', 'wiggle'); }); cardEl.addEventListener('dragend', () => { draggingCardID = ''; draggedElement = null; cardEl.classList.remove('dragging', 'wiggle'); }); cardEl.appendChild(removeBtn); return cardEl; } function renderCards(previousPositions = new Map()) { previewCards.innerHTML = ''; currentCards.forEach((card) => { previewCards.appendChild(buildCardElement(card)); }); animateReflow(previousPositions); } function setDeck(values, message) { const previousPositions = captureCardPositions(); 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() { previewScale.textContent = `Scale: ${scaleSelect.value}`; 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() { presetModalOverlay.classList.remove('hidden'); pickerToggleButton.setAttribute('aria-expanded', 'true'); renderPresetList(); } function hidePresetPicker() { presetModalOverlay.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) { return; } const previousPositions = captureCardPositions(); currentCards.push(createCard(value.slice(0, 8))); renderCards(previousPositions); customCardInput.value = ''; 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 (presetModalOverlay.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.'; } }); presetModalOverlay.addEventListener('click', (event) => { if (event.target === presetModalOverlay) { hidePresetPicker(); } }); presetModalCloseButton.addEventListener('click', hidePresetPicker); presetModalDoneButton.addEventListener('click', hidePresetPicker); customCardInput.addEventListener('keydown', (event) => { if (event.key === 'Enter') { event.preventDefault(); addCardButton.click(); } }); scaleSelect.addEventListener('change', () => { resetCardsForScale(); updatePreviewMeta(); statusLine.textContent = 'Card deck reset to selected estimation scale.'; }); maxPeopleInput.addEventListener('input', updatePreviewMeta); roomConfigForm.addEventListener('submit', async (event) => { event.preventDefault(); const formData = new FormData(roomConfigForm); const username = (formData.get('username') || '').toString().trim(); const roomName = (formData.get('roomName') || '').toString().trim(); if (!username || !roomName) { statusLine.textContent = 'Room name and username are required.'; return; } localStorage.setItem(USERNAME_KEY, username); const payload = { roomName, creatorUsername: username, maxPeople: Number(formData.get('maxPeople') || 50), cards: currentCards.map((card) => card.value), allowSpectators: Boolean(formData.get('allowSpectators')), anonymousVoting: Boolean(formData.get('anonymousVoting')), autoReset: Boolean(formData.get('autoReset')), revealMode: (formData.get('revealMode') || 'manual').toString(), votingTimeoutSec: Number(formData.get('votingTimeoutSec') || 0), password: (formData.get('password') || '').toString(), }; statusLine.textContent = 'Creating room...'; try { const response = await fetch('/api/rooms', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); const data = await response.json(); if (!response.ok) { statusLine.textContent = data.error || 'Failed to create room.'; return; } const target = `/room/${encodeURIComponent(data.roomId)}?participantId=${encodeURIComponent(data.creatorParticipantId)}&adminToken=${encodeURIComponent(data.adminToken)}`; window.location.assign(target); } catch (_err) { statusLine.textContent = 'Network error while creating room.'; } }); roomConfigForm.addEventListener('reset', () => { window.setTimeout(() => { updatePreviewMeta(); resetCardsForScale(); statusLine.textContent = 'Room settings reset to defaults.'; }, 0); }); updatePreviewMeta(); resetCardsForScale(); renderPresetList();