Detailed changes
@@ -0,0 +1,303 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// 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}>;
+}
@@ -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<string, Set<WebSocket>>();
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 });
}
@@ -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 = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-check"><polyline points="20 6 9 17 4 12"></polyline></svg>`;
@@ -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);
@@ -0,0 +1,79 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// 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');
+ }
+}
@@ -0,0 +1,119 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// 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);
+ });
+}