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