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