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