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