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