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