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