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