Files
scrum-solitare/static/js/config.js

539 lines
16 KiB
JavaScript
Raw Normal View History

2026-03-05 22:08:06 +02:00
const USERNAME_KEY = 'scrumPoker.username';
2026-03-05 22:21:50 +02:00
const PRESETS_KEY = 'scrumPoker.deckPresets.v1';
2026-03-05 22:08:06 +02:00
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', '?'],
};
2026-03-05 22:21:50 +02:00
const SPECIAL_CARD_ORDER = {
'?': 1,
'∞': 2,
'☕': 3,
};
2026-03-05 22:08:06 +02:00
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');
2026-03-05 22:21:50 +02:00
const autoSortButton = document.getElementById('auto-sort');
2026-03-05 22:08:06 +02:00
const usernameInput = document.getElementById('username');
2026-03-05 22:21:50 +02:00
const savePresetButton = document.getElementById('save-preset');
const pickerToggleButton = document.getElementById('preset-picker-toggle');
const shareDeckButton = document.getElementById('share-deck');
2026-03-05 22:30:37 +02:00
const presetModalOverlay = document.getElementById('preset-modal-overlay');
const presetModalCloseButton = document.getElementById('preset-modal-close');
const presetModalDoneButton = document.getElementById('preset-modal-done');
2026-03-05 22:21:50 +02:00
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');
2026-03-05 22:08:06 +02:00
let nextCardID = 1;
let currentCards = [];
let draggingCardID = '';
2026-03-05 22:21:50 +02:00
let draggedElement = null;
let deckPresets = loadPresets();
2026-03-05 22:08:06 +02:00
const savedUsername = localStorage.getItem(USERNAME_KEY);
if (savedUsername && !usernameInput.value) {
usernameInput.value = savedUsername;
}
2026-03-05 22:21:50 +02:00
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);
}
2026-03-05 22:08:06 +02:00
function createCard(value) {
return { id: String(nextCardID++), value: value.toString() };
}
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 });
}
2026-03-05 22:21:50 +02:00
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);
}
2026-03-05 22:08:06 +02:00
2026-03-05 22:21:50 +02:00
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;
});
2026-03-05 22:08:06 +02:00
const previousPositions = captureCardPositions();
2026-03-05 22:21:50 +02:00
if (!target) {
previewCards.appendChild(draggedElement);
} else {
previewCards.insertBefore(draggedElement, target);
}
syncCardsFromDOM();
animateReflow(previousPositions);
2026-03-05 22:08:06 +02:00
}
function buildCardElement(card) {
const cardEl = document.createElement('div');
cardEl.className = 'preview-card';
cardEl.dataset.cardId = card.id;
cardEl.textContent = card.value;
cardEl.draggable = true;
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;
2026-03-05 22:21:50 +02:00
draggedElement = cardEl;
cardEl.classList.add('dragging', 'wiggle');
2026-03-05 22:08:06 +02:00
});
cardEl.addEventListener('dragend', () => {
draggingCardID = '';
2026-03-05 22:21:50 +02:00
draggedElement = null;
cardEl.classList.remove('dragging', 'wiggle');
2026-03-05 22:08:06 +02:00
});
cardEl.appendChild(removeBtn);
return cardEl;
}
function renderCards(previousPositions = new Map()) {
previewCards.innerHTML = '';
currentCards.forEach((card) => {
previewCards.appendChild(buildCardElement(card));
});
animateReflow(previousPositions);
}
2026-03-05 22:21:50 +02:00
function setDeck(values, message) {
2026-03-05 22:08:06 +02:00
const previousPositions = captureCardPositions();
2026-03-05 22:21:50 +02:00
currentCards = values.map((value) => createCard(value));
2026-03-05 22:08:06 +02:00
renderCards(previousPositions);
2026-03-05 22:21:50 +02:00
if (message) {
statusLine.textContent = message;
}
}
function resetCardsForScale() {
const values = (SCALE_PRESETS[scaleSelect.value] || SCALE_PRESETS.fibonacci).slice();
setDeck(values);
2026-03-05 22:08:06 +02:00
}
function updatePreviewMeta() {
previewScale.textContent = `Scale: ${scaleSelect.value}`;
previewMaxPeople.textContent = `Max: ${maxPeopleInput.value || 0}`;
}
2026-03-05 22:21:50 +02:00
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() {
2026-03-05 22:30:37 +02:00
presetModalOverlay.classList.remove('hidden');
2026-03-05 22:21:50 +02:00
pickerToggleButton.setAttribute('aria-expanded', 'true');
renderPresetList();
}
function hidePresetPicker() {
2026-03-05 22:30:37 +02:00
presetModalOverlay.classList.add('hidden');
2026-03-05 22:21:50 +02:00
pickerToggleButton.setAttribute('aria-expanded', 'false');
}
previewCards.addEventListener('dragover', (event) => {
if (!draggingCardID || !draggedElement) {
return;
}
event.preventDefault();
moveDraggedCardNearCursor(event.clientX, event.clientY);
});
2026-03-05 22:08:06 +02:00
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();
});
2026-03-05 22:21:50 +02:00
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', () => {
2026-03-05 22:30:37 +02:00
if (presetModalOverlay.classList.contains('hidden')) {
2026-03-05 22:21:50 +02:00
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.';
}
});
2026-03-05 22:30:37 +02:00
presetModalOverlay.addEventListener('click', (event) => {
if (event.target === presetModalOverlay) {
hidePresetPicker();
2026-03-05 22:21:50 +02:00
}
});
2026-03-05 22:30:37 +02:00
presetModalCloseButton.addEventListener('click', hidePresetPicker);
presetModalDoneButton.addEventListener('click', hidePresetPicker);
2026-03-05 22:08:06 +02:00
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);
2026-03-05 22:21:50 +02:00
} catch (_err) {
2026-03-05 22:08:06 +02:00
statusLine.textContent = 'Network error while creating room.';
}
});
roomConfigForm.addEventListener('reset', () => {
window.setTimeout(() => {
updatePreviewMeta();
resetCardsForScale();
statusLine.textContent = 'Room settings reset to defaults.';
}, 0);
});
updatePreviewMeta();
resetCardsForScale();
2026-03-05 22:21:50 +02:00
renderPresetList();