app.js

  1// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
  2//
  3// SPDX-License-Identifier: AGPL-3.0-or-later
  4
  5let ws = null;
  6let currentRoom = null;
  7let roomTitle = null;
  8let userId = localStorage.getItem('userId') || crypto.randomUUID();
  9localStorage.setItem('userId', userId);
 10
 11let items = [];
 12let lastAction = null; // For undo
 13let selectedItemId = null; // For keyboard navigation
 14let selectedPosition = null; // Position to restore after voting
 15let lastSyncTime = null; // For connection status
 16let isReady = false; // Track if initial state received
 17
 18const 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>`;
 19
 20const startScreen = document.getElementById('start-screen');
 21const listScreen = document.getElementById('list-screen');
 22const roomTitleEl = document.getElementById('room-title');
 23const listContainer = document.getElementById('list-container');
 24const tieBreakerEl = document.getElementById('tie-breaker');
 25const breakTieBtn = document.getElementById('break-tie-btn');
 26const tieBreakResultEl = document.getElementById('tie-break-result');
 27
 28// Start screen handlers
 29document.getElementById('create-btn').addEventListener('click', async () => {
 30  const res = await fetch('/api/create');
 31  const { code } = await res.json();
 32  joinRoom(code);
 33});
 34
 35document.getElementById('join-btn').addEventListener('click', () => {
 36  const code = document.getElementById('join-code').value.trim();
 37  if (code) joinRoom(code);
 38});
 39
 40document.getElementById('join-code').addEventListener('keydown', (e) => {
 41  if (e.key === 'Enter' && !e.repeat) {
 42    e.preventDefault();
 43    const code = e.target.value.trim();
 44    if (code) joinRoom(code);
 45  }
 46});
 47
 48document.getElementById('leave-btn').addEventListener('click', () => {
 49  if (ws) ws.close();
 50  startScreen.classList.remove('hidden');
 51  listScreen.classList.add('hidden');
 52  currentRoom = null;
 53  items = [];
 54  messageQueue = [];
 55  listContainer.innerHTML = '';
 56  
 57  // Clear URL when leaving room
 58  history.replaceState(null, '', location.pathname);
 59});
 60
 61document.getElementById('copy-btn').addEventListener('click', async () => {
 62  const itemNames = items.map(item => item.text).join('\n');
 63  try {
 64    await navigator.clipboard.writeText(itemNames);
 65    const btn = document.getElementById('copy-btn');
 66    const original = btn.innerHTML;
 67    btn.innerHTML = CHECK_ICON_SVG;
 68    setTimeout(() => btn.innerHTML = original, 1000);
 69  } catch (err) {
 70    console.error('Failed to copy:', err);
 71  }
 72});
 73
 74document.getElementById('copy-link-btn').addEventListener('click', async () => {
 75  const inviteLink = `${location.origin}${location.pathname}?room=${currentRoom}`;
 76  try {
 77    await navigator.clipboard.writeText(inviteLink);
 78    const btn = document.getElementById('copy-link-btn');
 79    const original = btn.innerHTML;
 80    btn.innerHTML = CHECK_ICON_SVG;
 81    setTimeout(() => btn.innerHTML = original, 1000);
 82  } catch (err) {
 83    console.error('Failed to copy invite link:', err);
 84  }
 85});
 86
 87document.getElementById('reset-votes-btn').addEventListener('click', () => {
 88  if (confirm('Clear all votes and vetoes from all items?')) {
 89    sendMessage({ type: 'reset_votes' });
 90  }
 91});
 92
 93document.getElementById('set-title-btn').addEventListener('click', () => {
 94  const currentText = roomTitle || '';
 95  
 96  const input = document.createElement('input');
 97  input.type = 'text';
 98  input.className = 'title-edit-input';
 99  input.value = currentText;
100  input.placeholder = 'Enter room title';
101  
102  const finishEdit = () => {
103    const newText = input.value.trim();
104    if (newText !== currentText) {
105      setTitle(newText || null);
106    }
107    input.remove();
108  };
109  
110  input.addEventListener('blur', finishEdit);
111  input.addEventListener('keydown', (e) => {
112    if (e.key === 'Enter' && !e.repeat) {
113      e.preventDefault();
114      input.blur();
115    } else if (e.key === 'Escape') {
116      e.preventDefault();
117      input.remove();
118    }
119  });
120  
121  // Insert input after the header div
122  const header = listScreen.querySelector('header');
123  if (roomTitleEl.classList.contains('hidden')) {
124    roomTitleEl.parentElement.insertBefore(input, roomTitleEl);
125  } else {
126    roomTitleEl.style.display = 'none';
127    roomTitleEl.parentElement.insertBefore(input, roomTitleEl);
128  }
129  
130  input.focus();
131  input.select();
132});
133
134breakTieBtn.addEventListener('click', () => {
135  sendMessage({ type: 'break_tie' });
136});
137
138// Add items handlers
139document.getElementById('add-single-btn').addEventListener('click', () => {
140  const input = document.getElementById('single-input');
141  const text = input.value.trim();
142  if (text) {
143    sendMessage({ type: 'add_items', items: [text] });
144    input.value = '';
145  }
146});
147
148document.getElementById('single-input').addEventListener('keydown', (e) => {
149  if (e.key === 'Enter' && !e.repeat) {
150    e.preventDefault();
151    const text = e.target.value.trim();
152    if (text) {
153      sendMessage({ type: 'add_items', items: [text] });
154      e.target.value = '';
155    }
156  }
157});
158
159document.getElementById('add-bulk-btn').addEventListener('click', () => {
160  const textarea = document.getElementById('bulk-input');
161  const lines = textarea.value.split('\n')
162    .map(l => l.trim())
163    .filter(l => l);
164  if (lines.length) {
165    sendMessage({ type: 'add_items', items: lines });
166    textarea.value = '';
167  }
168});
169
170// Core functions
171let messageQueue = [];
172
173function joinRoom(code) {
174  currentRoom = code;
175  
176  isReady = false;
177  setUIEnabled(false);
178  updateConnectionStatus('connecting');
179  
180  const wsScheme = location.protocol === 'https:' ? 'wss:' : 'ws:';
181  ws = new WebSocket(`${wsScheme}//${location.host}/ws?room=${code}&user=${userId}`);
182  
183  ws.onopen = () => {
184    updateConnectionStatus('connected');
185    // Flush queued messages
186    while (messageQueue.length > 0) {
187      ws.send(JSON.stringify(messageQueue.shift()));
188    }
189  };
190  
191  ws.onmessage = (event) => {
192    const msg = JSON.parse(event.data);
193    lastSyncTime = Date.now();
194    updateLastSync();
195    handleMessage(msg);
196  };
197  
198  ws.onerror = (error) => {
199    console.error('WebSocket error:', error);
200    updateConnectionStatus('disconnected');
201  };
202  
203  ws.onclose = (event) => {
204    isReady = false;
205    setUIEnabled(false);
206    updateConnectionStatus('disconnected');
207    if (!event.wasClean) {
208      console.error('Connection closed unexpectedly');
209      alert('Connection lost. Please try rejoining.');
210      startScreen.classList.remove('hidden');
211      listScreen.classList.add('hidden');
212      currentRoom = null;
213      items = [];
214      messageQueue = [];
215      listContainer.innerHTML = '';
216    }
217  };
218  
219  startScreen.classList.add('hidden');
220  listScreen.classList.remove('hidden');
221}
222
223function sendMessage(msg) {
224  if (ws && ws.readyState === WebSocket.OPEN) {
225    ws.send(JSON.stringify(msg));
226  } else {
227    messageQueue.push(msg);
228  }
229}
230
231function handleMessage(msg) {
232  switch (msg.type) {
233    case 'state':
234      items = msg.items;
235      userId = msg.userId;
236      roomTitle = msg.roomTitle;
237      if (items.length > 0 && !selectedItemId) {
238        selectedItemId = getSortedItems()[0].id;
239      }
240      isReady = true;
241      setUIEnabled(true);
242      
243      // Update URL to include room code for easy copying
244      const newUrl = `${location.origin}${location.pathname}?room=${currentRoom}`;
245      history.replaceState(null, '', newUrl);
246      
247      renderTitle();
248      render();
249      break;
250    case 'items_added':
251      items.push(...msg.items);
252      if (!selectedItemId && items.length > 0) {
253        selectedItemId = getSortedItems()[0].id;
254      }
255      render();
256      break;
257    case 'vote_changed':
258      const item = items.find(i => i.id === msg.itemId);
259      if (item) {
260        item.votes[msg.userId] = msg.voteType;
261        // If we voted, restore selection to same position
262        if (msg.userId === userId && selectedPosition !== null) {
263          const sorted = getSortedItems();
264          const clampedPos = Math.min(selectedPosition, sorted.length - 1);
265          selectedItemId = sorted[clampedPos].id;
266          selectedPosition = null;
267        }
268        render();
269      }
270      break;
271    case 'vote_removed':
272      const item2 = items.find(i => i.id === msg.itemId);
273      if (item2) {
274        delete item2.votes[msg.userId];
275        // If we unvoted, restore selection to same position
276        if (msg.userId === userId && selectedPosition !== null) {
277          const sorted = getSortedItems();
278          const clampedPos = Math.min(selectedPosition, sorted.length - 1);
279          selectedItemId = sorted[clampedPos].id;
280          selectedPosition = null;
281        }
282        render();
283      }
284      break;
285    case 'item_edited':
286      const editedItem = items.find(i => i.id === msg.itemId);
287      if (editedItem) {
288        editedItem.text = msg.text;
289        render();
290      }
291      break;
292    case 'item_deleted':
293      if (selectedItemId === msg.itemId) {
294        const sorted = getSortedItems();
295        const idx = sorted.findIndex(i => i.id === msg.itemId);
296        const nextIdx = Math.min(idx, sorted.length - 2);
297        selectedItemId = nextIdx >= 0 ? sorted[nextIdx].id : null;
298      }
299      items = items.filter(i => i.id !== msg.itemId);
300      render();
301      break;
302    case 'votes_reset':
303      items.forEach(item => {
304        item.votes = {};
305      });
306      render();
307      break;
308    case 'title_changed':
309      roomTitle = msg.title;
310      renderTitle();
311      break;
312    case 'tie_broken':
313      tieBreakResultEl.textContent = `Winner: ${msg.text}`;
314      break;
315  }
316}
317
318function vote(itemId, voteType) {
319  const item = items.find(i => i.id === itemId);
320  const currentVote = item?.votes[userId];
321  
322  // Remember current position to select item at same position after re-sort
323  const sorted = getSortedItems();
324  selectedPosition = sorted.findIndex(i => i.id === itemId);
325  
326  // Keep the button visually pressed briefly to avoid flicker
327  const btn = document.querySelector(`[data-item-id="${itemId}"] [data-action="vote"][data-vote-type="${voteType}"]`);
328  if (btn) {
329    btn.classList.add('press-lock');
330    setTimeout(() => btn.classList.remove('press-lock'), 220);
331  }
332  
333  lastAction = { type: 'vote', itemId, previousVote: currentVote };
334  
335  if (currentVote === voteType) {
336    sendMessage({ type: 'unvote', itemId });
337  } else {
338    sendMessage({ type: 'vote', itemId, voteType });
339  }
340}
341
342function deleteItem(itemId) {
343  if (confirm('Delete this item?')) {
344    sendMessage({ type: 'delete_item', itemId });
345  }
346}
347
348function editItem(itemId) {
349  const item = items.find(i => i.id === itemId);
350  if (!item) return;
351  
352  const itemEl = document.querySelector(`[data-item-id="${itemId}"]`);
353  if (!itemEl) return;
354  
355  const textEl = itemEl.querySelector('.list-item-text');
356  const currentText = item.text;
357  
358  const input = document.createElement('input');
359  input.type = 'text';
360  input.className = 'edit-input';
361  input.value = currentText;
362  
363  const finishEdit = () => {
364    const newText = input.value.trim();
365    if (newText && newText !== currentText) {
366      sendMessage({ type: 'edit_item', itemId, text: newText });
367    }
368    textEl.textContent = item.text;
369    textEl.style.display = '';
370    input.remove();
371  };
372  
373  input.addEventListener('blur', finishEdit);
374  input.addEventListener('keydown', (e) => {
375    if (e.key === 'Enter' && !e.repeat) {
376      e.preventDefault();
377      input.blur();
378    } else if (e.key === 'Escape') {
379      e.preventDefault();
380      textEl.textContent = item.text;
381      textEl.style.display = '';
382      input.remove();
383    }
384  });
385  
386  textEl.style.display = 'none';
387  textEl.parentElement.insertBefore(input, textEl);
388  input.focus();
389  input.select();
390}
391
392function undo() {
393  if (!lastAction) return;
394  
395  const { itemId, previousVote } = lastAction;
396  
397  if (previousVote) {
398    sendMessage({ type: 'vote', itemId, voteType: previousVote });
399  } else {
400    sendMessage({ type: 'unvote', itemId });
401  }
402  
403  lastAction = null;
404}
405
406function renderTitle() {
407  roomTitleEl.style.display = '';
408  if (roomTitle) {
409    roomTitleEl.textContent = roomTitle;
410    roomTitleEl.classList.remove('hidden');
411  } else {
412    roomTitleEl.classList.add('hidden');
413  }
414}
415
416function setTitle(title) {
417  sendMessage({ type: 'set_title', title });
418}
419
420// Keyboard shortcuts
421document.addEventListener('keydown', (e) => {
422  // Open help modal on '?'
423  if (e.key === '?' && !e.target.matches('input, textarea')) {
424    e.preventDefault();
425    openHelpModal();
426    return;
427  }
428  
429  // Close help modal on Esc
430  if (e.key === 'Escape') {
431    const helpModal = document.getElementById('help-modal');
432    if (!helpModal.classList.contains('hidden')) {
433      e.preventDefault();
434      closeHelpModal();
435      return;
436    }
437  }
438  
439  // Ignore if typing in input/textarea
440  if (e.target.matches('input, textarea')) return;
441  
442  // Undo
443  if ((e.ctrlKey || e.metaKey) && e.key === 'z') {
444    e.preventDefault();
445    undo();
446    return;
447  }
448  
449  // Navigation and actions only work if we have items
450  if (items.length === 0) return;
451  
452  const sorted = getSortedItems();
453  
454  // Navigation: j/k or ArrowDown/ArrowUp
455  if (e.key === 'j' || e.key === 'ArrowDown') {
456    e.preventDefault();
457    const currentIdx = sorted.findIndex(i => i.id === selectedItemId);
458    const nextIdx = Math.min(currentIdx + 1, sorted.length - 1);
459    selectedItemId = sorted[nextIdx].id;
460    render();
461  } else if (e.key === 'k' || e.key === 'ArrowUp') {
462    e.preventDefault();
463    const currentIdx = sorted.findIndex(i => i.id === selectedItemId);
464    const prevIdx = Math.max(currentIdx - 1, 0);
465    selectedItemId = sorted[prevIdx].id;
466    render();
467  }
468  
469  // Actions on selected item
470  if (selectedItemId) {
471    if (e.key === '1' || e.key === 'Enter') {
472      e.preventDefault();
473      vote(selectedItemId, 'up');
474    } else if (e.key === '2') {
475      e.preventDefault();
476      vote(selectedItemId, 'down');
477    } else if (e.key === '3') {
478      e.preventDefault();
479      vote(selectedItemId, 'veto');
480    } else if (e.key === 'e') {
481      e.preventDefault();
482      editItem(selectedItemId);
483    } else if (e.key === 'Delete' || e.key === 'Backspace') {
484      e.preventDefault();
485      deleteItem(selectedItemId);
486    }
487  }
488});
489
490
491
492// Help modal
493const helpModal = document.getElementById('help-modal');
494const helpModalClose = helpModal.querySelector('.modal-close');
495
496function openHelpModal() {
497  helpModal.classList.remove('hidden');
498  helpModalClose.focus();
499}
500
501function closeHelpModal() {
502  helpModal.classList.add('hidden');
503}
504
505helpModalClose.addEventListener('click', closeHelpModal);
506
507// Close modal on backdrop click
508helpModal.addEventListener('click', (e) => {
509  if (e.target === helpModal) {
510    closeHelpModal();
511  }
512});
513
514// Connection status
515function updateConnectionStatus(status) {
516  const statusDot = document.querySelector('.status-dot');
517  const statusText = document.querySelector('.status-text');
518  
519  statusDot.className = 'status-dot ' + status;
520  
521  const statusLabels = {
522    connecting: 'Connecting...',
523    connected: 'Connected',
524    disconnected: 'Disconnected'
525  };
526  
527  statusText.textContent = statusLabels[status] || status;
528}
529
530function formatTimeSince(timestamp) {
531  const seconds = Math.floor((Date.now() - timestamp) / 1000);
532  
533  if (seconds < 5) return 'just now';
534  if (seconds < 60) return `${seconds}s ago`;
535  
536  const minutes = Math.floor(seconds / 60);
537  if (minutes < 60) return `${minutes}m ago`;
538  
539  const hours = Math.floor(minutes / 60);
540  if (hours < 24) return `${hours}h ago`;
541  
542  const days = Math.floor(hours / 24);
543  return `${days}d ago`;
544}
545
546function updateLastSync() {
547  const lastSyncEl = document.querySelector('.last-sync');
548  if (!lastSyncTime) {
549    lastSyncEl.textContent = '';
550    return;
551  }
552  
553  lastSyncEl.textContent = '• ' + formatTimeSince(lastSyncTime);
554}
555
556function setUIEnabled(enabled) {
557  const inputs = listScreen.querySelectorAll('input, textarea, button');
558  inputs.forEach(input => {
559    // Don't disable the leave button
560    if (input.id === 'leave-btn') return;
561    input.disabled = !enabled;
562  });
563  
564  if (enabled) {
565    listContainer.classList.remove('disabled');
566  } else {
567    listContainer.classList.add('disabled');
568  }
569}
570
571function getSortedItems() {
572  return [...items].sort((a, b) => {
573    const aVetoed = Object.values(a.votes).includes('veto');
574    const bVetoed = Object.values(b.votes).includes('veto');
575    if (aVetoed && !bVetoed) return 1;
576    if (!aVetoed && bVetoed) return -1;
577    const aScore = Object.values(a.votes).reduce((sum, v) => 
578      sum + (v === 'up' ? 1 : v === 'down' ? -1 : 0), 0);
579    const bScore = Object.values(b.votes).reduce((sum, v) => 
580      sum + (v === 'up' ? 1 : v === 'down' ? -1 : 0), 0);
581    return bScore - aScore;
582  });
583}
584
585function render() {
586  // FLIP animation: capture First positions
587  const oldPositions = new Map();
588  const existingItems = listContainer.querySelectorAll('.list-item');
589  existingItems.forEach(el => {
590    const itemId = el.dataset.itemId;
591    oldPositions.set(itemId, el.getBoundingClientRect());
592  });
593  
594  // Sort: vetoed items last, then by score
595  const sorted = getSortedItems();
596  
597  // Check if any votes have been cast
598  const hasAnyVotes = sorted.some(item => Object.keys(item.votes).length > 0);
599  
600  // Calculate highest score among non-vetoed items
601  const nonVetoedItems = sorted.filter(item => 
602    !Object.values(item.votes).includes('veto')
603  );
604  const highestScore = nonVetoedItems.length > 0 
605    ? Math.max(...nonVetoedItems.map(item => 
606        Object.values(item.votes).reduce((sum, v) => 
607          sum + (v === 'up' ? 1 : v === 'down' ? -1 : 0), 0)
608      ))
609    : -Infinity;
610  
611  // Check for tie at the top
612  const topItems = nonVetoedItems.filter(item => {
613    const score = Object.values(item.votes).reduce((sum, v) => 
614      sum + (v === 'up' ? 1 : v === 'down' ? -1 : 0), 0);
615    return score === highestScore;
616  });
617  const hasTie = hasAnyVotes && topItems.length > 1;
618  
619  // Show/hide tie-breaker UI and clear result when state changes
620  if (hasTie) {
621    tieBreakerEl.classList.remove('hidden');
622  } else {
623    tieBreakerEl.classList.add('hidden');
624    tieBreakResultEl.textContent = '';
625  }
626  
627  listContainer.innerHTML = sorted.map(item => {
628    const myVote = item.votes[userId];
629    const score = Object.values(item.votes).reduce((sum, v) => 
630      sum + (v === 'up' ? 1 : v === 'down' ? -1 : 0), 0);
631    const isVetoed = Object.values(item.votes).includes('veto');
632    const isSelected = item.id === selectedItemId;
633    const isTopVoted = hasAnyVotes && !isVetoed && score === highestScore && nonVetoedItems.length > 0;
634    
635    return `
636      <div class="list-item ${isVetoed ? 'vetoed' : ''} ${isSelected ? 'selected' : ''} ${isTopVoted ? 'top-voted' : ''}" data-item-id="${item.id}">
637        ${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>' : ''}
638        <div class="list-item-text">${escapeHtml(item.text)}</div>
639        <div class="list-item-actions">
640          <div class="score">${score > 0 ? '+' : ''}${score}</div>
641          <button class="vote-btn ${myVote === 'up' ? 'active' : ''}" 
642                  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>
643          <button class="vote-btn ${myVote === 'down' ? 'active' : ''}" 
644                  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>
645          <button class="vote-btn ${myVote === 'veto' ? 'veto-active' : ''}" 
646                  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>
647          <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>
648        </div>
649      </div>
650    `;
651  }).join('');
652  
653  // FLIP animation: capture Last positions and animate
654  const newItems = listContainer.querySelectorAll('.list-item');
655  newItems.forEach(el => {
656    const itemId = el.dataset.itemId;
657    const oldPos = oldPositions.get(itemId);
658    
659    if (oldPos) {
660      const newPos = el.getBoundingClientRect();
661      const deltaY = oldPos.top - newPos.top;
662      
663      // Invert: apply transform to make it appear at old position
664      if (deltaY !== 0) {
665        el.style.transform = `translateY(${deltaY}px)`;
666        el.style.transition = 'none';
667        
668        // Play: animate to new position
669        requestAnimationFrame(() => {
670          el.style.transition = '';
671          el.style.transform = '';
672        });
673      }
674    }
675  });
676  
677  // Scroll selected item into view
678  if (selectedItemId) {
679    const selectedEl = listContainer.querySelector(`[data-item-id="${selectedItemId}"]`);
680    if (selectedEl) {
681      selectedEl.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
682    }
683  }
684}
685
686function escapeHtml(text) {
687  const div = document.createElement('div');
688  div.textContent = text;
689  return div.innerHTML;
690}
691
692// Event delegation for list actions
693listContainer.addEventListener('click', (e) => {
694  const button = e.target.closest('[data-action]');
695  if (!button) return;
696  
697  const listItem = button.closest('.list-item');
698  if (!listItem) return;
699  
700  const itemId = listItem.dataset.itemId;
701  const action = button.dataset.action;
702  
703  if (action === 'vote') {
704    const voteType = button.dataset.voteType;
705    vote(itemId, voteType);
706  } else if (action === 'delete') {
707    deleteItem(itemId);
708  }
709});
710
711listContainer.addEventListener('dblclick', (e) => {
712  const textEl = e.target.closest('.list-item-text');
713  if (!textEl) return;
714  
715  const listItem = textEl.closest('.list-item');
716  if (!listItem) return;
717  
718  const itemId = listItem.dataset.itemId;
719  editItem(itemId);
720});
721
722// Minimal press-lock for non-vote buttons to smooth release
723document.addEventListener('click', (e) => {
724  const btn = e.target.closest('button');
725  if (!btn) return;
726  if (btn.classList.contains('vote-btn')) return; // handled in vote()
727  btn.classList.add('press-lock');
728  setTimeout(() => btn.classList.remove('press-lock'), 180);
729});
730
731// Make functions global
732window.vote = vote;
733window.deleteItem = deleteItem;
734window.editItem = editItem;
735
736// Deep linking: auto-join room from URL param
737const urlParams = new URLSearchParams(location.search);
738const roomParam = urlParams.get('room');
739if (roomParam) {
740  const code = roomParam.trim();
741  if (code) {
742    joinRoom(code);
743  }
744}
745
746// Update sync status every second
747setInterval(updateLastSync, 1000);