// SPDX-FileCopyrightText: Amolith // // SPDX-License-Identifier: AGPL-3.0-or-later import { getSortedItems, escapeHtml, updateConnectionStatus, updateLastSync, setUIEnabled } from './render.js'; import { createInlineEditor, setupKeyboardShortcuts, setupHelpModal, setupPressLock } from './ui.js'; import { RoomSession } from './room-session.js'; let session = null; const editor = createInlineEditor(); const userId = localStorage.getItem('userId') || crypto.randomUUID(); localStorage.setItem('userId', userId); const CHECK_ICON_SVG = ``; const startScreen = document.getElementById('start-screen'); const listScreen = document.getElementById('list-screen'); const roomTitleEl = document.getElementById('room-title'); const listContainer = document.getElementById('list-container'); const tieBreakerEl = document.getElementById('tie-breaker'); const breakTieBtn = document.getElementById('break-tie-btn'); const tieBreakResultEl = document.getElementById('tie-break-result'); // Start screen handlers document.getElementById('create-btn').addEventListener('click', async () => { const res = await fetch('/api/create'); const { code } = await res.json(); joinRoom(code); }); document.getElementById('join-btn').addEventListener('click', () => { const code = document.getElementById('join-code').value.trim(); if (code) joinRoom(code); }); document.getElementById('join-code').addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.repeat) { e.preventDefault(); const code = e.target.value.trim(); if (code) joinRoom(code); } }); document.getElementById('leave-btn').addEventListener('click', () => { editor.cancel(); session?.dispose(); session = null; startScreen.classList.remove('hidden'); listScreen.classList.add('hidden'); renderTitle(); render(); updateLastSync(null); // Clear URL when leaving room history.replaceState(null, '', location.pathname); }); document.getElementById('copy-btn').addEventListener('click', async () => { if (!session) return; const itemNames = session.items.map(item => item.text).join('\n'); try { await navigator.clipboard.writeText(itemNames); const btn = document.getElementById('copy-btn'); const original = btn.innerHTML; btn.innerHTML = CHECK_ICON_SVG; setTimeout(() => btn.innerHTML = original, 1000); } catch (err) { console.error('Failed to copy:', err); } }); document.getElementById('copy-link-btn').addEventListener('click', async () => { if (!session) return; const inviteLink = `${location.origin}${location.pathname}?room=${session.code}`; try { await navigator.clipboard.writeText(inviteLink); const btn = document.getElementById('copy-link-btn'); const original = btn.innerHTML; btn.innerHTML = CHECK_ICON_SVG; setTimeout(() => btn.innerHTML = original, 1000); } catch (err) { console.error('Failed to copy invite link:', err); } }); document.getElementById('reset-votes-btn').addEventListener('click', () => { if (confirm('Clear all votes and vetoes from all items?')) { sendMessage({ type: 'reset_votes' }); } }); document.getElementById('set-title-btn').addEventListener('click', () => { const editingSession = session; if (!editingSession) return; const currentText = editingSession.title || ''; editor.begin({ element: roomTitleEl, value: currentText, className: 'title-edit-input', placeholder: 'Enter room title', onCommit: text => { if (text !== currentText) editingSession.send({ type: 'set_title', title: text || null }); }, }); }); breakTieBtn.addEventListener('click', () => { sendMessage({ type: 'break_tie' }); }); // Add items handlers document.getElementById('add-single-btn').addEventListener('click', () => { const input = document.getElementById('single-input'); const text = input.value.trim(); if (text && sendMessage({ type: 'add_items', items: [text] })) { input.value = ''; } }); document.getElementById('single-input').addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.repeat) { e.preventDefault(); const text = e.target.value.trim(); if (text && sendMessage({ type: 'add_items', items: [text] })) { e.target.value = ''; } } }); document.getElementById('add-bulk-btn').addEventListener('click', () => { const textarea = document.getElementById('bulk-input'); const lines = textarea.value.split('\n') .map(l => l.trim()) .filter(l => l); if (lines.length && sendMessage({ type: 'add_items', items: lines })) { textarea.value = ''; } }); // Core functions function joinRoom(code) { editor.cancel(); session?.dispose(); const wsScheme = location.protocol === 'https:' ? 'wss:' : 'ws:'; session = new RoomSession({ code, userId, url: `${wsScheme}//${location.host}/ws?room=${encodeURIComponent(code)}&user=${encodeURIComponent(userId)}`, onStatus: status => { if (status !== 'connected') editor.cancel(); setUIEnabled(status === 'connected', listScreen, listContainer); updateConnectionStatus(status); }, onChange: type => { updateLastSync(session.lastSyncTime); if (type === 'state') { history.replaceState(null, '', `${location.pathname}?room=${encodeURIComponent(session.code)}`); } if (type === 'state' || type === 'title_changed') renderTitle(); if (type === 'tie_broken') tieBreakResultEl.textContent = session.winner; else if (type !== 'title_changed') render(); }, }); renderTitle(); render(); updateLastSync(null); startScreen.classList.add('hidden'); listScreen.classList.remove('hidden'); session.connect(); } function sendMessage(msg) { return session?.send(msg) ?? false; } function vote(itemId, voteType) { if (!session?.vote(itemId, voteType)) return; // Keep the button visually pressed briefly to avoid flicker const btn = document.querySelector(`[data-item-id="${itemId}"] [data-action="vote"][data-vote-type="${voteType}"]`); if (btn) { btn.classList.add('press-lock'); setTimeout(() => btn.classList.remove('press-lock'), 220); } } function deleteItem(itemId) { if (session && confirm('Delete this item?')) { session.shouldScroll = true; sendMessage({ type: 'delete_item', itemId }); } } function editItem(itemId) { const editingSession = session; const item = editingSession?.items.find(i => i.id === itemId); if (!item) return; const itemEl = document.querySelector(`[data-item-id="${itemId}"]`); if (!itemEl) return; const textEl = itemEl.querySelector('.list-item-text'); const currentText = item.text; editor.begin({ element: textEl, value: currentText, className: 'edit-input', onCommit: text => { if (text && text !== currentText) editingSession.send({ type: 'edit_item', itemId, text }); }, }); } function renderTitle() { editor.cancel(roomTitleEl.parentElement); const roomTitle = session?.title; roomTitleEl.style.display = ''; if (roomTitle) { roomTitleEl.textContent = roomTitle; roomTitleEl.classList.remove('hidden'); } else { roomTitleEl.classList.add('hidden'); } } function render() { const items = session?.items ?? []; const selectedItemId = session?.selectedItemId; let editingRow = listContainer.querySelector('.edit-input')?.closest('.list-item'); if (editingRow) { const item = items.find(item => item.id === editingRow.dataset.itemId); if (!item || item.text !== editingRow.querySelector('.list-item-text').textContent) { editor.cancel(editingRow); editingRow = null; } } // FLIP animation: capture First positions const oldPositions = new Map(); const existingItems = listContainer.querySelectorAll('.list-item'); existingItems.forEach(el => { const itemId = el.dataset.itemId; oldPositions.set(itemId, el.getBoundingClientRect()); }); // Sort: vetoed items last, then by score const sorted = getSortedItems(items); // Check if any votes have been cast const hasAnyVotes = sorted.some(item => Object.keys(item.votes).length > 0); // Calculate highest score among non-vetoed items const nonVetoedItems = sorted.filter(item => !Object.values(item.votes).includes('veto') ); const highestScore = nonVetoedItems.length > 0 ? Math.max(...nonVetoedItems.map(item => Object.values(item.votes).reduce((sum, v) => sum + (v === 'up' ? 1 : v === 'down' ? -1 : 0), 0) )) : -Infinity; // Check for tie at the top const topItems = nonVetoedItems.filter(item => { const score = Object.values(item.votes).reduce((sum, v) => sum + (v === 'up' ? 1 : v === 'down' ? -1 : 0), 0); return score === highestScore; }); const hasTie = hasAnyVotes && topItems.length > 1; // Show/hide tie-breaker UI and clear result when state changes if (hasTie) { tieBreakerEl.classList.remove('hidden'); tieBreakResultEl.textContent = session.winner; } else { tieBreakerEl.classList.add('hidden'); tieBreakResultEl.textContent = ''; if (session) session.winner = ''; } const template = document.createElement('template'); template.innerHTML = sorted.map(item => { const myVote = item.votes[session.userId]; const score = Object.values(item.votes).reduce((sum, v) => sum + (v === 'up' ? 1 : v === 'down' ? -1 : 0), 0); const isVetoed = Object.values(item.votes).includes('veto'); const isSelected = item.id === selectedItemId; const isTopVoted = hasAnyVotes && !isVetoed && score === highestScore && nonVetoedItems.length > 0; return `
${isTopVoted ? '' : ''}
${escapeHtml(item.text)}
${score > 0 ? '+' : ''}${score}
`; }).join(''); // Keep the focused input attached: detaching and refocusing loses native // selection/composition state and can turn a remote update into a blur-save. for (const row of existingItems) { if (row !== editingRow) row.remove(); } let nextRow = null; for (const row of Array.from(template.content.children).reverse()) { if (row.dataset.itemId === editingRow?.dataset.itemId) { editingRow.className = row.className; editingRow.querySelector('.list-item-actions').replaceWith(row.querySelector('.list-item-actions')); editingRow.querySelector('.top-check')?.remove(); const check = row.querySelector('.top-check'); if (check) editingRow.prepend(check); nextRow = editingRow; } else { listContainer.insertBefore(row, nextRow); nextRow = row; } } // FLIP animation: capture Last positions and animate const newItems = listContainer.querySelectorAll('.list-item'); newItems.forEach(el => { const itemId = el.dataset.itemId; const oldPos = oldPositions.get(itemId); if (oldPos) { const newPos = el.getBoundingClientRect(); const deltaY = oldPos.top - newPos.top; // Invert: apply transform to make it appear at old position if (deltaY !== 0) { el.style.transform = `translateY(${deltaY}px)`; el.style.transition = 'none'; // Play: animate to new position requestAnimationFrame(() => { el.style.transition = ''; el.style.transform = ''; }); } } }); // Scroll selected item into view on local actions if (session?.shouldScroll && selectedItemId) { const selectedEl = listContainer.querySelector(`[data-item-id="${selectedItemId}"]`); if (selectedEl) { selectedEl.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); } session.shouldScroll = false; } } // Event delegation for list actions listContainer.addEventListener('click', (e) => { const button = e.target.closest('[data-action]'); if (!button) return; const listItem = button.closest('.list-item'); if (!listItem) return; const itemId = listItem.dataset.itemId; const action = button.dataset.action; if (action === 'vote') { const voteType = button.dataset.voteType; vote(itemId, voteType); } else if (action === 'delete') { deleteItem(itemId); } }); listContainer.addEventListener('dblclick', (e) => { const textEl = e.target.closest('.list-item-text'); if (!textEl) return; const listItem = textEl.closest('.list-item'); if (!listItem) return; const itemId = listItem.dataset.itemId; editItem(itemId); }); // Initialize UI components setupHelpModal(); setupPressLock(); setupKeyboardShortcuts({ getItems: () => session?.items ?? [], vote, deleteItem, editItem, undo: () => session?.undo(), render, getSelectedItemId: () => session?.selectedItemId ?? null, setSelectedItemId: id => session?.select(id), setShouldScroll: value => { if (session) session.shouldScroll = value; } }); // Make functions global globalThis.vote = vote; globalThis.deleteItem = deleteItem; globalThis.editItem = editItem; // Deep linking: auto-join room from URL param const urlParams = new URLSearchParams(location.search); const roomParam = urlParams.get('room'); if (roomParam) { const code = roomParam.trim(); if (code) { joinRoom(code); } } // Update sync status every second setInterval(() => updateLastSync(session?.lastSyncTime), 1000);