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