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();
|
||||
|
||||
Reference in New Issue
Block a user