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