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);