Something went wrong. Try again.
Collaborative lists with real‑time voting and ranking
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>//// SPDX-License-Identifier: AGPL-3.0-or-later
import { getSortedItems, escapeHtml, updateConnectionStatus, updateLastSync, setUIEnabled } from './render.js';import { setupKeyboardShortcuts, setupHelpModal, setupPressLock } from './ui.js';
let ws = null;let currentRoom = null;let roomTitle = null;let userId = localStorage.getItem('userId') || crypto.randomUUID();localStorage.setItem('userId', userId);
let items = [];let lastAction = null; // For undolet selectedItemId = null; // For keyboard navigationlet selectedPosition = null; // Position to restore after votinglet lastSyncTime = null; // For connection statuslet shouldScrollSelectedIntoView = false; // Only scroll on local actions
const INITIAL_RECONNECT_DELAY = 1000;const MAX_RECONNECT_DELAY = 30000;let reconnectAttempts = 0;let reconnectTimer = null;let manualDisconnect = false;
const CHECK_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-check"><polyline points="20 6 9 17 4 12"></polyline></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 handlersdocument.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', () => { manualDisconnect = true; if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; } reconnectAttempts = 0; if (ws) { ws.close(); ws = null; } startScreen.classList.remove('hidden'); listScreen.classList.add('hidden'); currentRoom = null; items = []; messageQueue = []; listContainer.innerHTML = ''; // Clear URL when leaving room history.replaceState(null, '', location.pathname);});
document.getElementById('copy-btn').addEventListener('click', async () => { const itemNames = 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 () => { const inviteLink = `${location.origin}${location.pathname}?room=${currentRoom}`; 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 currentText = roomTitle || ''; const input = document.createElement('input'); input.type = 'text'; input.className = 'title-edit-input'; input.value = currentText; input.placeholder = 'Enter room title'; const finishEdit = () => { const newText = input.value.trim(); if (newText !== currentText) { setTitle(newText || null); } roomTitleEl.style.display = ''; input.remove(); }; input.addEventListener('blur', finishEdit); input.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.repeat) { e.preventDefault(); input.blur(); } else if (e.key === 'Escape') { e.preventDefault(); input.remove(); } }); // Insert input near title element if (roomTitleEl.classList.contains('hidden')) { roomTitleEl.parentElement.insertBefore(input, roomTitleEl); } else { roomTitleEl.style.display = 'none'; roomTitleEl.parentElement.insertBefore(input, roomTitleEl); } input.focus(); input.select();});
breakTieBtn.addEventListener('click', () => { sendMessage({ type: 'break_tie' });});
// Add items handlersdocument.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 functionslet messageQueue = [];
function joinRoom(code, { isReconnect = false } = {}) { manualDisconnect = false; if (!isReconnect) { reconnectAttempts = 0; } if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; } currentRoom = code; setUIEnabled(false, listScreen, listContainer); updateConnectionStatus('connecting'); const wsScheme = location.protocol === 'https:' ? 'wss:' : 'ws:'; ws = new WebSocket(`${wsScheme}//${location.host}/ws?room=${code}&user=${userId}`); ws.onopen = () => { reconnectAttempts = 0; if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; } updateConnectionStatus('connected'); // Flush queued messages while (messageQueue.length > 0) { ws.send(JSON.stringify(messageQueue.shift())); } }; ws.onmessage = (event) => { const msg = JSON.parse(event.data); lastSyncTime = Date.now(); updateLastSync(lastSyncTime); handleMessage(msg); }; ws.onerror = (error) => { console.error('WebSocket error:', error); setUIEnabled(false, listScreen, listContainer); updateConnectionStatus('disconnected'); }; ws.onclose = () => { ws = null; setUIEnabled(false, listScreen, listContainer); updateConnectionStatus('disconnected'); if (manualDisconnect || !currentRoom) { return; } scheduleReconnect(); }; startScreen.classList.add('hidden'); listScreen.classList.remove('hidden');}
function scheduleReconnect() { if (reconnectTimer || manualDisconnect || !currentRoom) { return; } const delay = Math.min(INITIAL_RECONNECT_DELAY * (2 ** reconnectAttempts), MAX_RECONNECT_DELAY); reconnectTimer = setTimeout(() => { reconnectTimer = null; joinRoom(currentRoom, { isReconnect: true }); }, delay); reconnectAttempts += 1;}
function sendMessage(msg) { if (ws && ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify(msg)); } else { messageQueue.push(msg); }}
function handleMessage(msg) { switch (msg.type) { case 'state': { items = msg.items; userId = msg.userId; roomTitle = msg.roomTitle; if (items.length > 0 && !selectedItemId) { selectedItemId = getSortedItems(items)[0].id; } setUIEnabled(true, listScreen, listContainer); // Update URL to include room code for easy copying const newUrl = `${location.origin}${location.pathname}?room=${currentRoom}`; history.replaceState(null, '', newUrl); renderTitle(); render(); break; } case 'items_added': items.push(...msg.items); if (!selectedItemId && items.length > 0) { selectedItemId = getSortedItems(items)[0].id; } render(); break; case 'vote_changed': { const item = items.find(i => i.id === msg.itemId); if (item) { item.votes[msg.userId] = msg.voteType; // If we voted, restore selection to same position if (msg.userId === userId && selectedPosition !== null) { const sorted = getSortedItems(items); const clampedPos = Math.min(selectedPosition, sorted.length - 1); selectedItemId = sorted[clampedPos].id; selectedPosition = null; } render(); } break; } case 'vote_removed': { const item2 = items.find(i => i.id === msg.itemId); if (item2) { delete item2.votes[msg.userId]; // If we unvoted, restore selection to same position if (msg.userId === userId && selectedPosition !== null) { const sorted = getSortedItems(items); const clampedPos = Math.min(selectedPosition, sorted.length - 1); selectedItemId = sorted[clampedPos].id; selectedPosition = null; } render(); } break; } case 'item_edited': { const editedItem = items.find(i => i.id === msg.itemId); if (editedItem) { editedItem.text = msg.text; render(); } break; } case 'item_deleted': if (selectedItemId === msg.itemId) { const sorted = getSortedItems(items); const idx = sorted.findIndex(i => i.id === msg.itemId); const nextIdx = Math.min(idx, sorted.length - 2); selectedItemId = nextIdx >= 0 ? sorted[nextIdx].id : null; } items = items.filter(i => i.id !== msg.itemId); render(); break; case 'votes_reset': items.forEach(item => { item.votes = {}; }); render(); break; case 'title_changed': roomTitle = msg.title; renderTitle(); break; case 'tie_broken': tieBreakResultEl.textContent = `Winner: ${msg.text}`; break; }}
function vote(itemId, voteType) { const item = items.find(i => i.id === itemId); const currentVote = item?.votes[userId]; // Remember current position to select item at same position after re-sort const sorted = getSortedItems(items); selectedPosition = sorted.findIndex(i => i.id === itemId); shouldScrollSelectedIntoView = true; // 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); } lastAction = { type: 'vote', itemId, previousVote: currentVote }; if (currentVote === voteType) { sendMessage({ type: 'unvote', itemId }); } else { sendMessage({ type: 'vote', itemId, voteType }); }}
function deleteItem(itemId) { if (confirm('Delete this item?')) { shouldScrollSelectedIntoView = true; sendMessage({ type: 'delete_item', itemId }); }}
function editItem(itemId) { const item = 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; const input = document.createElement('input'); input.type = 'text'; input.className = 'edit-input'; input.value = currentText; const finishEdit = () => { const newText = input.value.trim(); if (newText && newText !== currentText) { sendMessage({ type: 'edit_item', itemId, text: newText }); } textEl.textContent = item.text; textEl.style.display = ''; input.remove(); }; input.addEventListener('blur', finishEdit); input.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.repeat) { e.preventDefault(); input.blur(); } else if (e.key === 'Escape') { e.preventDefault(); textEl.textContent = item.text; textEl.style.display = ''; input.remove(); } }); textEl.style.display = 'none'; textEl.parentElement.insertBefore(input, textEl); input.focus(); input.select();}
function undo() { if (!lastAction) return; const { itemId, previousVote } = lastAction; if (previousVote) { sendMessage({ type: 'vote', itemId, voteType: previousVote }); } else { sendMessage({ type: 'unvote', itemId }); } lastAction = null;}
function renderTitle() { roomTitleEl.style.display = ''; if (roomTitle) { roomTitleEl.textContent = roomTitle; roomTitleEl.classList.remove('hidden'); } else { roomTitleEl.classList.add('hidden'); }}
function setTitle(title) { sendMessage({ type: 'set_title', title });}
function render() { // 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'); } else { tieBreakerEl.classList.add('hidden'); tieBreakResultEl.textContent = ''; } listContainer.innerHTML = sorted.map(item => { const myVote = item.votes[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 ` <div class="list-item ${isVetoed ? 'vetoed' : ''} ${isSelected ? 'selected' : ''} ${isTopVoted ? 'top-voted' : ''}" data-item-id="${item.id}"> ${isTopVoted ? '<svg class="top-check" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>' : ''} <div class="list-item-text">${escapeHtml(item.text)}</div> <div class="list-item-actions"> <div class="score">${score > 0 ? '+' : ''}${score}</div> <button class="vote-btn ${myVote === 'up' ? 'active' : ''}" data-action="vote" data-vote-type="up" title="Upvote"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 9V5a3 3 0 0 0-3-3l-4 9v11h11.28a2 2 0 0 0 2-1.7l1.38-9a2 2 0 0 0-2-2.3zM7 22H4a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2h3"></path></svg></button> <button class="vote-btn ${myVote === 'down' ? 'active' : ''}" data-action="vote" data-vote-type="down" title="Downvote"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 15v4a3 3 0 0 0 3 3l4-9V2H5.72a2 2 0 0 0-2 1.7l-1.38 9a2 2 0 0 0 2 2.3zm7-13h2.67A2.31 2.31 0 0 1 22 4v7a2.31 2.31 0 0 1-2.33 2H17"></path></svg></button> <button class="vote-btn ${myVote === 'veto' ? 'veto-active' : ''}" data-action="vote" data-vote-type="veto" title="Veto"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"></circle><line x1="4.93" y1="4.93" x2="19.07" y2="19.07"></line></svg></button> <button class="delete-btn" data-action="delete" title="Delete"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path><line x1="10" y1="11" x2="10" y2="17"></line><line x1="14" y1="11" x2="14" y2="17"></line></svg></button> </div> </div> `; }).join(''); // 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 (shouldScrollSelectedIntoView && selectedItemId) { const selectedEl = listContainer.querySelector(`[data-item-id="${selectedItemId}"]`); if (selectedEl) { selectedEl.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); } shouldScrollSelectedIntoView = false; }}
// Event delegation for list actionslistContainer.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 componentssetupHelpModal();setupPressLock();setupKeyboardShortcuts({ items, vote, deleteItem, editItem, undo, render, getSelectedItemId: () => selectedItemId, setSelectedItemId: (id) => { selectedItemId = id; }, setShouldScroll: (value) => { shouldScrollSelectedIntoView = value; }});
// Make functions globalglobalThis.vote = vote;globalThis.deleteItem = deleteItem;globalThis.editItem = editItem;
// Deep linking: auto-join room from URL paramconst urlParams = new URLSearchParams(location.search);const roomParam = urlParams.get('room');if (roomParam) { const code = roomParam.trim(); if (code) { joinRoom(code); }}
// Update sync status every secondsetInterval(() => updateLastSync(lastSyncTime), 1000);