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 startScreen = document.getElementById('start-screen');
19const listScreen = document.getElementById('list-screen');
20const roomTitleEl = document.getElementById('room-title');
21const listContainer = document.getElementById('list-container');
22
23// Start screen handlers
24document.getElementById('create-btn').addEventListener('click', async () => {
25 const res = await fetch('/api/create');
26 const { code } = await res.json();
27 joinRoom(code);
28});
29
30document.getElementById('join-btn').addEventListener('click', () => {
31 const code = document.getElementById('join-code').value.trim();
32 if (code) joinRoom(code);
33});
34
35document.getElementById('join-code').addEventListener('keydown', (e) => {
36 if (e.key === 'Enter' && !e.repeat) {
37 e.preventDefault();
38 const code = e.target.value.trim();
39 if (code) joinRoom(code);
40 }
41});
42
43document.getElementById('leave-btn').addEventListener('click', () => {
44 if (ws) ws.close();
45 startScreen.classList.remove('hidden');
46 listScreen.classList.add('hidden');
47 currentRoom = null;
48 items = [];
49 messageQueue = [];
50 listContainer.innerHTML = '';
51});
52
53document.getElementById('copy-btn').addEventListener('click', async () => {
54 const itemNames = items.map(item => item.text).join('\n');
55 try {
56 await navigator.clipboard.writeText(itemNames);
57 const btn = document.getElementById('copy-btn');
58 const original = btn.innerHTML;
59 btn.textContent = '✓';
60 setTimeout(() => btn.innerHTML = original, 1000);
61 } catch (err) {
62 console.error('Failed to copy:', err);
63 }
64});
65
66document.getElementById('copy-link-btn').addEventListener('click', async () => {
67 const inviteLink = `${location.origin}${location.pathname}?room=${currentRoom}`;
68 try {
69 await navigator.clipboard.writeText(inviteLink);
70 const btn = document.getElementById('copy-link-btn');
71 const original = btn.innerHTML;
72 btn.textContent = '✓';
73 setTimeout(() => btn.innerHTML = original, 1000);
74 } catch (err) {
75 console.error('Failed to copy invite link:', err);
76 }
77});
78
79document.getElementById('reset-votes-btn').addEventListener('click', () => {
80 if (confirm('Clear all votes and vetoes from all items?')) {
81 sendMessage({ type: 'reset_votes' });
82 }
83});
84
85document.getElementById('set-title-btn').addEventListener('click', () => {
86 const currentText = roomTitle || '';
87
88 const input = document.createElement('input');
89 input.type = 'text';
90 input.className = 'title-edit-input';
91 input.value = currentText;
92 input.placeholder = 'Enter room title';
93
94 const finishEdit = () => {
95 const newText = input.value.trim();
96 if (newText !== currentText) {
97 setTitle(newText || null);
98 }
99 input.remove();
100 };
101
102 input.addEventListener('blur', finishEdit);
103 input.addEventListener('keydown', (e) => {
104 if (e.key === 'Enter' && !e.repeat) {
105 e.preventDefault();
106 input.blur();
107 } else if (e.key === 'Escape') {
108 e.preventDefault();
109 input.remove();
110 }
111 });
112
113 // Insert input after the header div
114 const header = listScreen.querySelector('header');
115 if (roomTitleEl.classList.contains('hidden')) {
116 roomTitleEl.parentElement.insertBefore(input, roomTitleEl);
117 } else {
118 roomTitleEl.style.display = 'none';
119 roomTitleEl.parentElement.insertBefore(input, roomTitleEl);
120 }
121
122 input.focus();
123 input.select();
124});
125
126// Add items handlers
127document.getElementById('add-single-btn').addEventListener('click', () => {
128 const input = document.getElementById('single-input');
129 const text = input.value.trim();
130 if (text) {
131 sendMessage({ type: 'add_items', items: [text] });
132 input.value = '';
133 }
134});
135
136document.getElementById('single-input').addEventListener('keydown', (e) => {
137 if (e.key === 'Enter' && !e.repeat) {
138 e.preventDefault();
139 const text = e.target.value.trim();
140 if (text) {
141 sendMessage({ type: 'add_items', items: [text] });
142 e.target.value = '';
143 }
144 }
145});
146
147document.getElementById('add-bulk-btn').addEventListener('click', () => {
148 const textarea = document.getElementById('bulk-input');
149 const lines = textarea.value.split('\n')
150 .map(l => l.trim())
151 .filter(l => l);
152 if (lines.length) {
153 sendMessage({ type: 'add_items', items: lines });
154 textarea.value = '';
155 }
156});
157
158// Core functions
159let messageQueue = [];
160
161function joinRoom(code) {
162 currentRoom = code;
163
164 isReady = false;
165 setUIEnabled(false);
166 updateConnectionStatus('connecting');
167
168 const wsScheme = location.protocol === 'https:' ? 'wss:' : 'ws:';
169 ws = new WebSocket(`${wsScheme}//${location.host}/ws?room=${code}&user=${userId}`);
170
171 ws.onopen = () => {
172 updateConnectionStatus('connected');
173 // Flush queued messages
174 while (messageQueue.length > 0) {
175 ws.send(JSON.stringify(messageQueue.shift()));
176 }
177 };
178
179 ws.onmessage = (event) => {
180 const msg = JSON.parse(event.data);
181 lastSyncTime = Date.now();
182 updateLastSync();
183 handleMessage(msg);
184 };
185
186 ws.onerror = (error) => {
187 console.error('WebSocket error:', error);
188 updateConnectionStatus('disconnected');
189 };
190
191 ws.onclose = (event) => {
192 isReady = false;
193 setUIEnabled(false);
194 updateConnectionStatus('disconnected');
195 if (!event.wasClean) {
196 console.error('Connection closed unexpectedly');
197 alert('Connection lost. Please try rejoining.');
198 startScreen.classList.remove('hidden');
199 listScreen.classList.add('hidden');
200 currentRoom = null;
201 items = [];
202 messageQueue = [];
203 listContainer.innerHTML = '';
204 }
205 };
206
207 startScreen.classList.add('hidden');
208 listScreen.classList.remove('hidden');
209}
210
211function sendMessage(msg) {
212 if (ws && ws.readyState === WebSocket.OPEN) {
213 ws.send(JSON.stringify(msg));
214 } else {
215 messageQueue.push(msg);
216 }
217}
218
219function handleMessage(msg) {
220 switch (msg.type) {
221 case 'state':
222 items = msg.items;
223 userId = msg.userId;
224 roomTitle = msg.roomTitle;
225 if (items.length > 0 && !selectedItemId) {
226 selectedItemId = getSortedItems()[0].id;
227 }
228 isReady = true;
229 setUIEnabled(true);
230 renderTitle();
231 render();
232 break;
233 case 'items_added':
234 items.push(...msg.items);
235 if (!selectedItemId && items.length > 0) {
236 selectedItemId = getSortedItems()[0].id;
237 }
238 render();
239 break;
240 case 'vote_changed':
241 const item = items.find(i => i.id === msg.itemId);
242 if (item) {
243 item.votes[msg.userId] = msg.voteType;
244 // If we voted, restore selection to same position
245 if (msg.userId === userId && selectedPosition !== null) {
246 const sorted = getSortedItems();
247 const clampedPos = Math.min(selectedPosition, sorted.length - 1);
248 selectedItemId = sorted[clampedPos].id;
249 selectedPosition = null;
250 }
251 render();
252 }
253 break;
254 case 'vote_removed':
255 const item2 = items.find(i => i.id === msg.itemId);
256 if (item2) {
257 delete item2.votes[msg.userId];
258 // If we unvoted, restore selection to same position
259 if (msg.userId === userId && selectedPosition !== null) {
260 const sorted = getSortedItems();
261 const clampedPos = Math.min(selectedPosition, sorted.length - 1);
262 selectedItemId = sorted[clampedPos].id;
263 selectedPosition = null;
264 }
265 render();
266 }
267 break;
268 case 'item_edited':
269 const editedItem = items.find(i => i.id === msg.itemId);
270 if (editedItem) {
271 editedItem.text = msg.text;
272 render();
273 }
274 break;
275 case 'item_deleted':
276 if (selectedItemId === msg.itemId) {
277 const sorted = getSortedItems();
278 const idx = sorted.findIndex(i => i.id === msg.itemId);
279 const nextIdx = Math.min(idx, sorted.length - 2);
280 selectedItemId = nextIdx >= 0 ? sorted[nextIdx].id : null;
281 }
282 items = items.filter(i => i.id !== msg.itemId);
283 render();
284 break;
285 case 'votes_reset':
286 items.forEach(item => {
287 item.votes = {};
288 });
289 render();
290 break;
291 case 'title_changed':
292 roomTitle = msg.title;
293 renderTitle();
294 break;
295 }
296}
297
298function vote(itemId, voteType) {
299 const item = items.find(i => i.id === itemId);
300 const currentVote = item?.votes[userId];
301
302 // Remember current position to select item at same position after re-sort
303 const sorted = getSortedItems();
304 selectedPosition = sorted.findIndex(i => i.id === itemId);
305
306 lastAction = { type: 'vote', itemId, previousVote: currentVote };
307
308 if (currentVote === voteType) {
309 sendMessage({ type: 'unvote', itemId });
310 } else {
311 sendMessage({ type: 'vote', itemId, voteType });
312 }
313}
314
315function deleteItem(itemId) {
316 if (confirm('Delete this item?')) {
317 sendMessage({ type: 'delete_item', itemId });
318 }
319}
320
321function editItem(itemId) {
322 const item = items.find(i => i.id === itemId);
323 if (!item) return;
324
325 const itemEl = document.querySelector(`[data-item-id="${itemId}"]`);
326 if (!itemEl) return;
327
328 const textEl = itemEl.querySelector('.list-item-text');
329 const currentText = item.text;
330
331 const input = document.createElement('input');
332 input.type = 'text';
333 input.className = 'edit-input';
334 input.value = currentText;
335
336 const finishEdit = () => {
337 const newText = input.value.trim();
338 if (newText && newText !== currentText) {
339 sendMessage({ type: 'edit_item', itemId, text: newText });
340 }
341 textEl.textContent = item.text;
342 textEl.style.display = '';
343 input.remove();
344 };
345
346 input.addEventListener('blur', finishEdit);
347 input.addEventListener('keydown', (e) => {
348 if (e.key === 'Enter' && !e.repeat) {
349 e.preventDefault();
350 input.blur();
351 } else if (e.key === 'Escape') {
352 e.preventDefault();
353 textEl.textContent = item.text;
354 textEl.style.display = '';
355 input.remove();
356 }
357 });
358
359 textEl.style.display = 'none';
360 textEl.parentElement.insertBefore(input, textEl);
361 input.focus();
362 input.select();
363}
364
365function undo() {
366 if (!lastAction) return;
367
368 const { itemId, previousVote } = lastAction;
369
370 if (previousVote) {
371 sendMessage({ type: 'vote', itemId, voteType: previousVote });
372 } else {
373 sendMessage({ type: 'unvote', itemId });
374 }
375
376 lastAction = null;
377}
378
379function renderTitle() {
380 roomTitleEl.style.display = '';
381 if (roomTitle) {
382 roomTitleEl.textContent = roomTitle;
383 roomTitleEl.classList.remove('hidden');
384 } else {
385 roomTitleEl.classList.add('hidden');
386 }
387}
388
389function setTitle(title) {
390 sendMessage({ type: 'set_title', title });
391}
392
393// Keyboard shortcuts
394document.addEventListener('keydown', (e) => {
395 // Open help modal on '?'
396 if (e.key === '?' && !e.target.matches('input, textarea')) {
397 e.preventDefault();
398 openHelpModal();
399 return;
400 }
401
402 // Close help modal on Esc
403 if (e.key === 'Escape') {
404 const helpModal = document.getElementById('help-modal');
405 if (!helpModal.classList.contains('hidden')) {
406 e.preventDefault();
407 closeHelpModal();
408 return;
409 }
410 }
411
412 // Ignore if typing in input/textarea
413 if (e.target.matches('input, textarea')) return;
414
415 // Undo
416 if ((e.ctrlKey || e.metaKey) && e.key === 'z') {
417 e.preventDefault();
418 undo();
419 return;
420 }
421
422 // Navigation and actions only work if we have items
423 if (items.length === 0) return;
424
425 const sorted = getSortedItems();
426
427 // Navigation: j/k or ArrowDown/ArrowUp
428 if (e.key === 'j' || e.key === 'ArrowDown') {
429 e.preventDefault();
430 const currentIdx = sorted.findIndex(i => i.id === selectedItemId);
431 const nextIdx = Math.min(currentIdx + 1, sorted.length - 1);
432 selectedItemId = sorted[nextIdx].id;
433 render();
434 } else if (e.key === 'k' || e.key === 'ArrowUp') {
435 e.preventDefault();
436 const currentIdx = sorted.findIndex(i => i.id === selectedItemId);
437 const prevIdx = Math.max(currentIdx - 1, 0);
438 selectedItemId = sorted[prevIdx].id;
439 render();
440 }
441
442 // Actions on selected item
443 if (selectedItemId) {
444 if (e.key === '1' || e.key === 'Enter') {
445 e.preventDefault();
446 vote(selectedItemId, 'up');
447 } else if (e.key === '2') {
448 e.preventDefault();
449 vote(selectedItemId, 'down');
450 } else if (e.key === '3') {
451 e.preventDefault();
452 vote(selectedItemId, 'veto');
453 } else if (e.key === 'e') {
454 e.preventDefault();
455 editItem(selectedItemId);
456 } else if (e.key === 'Delete' || e.key === 'Backspace') {
457 e.preventDefault();
458 deleteItem(selectedItemId);
459 }
460 }
461});
462
463
464
465// Help modal
466const helpModal = document.getElementById('help-modal');
467const helpModalClose = helpModal.querySelector('.modal-close');
468
469function openHelpModal() {
470 helpModal.classList.remove('hidden');
471 helpModalClose.focus();
472}
473
474function closeHelpModal() {
475 helpModal.classList.add('hidden');
476}
477
478helpModalClose.addEventListener('click', closeHelpModal);
479
480// Close modal on backdrop click
481helpModal.addEventListener('click', (e) => {
482 if (e.target === helpModal) {
483 closeHelpModal();
484 }
485});
486
487// Connection status
488function updateConnectionStatus(status) {
489 const statusDot = document.querySelector('.status-dot');
490 const statusText = document.querySelector('.status-text');
491
492 statusDot.className = 'status-dot ' + status;
493
494 const statusLabels = {
495 connecting: 'Connecting...',
496 connected: 'Connected',
497 disconnected: 'Disconnected'
498 };
499
500 statusText.textContent = statusLabels[status] || status;
501}
502
503function formatTimeSince(timestamp) {
504 const seconds = Math.floor((Date.now() - timestamp) / 1000);
505
506 if (seconds < 5) return 'just now';
507 if (seconds < 60) return `${seconds}s ago`;
508
509 const minutes = Math.floor(seconds / 60);
510 if (minutes < 60) return `${minutes}m ago`;
511
512 const hours = Math.floor(minutes / 60);
513 if (hours < 24) return `${hours}h ago`;
514
515 const days = Math.floor(hours / 24);
516 return `${days}d ago`;
517}
518
519function updateLastSync() {
520 const lastSyncEl = document.querySelector('.last-sync');
521 if (!lastSyncTime) {
522 lastSyncEl.textContent = '';
523 return;
524 }
525
526 lastSyncEl.textContent = '• ' + formatTimeSince(lastSyncTime);
527}
528
529function setUIEnabled(enabled) {
530 const inputs = listScreen.querySelectorAll('input, textarea, button');
531 inputs.forEach(input => {
532 // Don't disable the leave button
533 if (input.id === 'leave-btn') return;
534 input.disabled = !enabled;
535 });
536
537 if (enabled) {
538 listContainer.classList.remove('disabled');
539 } else {
540 listContainer.classList.add('disabled');
541 }
542}
543
544function getSortedItems() {
545 return [...items].sort((a, b) => {
546 const aVetoed = Object.values(a.votes).includes('veto');
547 const bVetoed = Object.values(b.votes).includes('veto');
548 if (aVetoed && !bVetoed) return 1;
549 if (!aVetoed && bVetoed) return -1;
550 const aScore = Object.values(a.votes).reduce((sum, v) =>
551 sum + (v === 'up' ? 1 : v === 'down' ? -1 : 0), 0);
552 const bScore = Object.values(b.votes).reduce((sum, v) =>
553 sum + (v === 'up' ? 1 : v === 'down' ? -1 : 0), 0);
554 return bScore - aScore;
555 });
556}
557
558function render() {
559 // FLIP animation: capture First positions
560 const oldPositions = new Map();
561 const existingItems = listContainer.querySelectorAll('.list-item');
562 existingItems.forEach(el => {
563 const itemId = el.dataset.itemId;
564 oldPositions.set(itemId, el.getBoundingClientRect());
565 });
566
567 // Sort: vetoed items last, then by score
568 const sorted = getSortedItems();
569
570 listContainer.innerHTML = sorted.map(item => {
571 const myVote = item.votes[userId];
572 const score = Object.values(item.votes).reduce((sum, v) =>
573 sum + (v === 'up' ? 1 : v === 'down' ? -1 : 0), 0);
574 const isVetoed = Object.values(item.votes).includes('veto');
575 const isSelected = item.id === selectedItemId;
576
577 return `
578 <div class="list-item ${isVetoed ? 'vetoed' : ''} ${isSelected ? 'selected' : ''}" data-item-id="${item.id}">
579 <div class="list-item-text">${escapeHtml(item.text)}</div>
580 <div class="list-item-actions">
581 <div class="score">${score > 0 ? '+' : ''}${score}</div>
582 <button class="vote-btn ${myVote === 'up' ? 'active' : ''}"
583 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>
584 <button class="vote-btn ${myVote === 'down' ? 'active' : ''}"
585 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>
586 <button class="vote-btn ${myVote === 'veto' ? 'veto-active' : ''}"
587 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>
588 <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>
589 </div>
590 </div>
591 `;
592 }).join('');
593
594 // FLIP animation: capture Last positions and animate
595 const newItems = listContainer.querySelectorAll('.list-item');
596 newItems.forEach(el => {
597 const itemId = el.dataset.itemId;
598 const oldPos = oldPositions.get(itemId);
599
600 if (oldPos) {
601 const newPos = el.getBoundingClientRect();
602 const deltaY = oldPos.top - newPos.top;
603
604 // Invert: apply transform to make it appear at old position
605 if (deltaY !== 0) {
606 el.style.transform = `translateY(${deltaY}px)`;
607 el.style.transition = 'none';
608
609 // Play: animate to new position
610 requestAnimationFrame(() => {
611 el.style.transition = '';
612 el.style.transform = '';
613 });
614 }
615 }
616 });
617
618 // Scroll selected item into view
619 if (selectedItemId) {
620 const selectedEl = listContainer.querySelector(`[data-item-id="${selectedItemId}"]`);
621 if (selectedEl) {
622 selectedEl.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
623 }
624 }
625}
626
627function escapeHtml(text) {
628 const div = document.createElement('div');
629 div.textContent = text;
630 return div.innerHTML;
631}
632
633// Event delegation for list actions
634listContainer.addEventListener('click', (e) => {
635 const button = e.target.closest('[data-action]');
636 if (!button) return;
637
638 const listItem = button.closest('.list-item');
639 if (!listItem) return;
640
641 const itemId = listItem.dataset.itemId;
642 const action = button.dataset.action;
643
644 if (action === 'vote') {
645 const voteType = button.dataset.voteType;
646 vote(itemId, voteType);
647 } else if (action === 'delete') {
648 deleteItem(itemId);
649 }
650});
651
652listContainer.addEventListener('dblclick', (e) => {
653 const textEl = e.target.closest('.list-item-text');
654 if (!textEl) return;
655
656 const listItem = textEl.closest('.list-item');
657 if (!listItem) return;
658
659 const itemId = listItem.dataset.itemId;
660 editItem(itemId);
661});
662
663// Make functions global
664window.vote = vote;
665window.deleteItem = deleteItem;
666window.editItem = editItem;
667
668// Deep linking: auto-join room from URL param
669const urlParams = new URLSearchParams(location.search);
670const roomParam = urlParams.get('room');
671if (roomParam) {
672 const code = roomParam.trim();
673 if (code) {
674 joinRoom(code);
675 }
676}
677
678// Update sync status every second
679setInterval(updateLastSync, 1000);