diff --git a/db.ts b/db.ts new file mode 100644 index 0000000000000000000000000000000000000000..ceed120f1e25b6a1d1065e9e391192688ce2904d --- /dev/null +++ b/db.ts @@ -0,0 +1,303 @@ +// SPDX-FileCopyrightText: Amolith +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +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 + ); + `); +} + +export 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]; + }) + ) : {} + })) + }; +} + +export 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; +} + +export function createRoom(code: string) { + db.prepare('INSERT INTO rooms (code) VALUES (?)').run(code); +} + +export function roomExists(code: string): boolean { + const result = db.prepare('SELECT code FROM rooms WHERE code = ?').get(code); + return result !== undefined; +} + +export function addItems(roomCode: string, items: Array<{id: string, text: string}>) { + 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'); + throw err; + } +} + +export function upsertVote(itemId: string, userId: string, voteType: string) { + 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); +} + +export function deleteVote(itemId: string, userId: string) { + db.prepare('DELETE FROM votes WHERE item_id = ? AND user_id = ?').run(itemId, userId); +} + +export function updateItemText(itemId: string, text: string) { + db.prepare('UPDATE items SET text = ? WHERE id = ?').run(text, itemId); +} + +export function deleteItem(itemId: string) { + // 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); +} + +export function resetVotes(roomCode: string) { + db.prepare(` + DELETE FROM votes + WHERE item_id IN ( + SELECT id FROM items WHERE room_code = ? + ) + `).run(roomCode); +} + +export function updateRoomTitle(roomCode: string, title: string | null) { + db.prepare('UPDATE rooms SET title = ? WHERE code = ?').run(title, roomCode); +} + +export function getItemsWithVotes(roomCode: string) { + return db.prepare(` + SELECT i.id, i.text, + GROUP_CONCAT(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 + `).all(roomCode) as Array<{id: string, text: string, votes: string | null}>; +} diff --git a/server.ts b/server.ts index db666a0bd61b5042af1cb49f91b085371f742af2..a369463022be4d25ed9ebadd400b663294861334 100644 --- a/server.ts +++ b/server.ts @@ -3,201 +3,51 @@ // SPDX-License-Identifier: AGPL-3.0-or-later 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"; +import * as db from "./db.ts"; -const db = new Database("lists.db"); +// WebSocket message types +interface AddItemsMessage { + type: 'add_items'; + items: unknown; +} -// Enable foreign key constraints -db.exec("PRAGMA foreign_keys = ON;"); +interface VoteMessage { + type: 'vote'; + itemId: unknown; + voteType: unknown; +} -// Check schema version and migrate if needed -const currentVersion = (db.prepare('PRAGMA user_version').get() as { user_version: number }).user_version; +interface UnvoteMessage { + type: 'unvote'; + itemId: unknown; +} -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 - ); - `); +interface EditItemMessage { + type: 'edit_item'; + itemId: unknown; + text: unknown; +} + +interface DeleteItemMessage { + type: 'delete_item'; + itemId: unknown; +} + +interface ResetVotesMessage { + type: 'reset_votes'; +} + +interface SetTitleMessage { + type: 'set_title'; + title: unknown; } +interface BreakTieMessage { + type: 'break_tie'; +} + +type ClientMessage = AddItemsMessage | VoteMessage | UnvoteMessage | EditItemMessage | DeleteItemMessage | ResetVotesMessage | SetTitleMessage | BreakTieMessage; + const clients = new Map>(); function generateRoomId(): string { @@ -252,40 +102,6 @@ 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)) { @@ -295,7 +111,7 @@ function handleWebSocket(ws: WebSocket, roomCode: string, userId: string) { ws.onopen = () => { // Send current state once connection is open - const state = getState(roomCode); + const state = db.getState(roomCode); ws.send(JSON.stringify({ type: 'state', items: state.items, @@ -306,12 +122,12 @@ function handleWebSocket(ws: WebSocket, roomCode: string, userId: string) { ws.onmessage = (event) => { const msg = safeParseMessage(ws, event.data); - if (!msg || typeof msg !== 'object') return; + if (!msg || typeof msg !== 'object' || !('type' in msg)) return; try { - switch ((msg as any).type) { + switch ((msg as ClientMessage).type) { case 'add_items': { - const rawItems = (msg as any).items; + const rawItems = (msg as AddItemsMessage).items; if (!Array.isArray(rawItems)) { console.warn('add_items: items is not an array'); return; @@ -336,14 +152,8 @@ function handleWebSocket(ws: WebSocket, roomCode: string, userId: string) { } 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'); + db.addItems(roomCode, items); } catch (err) { - db.exec('ROLLBACK'); console.error('add_items transaction failed:', err); return; } @@ -356,7 +166,7 @@ function handleWebSocket(ws: WebSocket, roomCode: string, userId: string) { } case 'vote': { - const { itemId, voteType } = msg as any; + const { itemId, voteType } = msg as VoteMessage; if (typeof itemId !== 'string') { console.warn('vote: itemId is not a string'); @@ -368,17 +178,12 @@ function handleWebSocket(ws: WebSocket, roomCode: string, userId: string) { return; } - if (!itemBelongsToRoom(itemId, roomCode)) { + if (!db.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); + db.upsertVote(itemId, userId, voteType); broadcast(roomCode, { type: 'vote_changed', @@ -390,20 +195,19 @@ function handleWebSocket(ws: WebSocket, roomCode: string, userId: string) { } case 'unvote': { - const { itemId } = msg as any; + const { itemId } = msg as UnvoteMessage; if (typeof itemId !== 'string') { console.warn('unvote: itemId is not a string'); return; } - if (!itemBelongsToRoom(itemId, roomCode)) { + if (!db.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); + db.deleteVote(itemId, userId); broadcast(roomCode, { type: 'vote_removed', @@ -414,7 +218,7 @@ function handleWebSocket(ws: WebSocket, roomCode: string, userId: string) { } case 'edit_item': { - const { itemId, text } = msg as any; + const { itemId, text } = msg as EditItemMessage; if (typeof itemId !== 'string') { console.warn('edit_item: itemId is not a string'); @@ -432,13 +236,12 @@ function handleWebSocket(ws: WebSocket, roomCode: string, userId: string) { return; } - if (!itemBelongsToRoom(itemId, roomCode)) { + if (!db.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); + db.updateItemText(itemId, sanitized); broadcast(roomCode, { type: 'item_edited', @@ -449,22 +252,19 @@ function handleWebSocket(ws: WebSocket, roomCode: string, userId: string) { } case 'delete_item': { - const { itemId } = msg as any; + const { itemId } = msg as DeleteItemMessage; if (typeof itemId !== 'string') { console.warn('delete_item: itemId is not a string'); return; } - if (!itemBelongsToRoom(itemId, roomCode)) { + if (!db.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); + db.deleteItem(itemId); broadcast(roomCode, { type: 'item_deleted', @@ -474,13 +274,7 @@ function handleWebSocket(ws: WebSocket, roomCode: string, userId: string) { } 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); + db.resetVotes(roomCode); broadcast(roomCode, { type: 'votes_reset' @@ -489,7 +283,7 @@ function handleWebSocket(ws: WebSocket, roomCode: string, userId: string) { } case 'set_title': { - const { title } = msg as any; + const { title } = msg as SetTitleMessage; let sanitized: string | null = null; if (title !== null && title !== undefined) { @@ -504,8 +298,7 @@ function handleWebSocket(ws: WebSocket, roomCode: string, userId: string) { } } - db.prepare('UPDATE rooms SET title = ? WHERE code = ?') - .run(sanitized, roomCode); + db.updateRoomTitle(roomCode, sanitized); broadcast(roomCode, { type: 'title_changed', @@ -516,14 +309,7 @@ function handleWebSocket(ws: WebSocket, roomCode: string, userId: string) { case 'break_tie': { // Get all items in the room with their votes - const items = db.prepare(` - SELECT i.id, i.text, - GROUP_CONCAT(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 - `).all(roomCode) as Array<{id: string, text: string, votes: string | null}>; + const items = db.getItemsWithVotes(roomCode); // Filter to non-vetoed items and calculate scores const nonVetoed = items @@ -601,6 +387,14 @@ serve(async (req) => { const js = await Deno.readTextFile("./static/app.js"); return new Response(js, { headers: { "content-type": "application/javascript" } }); } + if (url.pathname === "/render.js") { + const js = await Deno.readTextFile("./static/render.js"); + return new Response(js, { headers: { "content-type": "application/javascript" } }); + } + if (url.pathname === "/ui.js") { + const js = await Deno.readTextFile("./static/ui.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 { @@ -614,7 +408,7 @@ serve(async (req) => { // Create new room if (url.pathname === "/api/create") { const code = generateRoomId(); - db.prepare('INSERT INTO rooms (code) VALUES (?)').run(code); + db.createRoom(code); return Response.json({ code }); } @@ -628,8 +422,7 @@ serve(async (req) => { } // Verify room exists - const roomExists = db.prepare('SELECT code FROM rooms WHERE code = ?').get(roomCode); - if (!roomExists) { + if (!db.roomExists(roomCode)) { return new Response("Room not found", { status: 404 }); } diff --git a/static/app.js b/static/app.js index 711d9377db4eb9858d4f546a914b6e2d52788bd7..380a93195ec021292e651e5d8386cd62ae7d3a1c 100644 --- a/static/app.js +++ b/static/app.js @@ -2,6 +2,9 @@ // // SPDX-License-Identifier: AGPL-3.0-or-later +import { getSortedItems, escapeHtml, updateConnectionStatus, updateLastSync, setUIEnabled } from './render.js'; +import { setupKeyboardShortcuts, setupHelpModal, setupPressLock } from './ui.js'; + let ws = null; let currentRoom = null; let roomTitle = null; @@ -13,7 +16,6 @@ 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 let shouldScrollSelectedIntoView = false; // Only scroll on local actions const CHECK_ICON_SVG = ``; @@ -119,8 +121,7 @@ document.getElementById('set-title-btn').addEventListener('click', () => { } }); - // Insert input after the header div - const header = listScreen.querySelector('header'); + // Insert input near title element if (roomTitleEl.classList.contains('hidden')) { roomTitleEl.parentElement.insertBefore(input, roomTitleEl); } else { @@ -174,8 +175,7 @@ let messageQueue = []; function joinRoom(code) { currentRoom = code; - isReady = false; - setUIEnabled(false); + setUIEnabled(false, listScreen, listContainer); updateConnectionStatus('connecting'); const wsScheme = location.protocol === 'https:' ? 'wss:' : 'ws:'; @@ -192,7 +192,7 @@ function joinRoom(code) { ws.onmessage = (event) => { const msg = JSON.parse(event.data); lastSyncTime = Date.now(); - updateLastSync(); + updateLastSync(lastSyncTime); handleMessage(msg); }; @@ -202,8 +202,7 @@ function joinRoom(code) { }; ws.onclose = (event) => { - isReady = false; - setUIEnabled(false); + setUIEnabled(false, listScreen, listContainer); updateConnectionStatus('disconnected'); if (!event.wasClean) { console.error('Connection closed unexpectedly'); @@ -231,15 +230,14 @@ function sendMessage(msg) { function handleMessage(msg) { switch (msg.type) { - case 'state': + case 'state': { items = msg.items; userId = msg.userId; roomTitle = msg.roomTitle; if (items.length > 0 && !selectedItemId) { - selectedItemId = getSortedItems()[0].id; + selectedItemId = getSortedItems(items)[0].id; } - isReady = true; - setUIEnabled(true); + setUIEnabled(true, listScreen, listContainer); // Update URL to include room code for easy copying const newUrl = `${location.origin}${location.pathname}?room=${currentRoom}`; @@ -248,20 +246,21 @@ function handleMessage(msg) { renderTitle(); render(); break; + } case 'items_added': items.push(...msg.items); if (!selectedItemId && items.length > 0) { - selectedItemId = getSortedItems()[0].id; + selectedItemId = getSortedItems(items)[0].id; } render(); break; - case 'vote_changed': + 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 sorted = getSortedItems(items); const clampedPos = Math.min(selectedPosition, sorted.length - 1); selectedItemId = sorted[clampedPos].id; selectedPosition = null; @@ -269,13 +268,14 @@ function handleMessage(msg) { render(); } break; - case 'vote_removed': + } + 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 sorted = getSortedItems(items); const clampedPos = Math.min(selectedPosition, sorted.length - 1); selectedItemId = sorted[clampedPos].id; selectedPosition = null; @@ -283,16 +283,18 @@ function handleMessage(msg) { render(); } break; - case 'item_edited': + } + 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 sorted = getSortedItems(items); const idx = sorted.findIndex(i => i.id === msg.itemId); const nextIdx = Math.min(idx, sorted.length - 2); selectedItemId = nextIdx >= 0 ? sorted[nextIdx].id : null; @@ -321,7 +323,7 @@ function vote(itemId, voteType) { const currentVote = item?.votes[userId]; // Remember current position to select item at same position after re-sort - const sorted = getSortedItems(); + const sorted = getSortedItems(items); selectedPosition = sorted.findIndex(i => i.id === itemId); shouldScrollSelectedIntoView = true; @@ -420,173 +422,6 @@ 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; - shouldScrollSelectedIntoView = true; - 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; - shouldScrollSelectedIntoView = true; - 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(); @@ -597,7 +432,7 @@ function render() { }); // Sort: vetoed items last, then by score - const sorted = getSortedItems(); + const sorted = getSortedItems(items); // Check if any votes have been cast const hasAnyVotes = sorted.some(item => Object.keys(item.votes).length > 0); @@ -689,12 +524,6 @@ function render() { } } -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]'); @@ -725,19 +554,25 @@ listContainer.addEventListener('dblclick', (e) => { editItem(itemId); }); -// Minimal press-lock for non-vote buttons to smooth release -document.addEventListener('click', (e) => { - const btn = e.target.closest('button'); - if (!btn) return; - if (btn.classList.contains('vote-btn')) return; // handled in vote() - btn.classList.add('press-lock'); - setTimeout(() => btn.classList.remove('press-lock'), 180); +// Initialize UI components +setupHelpModal(); +setupPressLock(); +setupKeyboardShortcuts({ + items, + vote, + deleteItem, + editItem, + undo, + render, + getSelectedItemId: () => selectedItemId, + setSelectedItemId: (id) => { selectedItemId = id; }, + setShouldScroll: (value) => { shouldScrollSelectedIntoView = value; } }); // Make functions global -window.vote = vote; -window.deleteItem = deleteItem; -window.editItem = editItem; +globalThis.vote = vote; +globalThis.deleteItem = deleteItem; +globalThis.editItem = editItem; // Deep linking: auto-join room from URL param const urlParams = new URLSearchParams(location.search); @@ -750,4 +585,4 @@ if (roomParam) { } // Update sync status every second -setInterval(updateLastSync, 1000); +setInterval(() => updateLastSync(lastSyncTime), 1000); diff --git a/static/render.js b/static/render.js new file mode 100644 index 0000000000000000000000000000000000000000..9f4b26ad8d69cc0072f63867987998149456cc3f --- /dev/null +++ b/static/render.js @@ -0,0 +1,79 @@ +// SPDX-FileCopyrightText: Amolith +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +export function getSortedItems(items) { + 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; + }); +} + +export function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} + +export 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`; +} + +export function updateLastSync(lastSyncTime) { + const lastSyncEl = document.querySelector('.last-sync'); + if (!lastSyncTime) { + lastSyncEl.textContent = ''; + return; + } + + lastSyncEl.textContent = '• ' + formatTimeSince(lastSyncTime); +} + +export function setUIEnabled(enabled, listScreen, listContainer) { + 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'); + } +} diff --git a/static/ui.js b/static/ui.js new file mode 100644 index 0000000000000000000000000000000000000000..edd98664a9819c25a4aa3598441c228f3982ca20 --- /dev/null +++ b/static/ui.js @@ -0,0 +1,119 @@ +// SPDX-FileCopyrightText: Amolith +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { getSortedItems } from './render.js'; + +export function setupKeyboardShortcuts(options) { + const { items, vote, deleteItem, editItem, undo, render, getSelectedItemId, setSelectedItemId, setShouldScroll } = options; + + 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(items); + const selectedItemId = getSelectedItemId(); + + // 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); + setSelectedItemId(sorted[nextIdx].id); + setShouldScroll(true); + 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); + setSelectedItemId(sorted[prevIdx].id); + setShouldScroll(true); + 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 +export function setupHelpModal() { + const helpModal = document.getElementById('help-modal'); + const helpModalClose = helpModal.querySelector('.modal-close'); + + helpModalClose.addEventListener('click', closeHelpModal); + + // Close modal on backdrop click + helpModal.addEventListener('click', (e) => { + if (e.target === helpModal) { + closeHelpModal(); + } + }); +} + +export function openHelpModal() { + const helpModal = document.getElementById('help-modal'); + const helpModalClose = helpModal.querySelector('.modal-close'); + helpModal.classList.remove('hidden'); + helpModalClose.focus(); +} + +export function closeHelpModal() { + const helpModal = document.getElementById('help-modal'); + helpModal.classList.add('hidden'); +} + +export function setupPressLock() { + // Minimal press-lock for non-vote buttons to smooth release + document.addEventListener('click', (e) => { + const btn = e.target.closest('button'); + if (!btn) return; + if (btn.classList.contains('vote-btn')) return; // handled in vote() + btn.classList.add('press-lock'); + setTimeout(() => btn.classList.remove('press-lock'), 180); + }); +}