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