This commit is contained in:
2026-03-05 22:21:50 +02:00
parent 7029eb29d4
commit 817bbfb44c
12528 changed files with 21319 additions and 124 deletions

View File

@@ -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();