app.js

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