diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..98e6ef67fad8af56cb3721edbd420b3d6fcc0bb1 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +*.db diff --git a/crush.json b/crush.json new file mode 100644 index 0000000000000000000000000000000000000000..93cc29ea1709656208ea5a6f04bb164dfba4738a --- /dev/null +++ b/crush.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://charm.land/crush.json", + "lsp": { + "deno": { + "command": "deno", + "args": ["lsp"] + } + } +} diff --git a/server.ts b/server.ts new file mode 100644 index 0000000000000000000000000000000000000000..49d2c5cf8951cfad84a664e48f6213a28ff3f200 --- /dev/null +++ b/server.ts @@ -0,0 +1,590 @@ +import { serve } from "https://deno.land/std@0.208.0/http/server.ts"; +import { Database } from "https://deno.land/x/sqlite3@0.11.1/mod.ts"; + +const db = new Database("lists.db"); + +// Enable foreign key constraints +db.exec("PRAGMA foreign_keys = ON;"); + +// Check schema version and migrate if needed +const currentVersion = (db.prepare('PRAGMA user_version').get() as { user_version: number }).user_version; + +if (currentVersion < 1) { + console.log('🔄 Migrating database schema to version 1...'); + + try { + db.exec('BEGIN'); + + // Temporarily disable foreign keys for migration + db.exec('PRAGMA foreign_keys = OFF'); + + // Create new tables with constraints + db.exec(` + CREATE TABLE rooms_new ( + code TEXT PRIMARY KEY NOT NULL, + created_at INTEGER DEFAULT (unixepoch()) + ); + + CREATE TABLE items_new ( + id TEXT PRIMARY KEY NOT NULL, + room_code TEXT NOT NULL, + text TEXT NOT NULL CHECK(LENGTH(text) BETWEEN 1 AND 200), + created_at INTEGER DEFAULT (unixepoch()), + FOREIGN KEY (room_code) REFERENCES rooms_new(code) ON DELETE CASCADE + ); + + CREATE TABLE votes_new ( + item_id TEXT NOT NULL, + user_id TEXT NOT NULL, + vote_type TEXT NOT NULL CHECK(vote_type IN ('up', 'down', 'veto')), + created_at INTEGER DEFAULT (unixepoch()), + PRIMARY KEY (item_id, user_id), + FOREIGN KEY (item_id) REFERENCES items_new(id) ON DELETE CASCADE + ); + `); + + // Copy data if old tables exist, filtering/truncating as needed + const tableExists = (name: string) => { + const result = db.prepare(` + SELECT name FROM sqlite_master + WHERE type='table' AND name=? + `).get(name); + return result !== undefined; + }; + + if (tableExists('rooms')) { + db.exec(` + INSERT INTO rooms_new (code, created_at) + SELECT code, created_at + FROM rooms + `); + } + + if (tableExists('items')) { + db.exec(` + INSERT INTO items_new (id, room_code, text, created_at) + SELECT i.id, i.room_code, + SUBSTR(TRIM(REPLACE(REPLACE(i.text, char(13)||char(10), char(10)), char(13), char(10))), 1, 200) as text, + i.created_at + FROM items i + WHERE i.room_code IN (SELECT code FROM rooms_new) + AND LENGTH(TRIM(i.text)) > 0 + `); + } + + if (tableExists('votes')) { + db.exec(` + INSERT INTO votes_new (item_id, user_id, vote_type, created_at) + SELECT v.item_id, v.user_id, v.vote_type, v.created_at + FROM votes v + WHERE v.item_id IN (SELECT id FROM items_new) + AND v.vote_type IN ('up', 'down', 'veto') + `); + } + + // Drop old tables if they exist + if (tableExists('votes')) db.exec('DROP TABLE votes'); + if (tableExists('items')) db.exec('DROP TABLE items'); + if (tableExists('rooms')) db.exec('DROP TABLE rooms'); + + // Rename new tables + db.exec('ALTER TABLE rooms_new RENAME TO rooms'); + db.exec('ALTER TABLE items_new RENAME TO items'); + db.exec('ALTER TABLE votes_new RENAME TO votes'); + + // Create indexes + db.exec(` + CREATE INDEX idx_items_room_code ON items(room_code); + CREATE INDEX idx_votes_item_id ON votes(item_id); + CREATE INDEX idx_votes_user_id ON votes(user_id); + `); + + // Re-enable foreign keys + db.exec('PRAGMA foreign_keys = ON'); + + // Set schema version + db.exec('PRAGMA user_version = 2'); + + db.exec('COMMIT'); + console.log('✅ Migration complete'); + } catch (err) { + db.exec('ROLLBACK'); + console.error('❌ Migration failed:', err); + throw err; + } +} else if (currentVersion === 1) { + console.log('🔄 Migrating database schema from version 1 to 2...'); + + try { + db.exec('BEGIN'); + db.exec('PRAGMA foreign_keys = OFF'); + + // Create new rooms table without last_activity and length constraint + db.exec(` + CREATE TABLE rooms_new ( + code TEXT PRIMARY KEY NOT NULL, + created_at INTEGER DEFAULT (unixepoch()) + ); + `); + + // Copy existing rooms + db.exec(` + INSERT INTO rooms_new (code, created_at) + SELECT code, created_at + FROM rooms + `); + + // Drop old and rename + db.exec('DROP TABLE rooms'); + db.exec('ALTER TABLE rooms_new RENAME TO rooms'); + + db.exec('PRAGMA foreign_keys = ON'); + db.exec('PRAGMA user_version = 2'); + db.exec('COMMIT'); + + console.log('✅ Migration from v1 to v2 complete'); + } catch (err) { + db.exec('ROLLBACK'); + console.error('❌ Migration failed:', err); + throw err; + } +} else if (currentVersion === 2) { + console.log('🔄 Migrating database schema from version 2 to 3...'); + + try { + db.exec('BEGIN'); + + // Add title column to rooms table + db.exec('ALTER TABLE rooms ADD COLUMN title TEXT'); + + db.exec('PRAGMA user_version = 3'); + db.exec('COMMIT'); + + console.log('✅ Migration from v2 to v3 complete'); + } catch (err) { + db.exec('ROLLBACK'); + console.error('❌ Migration failed:', err); + throw err; + } +} else { + // Plant the schema if soil is fresh (for new databases) + db.exec(` + CREATE TABLE IF NOT EXISTS rooms ( + code TEXT PRIMARY KEY NOT NULL, + created_at INTEGER DEFAULT (unixepoch()), + title TEXT + ); + + CREATE TABLE IF NOT EXISTS items ( + id TEXT PRIMARY KEY NOT NULL, + room_code TEXT NOT NULL, + text TEXT NOT NULL CHECK(LENGTH(text) BETWEEN 1 AND 200), + created_at INTEGER DEFAULT (unixepoch()), + FOREIGN KEY (room_code) REFERENCES rooms(code) ON DELETE CASCADE + ); + + CREATE TABLE IF NOT EXISTS votes ( + item_id TEXT NOT NULL, + user_id TEXT NOT NULL, + vote_type TEXT NOT NULL CHECK(vote_type IN ('up', 'down', 'veto')), + created_at INTEGER DEFAULT (unixepoch()), + PRIMARY KEY (item_id, user_id), + FOREIGN KEY (item_id) REFERENCES items(id) ON DELETE CASCADE + ); + `); +} + +const clients = new Map>(); + +function generateRoomId(): string { + return crypto.randomUUID(); +} + +function broadcast(roomCode: string, message: unknown, except?: WebSocket) { + const room = clients.get(roomCode); + if (!room) return; + + const payload = JSON.stringify(message); + for (const client of room) { + if (client !== except && client.readyState === WebSocket.OPEN) { + client.send(payload); + } + } +} + +// Validation constants +const MAX_ITEM_TEXT_LEN = 200; +const MAX_BULK_ITEMS = 100; +const MAX_WS_MESSAGE_BYTES = 32768; + +// Validation helpers +function safeParseMessage(ws: WebSocket, raw: string): unknown | null { + if (raw.length > MAX_WS_MESSAGE_BYTES) { + console.warn(`Message too large: ${raw.length} bytes`); + ws.close(1009, 'Message too large'); + return null; + } + + try { + return JSON.parse(raw); + } catch (err) { + console.warn('Invalid JSON:', err); + return null; + } +} + +function sanitizeItemText(s: string): string | null { + // Trim and collapse internal CRLF to \n + const cleaned = s.trim().replace(/\r\n/g, '\n').replace(/\r/g, '\n'); + + if (cleaned.length < 1 || cleaned.length > MAX_ITEM_TEXT_LEN) { + return null; + } + + return cleaned; +} + +function isValidVoteType(x: unknown): x is 'up' | 'down' | 'veto' { + return x === 'up' || x === 'down' || x === 'veto'; +} + +function itemBelongsToRoom(itemId: string, roomCode: string): boolean { + const result = db.prepare('SELECT 1 FROM items WHERE id = ? AND room_code = ?').get(itemId, roomCode); + return result !== undefined; +} + +function getState(roomCode: string) { + const room = db.prepare('SELECT title FROM rooms WHERE code = ?').get(roomCode) as { title: string | null } | undefined; + const roomTitle = room?.title || null; + + const items = db.prepare(` + SELECT i.id, i.text, i.created_at, + GROUP_CONCAT(v.user_id || ':' || v.vote_type) as votes + FROM items i + LEFT JOIN votes v ON i.id = v.item_id + WHERE i.room_code = ? + GROUP BY i.id + ORDER BY i.created_at + `).all(roomCode) as Array<{id: string, text: string, created_at: number, votes: string | null}>; + + return { + roomTitle, + items: items.map(item => ({ + id: item.id, + text: item.text, + votes: item.votes ? Object.fromEntries( + item.votes.split(',').map(v => { + const [userId, voteType] = v.split(':'); + return [userId, voteType]; + }) + ) : {} + })) + }; +} + +function handleWebSocket(ws: WebSocket, roomCode: string, userId: string) { + // Add to room + if (!clients.has(roomCode)) { + clients.set(roomCode, new Set()); + } + clients.get(roomCode)!.add(ws); + + ws.onopen = () => { + // Send current state once connection is open + const state = getState(roomCode); + ws.send(JSON.stringify({ + type: 'state', + items: state.items, + roomTitle: state.roomTitle, + userId + })); + }; + + ws.onmessage = (event) => { + const msg = safeParseMessage(ws, event.data); + if (!msg || typeof msg !== 'object') return; + + try { + switch ((msg as any).type) { + case 'add_items': { + const rawItems = (msg as any).items; + if (!Array.isArray(rawItems)) { + console.warn('add_items: items is not an array'); + return; + } + + if (rawItems.length < 1 || rawItems.length > MAX_BULK_ITEMS) { + console.warn(`add_items: invalid count ${rawItems.length}`); + return; + } + + const items = rawItems + .map((text: unknown) => { + if (typeof text !== 'string') return null; + const sanitized = sanitizeItemText(text); + return sanitized ? { id: crypto.randomUUID(), text: sanitized } : null; + }) + .filter((item): item is { id: string; text: string } => item !== null); + + if (items.length === 0) { + console.warn('add_items: no valid items after sanitization'); + return; + } + + try { + db.exec('BEGIN IMMEDIATE'); + const stmt = db.prepare('INSERT INTO items (id, room_code, text) VALUES (?, ?, ?)'); + for (const item of items) { + stmt.run(item.id, roomCode, item.text); + } + db.exec('COMMIT'); + } catch (err) { + db.exec('ROLLBACK'); + console.error('add_items transaction failed:', err); + return; + } + + broadcast(roomCode, { + type: 'items_added', + items: items.map(i => ({ ...i, votes: {} })) + }); + break; + } + + case 'vote': { + const { itemId, voteType } = msg as any; + + if (typeof itemId !== 'string') { + console.warn('vote: itemId is not a string'); + return; + } + + if (!isValidVoteType(voteType)) { + console.warn(`vote: invalid voteType ${voteType}`); + return; + } + + if (!itemBelongsToRoom(itemId, roomCode)) { + console.warn(`vote: item ${itemId} does not belong to room ${roomCode}`); + return; + } + + db.prepare(` + INSERT INTO votes (item_id, user_id, vote_type) + VALUES (?, ?, ?) + ON CONFLICT (item_id, user_id) + DO UPDATE SET vote_type = excluded.vote_type + `).run(itemId, userId, voteType); + + broadcast(roomCode, { + type: 'vote_changed', + itemId, + userId, + voteType + }); + break; + } + + case 'unvote': { + const { itemId } = msg as any; + + if (typeof itemId !== 'string') { + console.warn('unvote: itemId is not a string'); + return; + } + + if (!itemBelongsToRoom(itemId, roomCode)) { + console.warn(`unvote: item ${itemId} does not belong to room ${roomCode}`); + return; + } + + db.prepare('DELETE FROM votes WHERE item_id = ? AND user_id = ?') + .run(itemId, userId); + + broadcast(roomCode, { + type: 'vote_removed', + itemId, + userId + }); + break; + } + + case 'edit_item': { + const { itemId, text } = msg as any; + + if (typeof itemId !== 'string') { + console.warn('edit_item: itemId is not a string'); + return; + } + + if (typeof text !== 'string') { + console.warn('edit_item: text is not a string'); + return; + } + + const sanitized = sanitizeItemText(text); + if (!sanitized) { + console.warn('edit_item: text failed sanitization'); + return; + } + + if (!itemBelongsToRoom(itemId, roomCode)) { + console.warn(`edit_item: item ${itemId} does not belong to room ${roomCode}`); + return; + } + + db.prepare('UPDATE items SET text = ? WHERE id = ?') + .run(sanitized, itemId); + + broadcast(roomCode, { + type: 'item_edited', + itemId, + text: sanitized + }); + break; + } + + case 'delete_item': { + const { itemId } = msg as any; + + if (typeof itemId !== 'string') { + console.warn('delete_item: itemId is not a string'); + return; + } + + if (!itemBelongsToRoom(itemId, roomCode)) { + console.warn(`delete_item: item ${itemId} does not belong to room ${roomCode}`); + return; + } + + // Delete votes first (foreign key constraint) + db.prepare('DELETE FROM votes WHERE item_id = ?').run(itemId); + // Delete the item + db.prepare('DELETE FROM items WHERE id = ?').run(itemId); + + broadcast(roomCode, { + type: 'item_deleted', + itemId + }); + break; + } + + case 'reset_votes': { + // Delete all votes for all items in this room + db.prepare(` + DELETE FROM votes + WHERE item_id IN ( + SELECT id FROM items WHERE room_code = ? + ) + `).run(roomCode); + + broadcast(roomCode, { + type: 'votes_reset' + }); + break; + } + + case 'set_title': { + const { title } = msg as any; + + let sanitized: string | null = null; + if (title !== null && title !== undefined) { + if (typeof title !== 'string') { + console.warn('set_title: title is not a string'); + return; + } + sanitized = sanitizeItemText(title); + if (!sanitized) { + console.warn('set_title: title failed sanitization'); + return; + } + } + + db.prepare('UPDATE rooms SET title = ? WHERE code = ?') + .run(sanitized, roomCode); + + broadcast(roomCode, { + type: 'title_changed', + title: sanitized + }); + break; + } + } + } catch (err) { + console.error('Message handling error:', err); + } + }; + + ws.onclose = () => { + const room = clients.get(roomCode); + if (room) { + room.delete(ws); + if (room.size === 0) { + clients.delete(roomCode); + } + } + }; +} + +serve(async (req) => { + const url = new URL(req.url); + + // Serve static files + if (url.pathname === "/" || url.pathname === "/index.html") { + const html = await Deno.readTextFile("./static/index.html"); + return new Response(html, { headers: { "content-type": "text/html" } }); + } + if (url.pathname === "/style.css") { + const css = await Deno.readTextFile("./static/style.css"); + return new Response(css, { headers: { "content-type": "text/css" } }); + } + if (url.pathname === "/app.js") { + const js = await Deno.readTextFile("./static/app.js"); + return new Response(js, { headers: { "content-type": "application/javascript" } }); + } + if (url.pathname.startsWith("/icons/") && url.pathname.endsWith(".svg")) { + const iconName = url.pathname.slice(7); // Remove "/icons/" + try { + const svg = await Deno.readTextFile(`./static/icons/${iconName}`); + return new Response(svg, { headers: { "content-type": "image/svg+xml" } }); + } catch { + return new Response("Not found", { status: 404 }); + } + } + + // Create new room + if (url.pathname === "/api/create") { + const code = generateRoomId(); + db.prepare('INSERT INTO rooms (code) VALUES (?)').run(code); + return Response.json({ code }); + } + + // WebSocket connection + if (url.pathname === "/ws") { + const roomCode = url.searchParams.get("room"); + const userId = url.searchParams.get("user") || crypto.randomUUID(); + + if (!roomCode) { + return new Response("Missing room code", { status: 400 }); + } + + // Verify room exists + const roomExists = db.prepare('SELECT code FROM rooms WHERE code = ?').get(roomCode); + if (!roomExists) { + return new Response("Room not found", { status: 404 }); + } + + const upgrade = req.headers.get("upgrade") || ""; + if (upgrade.toLowerCase() !== "websocket") { + return new Response("Expected websocket", { status: 426 }); + } + + const { socket, response } = Deno.upgradeWebSocket(req); + handleWebSocket(socket, roomCode, userId); + return response; + } + + return new Response("Not found", { status: 404 }); +}, { port: 8294 }); + +console.log("🌿 Sift ready at http://localhost:8294"); diff --git a/static/app.js b/static/app.js new file mode 100644 index 0000000000000000000000000000000000000000..ef62862d3acbc73d6e8083fd065fd8d64dd4c4d5 --- /dev/null +++ b/static/app.js @@ -0,0 +1,675 @@ +let ws = null; +let currentRoom = null; +let roomTitle = null; +let userId = localStorage.getItem('userId') || crypto.randomUUID(); +localStorage.setItem('userId', userId); + +let items = []; +let lastAction = null; // For undo +let selectedItemId = null; // For keyboard navigation +let selectedPosition = null; // Position to restore after voting +let lastSyncTime = null; // For connection status +let isReady = false; // Track if initial state received + +const startScreen = document.getElementById('start-screen'); +const listScreen = document.getElementById('list-screen'); +const roomTitleEl = document.getElementById('room-title'); +const listContainer = document.getElementById('list-container'); + +// Start screen handlers +document.getElementById('create-btn').addEventListener('click', async () => { + const res = await fetch('/api/create'); + const { code } = await res.json(); + joinRoom(code); +}); + +document.getElementById('join-btn').addEventListener('click', () => { + const code = document.getElementById('join-code').value.trim(); + if (code) joinRoom(code); +}); + +document.getElementById('join-code').addEventListener('keydown', (e) => { + if (e.key === 'Enter' && !e.repeat) { + e.preventDefault(); + const code = e.target.value.trim(); + if (code) joinRoom(code); + } +}); + +document.getElementById('leave-btn').addEventListener('click', () => { + if (ws) ws.close(); + startScreen.classList.remove('hidden'); + listScreen.classList.add('hidden'); + currentRoom = null; + items = []; + messageQueue = []; + listContainer.innerHTML = ''; +}); + +document.getElementById('copy-btn').addEventListener('click', async () => { + const itemNames = items.map(item => item.text).join('\n'); + try { + await navigator.clipboard.writeText(itemNames); + const btn = document.getElementById('copy-btn'); + const original = btn.textContent; + btn.textContent = '✓'; + setTimeout(() => btn.textContent = original, 1000); + } catch (err) { + console.error('Failed to copy:', err); + } +}); + +document.getElementById('copy-link-btn').addEventListener('click', async () => { + const inviteLink = `${location.origin}${location.pathname}?room=${currentRoom}`; + try { + await navigator.clipboard.writeText(inviteLink); + const btn = document.getElementById('copy-link-btn'); + const original = btn.textContent; + btn.textContent = '✓'; + setTimeout(() => btn.textContent = original, 1000); + } catch (err) { + console.error('Failed to copy invite link:', err); + } +}); + +document.getElementById('reset-votes-btn').addEventListener('click', () => { + if (confirm('Clear all votes and vetoes from all items?')) { + sendMessage({ type: 'reset_votes' }); + } +}); + +document.getElementById('set-title-btn').addEventListener('click', () => { + const currentText = roomTitle || ''; + + const input = document.createElement('input'); + input.type = 'text'; + input.className = 'title-edit-input'; + input.value = currentText; + input.placeholder = 'Enter room title'; + + const finishEdit = () => { + const newText = input.value.trim(); + if (newText !== currentText) { + setTitle(newText || null); + } + input.remove(); + }; + + input.addEventListener('blur', finishEdit); + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter' && !e.repeat) { + e.preventDefault(); + input.blur(); + } else if (e.key === 'Escape') { + e.preventDefault(); + input.remove(); + } + }); + + // Insert input after the header div + const header = listScreen.querySelector('header'); + if (roomTitleEl.classList.contains('hidden')) { + roomTitleEl.parentElement.insertBefore(input, roomTitleEl); + } else { + roomTitleEl.style.display = 'none'; + roomTitleEl.parentElement.insertBefore(input, roomTitleEl); + } + + input.focus(); + input.select(); +}); + +// Add items handlers +document.getElementById('add-single-btn').addEventListener('click', () => { + const input = document.getElementById('single-input'); + const text = input.value.trim(); + if (text) { + sendMessage({ type: 'add_items', items: [text] }); + input.value = ''; + } +}); + +document.getElementById('single-input').addEventListener('keydown', (e) => { + if (e.key === 'Enter' && !e.repeat) { + e.preventDefault(); + const text = e.target.value.trim(); + if (text) { + sendMessage({ type: 'add_items', items: [text] }); + e.target.value = ''; + } + } +}); + +document.getElementById('add-bulk-btn').addEventListener('click', () => { + const textarea = document.getElementById('bulk-input'); + const lines = textarea.value.split('\n') + .map(l => l.trim()) + .filter(l => l); + if (lines.length) { + sendMessage({ type: 'add_items', items: lines }); + textarea.value = ''; + } +}); + +// Core functions +let messageQueue = []; + +function joinRoom(code) { + currentRoom = code; + + isReady = false; + setUIEnabled(false); + updateConnectionStatus('connecting'); + + const wsScheme = location.protocol === 'https:' ? 'wss:' : 'ws:'; + ws = new WebSocket(`${wsScheme}//${location.host}/ws?room=${code}&user=${userId}`); + + ws.onopen = () => { + updateConnectionStatus('connected'); + // Flush queued messages + while (messageQueue.length > 0) { + ws.send(JSON.stringify(messageQueue.shift())); + } + }; + + ws.onmessage = (event) => { + const msg = JSON.parse(event.data); + lastSyncTime = Date.now(); + updateLastSync(); + handleMessage(msg); + }; + + ws.onerror = (error) => { + console.error('WebSocket error:', error); + updateConnectionStatus('disconnected'); + }; + + ws.onclose = (event) => { + isReady = false; + setUIEnabled(false); + updateConnectionStatus('disconnected'); + if (!event.wasClean) { + console.error('Connection closed unexpectedly'); + alert('Connection lost. Please try rejoining.'); + startScreen.classList.remove('hidden'); + listScreen.classList.add('hidden'); + currentRoom = null; + items = []; + messageQueue = []; + listContainer.innerHTML = ''; + } + }; + + startScreen.classList.add('hidden'); + listScreen.classList.remove('hidden'); +} + +function sendMessage(msg) { + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify(msg)); + } else { + messageQueue.push(msg); + } +} + +function handleMessage(msg) { + switch (msg.type) { + case 'state': + items = msg.items; + userId = msg.userId; + roomTitle = msg.roomTitle; + if (items.length > 0 && !selectedItemId) { + selectedItemId = getSortedItems()[0].id; + } + isReady = true; + setUIEnabled(true); + renderTitle(); + render(); + break; + case 'items_added': + items.push(...msg.items); + if (!selectedItemId && items.length > 0) { + selectedItemId = getSortedItems()[0].id; + } + render(); + break; + case 'vote_changed': + const item = items.find(i => i.id === msg.itemId); + if (item) { + item.votes[msg.userId] = msg.voteType; + // If we voted, restore selection to same position + if (msg.userId === userId && selectedPosition !== null) { + const sorted = getSortedItems(); + const clampedPos = Math.min(selectedPosition, sorted.length - 1); + selectedItemId = sorted[clampedPos].id; + selectedPosition = null; + } + render(); + } + break; + case 'vote_removed': + const item2 = items.find(i => i.id === msg.itemId); + if (item2) { + delete item2.votes[msg.userId]; + // If we unvoted, restore selection to same position + if (msg.userId === userId && selectedPosition !== null) { + const sorted = getSortedItems(); + const clampedPos = Math.min(selectedPosition, sorted.length - 1); + selectedItemId = sorted[clampedPos].id; + selectedPosition = null; + } + render(); + } + break; + case 'item_edited': + const editedItem = items.find(i => i.id === msg.itemId); + if (editedItem) { + editedItem.text = msg.text; + render(); + } + break; + case 'item_deleted': + if (selectedItemId === msg.itemId) { + const sorted = getSortedItems(); + const idx = sorted.findIndex(i => i.id === msg.itemId); + const nextIdx = Math.min(idx, sorted.length - 2); + selectedItemId = nextIdx >= 0 ? sorted[nextIdx].id : null; + } + items = items.filter(i => i.id !== msg.itemId); + render(); + break; + case 'votes_reset': + items.forEach(item => { + item.votes = {}; + }); + render(); + break; + case 'title_changed': + roomTitle = msg.title; + renderTitle(); + break; + } +} + +function vote(itemId, voteType) { + const item = items.find(i => i.id === itemId); + const currentVote = item?.votes[userId]; + + // Remember current position to select item at same position after re-sort + const sorted = getSortedItems(); + selectedPosition = sorted.findIndex(i => i.id === itemId); + + lastAction = { type: 'vote', itemId, previousVote: currentVote }; + + if (currentVote === voteType) { + sendMessage({ type: 'unvote', itemId }); + } else { + sendMessage({ type: 'vote', itemId, voteType }); + } +} + +function deleteItem(itemId) { + if (confirm('Delete this item?')) { + sendMessage({ type: 'delete_item', itemId }); + } +} + +function editItem(itemId) { + const item = items.find(i => i.id === itemId); + if (!item) return; + + const itemEl = document.querySelector(`[data-item-id="${itemId}"]`); + if (!itemEl) return; + + const textEl = itemEl.querySelector('.list-item-text'); + const currentText = item.text; + + const input = document.createElement('input'); + input.type = 'text'; + input.className = 'edit-input'; + input.value = currentText; + + const finishEdit = () => { + const newText = input.value.trim(); + if (newText && newText !== currentText) { + sendMessage({ type: 'edit_item', itemId, text: newText }); + } + textEl.textContent = item.text; + textEl.style.display = ''; + input.remove(); + }; + + input.addEventListener('blur', finishEdit); + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter' && !e.repeat) { + e.preventDefault(); + input.blur(); + } else if (e.key === 'Escape') { + e.preventDefault(); + textEl.textContent = item.text; + textEl.style.display = ''; + input.remove(); + } + }); + + textEl.style.display = 'none'; + textEl.parentElement.insertBefore(input, textEl); + input.focus(); + input.select(); +} + +function undo() { + if (!lastAction) return; + + const { itemId, previousVote } = lastAction; + + if (previousVote) { + sendMessage({ type: 'vote', itemId, voteType: previousVote }); + } else { + sendMessage({ type: 'unvote', itemId }); + } + + lastAction = null; +} + +function renderTitle() { + roomTitleEl.style.display = ''; + if (roomTitle) { + roomTitleEl.textContent = roomTitle; + roomTitleEl.classList.remove('hidden'); + } else { + roomTitleEl.classList.add('hidden'); + } +} + +function setTitle(title) { + sendMessage({ type: 'set_title', title }); +} + +// Keyboard shortcuts +document.addEventListener('keydown', (e) => { + // Open help modal on '?' + if (e.key === '?' && !e.target.matches('input, textarea')) { + e.preventDefault(); + openHelpModal(); + return; + } + + // Close help modal on Esc + if (e.key === 'Escape') { + const helpModal = document.getElementById('help-modal'); + if (!helpModal.classList.contains('hidden')) { + e.preventDefault(); + closeHelpModal(); + return; + } + } + + // Ignore if typing in input/textarea + if (e.target.matches('input, textarea')) return; + + // Undo + if ((e.ctrlKey || e.metaKey) && e.key === 'z') { + e.preventDefault(); + undo(); + return; + } + + // Navigation and actions only work if we have items + if (items.length === 0) return; + + const sorted = getSortedItems(); + + // Navigation: j/k or ArrowDown/ArrowUp + if (e.key === 'j' || e.key === 'ArrowDown') { + e.preventDefault(); + const currentIdx = sorted.findIndex(i => i.id === selectedItemId); + const nextIdx = Math.min(currentIdx + 1, sorted.length - 1); + selectedItemId = sorted[nextIdx].id; + render(); + } else if (e.key === 'k' || e.key === 'ArrowUp') { + e.preventDefault(); + const currentIdx = sorted.findIndex(i => i.id === selectedItemId); + const prevIdx = Math.max(currentIdx - 1, 0); + selectedItemId = sorted[prevIdx].id; + render(); + } + + // Actions on selected item + if (selectedItemId) { + if (e.key === '1' || e.key === 'Enter') { + e.preventDefault(); + vote(selectedItemId, 'up'); + } else if (e.key === '2') { + e.preventDefault(); + vote(selectedItemId, 'down'); + } else if (e.key === '3') { + e.preventDefault(); + vote(selectedItemId, 'veto'); + } else if (e.key === 'e') { + e.preventDefault(); + editItem(selectedItemId); + } else if (e.key === 'Delete' || e.key === 'Backspace') { + e.preventDefault(); + deleteItem(selectedItemId); + } + } +}); + + + +// Help modal +const helpModal = document.getElementById('help-modal'); +const helpModalClose = helpModal.querySelector('.modal-close'); + +function openHelpModal() { + helpModal.classList.remove('hidden'); + helpModalClose.focus(); +} + +function closeHelpModal() { + helpModal.classList.add('hidden'); +} + +helpModalClose.addEventListener('click', closeHelpModal); + +// Close modal on backdrop click +helpModal.addEventListener('click', (e) => { + if (e.target === helpModal) { + closeHelpModal(); + } +}); + +// Connection status +function updateConnectionStatus(status) { + const statusDot = document.querySelector('.status-dot'); + const statusText = document.querySelector('.status-text'); + + statusDot.className = 'status-dot ' + status; + + const statusLabels = { + connecting: 'Connecting...', + connected: 'Connected', + disconnected: 'Disconnected' + }; + + statusText.textContent = statusLabels[status] || status; +} + +function formatTimeSince(timestamp) { + const seconds = Math.floor((Date.now() - timestamp) / 1000); + + if (seconds < 5) return 'just now'; + if (seconds < 60) return `${seconds}s ago`; + + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m ago`; + + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + + const days = Math.floor(hours / 24); + return `${days}d ago`; +} + +function updateLastSync() { + const lastSyncEl = document.querySelector('.last-sync'); + if (!lastSyncTime) { + lastSyncEl.textContent = ''; + return; + } + + lastSyncEl.textContent = '• ' + formatTimeSince(lastSyncTime); +} + +function setUIEnabled(enabled) { + const inputs = listScreen.querySelectorAll('input, textarea, button'); + inputs.forEach(input => { + // Don't disable the leave button + if (input.id === 'leave-btn') return; + input.disabled = !enabled; + }); + + if (enabled) { + listContainer.classList.remove('disabled'); + } else { + listContainer.classList.add('disabled'); + } +} + +function getSortedItems() { + return [...items].sort((a, b) => { + const aVetoed = Object.values(a.votes).includes('veto'); + const bVetoed = Object.values(b.votes).includes('veto'); + if (aVetoed && !bVetoed) return 1; + if (!aVetoed && bVetoed) return -1; + const aScore = Object.values(a.votes).reduce((sum, v) => + sum + (v === 'up' ? 1 : v === 'down' ? -1 : 0), 0); + const bScore = Object.values(b.votes).reduce((sum, v) => + sum + (v === 'up' ? 1 : v === 'down' ? -1 : 0), 0); + return bScore - aScore; + }); +} + +function render() { + // FLIP animation: capture First positions + const oldPositions = new Map(); + const existingItems = listContainer.querySelectorAll('.list-item'); + existingItems.forEach(el => { + const itemId = el.dataset.itemId; + oldPositions.set(itemId, el.getBoundingClientRect()); + }); + + // Sort: vetoed items last, then by score + const sorted = getSortedItems(); + + listContainer.innerHTML = sorted.map(item => { + const myVote = item.votes[userId]; + const score = Object.values(item.votes).reduce((sum, v) => + sum + (v === 'up' ? 1 : v === 'down' ? -1 : 0), 0); + const isVetoed = Object.values(item.votes).includes('veto'); + const isSelected = item.id === selectedItemId; + + return ` +
+
${escapeHtml(item.text)}
+
+
${score > 0 ? '+' : ''}${score}
+ + + + +
+
+ `; + }).join(''); + + // FLIP animation: capture Last positions and animate + const newItems = listContainer.querySelectorAll('.list-item'); + newItems.forEach(el => { + const itemId = el.dataset.itemId; + const oldPos = oldPositions.get(itemId); + + if (oldPos) { + const newPos = el.getBoundingClientRect(); + const deltaY = oldPos.top - newPos.top; + + // Invert: apply transform to make it appear at old position + if (deltaY !== 0) { + el.style.transform = `translateY(${deltaY}px)`; + el.style.transition = 'none'; + + // Play: animate to new position + requestAnimationFrame(() => { + el.style.transition = ''; + el.style.transform = ''; + }); + } + } + }); + + // Scroll selected item into view + if (selectedItemId) { + const selectedEl = listContainer.querySelector(`[data-item-id="${selectedItemId}"]`); + if (selectedEl) { + selectedEl.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); + } + } +} + +function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} + +// Event delegation for list actions +listContainer.addEventListener('click', (e) => { + const button = e.target.closest('[data-action]'); + if (!button) return; + + const listItem = button.closest('.list-item'); + if (!listItem) return; + + const itemId = listItem.dataset.itemId; + const action = button.dataset.action; + + if (action === 'vote') { + const voteType = button.dataset.voteType; + vote(itemId, voteType); + } else if (action === 'delete') { + deleteItem(itemId); + } +}); + +listContainer.addEventListener('dblclick', (e) => { + const textEl = e.target.closest('.list-item-text'); + if (!textEl) return; + + const listItem = textEl.closest('.list-item'); + if (!listItem) return; + + const itemId = listItem.dataset.itemId; + editItem(itemId); +}); + +// Make functions global +window.vote = vote; +window.deleteItem = deleteItem; +window.editItem = editItem; + +// Deep linking: auto-join room from URL param +const urlParams = new URLSearchParams(location.search); +const roomParam = urlParams.get('room'); +if (roomParam) { + const code = roomParam.trim(); + if (code) { + joinRoom(code); + } +} + +// Update sync status every second +setInterval(updateLastSync, 1000); diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000000000000000000000000000000000000..47702cd0b20d5613574c954520368fcd83b75221 --- /dev/null +++ b/static/index.html @@ -0,0 +1,90 @@ + + + + + + Sift + + + +
+
+

Sift

+
+ +
+ + +
+
+
+ + + + + +
+ + + + diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000000000000000000000000000000000000..dd0983addfd73e4990864da291fb48c009d6c526 --- /dev/null +++ b/static/style.css @@ -0,0 +1,480 @@ +:root { + color-scheme: light dark; + + /* Light theme (default) */ + --bg-page: #f5f5f5; + --bg-surface: white; + --bg-header-code: #e8f5e0; + + --text-primary: #333; + --text-on-accent: white; + + --accent: #5a8c3a; + --accent-hover: #4a7230; + --accent-dark: #2d5016; + --accent-danger: #b22222; + + --border: #ddd; + --border-accent: #5a8c3a; + --shadow: rgba(0, 0, 0, 0.1); +} + +@media (prefers-color-scheme: dark) { + :root { + --bg-page: #1a1a1a; + --bg-surface: #2a2a2a; + --bg-header-code: #3a4a34; + + --text-primary: #e0e0e0; + --text-on-accent: white; + + --accent: #7ab859; + --accent-hover: #8cc96f; + --accent-dark: #5a8c3a; + --accent-danger: #d84444; + + --border: #444; + --border-accent: #7ab859; + --shadow: rgba(0, 0, 0, 0.3); + } +} + +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + font-family: "Atkinson Hyperlegible Next", system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, sans-serif; + background: var(--bg-page); + color: var(--text-primary); + line-height: 1.6; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +#app { + max-width: 800px; + margin: 0 auto; + padding: 2rem 1rem; +} + +.hidden { + display: none !important; +} + +/* Start screen */ +#start-screen { + text-align: center; + padding: 4rem 2rem; +} + +#start-screen h1 { + margin-bottom: 3rem; + color: var(--accent-dark); +} + +.buttons { + display: flex; + flex-direction: column; + gap: 1rem; + align-items: center; +} + +.join-section { + display: flex; + gap: 0.5rem; +} + +button { + padding: 0.75rem 1.5rem; + background: var(--accent); + color: var(--text-on-accent); + border: none; + border-radius: 6px; + cursor: pointer; + font-size: 1rem; + transition: background 0.2s, transform 0.05s, box-shadow 0.2s; +} + +button:hover { + background: var(--accent-hover); +} + +button:active { + transform: translateY(1px); +} + +button:focus-visible, +input:focus-visible, +textarea:focus-visible { + outline: 2px solid var(--border-accent); + outline-offset: 2px; +} + +input, textarea { + padding: 0.75rem; + border: 1px solid var(--border); + border-radius: 6px; + font-size: 1rem; + font-family: inherit; + background: var(--bg-surface); + color: var(--text-primary); +} + +input::placeholder, +textarea::placeholder { + color: color-mix(in srgb, var(--text-primary) 50%, transparent); +} + +#join-code { + width: 200px; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +#join-code::placeholder { + text-transform: none; +} + +.expiry-notice { + font-size: 0.85rem; + color: color-mix(in srgb, var(--text-primary) 60%, transparent); + margin-top: 1rem; +} + +#start-screen .expiry-notice { + text-align: center; +} + +#list-screen .expiry-notice { + margin: 0.5rem 0 0 0; + font-size: 0.8rem; +} + +/* List screen */ +#list-screen header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 2rem; + padding-bottom: 1rem; + border-bottom: 2px solid var(--border-accent); +} + +#list-screen header h2 { + margin-bottom: 0.25rem; +} + +.connection-status { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.85rem; + color: color-mix(in srgb, var(--text-primary) 70%, transparent); +} + +.status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: #888; +} + +.status-dot.connected { + background: #4caf50; + box-shadow: 0 0 4px #4caf50; +} + +.status-dot.connecting { + background: #ff9800; + animation: pulse 1.5s ease-in-out infinite; +} + +.status-dot.disconnected { + background: #f44336; +} + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } +} + +.header-actions { + display: flex; + gap: 0.5rem; +} + +#room-code { + font-family: monospace; + background: var(--bg-header-code); + padding: 0.25rem 0.5rem; + border-radius: 6px; +} + +#copy-btn, #copy-link-btn, #set-title-btn, #reset-votes-btn { + background: transparent; + color: var(--text-primary); + border: 1px solid var(--border); + width: 40px; + height: 40px; + padding: 0; + border-radius: 999px; + display: grid; + place-items: center; +} + +#copy-btn:hover, #copy-link-btn:hover, #set-title-btn:hover, #reset-votes-btn:hover { + background: var(--bg-header-code); + border-color: var(--border-accent); +} + +.header-actions button svg { + width: 20px; + height: 20px; + display: block; +} + +#leave-btn { + background: transparent; + color: var(--accent-dark); + border: 1px solid var(--border-accent); +} + +#leave-btn:hover { + background: var(--bg-header-code); +} + +.add-section { + display: flex; + gap: 0.5rem; + margin-bottom: 1rem; +} + +.add-section input { + flex: 1; +} + +.bulk-section { + margin-bottom: 2rem; +} + +.bulk-section textarea { + width: 100%; + min-height: 100px; + margin-bottom: 0.5rem; + resize: vertical; +} + +/* List items */ +#list-container.disabled { + pointer-events: none; + opacity: 0.6; +} + +.list-item { + background: var(--bg-surface); + padding: 1rem; + margin-bottom: 0.5rem; + border-radius: 8px; + box-shadow: 0 1px 3px var(--shadow); + display: flex; + justify-content: space-between; + align-items: center; + transition: transform 0.3s ease, box-shadow 0.2s ease; + border-left: 3px solid transparent; +} + +.list-item:hover { + transform: translateY(-1px); + box-shadow: 0 4px 12px var(--shadow); +} + +.list-item.selected { + outline: 2px solid var(--border-accent); + outline-offset: 2px; +} + +.list-item.vetoed { + opacity: 0.4; + max-height: 3rem; + overflow: hidden; +} + +.list-item:has(.vote-btn.active) { + border-left-color: var(--accent); +} + +.list-item:has(.vote-btn.veto-active) { + border-left-color: var(--accent-danger); +} + +.list-item-text { + flex: 1; + padding-right: 1rem; + cursor: pointer; + user-select: none; +} + +.list-item-text:hover { + opacity: 0.9; +} + +.edit-input { + flex: 1; + margin-right: 1rem; +} + +.list-item-actions { + display: flex; + gap: 0.5rem; + flex-shrink: 0; +} + +.vote-btn { + padding: 0.5rem; + font-size: 0.9rem; +} + +.delete-btn { + padding: 0.5rem; + font-size: 0.9rem; + background: var(--accent-danger); + opacity: 0.7; + transition: opacity 0.2s, background 0.2s; +} + +.delete-btn:hover { + opacity: 1; + background: var(--accent-danger); +} + +.vote-btn.active { + background: var(--accent-dark); +} + +.vote-btn.veto-active { + background: var(--accent-danger); +} + +.vote-btn svg, .delete-btn svg { + width: 16px; + height: 16px; + display: block; +} + +.score { + min-width: 40px; + text-align: center; + font-weight: bold; + color: var(--accent); + background: var(--bg-header-code); + padding: 0.25rem 0.5rem; + border-radius: 999px; +} + +/* Modal */ +.modal { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.modal-content { + background: var(--bg-surface); + padding: 2rem; + border-radius: 8px; + max-width: 500px; + width: 90%; + max-height: 80vh; + overflow-y: auto; + box-shadow: 0 4px 20px var(--shadow); +} + +.modal-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1.5rem; + padding-bottom: 0.5rem; + border-bottom: 2px solid var(--border); +} + +.modal-header h3 { + margin: 0; + color: var(--accent-dark); +} + +.modal-close { + background: transparent; + color: var(--text-primary); + border: none; + font-size: 2rem; + line-height: 1; + padding: 0; + width: 2rem; + height: 2rem; + cursor: pointer; +} + +.modal-close:hover { + color: var(--accent-danger); +} + +.modal-body h4 { + color: var(--accent); + margin-top: 1rem; + margin-bottom: 0.5rem; +} + +.modal-body h4:first-child { + margin-top: 0; +} + +.modal-body ul { + margin: 0; + padding-left: 1.5rem; +} + +.modal-body li { + margin-bottom: 0.5rem; +} + +kbd { + display: inline-block; + padding: 0.2rem 0.4rem; + font-size: 0.85rem; + font-family: monospace; + background: var(--bg-header-code); + border: 1px solid var(--border); + border-radius: 4px; + box-shadow: 0 1px 2px var(--shadow); +} + +/* Screen reader only */ +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border-width: 0; +} + +@media (prefers-reduced-motion: reduce) { + * { + transition: none !important; + animation: none !important; + } +}