app.js

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