polls on atproto pollz.waow.tech
atproto zig

initial commit: pollz - polls on atproto

- frontend: vite + typescript SPA for creating/viewing polls
- backend: zig + httpz server with sqlite persistence
- cloudflare pages function for dynamic OG tags on poll links
- jetstream integration for real-time vote/poll sync
- justfile with deploy targets for fly.io and cloudflare pages

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

+5
.gitignore
··· 1 + node_modules 2 + dist 3 + .vite 4 + .zig-cache 5 + *.db
+34
backend/Dockerfile
··· 1 + # build stage 2 + FROM debian:bookworm-slim AS builder 3 + 4 + RUN apt-get update && apt-get install -y --no-install-recommends \ 5 + ca-certificates \ 6 + curl \ 7 + xz-utils \ 8 + && rm -rf /var/lib/apt/lists/* 9 + 10 + # install zig 0.15.2 11 + RUN curl -L https://ziglang.org/download/0.15.2/zig-x86_64-linux-0.15.2.tar.xz | tar -xJ -C /usr/local \ 12 + && ln -s /usr/local/zig-x86_64-linux-0.15.2/zig /usr/local/bin/zig 13 + 14 + WORKDIR /app 15 + COPY build.zig build.zig.zon ./ 16 + COPY src ./src 17 + 18 + RUN zig build -Doptimize=ReleaseSafe 19 + 20 + # runtime stage 21 + FROM debian:bookworm-slim 22 + 23 + RUN apt-get update && apt-get install -y --no-install-recommends \ 24 + ca-certificates \ 25 + && rm -rf /var/lib/apt/lists/* \ 26 + # prefer IPv4 over IPv6 for outbound connections (IPv6 times out in Fly.io) 27 + && echo 'precedence ::ffff:0:0/96 100' >> /etc/gai.conf 28 + 29 + WORKDIR /app 30 + COPY --from=builder /app/zig-out/bin/pollz . 31 + 32 + EXPOSE 3000 33 + 34 + CMD ["./pollz"]
+40
backend/build.zig
··· 1 + const std = @import("std"); 2 + 3 + pub fn build(b: *std.Build) void { 4 + const target = b.standardTargetOptions(.{}); 5 + const optimize = b.standardOptimizeOption(.{}); 6 + 7 + const websocket = b.dependency("websocket", .{ 8 + .target = target, 9 + .optimize = optimize, 10 + }); 11 + 12 + const zqlite = b.dependency("zqlite", .{ 13 + .target = target, 14 + .optimize = optimize, 15 + }); 16 + 17 + const exe = b.addExecutable(.{ 18 + .name = "pollz", 19 + .root_module = b.createModule(.{ 20 + .root_source_file = b.path("src/main.zig"), 21 + .target = target, 22 + .optimize = optimize, 23 + .imports = &.{ 24 + .{ .name = "websocket", .module = websocket.module("websocket") }, 25 + .{ .name = "zqlite", .module = zqlite.module("zqlite") }, 26 + }, 27 + }), 28 + }); 29 + 30 + b.installArtifact(exe); 31 + 32 + const run_cmd = b.addRunArtifact(exe); 33 + run_cmd.step.dependOn(b.getInstallStep()); 34 + if (b.args) |args| { 35 + run_cmd.addArgs(args); 36 + } 37 + 38 + const run_step = b.step("run", "Run the server"); 39 + run_step.dependOn(&run_cmd.step); 40 + }
+21
backend/build.zig.zon
··· 1 + .{ 2 + .name = .pollz_backend, 3 + .version = "0.0.1", 4 + .fingerprint = 0x855197507943a911, 5 + .minimum_zig_version = "0.15.0", 6 + .dependencies = .{ 7 + .websocket = .{ 8 + .url = "https://github.com/karlseguin/websocket.zig/archive/refs/heads/master.tar.gz", 9 + .hash = "websocket-0.1.0-ZPISdRNzAwAGszh62EpRtoQxu8wb1MSMVI6Ow0o2dmyJ", 10 + }, 11 + .zqlite = .{ 12 + .url = "git+https://github.com/karlseguin/zqlite.zig?ref=master#e041f81c6b11b7381b6358030d57ca95dcd54d30", 13 + .hash = "zqlite-0.0.0-RWLaYzS6mAAAzVSs8HPbmwl4DqH5kXG0Ob87asf1YNGL", 14 + }, 15 + }, 16 + .paths = .{ 17 + "build.zig", 18 + "build.zig.zon", 19 + "src", 20 + }, 21 + }
+21
backend/fly.toml
··· 1 + app = 'pollz-backend' 2 + primary_region = 'iad' 3 + 4 + [build] 5 + 6 + [http_service] 7 + internal_port = 3000 8 + force_https = true 9 + auto_stop_machines = 'stop' 10 + auto_start_machines = true 11 + min_machines_running = 1 12 + processes = ['app'] 13 + 14 + [[vm]] 15 + memory = '256mb' 16 + cpu_kind = 'shared' 17 + cpus = 1 18 + 19 + [mounts] 20 + source = 'pollz_data' 21 + destination = '/data'
+29
backend/justfile
··· 1 + # pollz backend 2 + 3 + # dev server with hot reload 4 + dev: 5 + watchexec -r -w src -- zig build run 6 + 7 + # build release binary 8 + build: 9 + zig build -Doptimize=ReleaseSafe 10 + 11 + # run release binary 12 + run: build 13 + ./zig-out/bin/pollz 14 + 15 + # format code 16 + fmt: 17 + zig fmt src/ 18 + 19 + # check formatting without modifying 20 + check: 21 + zig fmt --check src/ 22 + 23 + # clean build artifacts 24 + clean: 25 + rm -rf zig-out .zig-cache pollz.db 26 + 27 + # deploy to fly.io 28 + deploy: 29 + fly deploy
+97
backend/src/backfill.zig
··· 1 + const std = @import("std"); 2 + const mem = std.mem; 3 + const json = std.json; 4 + const Allocator = mem.Allocator; 5 + const db = @import("db.zig"); 6 + const jetstream = @import("jetstream.zig"); 7 + 8 + pub fn run(allocator: Allocator) void { 9 + std.debug.print("starting backfill from ufos-api.microcosm.blue...\n", .{}); 10 + 11 + backfillCollection(allocator, "tech.waow.poll"); 12 + backfillCollection(allocator, "tech.waow.vote"); 13 + 14 + std.debug.print("backfill complete\n", .{}); 15 + } 16 + 17 + fn backfillCollection(allocator: Allocator, collection: []const u8) void { 18 + var url_buf: [256]u8 = undefined; 19 + const url = std.fmt.bufPrint(&url_buf, "https://ufos-api.microcosm.blue/records?collection={s}", .{collection}) catch return; 20 + 21 + std.debug.print("backfill: fetching {s} from ufos\n", .{collection}); 22 + 23 + // make https request 24 + var client = std.http.Client{ .allocator = allocator }; 25 + defer client.deinit(); 26 + 27 + const uri = std.Uri.parse(url) catch return; 28 + 29 + var req = client.request(.GET, uri, .{ 30 + .headers = .{ .accept_encoding = .{ .override = "identity" } }, 31 + }) catch |err| { 32 + std.debug.print("backfill: http request error: {}\n", .{err}); 33 + return; 34 + }; 35 + defer req.deinit(); 36 + 37 + req.sendBodiless() catch |err| { 38 + std.debug.print("backfill: http send error: {}\n", .{err}); 39 + return; 40 + }; 41 + 42 + var redirect_buf: [8192]u8 = undefined; 43 + var response = req.receiveHead(&redirect_buf) catch |err| { 44 + std.debug.print("backfill: http receive error: {}\n", .{err}); 45 + return; 46 + }; 47 + 48 + if (response.head.status != .ok) { 49 + std.debug.print("backfill: http status {}\n", .{response.head.status}); 50 + return; 51 + } 52 + 53 + // read response body 54 + var reader = response.reader(&.{}); 55 + const body = reader.allocRemaining(allocator, std.Io.Limit.limited(65536)) catch |err| { 56 + std.debug.print("backfill: http read error: {}\n", .{err}); 57 + return; 58 + }; 59 + defer allocator.free(body); 60 + 61 + // parse json response - UFOs returns array of {did, collection, rkey, record} 62 + const parsed = json.parseFromSlice(json.Value, allocator, body, .{}) catch |err| { 63 + std.debug.print("backfill: json parse error: {}\n", .{err}); 64 + return; 65 + }; 66 + defer parsed.deinit(); 67 + 68 + if (parsed.value != .array) return; 69 + 70 + var count: usize = 0; 71 + for (parsed.value.array.items) |item| { 72 + if (item != .object) continue; 73 + 74 + const did = item.object.get("did") orelse continue; 75 + if (did != .string) continue; 76 + 77 + const rkey = item.object.get("rkey") orelse continue; 78 + if (rkey != .string) continue; 79 + 80 + const record = item.object.get("record") orelse continue; 81 + if (record != .object) continue; 82 + 83 + // construct record uri 84 + const record_uri = std.fmt.allocPrint(allocator, "at://{s}/{s}/{s}", .{ did.string, collection, rkey.string }) catch continue; 85 + defer allocator.free(record_uri); 86 + 87 + if (mem.eql(u8, collection, "tech.waow.poll")) { 88 + jetstream.processPoll(allocator, record_uri, did.string, rkey.string, record.object) catch continue; 89 + count += 1; 90 + } else if (mem.eql(u8, collection, "tech.waow.vote")) { 91 + jetstream.processVote(record_uri, did.string, record.object) catch continue; 92 + count += 1; 93 + } 94 + } 95 + 96 + std.debug.print("backfill: indexed {d} {s} records\n", .{ count, collection }); 97 + }
+155
backend/src/db.zig
··· 1 + const std = @import("std"); 2 + const json = std.json; 3 + const mem = std.mem; 4 + const Thread = std.Thread; 5 + const Allocator = mem.Allocator; 6 + const zqlite = @import("zqlite"); 7 + 8 + pub var conn: zqlite.Conn = undefined; 9 + pub var mutex: Thread.Mutex = .{}; 10 + 11 + pub fn init(path: [*:0]const u8) !void { 12 + std.debug.print("opening database at: {s}\n", .{path}); 13 + conn = zqlite.open(path, zqlite.OpenFlags.Create | zqlite.OpenFlags.ReadWrite) catch |err| { 14 + std.debug.print("failed to open database: {}\n", .{err}); 15 + return err; 16 + }; 17 + try initSchema(); 18 + } 19 + 20 + pub fn close() void { 21 + conn.close(); 22 + } 23 + 24 + fn initSchema() !void { 25 + mutex.lock(); 26 + defer mutex.unlock(); 27 + 28 + conn.execNoArgs( 29 + \\CREATE TABLE IF NOT EXISTS polls ( 30 + \\ uri TEXT PRIMARY KEY, 31 + \\ repo TEXT NOT NULL, 32 + \\ rkey TEXT NOT NULL, 33 + \\ text TEXT NOT NULL, 34 + \\ options TEXT NOT NULL, 35 + \\ created_at TEXT NOT NULL 36 + \\) 37 + ) catch |err| { 38 + std.debug.print("failed to create polls table: {}\n", .{err}); 39 + return err; 40 + }; 41 + 42 + conn.execNoArgs( 43 + \\CREATE TABLE IF NOT EXISTS votes ( 44 + \\ uri TEXT PRIMARY KEY, 45 + \\ subject TEXT NOT NULL, 46 + \\ option INTEGER NOT NULL, 47 + \\ voter TEXT NOT NULL, 48 + \\ created_at TEXT, 49 + \\ UNIQUE(subject, voter) 50 + \\) 51 + ) catch |err| { 52 + std.debug.print("failed to create votes table: {}\n", .{err}); 53 + return err; 54 + }; 55 + 56 + // add created_at column if it doesn't exist (migration for existing DBs) 57 + conn.execNoArgs("ALTER TABLE votes ADD COLUMN created_at TEXT") catch {}; 58 + 59 + conn.execNoArgs( 60 + \\CREATE INDEX IF NOT EXISTS idx_votes_subject ON votes(subject) 61 + ) catch |err| { 62 + std.debug.print("failed to create index: {}\n", .{err}); 63 + return err; 64 + }; 65 + 66 + conn.execNoArgs( 67 + \\CREATE INDEX IF NOT EXISTS idx_votes_voter ON votes(subject, voter) 68 + ) catch |err| { 69 + std.debug.print("failed to create voter index: {}\n", .{err}); 70 + return err; 71 + }; 72 + 73 + conn.execNoArgs( 74 + \\CREATE TABLE IF NOT EXISTS cursor ( 75 + \\ id INTEGER PRIMARY KEY CHECK (id = 1), 76 + \\ time_us INTEGER NOT NULL 77 + \\) 78 + ) catch |err| { 79 + std.debug.print("failed to create cursor table: {}\n", .{err}); 80 + return err; 81 + }; 82 + 83 + std.debug.print("database schema initialized\n", .{}); 84 + } 85 + 86 + pub fn getCursor() ?i64 { 87 + mutex.lock(); 88 + defer mutex.unlock(); 89 + 90 + const row = conn.row("SELECT time_us FROM cursor WHERE id = 1", .{}) catch return null; 91 + if (row == null) return null; 92 + defer row.?.deinit(); 93 + return row.?.int(0); 94 + } 95 + 96 + pub fn saveCursor(time_us: i64) void { 97 + mutex.lock(); 98 + defer mutex.unlock(); 99 + 100 + conn.exec("INSERT OR REPLACE INTO cursor (id, time_us) VALUES (1, ?)", .{time_us}) catch |err| { 101 + std.debug.print("failed to save cursor: {}\n", .{err}); 102 + }; 103 + } 104 + 105 + pub fn insertPoll(uri: []const u8, did: []const u8, rkey: []const u8, text_json: []const u8, options_json: []const u8, created_at: []const u8) !void { 106 + mutex.lock(); 107 + defer mutex.unlock(); 108 + 109 + conn.exec( 110 + "INSERT OR IGNORE INTO polls (uri, repo, rkey, text, options, created_at) VALUES (?, ?, ?, ?, ?, ?)", 111 + .{ uri, did, rkey, text_json, options_json, created_at }, 112 + ) catch |err| { 113 + std.debug.print("db insert poll error: {}\n", .{err}); 114 + return err; 115 + }; 116 + } 117 + 118 + pub fn insertVote(uri: []const u8, subject: []const u8, option: i32, voter: []const u8, created_at: ?[]const u8) !void { 119 + mutex.lock(); 120 + defer mutex.unlock(); 121 + 122 + // delete any existing vote by this user on this poll, then insert new one 123 + // this enforces one vote per user per poll 124 + conn.exec("DELETE FROM votes WHERE subject = ? AND voter = ?", .{ subject, voter }) catch {}; 125 + 126 + conn.exec( 127 + "INSERT INTO votes (uri, subject, option, voter, created_at) VALUES (?, ?, ?, ?, ?)", 128 + .{ uri, subject, option, voter, created_at }, 129 + ) catch |err| { 130 + std.debug.print("db insert vote error: {}\n", .{err}); 131 + return err; 132 + }; 133 + } 134 + 135 + pub fn deletePoll(uri: []const u8) void { 136 + mutex.lock(); 137 + defer mutex.unlock(); 138 + 139 + conn.exec("DELETE FROM polls WHERE uri = ?", .{uri}) catch |err| { 140 + std.debug.print("db delete poll error: {}\n", .{err}); 141 + }; 142 + // also delete associated votes 143 + conn.exec("DELETE FROM votes WHERE subject = ?", .{uri}) catch |err| { 144 + std.debug.print("db delete votes error: {}\n", .{err}); 145 + }; 146 + } 147 + 148 + pub fn deleteVote(uri: []const u8) void { 149 + mutex.lock(); 150 + defer mutex.unlock(); 151 + 152 + conn.exec("DELETE FROM votes WHERE uri = ?", .{uri}) catch |err| { 153 + std.debug.print("db delete vote error: {}\n", .{err}); 154 + }; 155 + }
+276
backend/src/http.zig
··· 1 + const std = @import("std"); 2 + const net = std.net; 3 + const http = std.http; 4 + const mem = std.mem; 5 + const json = std.json; 6 + const db = @import("db.zig"); 7 + 8 + pub fn handleConnection(conn: net.Server.Connection) void { 9 + defer conn.stream.close(); 10 + 11 + var read_buffer: [8192]u8 = undefined; 12 + var write_buffer: [8192]u8 = undefined; 13 + 14 + var reader = conn.stream.reader(&read_buffer); 15 + var writer = conn.stream.writer(&write_buffer); 16 + 17 + var server = http.Server.init(reader.interface(), &writer.interface); 18 + 19 + while (true) { 20 + var request = server.receiveHead() catch |err| { 21 + // this is expected for idle connections 22 + if (err != error.HttpConnectionClosing and err != error.EndOfStream) { 23 + std.debug.print("http receive error: {}\n", .{err}); 24 + } 25 + return; 26 + }; 27 + handleRequest(&server, &request) catch |err| { 28 + std.debug.print("request error: {}\n", .{err}); 29 + return; 30 + }; 31 + if (!request.head.keep_alive) return; 32 + } 33 + } 34 + 35 + fn handleRequest(server: *http.Server, request: *http.Server.Request) !void { 36 + _ = server; 37 + const target = request.head.target; 38 + 39 + // cors preflight 40 + if (request.head.method == .OPTIONS) { 41 + try sendCorsHeaders(request, ""); 42 + return; 43 + } 44 + 45 + if (mem.startsWith(u8, target, "/api/polls")) { 46 + if (mem.eql(u8, target, "/api/polls")) { 47 + try handleGetPolls(request); 48 + } else if (mem.indexOf(u8, target, "/votes")) |votes_idx| { 49 + // /api/polls/:uri/votes 50 + const uri_encoded = target["/api/polls/".len..votes_idx]; 51 + try handleGetVotes(request, uri_encoded); 52 + } else if (mem.startsWith(u8, target, "/api/polls/")) { 53 + const uri_encoded = target["/api/polls/".len..]; 54 + try handleGetPoll(request, uri_encoded); 55 + } 56 + } else if (mem.eql(u8, target, "/health")) { 57 + try sendJson(request, "{\"status\":\"ok\"}"); 58 + } else { 59 + try sendNotFound(request); 60 + } 61 + } 62 + 63 + fn handleGetPolls(request: *http.Server.Request) !void { 64 + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 65 + defer arena.deinit(); 66 + const alloc = arena.allocator(); 67 + 68 + db.mutex.lock(); 69 + defer db.mutex.unlock(); 70 + 71 + var response: std.ArrayList(u8) = .{}; 72 + defer response.deinit(alloc); 73 + 74 + try response.appendSlice(alloc, "["); 75 + 76 + var rows = db.conn.rows( 77 + "SELECT uri, repo, rkey, text, options, created_at FROM polls ORDER BY created_at DESC", 78 + .{}, 79 + ) catch { 80 + try sendJson(request, "[]"); 81 + return; 82 + }; 83 + defer rows.deinit(); 84 + 85 + var first = true; 86 + while (rows.next()) |row| { 87 + if (!first) try response.appendSlice(alloc, ","); 88 + first = false; 89 + 90 + const uri = row.text(0); 91 + const repo = row.text(1); 92 + const rkey = row.text(2); 93 + const text_json = row.text(3); 94 + const options_json = row.text(4); 95 + const created_at = row.text(5); 96 + 97 + // count votes for this poll 98 + const vote_count: i64 = blk: { 99 + const vrow = db.conn.row("SELECT COUNT(*) FROM votes WHERE subject = ?", .{uri}) catch break :blk 0; 100 + if (vrow) |r| { 101 + defer r.deinit(); 102 + break :blk r.int(0); 103 + } 104 + break :blk 0; 105 + }; 106 + 107 + try response.print(alloc, 108 + \\{{"uri":"{s}","repo":"{s}","rkey":"{s}","text":{s},"options":{s},"createdAt":"{s}","voteCount":{d}}} 109 + , .{ uri, repo, rkey, text_json, options_json, created_at, vote_count }); 110 + } 111 + 112 + if (rows.err) |err| { 113 + std.debug.print("rows error: {}\n", .{err}); 114 + } 115 + 116 + try response.appendSlice(alloc, "]"); 117 + try sendJson(request, response.items); 118 + } 119 + 120 + fn handleGetPoll(request: *http.Server.Request, uri_encoded: []const u8) !void { 121 + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 122 + defer arena.deinit(); 123 + const alloc = arena.allocator(); 124 + 125 + // decode uri 126 + const uri_buf = try alloc.dupe(u8, uri_encoded); 127 + const uri = std.Uri.percentDecodeInPlace(uri_buf); 128 + 129 + db.mutex.lock(); 130 + defer db.mutex.unlock(); 131 + 132 + const row = db.conn.row("SELECT uri, repo, rkey, text, options, created_at FROM polls WHERE uri = ?", .{uri}) catch { 133 + try sendNotFound(request); 134 + return; 135 + }; 136 + if (row == null) { 137 + try sendNotFound(request); 138 + return; 139 + } 140 + defer row.?.deinit(); 141 + 142 + const poll_uri = row.?.text(0); 143 + const repo = row.?.text(1); 144 + const rkey = row.?.text(2); 145 + const text_json = row.?.text(3); 146 + const options_json = row.?.text(4); 147 + const created_at = row.?.text(5); 148 + 149 + // parse options array to get count 150 + const parsed = json.parseFromSlice(json.Value, alloc, options_json, .{}) catch { 151 + try sendNotFound(request); 152 + return; 153 + }; 154 + defer parsed.deinit(); 155 + 156 + if (parsed.value != .array) { 157 + try sendNotFound(request); 158 + return; 159 + } 160 + 161 + const options = parsed.value.array.items; 162 + 163 + // build response 164 + var response: std.ArrayList(u8) = .{}; 165 + defer response.deinit(alloc); 166 + 167 + try response.print(alloc, 168 + \\{{"uri":"{s}","repo":"{s}","rkey":"{s}","text":{s},"options":[ 169 + , .{ poll_uri, repo, rkey, text_json }); 170 + 171 + for (options, 0..) |opt, i| { 172 + if (i > 0) try response.appendSlice(alloc, ","); 173 + 174 + const count: i64 = blk: { 175 + const vrow = db.conn.row("SELECT COUNT(*) FROM votes WHERE subject = ? AND option = ?", .{ poll_uri, @as(i32, @intCast(i)) }) catch break :blk 0; 176 + if (vrow) |r| { 177 + defer r.deinit(); 178 + break :blk r.int(0); 179 + } 180 + break :blk 0; 181 + }; 182 + 183 + const opt_text = if (opt == .string) opt.string else ""; 184 + try response.print(alloc, 185 + \\{{"text":"{s}","count":{d}}} 186 + , .{ opt_text, count }); 187 + } 188 + 189 + try response.print(alloc, 190 + \\],"createdAt":"{s}"}} 191 + , .{created_at}); 192 + 193 + try sendJson(request, response.items); 194 + } 195 + 196 + fn handleGetVotes(request: *http.Server.Request, uri_encoded: []const u8) !void { 197 + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 198 + defer arena.deinit(); 199 + const alloc = arena.allocator(); 200 + 201 + // decode uri 202 + const uri_buf = try alloc.dupe(u8, uri_encoded); 203 + const uri = std.Uri.percentDecodeInPlace(uri_buf); 204 + 205 + db.mutex.lock(); 206 + defer db.mutex.unlock(); 207 + 208 + var response: std.ArrayList(u8) = .{}; 209 + defer response.deinit(alloc); 210 + 211 + try response.appendSlice(alloc, "["); 212 + 213 + var rows = db.conn.rows( 214 + "SELECT voter, option, uri, created_at FROM votes WHERE subject = ?", 215 + .{uri}, 216 + ) catch { 217 + try sendJson(request, "[]"); 218 + return; 219 + }; 220 + defer rows.deinit(); 221 + 222 + var first = true; 223 + while (rows.next()) |row| { 224 + if (!first) try response.appendSlice(alloc, ","); 225 + first = false; 226 + 227 + const voter = row.text(0); 228 + const option = row.int(1); 229 + const vote_uri = row.text(2); 230 + const created_at = row.text(3); 231 + 232 + try response.print(alloc, 233 + \\{{"voter":"{s}","option":{d},"uri":"{s}","createdAt":"{s}"}} 234 + , .{ voter, option, vote_uri, created_at }); 235 + } 236 + 237 + if (rows.err) |err| { 238 + std.debug.print("votes query error: {}\n", .{err}); 239 + } 240 + 241 + try response.appendSlice(alloc, "]"); 242 + try sendJson(request, response.items); 243 + } 244 + 245 + fn sendJson(request: *http.Server.Request, body: []const u8) !void { 246 + try request.respond(body, .{ 247 + .status = .ok, 248 + .extra_headers = &.{ 249 + .{ .name = "content-type", .value = "application/json" }, 250 + .{ .name = "access-control-allow-origin", .value = "*" }, 251 + .{ .name = "access-control-allow-methods", .value = "GET, OPTIONS" }, 252 + .{ .name = "access-control-allow-headers", .value = "content-type" }, 253 + }, 254 + }); 255 + } 256 + 257 + fn sendCorsHeaders(request: *http.Server.Request, body: []const u8) !void { 258 + try request.respond(body, .{ 259 + .status = .no_content, 260 + .extra_headers = &.{ 261 + .{ .name = "access-control-allow-origin", .value = "*" }, 262 + .{ .name = "access-control-allow-methods", .value = "GET, OPTIONS" }, 263 + .{ .name = "access-control-allow-headers", .value = "content-type" }, 264 + }, 265 + }); 266 + } 267 + 268 + fn sendNotFound(request: *http.Server.Request) !void { 269 + try request.respond("{\"error\":\"not found\"}", .{ 270 + .status = .not_found, 271 + .extra_headers = &.{ 272 + .{ .name = "content-type", .value = "application/json" }, 273 + .{ .name = "access-control-allow-origin", .value = "*" }, 274 + }, 275 + }); 276 + }
+180
backend/src/jetstream.zig
··· 1 + const std = @import("std"); 2 + const mem = std.mem; 3 + const json = std.json; 4 + const posix = std.posix; 5 + const Allocator = mem.Allocator; 6 + const websocket = @import("websocket"); 7 + const db = @import("db.zig"); 8 + 9 + const POLL_COLLECTION = "tech.waow.poll"; 10 + const VOTE_COLLECTION = "tech.waow.vote"; 11 + 12 + pub fn consumer(allocator: Allocator) void { 13 + while (true) { 14 + connect(allocator) catch |err| { 15 + std.debug.print("jetstream error: {}, reconnecting in 3s...\n", .{err}); 16 + }; 17 + posix.nanosleep(3, 0); 18 + } 19 + } 20 + 21 + const Handler = struct { 22 + allocator: Allocator, 23 + msg_count: usize = 0, 24 + 25 + pub fn serverMessage(self: *Handler, data: []const u8) !void { 26 + self.msg_count += 1; 27 + if (self.msg_count % 100 == 1) { 28 + std.debug.print("jetstream: received {} messages\n", .{self.msg_count}); 29 + } 30 + processMessage(self.allocator, data) catch |err| { 31 + std.debug.print("message processing error: {}\n", .{err}); 32 + }; 33 + } 34 + 35 + pub fn close(_: *Handler) void { 36 + std.debug.print("jetstream connection closed\n", .{}); 37 + } 38 + }; 39 + 40 + fn connect(allocator: Allocator) !void { 41 + const host = "jetstream1.us-east.bsky.network"; 42 + 43 + var path_buf: [512]u8 = undefined; 44 + 45 + // only use saved cursor if we have one (for resuming after disconnect) 46 + // otherwise start from NOW - UFOs handles backfill, Jetstream is for live events only 47 + const path = if (db.getCursor()) |cursor| 48 + std.fmt.bufPrint(&path_buf, "/subscribe?wantedCollections={s}&wantedCollections={s}&cursor={d}", .{ POLL_COLLECTION, VOTE_COLLECTION, cursor }) catch "/subscribe" 49 + else 50 + std.fmt.bufPrint(&path_buf, "/subscribe?wantedCollections={s}&wantedCollections={s}", .{ POLL_COLLECTION, VOTE_COLLECTION }) catch "/subscribe"; 51 + 52 + std.debug.print("connecting to wss://{s}{s}\n", .{ host, path }); 53 + 54 + var client = websocket.Client.init(allocator, .{ 55 + .host = host, 56 + .port = 443, 57 + .tls = true, 58 + }) catch |err| { 59 + std.debug.print("websocket client init failed: {}\n", .{err}); 60 + return err; 61 + }; 62 + defer client.deinit(); 63 + 64 + std.debug.print("tcp+tls connected, starting handshake...\n", .{}); 65 + 66 + // add Host header which is required for websocket handshake 67 + var host_header_buf: [128]u8 = undefined; 68 + const host_header = std.fmt.bufPrint(&host_header_buf, "Host: {s}\r\n", .{host}) catch "Host: jetstream1.us-east.bsky.network\r\n"; 69 + 70 + client.handshake(path, .{ .headers = host_header }) catch |err| { 71 + std.debug.print("websocket handshake failed: {}\n", .{err}); 72 + return err; 73 + }; 74 + 75 + std.debug.print("jetstream connected!\n", .{}); 76 + 77 + var handler = Handler{ .allocator = allocator }; 78 + client.readLoop(&handler) catch |err| { 79 + std.debug.print("websocket read loop error: {}\n", .{err}); 80 + return err; 81 + }; 82 + } 83 + 84 + fn processMessage(allocator: Allocator, payload: []const u8) !void { 85 + // parse jetstream event 86 + const parsed = json.parseFromSlice(json.Value, allocator, payload, .{}) catch return; 87 + defer parsed.deinit(); 88 + 89 + const root = parsed.value.object; 90 + 91 + // save cursor from event timestamp 92 + if (root.get("time_us")) |time_us_val| { 93 + if (time_us_val == .integer) { 94 + db.saveCursor(time_us_val.integer); 95 + } 96 + } 97 + 98 + const kind = root.get("kind") orelse return; 99 + if (kind != .string) return; 100 + 101 + if (!mem.eql(u8, kind.string, "commit")) return; 102 + 103 + const commit = root.get("commit") orelse return; 104 + if (commit != .object) return; 105 + 106 + const collection = commit.object.get("collection") orelse return; 107 + if (collection != .string) return; 108 + 109 + const operation = commit.object.get("operation") orelse return; 110 + if (operation != .string) return; 111 + 112 + const did = root.get("did") orelse return; 113 + if (did != .string) return; 114 + 115 + const rkey = commit.object.get("rkey") orelse return; 116 + if (rkey != .string) return; 117 + 118 + const uri_str = try std.fmt.allocPrint(allocator, "at://{s}/{s}/{s}", .{ did.string, collection.string, rkey.string }); 119 + defer allocator.free(uri_str); 120 + 121 + if (mem.eql(u8, operation.string, "create")) { 122 + const record = commit.object.get("record") orelse return; 123 + if (record != .object) return; 124 + 125 + if (mem.eql(u8, collection.string, POLL_COLLECTION)) { 126 + processPoll(allocator, uri_str, did.string, rkey.string, record.object) catch |err| { 127 + std.debug.print("poll processing error: {}\n", .{err}); 128 + }; 129 + } else if (mem.eql(u8, collection.string, VOTE_COLLECTION)) { 130 + processVote(uri_str, did.string, record.object) catch |err| { 131 + std.debug.print("vote processing error: {}\n", .{err}); 132 + }; 133 + } 134 + } else if (mem.eql(u8, operation.string, "delete")) { 135 + if (mem.eql(u8, collection.string, POLL_COLLECTION)) { 136 + db.deletePoll(uri_str); 137 + std.debug.print("deleted poll: {s}\n", .{uri_str}); 138 + } else if (mem.eql(u8, collection.string, VOTE_COLLECTION)) { 139 + db.deleteVote(uri_str); 140 + std.debug.print("deleted vote: {s}\n", .{uri_str}); 141 + } 142 + } 143 + } 144 + 145 + pub fn processPoll(allocator: Allocator, uri: []const u8, did: []const u8, rkey: []const u8, record: json.ObjectMap) !void { 146 + const text_val = record.get("text") orelse return; 147 + if (text_val != .string) return; 148 + 149 + const options_val = record.get("options") orelse return; 150 + if (options_val != .array) return; 151 + 152 + const created_at_val = record.get("createdAt") orelse return; 153 + if (created_at_val != .string) return; 154 + 155 + // serialize options as json 156 + var options_buf: std.ArrayList(u8) = .{}; 157 + defer options_buf.deinit(allocator); 158 + try options_buf.print(allocator, "{f}", .{json.fmt(options_val, .{})}); 159 + 160 + // serialize text as json (to escape quotes properly) 161 + var text_buf: std.ArrayList(u8) = .{}; 162 + defer text_buf.deinit(allocator); 163 + try text_buf.print(allocator, "{f}", .{json.fmt(text_val, .{})}); 164 + 165 + try db.insertPoll(uri, did, rkey, text_buf.items, options_buf.items, created_at_val.string); 166 + std.debug.print("indexed poll: {s}\n", .{uri}); 167 + } 168 + 169 + pub fn processVote(uri: []const u8, did: []const u8, record: json.ObjectMap) !void { 170 + const subject_val = record.get("subject") orelse return; 171 + if (subject_val != .string) return; 172 + 173 + const option_val = record.get("option") orelse return; 174 + if (option_val != .integer) return; 175 + 176 + const created_at: ?[]const u8 = if (record.get("createdAt")) |v| (if (v == .string) v.string else null) else null; 177 + 178 + try db.insertVote(uri, subject_val.string, @as(i32, @intCast(option_val.integer)), did, created_at); 179 + std.debug.print("indexed vote: {s} -> {s}\n", .{ uri, subject_val.string }); 180 + }
+37
backend/src/main.zig
··· 1 + const std = @import("std"); 2 + const net = std.net; 3 + const Thread = std.Thread; 4 + const db = @import("db.zig"); 5 + const http_server = @import("http.zig"); 6 + const jetstream = @import("jetstream.zig"); 7 + const backfill = @import("backfill.zig"); 8 + 9 + pub fn main() !void { 10 + var gpa = std.heap.GeneralPurposeAllocator(.{}){}; 11 + defer _ = gpa.deinit(); 12 + const allocator = gpa.allocator(); 13 + 14 + // init sqlite - use DATA_PATH env or default to /data/pollz.db 15 + const db_path = std.posix.getenv("DATA_PATH") orelse "/data/pollz.db"; 16 + try db.init(db_path); 17 + defer db.close(); 18 + 19 + // backfill existing records from known repos at startup 20 + backfill.run(allocator); 21 + 22 + // start jetstream consumer in background 23 + const jetstream_thread = try Thread.spawn(.{}, jetstream.consumer, .{allocator}); 24 + defer jetstream_thread.join(); 25 + 26 + // start http server (bind to 0.0.0.0 for containerized deployments) 27 + const address = try net.Address.parseIp("0.0.0.0", 3000); 28 + var server = try address.listen(.{ .reuse_address = true }); 29 + defer server.deinit(); 30 + 31 + std.debug.print("pollz backend listening on http://127.0.0.1:3000\n", .{}); 32 + 33 + while (true) { 34 + const conn = try server.accept(); 35 + _ = try Thread.spawn(.{}, http_server.handleConnection, .{conn}); 36 + } 37 + }
backend/zig-out/bin/jetstream-test

This is a binary file and will not be displayed.

backend/zig-out/bin/pollz

This is a binary file and will not be displayed.

+137
backend/zignotes.md
··· 1 + # zig 0.15 notes 2 + 3 + ## breaking changes from 0.14 4 + 5 + ### `json.stringify` → `json.fmt` 6 + ```zig 7 + // old: json.stringify(value, .{}, writer); 8 + // new: use json.fmt formatter 9 + try buffer.print(allocator, "{f}", .{json.fmt(value, .{})}); 10 + ``` 11 + 12 + ### `std.builtin.Mode` → `std.builtin.OptimizeMode` 13 + the optimization mode enum was renamed 14 + 15 + ### `std.time.sleep` removed 16 + use `std.posix.nanosleep(seconds, nanoseconds)` instead 17 + 18 + ### `std.ArrayList` is now unmanaged by default 19 + the old `ArrayList` with embedded allocator is now `array_list.AlignedManaged`. 20 + the new `std.ArrayList(T)` returns an unmanaged struct that: 21 + - doesn't store the allocator internally 22 + - requires passing allocator to methods like `appendSlice(alloc, slice)` 23 + - initializes with `.{}` or `.empty` instead of `.init(allocator)` 24 + - `deinit(alloc)` takes allocator as argument 25 + 26 + ```zig 27 + // old 0.14 style: 28 + var list = std.ArrayList(u8).init(allocator); 29 + try list.appendSlice("hello"); 30 + list.deinit(); 31 + 32 + // new 0.15 style: 33 + var list: std.ArrayList(u8) = .{}; 34 + try list.appendSlice(allocator, "hello"); 35 + list.deinit(allocator); 36 + ``` 37 + 38 + ### build.zig changes 39 + - `root_source_file` → `root_module` with `b.createModule(...)` 40 + - `strip` field removed from `ExecutableOptions` 41 + - fingerprint field required in `build.zig.zon` 42 + 43 + ### `std.http.Server` API 44 + requires `*std.Io.Reader` and `*std.Io.Writer`, not raw `net.Stream` 45 + 46 + ## net.Stream → http.Server 47 + 48 + ```zig 49 + var read_buffer: [8192]u8 = undefined; 50 + var write_buffer: [8192]u8 = undefined; 51 + 52 + var reader = conn.stream.reader(&read_buffer); 53 + var writer = conn.stream.writer(&write_buffer); 54 + 55 + // reader has .interface() method that returns *Io.Reader 56 + // writer has .interface field that is Io.Writer 57 + var server = http.Server.init(reader.interface(), &writer.interface); 58 + ``` 59 + 60 + ### http.Server.Request.respond 61 + the `respond` method is on `Request`, not `Server`: 62 + ```zig 63 + try request.respond(body, .{ 64 + .status = .ok, 65 + .extra_headers = &.{ 66 + .{ .name = "content-type", .value = "application/json" }, 67 + }, 68 + }); 69 + ``` 70 + 71 + ### `std.Uri.percentDecode` → `std.Uri.percentDecodeInPlace` 72 + there's no allocating `percentDecode` anymore. use in-place decoding: 73 + ```zig 74 + // copy to mutable buffer first, then decode in place 75 + const uri_buf = try alloc.dupe(u8, uri_encoded); 76 + const uri = std.Uri.percentDecodeInPlace(uri_buf); 77 + ``` 78 + 79 + ### `std.http.Client` for outgoing requests 80 + ```zig 81 + var client = std.http.Client{ .allocator = allocator }; 82 + defer client.deinit(); 83 + 84 + const uri = std.Uri.parse("https://example.com/api") catch return; 85 + 86 + // use .headers to control accept-encoding (default is gzip/deflate) 87 + var req = client.request(.GET, uri, .{ 88 + .headers = .{ .accept_encoding = .{ .override = "identity" } }, 89 + }) catch return; 90 + defer req.deinit(); 91 + 92 + req.sendBodiless() catch return; 93 + 94 + var redirect_buf: [8192]u8 = undefined; 95 + var response = req.receiveHead(&redirect_buf) catch return; 96 + 97 + if (response.head.status != .ok) return; 98 + 99 + // read response body - use allocRemaining with Limit 100 + var reader = response.reader(&.{}); 101 + const body = reader.allocRemaining(allocator, std.Io.Limit.limited(65536)) catch return; 102 + defer allocator.free(body); 103 + ``` 104 + 105 + ## external libraries 106 + 107 + ### websocket.zig (karlseguin/websocket.zig) 108 + use for websocket client/server. add to `build.zig.zon`: 109 + ```zig 110 + .dependencies = .{ 111 + .websocket = .{ 112 + .url = "https://github.com/karlseguin/websocket.zig/archive/refs/heads/master.tar.gz", 113 + .hash = "websocket-0.1.0-ZPISdRNzAwAGszh62EpRtoQxu8wb1MSMVI6Ow0o2dmyJ", 114 + }, 115 + }, 116 + ``` 117 + 118 + client usage: 119 + ```zig 120 + const websocket = @import("websocket"); 121 + 122 + var client = try websocket.Client.init(allocator, .{ 123 + .host = "example.com", 124 + .port = 443, 125 + .tls = true, 126 + }); 127 + defer client.deinit(); 128 + 129 + // Host header is NOT automatically added - must provide it 130 + client.handshake("/path", .{ .headers = "Host: example.com\r\n" }) catch |err| { 131 + // handle error 132 + }; 133 + 134 + // handler must have serverMessage(self, data) function 135 + var handler = MyHandler{}; 136 + try client.readLoop(&handler); 137 + ```
+83
functions/poll/[repo]/[rkey].ts
··· 1 + // Cloudflare Pages Function for dynamic OG tags 2 + const BACKEND_URL = "https://pollz-backend.fly.dev"; 3 + 4 + interface Poll { 5 + uri: string; 6 + repo: string; 7 + rkey: string; 8 + text: string; 9 + options: Array<{ text: string; count: number }>; 10 + createdAt: string; 11 + } 12 + 13 + export const onRequest: PagesFunction = async (context) => { 14 + const { repo, rkey } = context.params as { repo: string; rkey: string }; 15 + const userAgent = context.request.headers.get("user-agent") || ""; 16 + 17 + // check if this is a bot/crawler requesting the page 18 + const isCrawler = /bot|crawler|spider|facebook|twitter|slack|discord|telegram|whatsapp|linkedin|preview/i.test(userAgent); 19 + 20 + if (!isCrawler) { 21 + // not a crawler, serve the SPA normally 22 + return context.next(); 23 + } 24 + 25 + // fetch poll data from backend 26 + const pollUri = `at://${repo}/tech.waow.poll/${rkey}`; 27 + try { 28 + const res = await fetch(`${BACKEND_URL}/api/polls/${encodeURIComponent(pollUri)}`); 29 + if (!res.ok) { 30 + return context.next(); 31 + } 32 + 33 + const poll: Poll = await res.json(); 34 + const total = poll.options.reduce((sum, o) => sum + o.count, 0); 35 + const optionsText = poll.options.map(o => `${o.text}: ${o.count}`).join(" | "); 36 + const description = `${total} vote${total === 1 ? "" : "s"} · ${optionsText}`; 37 + const url = `https://pollz.waow.tech/poll/${repo}/${rkey}`; 38 + 39 + // return HTML with proper OG tags for crawlers 40 + const html = `<!DOCTYPE html> 41 + <html lang="en"> 42 + <head> 43 + <meta charset="UTF-8"> 44 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 45 + <title>${escapeHtml(poll.text)} - pollz</title> 46 + <meta name="description" content="${escapeHtml(description)}"> 47 + 48 + <!-- Open Graph --> 49 + <meta property="og:type" content="website"> 50 + <meta property="og:title" content="${escapeHtml(poll.text)}"> 51 + <meta property="og:description" content="${escapeHtml(description)}"> 52 + <meta property="og:url" content="${url}"> 53 + <meta property="og:site_name" content="pollz"> 54 + 55 + <!-- Twitter --> 56 + <meta name="twitter:card" content="summary"> 57 + <meta name="twitter:title" content="${escapeHtml(poll.text)}"> 58 + <meta name="twitter:description" content="${escapeHtml(description)}"> 59 + 60 + <meta http-equiv="refresh" content="0;url=${url}"> 61 + </head> 62 + <body> 63 + <p>redirecting to <a href="${url}">${escapeHtml(poll.text)}</a>...</p> 64 + </body> 65 + </html>`; 66 + 67 + return new Response(html, { 68 + headers: { "content-type": "text/html;charset=UTF-8" }, 69 + }); 70 + } catch (e) { 71 + console.error("failed to fetch poll for OG tags:", e); 72 + return context.next(); 73 + } 74 + }; 75 + 76 + function escapeHtml(str: string): string { 77 + return str 78 + .replace(/&/g, "&amp;") 79 + .replace(/</g, "&lt;") 80 + .replace(/>/g, "&gt;") 81 + .replace(/"/g, "&quot;") 82 + .replace(/'/g, "&#39;"); 83 + }
+145
index.html
··· 1 + <!DOCTYPE html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="UTF-8"> 5 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 6 + <title>pollz</title> 7 + <meta name="description" content="polls on atproto"> 8 + 9 + <!-- Open Graph --> 10 + <meta property="og:type" content="website"> 11 + <meta property="og:title" content="pollz"> 12 + <meta property="og:description" content="polls on atproto"> 13 + <meta property="og:url" content="https://pollz.waow.tech"> 14 + <meta property="og:site_name" content="pollz"> 15 + 16 + <!-- Twitter --> 17 + <meta name="twitter:card" content="summary"> 18 + <meta name="twitter:title" content="pollz"> 19 + <meta name="twitter:description" content="polls on atproto"> 20 + <style> 21 + * { box-sizing: border-box; margin: 0; padding: 0; } 22 + body { 23 + font-family: monospace; 24 + max-width: 600px; 25 + margin: 0 auto; 26 + padding: 1rem; 27 + background: #0a0a0a; 28 + color: #ccc; 29 + font-size: 14px; 30 + line-height: 1.6; 31 + } 32 + a { color: #888; text-decoration: none; } 33 + a:hover { color: #fff; } 34 + header { 35 + display: flex; 36 + justify-content: space-between; 37 + align-items: center; 38 + padding: 1rem 0; 39 + border-bottom: 1px solid #222; 40 + margin-bottom: 1rem; 41 + } 42 + header h1 { font-size: 1rem; font-weight: normal; } 43 + input, button, textarea { 44 + font-family: monospace; 45 + font-size: 14px; 46 + padding: 0.5rem; 47 + border: 1px solid #333; 48 + background: #111; 49 + color: #ccc; 50 + } 51 + input:focus, textarea:focus { outline: 1px solid #444; } 52 + button { cursor: pointer; } 53 + button:hover { background: #222; } 54 + .poll { 55 + border-bottom: 1px solid #222; 56 + padding: 1rem 0; 57 + } 58 + .poll-question { color: #fff; margin-bottom: 0.5rem; } 59 + .poll-meta { color: #555; font-size: 12px; margin-bottom: 0.5rem; } 60 + .option { 61 + display: flex; 62 + justify-content: space-between; 63 + align-items: center; 64 + padding: 0.5rem 0; 65 + cursor: pointer; 66 + } 67 + .option:hover { color: #fff; } 68 + .option-text { flex: 1; } 69 + .option-count { color: #888; font-size: 12px; margin-left: 1rem; } 70 + .poll-detail .option { 71 + position: relative; 72 + padding: 0.75rem; 73 + margin: 0.5rem 0; 74 + background: #111; 75 + border: 1px solid #222; 76 + } 77 + .poll-detail .option-bar { 78 + position: absolute; 79 + left: 0; 80 + top: 0; 81 + height: 100%; 82 + background: #1a3a1a; 83 + z-index: 0; 84 + transition: width 0.3s; 85 + } 86 + .poll-detail .option-text, 87 + .poll-detail .option-count { 88 + position: relative; 89 + z-index: 1; 90 + } 91 + .poll-detail .poll-meta { 92 + margin-top: 1rem; 93 + } 94 + .create-form { margin-bottom: 2rem; } 95 + .create-form input, .create-form textarea { width: 100%; margin-bottom: 0.5rem; } 96 + .hidden { display: none; } 97 + #status { color: #666; padding: 0.5rem 0; } 98 + .vote-count { cursor: help; border-bottom: 1px dotted #555; } 99 + .vote-count:hover { color: #fff; } 100 + .voters-tooltip { 101 + background: #1a1a1a; 102 + border: 1px solid #333; 103 + padding: 0.5rem; 104 + font-size: 12px; 105 + z-index: 1000; 106 + max-width: 300px; 107 + box-shadow: 0 2px 8px rgba(0,0,0,0.5); 108 + } 109 + .voter { padding: 0.25rem 0; color: #aaa; word-break: break-all; } 110 + .voter-link { color: #6af; font-size: 11px; } 111 + .voter-link:hover { color: #8cf; text-decoration: underline; } 112 + .vote-time { color: #555; font-size: 10px; } 113 + .share-btn { 114 + background: none; 115 + border: 1px solid #333; 116 + color: #888; 117 + padding: 0.4rem 0.8rem; 118 + font-family: monospace; 119 + font-size: 12px; 120 + cursor: pointer; 121 + border-radius: 4px; 122 + transition: all 0.2s; 123 + } 124 + .share-btn:hover { border-color: #555; color: #ccc; } 125 + .share-btn.copied { border-color: #4a4; color: #6c6; } 126 + .poll-header { 127 + display: flex; 128 + justify-content: space-between; 129 + align-items: flex-start; 130 + gap: 1rem; 131 + margin-bottom: 1rem; 132 + } 133 + .poll-header h2 { margin: 0; flex: 1; } 134 + </style> 135 + </head> 136 + <body> 137 + <header> 138 + <h1><a href="/">pollz</a></h1> 139 + <nav id="nav"></nav> 140 + </header> 141 + <main id="app"></main> 142 + <p id="status"></p> 143 + <script type="module" src="/src/main.ts"></script> 144 + </body> 145 + </html>
+22
justfile
··· 1 + # pollz 2 + mod backend 3 + 4 + # show available commands 5 + default: 6 + @just --list 7 + 8 + # build frontend 9 + build: 10 + pnpm build 11 + 12 + # deploy frontend to cloudflare pages 13 + deploy-frontend: 14 + pnpm build 15 + npx wrangler pages deploy dist --project-name=pollz-waow-tech --commit-dirty=true 16 + 17 + # deploy backend to fly.io 18 + deploy-backend: 19 + just backend::deploy 20 + 21 + # deploy everything 22 + deploy: deploy-backend deploy-frontend
+34
lexicons/tech/waow/poll.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "tech.waow.poll", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "key": "tid", 8 + "description": "a poll", 9 + "record": { 10 + "type": "object", 11 + "required": ["text", "options", "createdAt"], 12 + "properties": { 13 + "text": { 14 + "type": "string", 15 + "maxLength": 300 16 + }, 17 + "options": { 18 + "type": "array", 19 + "minLength": 2, 20 + "maxLength": 10, 21 + "items": { 22 + "type": "string", 23 + "maxLength": 100 24 + } 25 + }, 26 + "createdAt": { 27 + "type": "string", 28 + "format": "datetime" 29 + } 30 + } 31 + } 32 + } 33 + } 34 + }
+29
lexicons/tech/waow/vote.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "tech.waow.vote", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "key": "any", 8 + "description": "a vote on a poll", 9 + "record": { 10 + "type": "object", 11 + "required": ["subject", "option", "createdAt"], 12 + "properties": { 13 + "subject": { 14 + "type": "string", 15 + "format": "at-uri" 16 + }, 17 + "option": { 18 + "type": "integer", 19 + "minimum": 0 20 + }, 21 + "createdAt": { 22 + "type": "string", 23 + "format": "datetime" 24 + } 25 + } 26 + } 27 + } 28 + } 29 + }
+19
package.json
··· 1 + { 2 + "name": "pollz", 3 + "type": "module", 4 + "scripts": { 5 + "dev": "vite", 6 + "build": "vite build", 7 + "preview": "vite preview" 8 + }, 9 + "devDependencies": { 10 + "typescript": "^5.9.3", 11 + "vite": "^7.2.7" 12 + }, 13 + "dependencies": { 14 + "@atcute/client": "^4.1.1", 15 + "@atcute/identity": "^1.1.3", 16 + "@atcute/identity-resolver": "^1.2.0", 17 + "@atcute/oauth-browser-client": "^2.0.3" 18 + } 19 + }
+729
pnpm-lock.yaml
··· 1 + lockfileVersion: '9.0' 2 + 3 + settings: 4 + autoInstallPeers: true 5 + excludeLinksFromLockfile: false 6 + 7 + importers: 8 + 9 + .: 10 + dependencies: 11 + '@atcute/client': 12 + specifier: ^4.1.1 13 + version: 4.1.1 14 + '@atcute/identity': 15 + specifier: ^1.1.3 16 + version: 1.1.3 17 + '@atcute/identity-resolver': 18 + specifier: ^1.2.0 19 + version: 1.2.0(@atcute/identity@1.1.3) 20 + '@atcute/oauth-browser-client': 21 + specifier: ^2.0.3 22 + version: 2.0.3(@atcute/identity@1.1.3) 23 + devDependencies: 24 + typescript: 25 + specifier: ^5.9.3 26 + version: 5.9.3 27 + vite: 28 + specifier: ^7.2.7 29 + version: 7.3.0 30 + 31 + packages: 32 + 33 + '@atcute/client@4.1.1': 34 + resolution: {integrity: sha512-FROCbTTCeL5u4tO/n72jDEKyKqjdlXMB56Ehve3W/gnnLGCYWvN42sS7tvL1Mgu6sbO3yZwsXKDrmM2No4XpjA==} 35 + 36 + '@atcute/identity-resolver@1.2.0': 37 + resolution: {integrity: sha512-5UbSJfdV3JIkF8ksXz7g4nKBWasf2wROvzM66cfvTIWydWFO6/oS1KZd+zo9Eokje5Scf5+jsY9ZfgVARLepXg==} 38 + peerDependencies: 39 + '@atcute/identity': ^1.0.0 40 + 41 + '@atcute/identity@1.1.3': 42 + resolution: {integrity: sha512-oIqPoI8TwWeQxvcLmFEZLdN2XdWcaLVtlm8pNk0E72As9HNzzD9pwKPrLr3rmTLRIoULPPFmq9iFNsTeCIU9ng==} 43 + 44 + '@atcute/lexicons@1.2.5': 45 + resolution: {integrity: sha512-9yO9WdgxW8jZ7SbzUycH710z+JmsQ9W9n5S6i6eghYju32kkluFmgBeS47r8e8p2+Dv4DemS7o/3SUGsX9FR5Q==} 46 + 47 + '@atcute/multibase@1.1.6': 48 + resolution: {integrity: sha512-HBxuCgYLKPPxETV0Rot4VP9e24vKl8JdzGCZOVsDaOXJgbRZoRIF67Lp0H/OgnJeH/Xpva8Z5ReoTNJE5dn3kg==} 49 + 50 + '@atcute/oauth-browser-client@2.0.3': 51 + resolution: {integrity: sha512-rzUjwhjE4LRRKdQnCFQag/zXRZMEAB1hhBoLfnoQuHwWbmDUCL7fzwC3jRhDPp3om8XaYNDj8a/iqRip0wRqoQ==} 52 + 53 + '@atcute/uint8array@1.0.6': 54 + resolution: {integrity: sha512-ucfRBQc7BFT8n9eCyGOzDHEMKF/nZwhS2pPao4Xtab1ML3HdFYcX2DM1tadCzas85QTGxHe5urnUAAcNKGRi9A==} 55 + 56 + '@atcute/util-fetch@1.0.4': 57 + resolution: {integrity: sha512-sIU9Qk0dE8PLEXSfhy+gIJV+HpiiknMytCI2SqLlqd0vgZUtEKI/EQfP+23LHWvP+CLCzVDOa6cpH045OlmNBg==} 58 + 59 + '@badrap/valita@0.4.6': 60 + resolution: {integrity: sha512-4kdqcjyxo/8RQ8ayjms47HCWZIF5981oE5nIenbfThKDxWXtEHKipAOWlflpPJzZx9y/JWYQkp18Awr7VuepFg==} 61 + engines: {node: '>= 18'} 62 + 63 + '@esbuild/aix-ppc64@0.27.2': 64 + resolution: {integrity: sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==} 65 + engines: {node: '>=18'} 66 + cpu: [ppc64] 67 + os: [aix] 68 + 69 + '@esbuild/android-arm64@0.27.2': 70 + resolution: {integrity: sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==} 71 + engines: {node: '>=18'} 72 + cpu: [arm64] 73 + os: [android] 74 + 75 + '@esbuild/android-arm@0.27.2': 76 + resolution: {integrity: sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==} 77 + engines: {node: '>=18'} 78 + cpu: [arm] 79 + os: [android] 80 + 81 + '@esbuild/android-x64@0.27.2': 82 + resolution: {integrity: sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==} 83 + engines: {node: '>=18'} 84 + cpu: [x64] 85 + os: [android] 86 + 87 + '@esbuild/darwin-arm64@0.27.2': 88 + resolution: {integrity: sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==} 89 + engines: {node: '>=18'} 90 + cpu: [arm64] 91 + os: [darwin] 92 + 93 + '@esbuild/darwin-x64@0.27.2': 94 + resolution: {integrity: sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==} 95 + engines: {node: '>=18'} 96 + cpu: [x64] 97 + os: [darwin] 98 + 99 + '@esbuild/freebsd-arm64@0.27.2': 100 + resolution: {integrity: sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==} 101 + engines: {node: '>=18'} 102 + cpu: [arm64] 103 + os: [freebsd] 104 + 105 + '@esbuild/freebsd-x64@0.27.2': 106 + resolution: {integrity: sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==} 107 + engines: {node: '>=18'} 108 + cpu: [x64] 109 + os: [freebsd] 110 + 111 + '@esbuild/linux-arm64@0.27.2': 112 + resolution: {integrity: sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==} 113 + engines: {node: '>=18'} 114 + cpu: [arm64] 115 + os: [linux] 116 + 117 + '@esbuild/linux-arm@0.27.2': 118 + resolution: {integrity: sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==} 119 + engines: {node: '>=18'} 120 + cpu: [arm] 121 + os: [linux] 122 + 123 + '@esbuild/linux-ia32@0.27.2': 124 + resolution: {integrity: sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==} 125 + engines: {node: '>=18'} 126 + cpu: [ia32] 127 + os: [linux] 128 + 129 + '@esbuild/linux-loong64@0.27.2': 130 + resolution: {integrity: sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==} 131 + engines: {node: '>=18'} 132 + cpu: [loong64] 133 + os: [linux] 134 + 135 + '@esbuild/linux-mips64el@0.27.2': 136 + resolution: {integrity: sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==} 137 + engines: {node: '>=18'} 138 + cpu: [mips64el] 139 + os: [linux] 140 + 141 + '@esbuild/linux-ppc64@0.27.2': 142 + resolution: {integrity: sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==} 143 + engines: {node: '>=18'} 144 + cpu: [ppc64] 145 + os: [linux] 146 + 147 + '@esbuild/linux-riscv64@0.27.2': 148 + resolution: {integrity: sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==} 149 + engines: {node: '>=18'} 150 + cpu: [riscv64] 151 + os: [linux] 152 + 153 + '@esbuild/linux-s390x@0.27.2': 154 + resolution: {integrity: sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==} 155 + engines: {node: '>=18'} 156 + cpu: [s390x] 157 + os: [linux] 158 + 159 + '@esbuild/linux-x64@0.27.2': 160 + resolution: {integrity: sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==} 161 + engines: {node: '>=18'} 162 + cpu: [x64] 163 + os: [linux] 164 + 165 + '@esbuild/netbsd-arm64@0.27.2': 166 + resolution: {integrity: sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==} 167 + engines: {node: '>=18'} 168 + cpu: [arm64] 169 + os: [netbsd] 170 + 171 + '@esbuild/netbsd-x64@0.27.2': 172 + resolution: {integrity: sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==} 173 + engines: {node: '>=18'} 174 + cpu: [x64] 175 + os: [netbsd] 176 + 177 + '@esbuild/openbsd-arm64@0.27.2': 178 + resolution: {integrity: sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==} 179 + engines: {node: '>=18'} 180 + cpu: [arm64] 181 + os: [openbsd] 182 + 183 + '@esbuild/openbsd-x64@0.27.2': 184 + resolution: {integrity: sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==} 185 + engines: {node: '>=18'} 186 + cpu: [x64] 187 + os: [openbsd] 188 + 189 + '@esbuild/openharmony-arm64@0.27.2': 190 + resolution: {integrity: sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==} 191 + engines: {node: '>=18'} 192 + cpu: [arm64] 193 + os: [openharmony] 194 + 195 + '@esbuild/sunos-x64@0.27.2': 196 + resolution: {integrity: sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==} 197 + engines: {node: '>=18'} 198 + cpu: [x64] 199 + os: [sunos] 200 + 201 + '@esbuild/win32-arm64@0.27.2': 202 + resolution: {integrity: sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==} 203 + engines: {node: '>=18'} 204 + cpu: [arm64] 205 + os: [win32] 206 + 207 + '@esbuild/win32-ia32@0.27.2': 208 + resolution: {integrity: sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==} 209 + engines: {node: '>=18'} 210 + cpu: [ia32] 211 + os: [win32] 212 + 213 + '@esbuild/win32-x64@0.27.2': 214 + resolution: {integrity: sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==} 215 + engines: {node: '>=18'} 216 + cpu: [x64] 217 + os: [win32] 218 + 219 + '@rollup/rollup-android-arm-eabi@4.53.5': 220 + resolution: {integrity: sha512-iDGS/h7D8t7tvZ1t6+WPK04KD0MwzLZrG0se1hzBjSi5fyxlsiggoJHwh18PCFNn7tG43OWb6pdZ6Y+rMlmyNQ==} 221 + cpu: [arm] 222 + os: [android] 223 + 224 + '@rollup/rollup-android-arm64@4.53.5': 225 + resolution: {integrity: sha512-wrSAViWvZHBMMlWk6EJhvg8/rjxzyEhEdgfMMjREHEq11EtJ6IP6yfcCH57YAEca2Oe3FNCE9DSTgU70EIGmVw==} 226 + cpu: [arm64] 227 + os: [android] 228 + 229 + '@rollup/rollup-darwin-arm64@4.53.5': 230 + resolution: {integrity: sha512-S87zZPBmRO6u1YXQLwpveZm4JfPpAa6oHBX7/ghSiGH3rz/KDgAu1rKdGutV+WUI6tKDMbaBJomhnT30Y2t4VQ==} 231 + cpu: [arm64] 232 + os: [darwin] 233 + 234 + '@rollup/rollup-darwin-x64@4.53.5': 235 + resolution: {integrity: sha512-YTbnsAaHo6VrAczISxgpTva8EkfQus0VPEVJCEaboHtZRIb6h6j0BNxRBOwnDciFTZLDPW5r+ZBmhL/+YpTZgA==} 236 + cpu: [x64] 237 + os: [darwin] 238 + 239 + '@rollup/rollup-freebsd-arm64@4.53.5': 240 + resolution: {integrity: sha512-1T8eY2J8rKJWzaznV7zedfdhD1BqVs1iqILhmHDq/bqCUZsrMt+j8VCTHhP0vdfbHK3e1IQ7VYx3jlKqwlf+vw==} 241 + cpu: [arm64] 242 + os: [freebsd] 243 + 244 + '@rollup/rollup-freebsd-x64@4.53.5': 245 + resolution: {integrity: sha512-sHTiuXyBJApxRn+VFMaw1U+Qsz4kcNlxQ742snICYPrY+DDL8/ZbaC4DVIB7vgZmp3jiDaKA0WpBdP0aqPJoBQ==} 246 + cpu: [x64] 247 + os: [freebsd] 248 + 249 + '@rollup/rollup-linux-arm-gnueabihf@4.53.5': 250 + resolution: {integrity: sha512-dV3T9MyAf0w8zPVLVBptVlzaXxka6xg1f16VAQmjg+4KMSTWDvhimI/Y6mp8oHwNrmnmVl9XxJ/w/mO4uIQONA==} 251 + cpu: [arm] 252 + os: [linux] 253 + 254 + '@rollup/rollup-linux-arm-musleabihf@4.53.5': 255 + resolution: {integrity: sha512-wIGYC1x/hyjP+KAu9+ewDI+fi5XSNiUi9Bvg6KGAh2TsNMA3tSEs+Sh6jJ/r4BV/bx/CyWu2ue9kDnIdRyafcQ==} 256 + cpu: [arm] 257 + os: [linux] 258 + 259 + '@rollup/rollup-linux-arm64-gnu@4.53.5': 260 + resolution: {integrity: sha512-Y+qVA0D9d0y2FRNiG9oM3Hut/DgODZbU9I8pLLPwAsU0tUKZ49cyV1tzmB/qRbSzGvY8lpgGkJuMyuhH7Ma+Vg==} 261 + cpu: [arm64] 262 + os: [linux] 263 + 264 + '@rollup/rollup-linux-arm64-musl@4.53.5': 265 + resolution: {integrity: sha512-juaC4bEgJsyFVfqhtGLz8mbopaWD+WeSOYr5E16y+1of6KQjc0BpwZLuxkClqY1i8sco+MdyoXPNiCkQou09+g==} 266 + cpu: [arm64] 267 + os: [linux] 268 + 269 + '@rollup/rollup-linux-loong64-gnu@4.53.5': 270 + resolution: {integrity: sha512-rIEC0hZ17A42iXtHX+EPJVL/CakHo+tT7W0pbzdAGuWOt2jxDFh7A/lRhsNHBcqL4T36+UiAgwO8pbmn3dE8wA==} 271 + cpu: [loong64] 272 + os: [linux] 273 + 274 + '@rollup/rollup-linux-ppc64-gnu@4.53.5': 275 + resolution: {integrity: sha512-T7l409NhUE552RcAOcmJHj3xyZ2h7vMWzcwQI0hvn5tqHh3oSoclf9WgTl+0QqffWFG8MEVZZP1/OBglKZx52Q==} 276 + cpu: [ppc64] 277 + os: [linux] 278 + 279 + '@rollup/rollup-linux-riscv64-gnu@4.53.5': 280 + resolution: {integrity: sha512-7OK5/GhxbnrMcxIFoYfhV/TkknarkYC1hqUw1wU2xUN3TVRLNT5FmBv4KkheSG2xZ6IEbRAhTooTV2+R5Tk0lQ==} 281 + cpu: [riscv64] 282 + os: [linux] 283 + 284 + '@rollup/rollup-linux-riscv64-musl@4.53.5': 285 + resolution: {integrity: sha512-GwuDBE/PsXaTa76lO5eLJTyr2k8QkPipAyOrs4V/KJufHCZBJ495VCGJol35grx9xryk4V+2zd3Ri+3v7NPh+w==} 286 + cpu: [riscv64] 287 + os: [linux] 288 + 289 + '@rollup/rollup-linux-s390x-gnu@4.53.5': 290 + resolution: {integrity: sha512-IAE1Ziyr1qNfnmiQLHBURAD+eh/zH1pIeJjeShleII7Vj8kyEm2PF77o+lf3WTHDpNJcu4IXJxNO0Zluro8bOw==} 291 + cpu: [s390x] 292 + os: [linux] 293 + 294 + '@rollup/rollup-linux-x64-gnu@4.53.5': 295 + resolution: {integrity: sha512-Pg6E+oP7GvZ4XwgRJBuSXZjcqpIW3yCBhK4BcsANvb47qMvAbCjR6E+1a/U2WXz1JJxp9/4Dno3/iSJLcm5auw==} 296 + cpu: [x64] 297 + os: [linux] 298 + 299 + '@rollup/rollup-linux-x64-musl@4.53.5': 300 + resolution: {integrity: sha512-txGtluxDKTxaMDzUduGP0wdfng24y1rygUMnmlUJ88fzCCULCLn7oE5kb2+tRB+MWq1QDZT6ObT5RrR8HFRKqg==} 301 + cpu: [x64] 302 + os: [linux] 303 + 304 + '@rollup/rollup-openharmony-arm64@4.53.5': 305 + resolution: {integrity: sha512-3DFiLPnTxiOQV993fMc+KO8zXHTcIjgaInrqlG8zDp1TlhYl6WgrOHuJkJQ6M8zHEcntSJsUp1XFZSY8C1DYbg==} 306 + cpu: [arm64] 307 + os: [openharmony] 308 + 309 + '@rollup/rollup-win32-arm64-msvc@4.53.5': 310 + resolution: {integrity: sha512-nggc/wPpNTgjGg75hu+Q/3i32R00Lq1B6N1DO7MCU340MRKL3WZJMjA9U4K4gzy3dkZPXm9E1Nc81FItBVGRlA==} 311 + cpu: [arm64] 312 + os: [win32] 313 + 314 + '@rollup/rollup-win32-ia32-msvc@4.53.5': 315 + resolution: {integrity: sha512-U/54pTbdQpPLBdEzCT6NBCFAfSZMvmjr0twhnD9f4EIvlm9wy3jjQ38yQj1AGznrNO65EWQMgm/QUjuIVrYF9w==} 316 + cpu: [ia32] 317 + os: [win32] 318 + 319 + '@rollup/rollup-win32-x64-gnu@4.53.5': 320 + resolution: {integrity: sha512-2NqKgZSuLH9SXBBV2dWNRCZmocgSOx8OJSdpRaEcRlIfX8YrKxUT6z0F1NpvDVhOsl190UFTRh2F2WDWWCYp3A==} 321 + cpu: [x64] 322 + os: [win32] 323 + 324 + '@rollup/rollup-win32-x64-msvc@4.53.5': 325 + resolution: {integrity: sha512-JRpZUhCfhZ4keB5v0fe02gQJy05GqboPOaxvjugW04RLSYYoB/9t2lx2u/tMs/Na/1NXfY8QYjgRljRpN+MjTQ==} 326 + cpu: [x64] 327 + os: [win32] 328 + 329 + '@standard-schema/spec@1.1.0': 330 + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} 331 + 332 + '@types/estree@1.0.8': 333 + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} 334 + 335 + esbuild@0.27.2: 336 + resolution: {integrity: sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==} 337 + engines: {node: '>=18'} 338 + hasBin: true 339 + 340 + esm-env@1.2.2: 341 + resolution: {integrity: sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==} 342 + 343 + fdir@6.5.0: 344 + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} 345 + engines: {node: '>=12.0.0'} 346 + peerDependencies: 347 + picomatch: ^3 || ^4 348 + peerDependenciesMeta: 349 + picomatch: 350 + optional: true 351 + 352 + fsevents@2.3.3: 353 + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} 354 + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} 355 + os: [darwin] 356 + 357 + nanoid@3.3.11: 358 + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} 359 + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} 360 + hasBin: true 361 + 362 + nanoid@5.1.6: 363 + resolution: {integrity: sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==} 364 + engines: {node: ^18 || >=20} 365 + hasBin: true 366 + 367 + picocolors@1.1.1: 368 + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} 369 + 370 + picomatch@4.0.3: 371 + resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} 372 + engines: {node: '>=12'} 373 + 374 + postcss@8.5.6: 375 + resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} 376 + engines: {node: ^10 || ^12 || >=14} 377 + 378 + rollup@4.53.5: 379 + resolution: {integrity: sha512-iTNAbFSlRpcHeeWu73ywU/8KuU/LZmNCSxp6fjQkJBD3ivUb8tpDrXhIxEzA05HlYMEwmtaUnb3RP+YNv162OQ==} 380 + engines: {node: '>=18.0.0', npm: '>=8.0.0'} 381 + hasBin: true 382 + 383 + source-map-js@1.2.1: 384 + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} 385 + engines: {node: '>=0.10.0'} 386 + 387 + tinyglobby@0.2.15: 388 + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} 389 + engines: {node: '>=12.0.0'} 390 + 391 + typescript@5.9.3: 392 + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} 393 + engines: {node: '>=14.17'} 394 + hasBin: true 395 + 396 + vite@7.3.0: 397 + resolution: {integrity: sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==} 398 + engines: {node: ^20.19.0 || >=22.12.0} 399 + hasBin: true 400 + peerDependencies: 401 + '@types/node': ^20.19.0 || >=22.12.0 402 + jiti: '>=1.21.0' 403 + less: ^4.0.0 404 + lightningcss: ^1.21.0 405 + sass: ^1.70.0 406 + sass-embedded: ^1.70.0 407 + stylus: '>=0.54.8' 408 + sugarss: ^5.0.0 409 + terser: ^5.16.0 410 + tsx: ^4.8.1 411 + yaml: ^2.4.2 412 + peerDependenciesMeta: 413 + '@types/node': 414 + optional: true 415 + jiti: 416 + optional: true 417 + less: 418 + optional: true 419 + lightningcss: 420 + optional: true 421 + sass: 422 + optional: true 423 + sass-embedded: 424 + optional: true 425 + stylus: 426 + optional: true 427 + sugarss: 428 + optional: true 429 + terser: 430 + optional: true 431 + tsx: 432 + optional: true 433 + yaml: 434 + optional: true 435 + 436 + snapshots: 437 + 438 + '@atcute/client@4.1.1': 439 + dependencies: 440 + '@atcute/identity': 1.1.3 441 + '@atcute/lexicons': 1.2.5 442 + 443 + '@atcute/identity-resolver@1.2.0(@atcute/identity@1.1.3)': 444 + dependencies: 445 + '@atcute/identity': 1.1.3 446 + '@atcute/lexicons': 1.2.5 447 + '@atcute/util-fetch': 1.0.4 448 + '@badrap/valita': 0.4.6 449 + 450 + '@atcute/identity@1.1.3': 451 + dependencies: 452 + '@atcute/lexicons': 1.2.5 453 + '@badrap/valita': 0.4.6 454 + 455 + '@atcute/lexicons@1.2.5': 456 + dependencies: 457 + '@standard-schema/spec': 1.1.0 458 + esm-env: 1.2.2 459 + 460 + '@atcute/multibase@1.1.6': 461 + dependencies: 462 + '@atcute/uint8array': 1.0.6 463 + 464 + '@atcute/oauth-browser-client@2.0.3(@atcute/identity@1.1.3)': 465 + dependencies: 466 + '@atcute/client': 4.1.1 467 + '@atcute/identity-resolver': 1.2.0(@atcute/identity@1.1.3) 468 + '@atcute/lexicons': 1.2.5 469 + '@atcute/multibase': 1.1.6 470 + '@atcute/uint8array': 1.0.6 471 + nanoid: 5.1.6 472 + transitivePeerDependencies: 473 + - '@atcute/identity' 474 + 475 + '@atcute/uint8array@1.0.6': {} 476 + 477 + '@atcute/util-fetch@1.0.4': 478 + dependencies: 479 + '@badrap/valita': 0.4.6 480 + 481 + '@badrap/valita@0.4.6': {} 482 + 483 + '@esbuild/aix-ppc64@0.27.2': 484 + optional: true 485 + 486 + '@esbuild/android-arm64@0.27.2': 487 + optional: true 488 + 489 + '@esbuild/android-arm@0.27.2': 490 + optional: true 491 + 492 + '@esbuild/android-x64@0.27.2': 493 + optional: true 494 + 495 + '@esbuild/darwin-arm64@0.27.2': 496 + optional: true 497 + 498 + '@esbuild/darwin-x64@0.27.2': 499 + optional: true 500 + 501 + '@esbuild/freebsd-arm64@0.27.2': 502 + optional: true 503 + 504 + '@esbuild/freebsd-x64@0.27.2': 505 + optional: true 506 + 507 + '@esbuild/linux-arm64@0.27.2': 508 + optional: true 509 + 510 + '@esbuild/linux-arm@0.27.2': 511 + optional: true 512 + 513 + '@esbuild/linux-ia32@0.27.2': 514 + optional: true 515 + 516 + '@esbuild/linux-loong64@0.27.2': 517 + optional: true 518 + 519 + '@esbuild/linux-mips64el@0.27.2': 520 + optional: true 521 + 522 + '@esbuild/linux-ppc64@0.27.2': 523 + optional: true 524 + 525 + '@esbuild/linux-riscv64@0.27.2': 526 + optional: true 527 + 528 + '@esbuild/linux-s390x@0.27.2': 529 + optional: true 530 + 531 + '@esbuild/linux-x64@0.27.2': 532 + optional: true 533 + 534 + '@esbuild/netbsd-arm64@0.27.2': 535 + optional: true 536 + 537 + '@esbuild/netbsd-x64@0.27.2': 538 + optional: true 539 + 540 + '@esbuild/openbsd-arm64@0.27.2': 541 + optional: true 542 + 543 + '@esbuild/openbsd-x64@0.27.2': 544 + optional: true 545 + 546 + '@esbuild/openharmony-arm64@0.27.2': 547 + optional: true 548 + 549 + '@esbuild/sunos-x64@0.27.2': 550 + optional: true 551 + 552 + '@esbuild/win32-arm64@0.27.2': 553 + optional: true 554 + 555 + '@esbuild/win32-ia32@0.27.2': 556 + optional: true 557 + 558 + '@esbuild/win32-x64@0.27.2': 559 + optional: true 560 + 561 + '@rollup/rollup-android-arm-eabi@4.53.5': 562 + optional: true 563 + 564 + '@rollup/rollup-android-arm64@4.53.5': 565 + optional: true 566 + 567 + '@rollup/rollup-darwin-arm64@4.53.5': 568 + optional: true 569 + 570 + '@rollup/rollup-darwin-x64@4.53.5': 571 + optional: true 572 + 573 + '@rollup/rollup-freebsd-arm64@4.53.5': 574 + optional: true 575 + 576 + '@rollup/rollup-freebsd-x64@4.53.5': 577 + optional: true 578 + 579 + '@rollup/rollup-linux-arm-gnueabihf@4.53.5': 580 + optional: true 581 + 582 + '@rollup/rollup-linux-arm-musleabihf@4.53.5': 583 + optional: true 584 + 585 + '@rollup/rollup-linux-arm64-gnu@4.53.5': 586 + optional: true 587 + 588 + '@rollup/rollup-linux-arm64-musl@4.53.5': 589 + optional: true 590 + 591 + '@rollup/rollup-linux-loong64-gnu@4.53.5': 592 + optional: true 593 + 594 + '@rollup/rollup-linux-ppc64-gnu@4.53.5': 595 + optional: true 596 + 597 + '@rollup/rollup-linux-riscv64-gnu@4.53.5': 598 + optional: true 599 + 600 + '@rollup/rollup-linux-riscv64-musl@4.53.5': 601 + optional: true 602 + 603 + '@rollup/rollup-linux-s390x-gnu@4.53.5': 604 + optional: true 605 + 606 + '@rollup/rollup-linux-x64-gnu@4.53.5': 607 + optional: true 608 + 609 + '@rollup/rollup-linux-x64-musl@4.53.5': 610 + optional: true 611 + 612 + '@rollup/rollup-openharmony-arm64@4.53.5': 613 + optional: true 614 + 615 + '@rollup/rollup-win32-arm64-msvc@4.53.5': 616 + optional: true 617 + 618 + '@rollup/rollup-win32-ia32-msvc@4.53.5': 619 + optional: true 620 + 621 + '@rollup/rollup-win32-x64-gnu@4.53.5': 622 + optional: true 623 + 624 + '@rollup/rollup-win32-x64-msvc@4.53.5': 625 + optional: true 626 + 627 + '@standard-schema/spec@1.1.0': {} 628 + 629 + '@types/estree@1.0.8': {} 630 + 631 + esbuild@0.27.2: 632 + optionalDependencies: 633 + '@esbuild/aix-ppc64': 0.27.2 634 + '@esbuild/android-arm': 0.27.2 635 + '@esbuild/android-arm64': 0.27.2 636 + '@esbuild/android-x64': 0.27.2 637 + '@esbuild/darwin-arm64': 0.27.2 638 + '@esbuild/darwin-x64': 0.27.2 639 + '@esbuild/freebsd-arm64': 0.27.2 640 + '@esbuild/freebsd-x64': 0.27.2 641 + '@esbuild/linux-arm': 0.27.2 642 + '@esbuild/linux-arm64': 0.27.2 643 + '@esbuild/linux-ia32': 0.27.2 644 + '@esbuild/linux-loong64': 0.27.2 645 + '@esbuild/linux-mips64el': 0.27.2 646 + '@esbuild/linux-ppc64': 0.27.2 647 + '@esbuild/linux-riscv64': 0.27.2 648 + '@esbuild/linux-s390x': 0.27.2 649 + '@esbuild/linux-x64': 0.27.2 650 + '@esbuild/netbsd-arm64': 0.27.2 651 + '@esbuild/netbsd-x64': 0.27.2 652 + '@esbuild/openbsd-arm64': 0.27.2 653 + '@esbuild/openbsd-x64': 0.27.2 654 + '@esbuild/openharmony-arm64': 0.27.2 655 + '@esbuild/sunos-x64': 0.27.2 656 + '@esbuild/win32-arm64': 0.27.2 657 + '@esbuild/win32-ia32': 0.27.2 658 + '@esbuild/win32-x64': 0.27.2 659 + 660 + esm-env@1.2.2: {} 661 + 662 + fdir@6.5.0(picomatch@4.0.3): 663 + optionalDependencies: 664 + picomatch: 4.0.3 665 + 666 + fsevents@2.3.3: 667 + optional: true 668 + 669 + nanoid@3.3.11: {} 670 + 671 + nanoid@5.1.6: {} 672 + 673 + picocolors@1.1.1: {} 674 + 675 + picomatch@4.0.3: {} 676 + 677 + postcss@8.5.6: 678 + dependencies: 679 + nanoid: 3.3.11 680 + picocolors: 1.1.1 681 + source-map-js: 1.2.1 682 + 683 + rollup@4.53.5: 684 + dependencies: 685 + '@types/estree': 1.0.8 686 + optionalDependencies: 687 + '@rollup/rollup-android-arm-eabi': 4.53.5 688 + '@rollup/rollup-android-arm64': 4.53.5 689 + '@rollup/rollup-darwin-arm64': 4.53.5 690 + '@rollup/rollup-darwin-x64': 4.53.5 691 + '@rollup/rollup-freebsd-arm64': 4.53.5 692 + '@rollup/rollup-freebsd-x64': 4.53.5 693 + '@rollup/rollup-linux-arm-gnueabihf': 4.53.5 694 + '@rollup/rollup-linux-arm-musleabihf': 4.53.5 695 + '@rollup/rollup-linux-arm64-gnu': 4.53.5 696 + '@rollup/rollup-linux-arm64-musl': 4.53.5 697 + '@rollup/rollup-linux-loong64-gnu': 4.53.5 698 + '@rollup/rollup-linux-ppc64-gnu': 4.53.5 699 + '@rollup/rollup-linux-riscv64-gnu': 4.53.5 700 + '@rollup/rollup-linux-riscv64-musl': 4.53.5 701 + '@rollup/rollup-linux-s390x-gnu': 4.53.5 702 + '@rollup/rollup-linux-x64-gnu': 4.53.5 703 + '@rollup/rollup-linux-x64-musl': 4.53.5 704 + '@rollup/rollup-openharmony-arm64': 4.53.5 705 + '@rollup/rollup-win32-arm64-msvc': 4.53.5 706 + '@rollup/rollup-win32-ia32-msvc': 4.53.5 707 + '@rollup/rollup-win32-x64-gnu': 4.53.5 708 + '@rollup/rollup-win32-x64-msvc': 4.53.5 709 + fsevents: 2.3.3 710 + 711 + source-map-js@1.2.1: {} 712 + 713 + tinyglobby@0.2.15: 714 + dependencies: 715 + fdir: 6.5.0(picomatch@4.0.3) 716 + picomatch: 4.0.3 717 + 718 + typescript@5.9.3: {} 719 + 720 + vite@7.3.0: 721 + dependencies: 722 + esbuild: 0.27.2 723 + fdir: 6.5.0(picomatch@4.0.3) 724 + picomatch: 4.0.3 725 + postcss: 8.5.6 726 + rollup: 4.53.5 727 + tinyglobby: 0.2.15 728 + optionalDependencies: 729 + fsevents: 2.3.3
+1
public/_redirects
··· 1 + /* /index.html 200
+12
public/oauth-client-metadata.json
··· 1 + { 2 + "client_id": "https://pollz.waow.tech/oauth-client-metadata.json", 3 + "client_name": "pollz", 4 + "client_uri": "https://pollz.waow.tech", 5 + "redirect_uris": ["https://pollz.waow.tech/"], 6 + "grant_types": ["authorization_code", "refresh_token"], 7 + "response_types": ["code"], 8 + "scope": "atproto repo:tech.waow.poll repo:tech.waow.vote", 9 + "token_endpoint_auth_method": "none", 10 + "application_type": "web", 11 + "dpop_bound_access_tokens": true 12 + }
+702
src/main.ts
··· 1 + import { Client, simpleFetchHandler } from "@atcute/client"; 2 + import { 3 + CompositeDidDocumentResolver, 4 + CompositeHandleResolver, 5 + DohJsonHandleResolver, 6 + PlcDidDocumentResolver, 7 + AtprotoWebDidDocumentResolver, 8 + WellKnownHandleResolver, 9 + } from "@atcute/identity-resolver"; 10 + import { 11 + configureOAuth, 12 + createAuthorizationUrl, 13 + defaultIdentityResolver, 14 + finalizeAuthorization, 15 + getSession, 16 + OAuthUserAgent, 17 + deleteStoredSession, 18 + } from "@atcute/oauth-browser-client"; 19 + 20 + const POLL = "tech.waow.poll"; 21 + const VOTE = "tech.waow.vote"; 22 + 23 + const didDocumentResolver = new CompositeDidDocumentResolver({ 24 + methods: { 25 + plc: new PlcDidDocumentResolver(), 26 + web: new AtprotoWebDidDocumentResolver(), 27 + }, 28 + }); 29 + 30 + const handleResolver = new CompositeHandleResolver({ 31 + strategy: "dns-first", 32 + methods: { 33 + dns: new DohJsonHandleResolver({ dohUrl: "https://dns.google/resolve?" }), 34 + http: new WellKnownHandleResolver(), 35 + }, 36 + }); 37 + 38 + const BASE_URL = import.meta.env.VITE_BASE_URL || "https://pollz.waow.tech"; 39 + 40 + configureOAuth({ 41 + metadata: { 42 + client_id: `${BASE_URL}/oauth-client-metadata.json`, 43 + redirect_uri: `${BASE_URL}/`, 44 + }, 45 + identityResolver: defaultIdentityResolver({ 46 + handleResolver, 47 + didDocumentResolver, 48 + }), 49 + }); 50 + 51 + const app = document.getElementById("app")!; 52 + const nav = document.getElementById("nav")!; 53 + const status = document.getElementById("status")!; 54 + 55 + let agent: OAuthUserAgent | null = null; 56 + let currentDid: string | null = null; 57 + let jetstream: WebSocket | null = null; 58 + 59 + const setStatus = (msg: string) => (status.textContent = msg); 60 + 61 + type Poll = { 62 + uri: string; 63 + repo: string; 64 + rkey: string; 65 + text: string; 66 + options: string[]; 67 + createdAt: string; 68 + votes: Map<string, number>; 69 + voteCount?: number; // from backend, used when votes map is empty 70 + }; 71 + 72 + const polls = new Map<string, Poll>(); 73 + 74 + // jetstream - replay last 24h on connect, then live updates 75 + const connectJetstream = () => { 76 + if (jetstream?.readyState === WebSocket.OPEN) return; 77 + 78 + // cursor is microseconds since epoch - go back 24 hours 79 + const cursor = (Date.now() - 24 * 60 * 60 * 1000) * 1000; 80 + const url = `wss://jetstream1.us-east.bsky.network/subscribe?wantedCollections=${POLL}&wantedCollections=${VOTE}&cursor=${cursor}`; 81 + jetstream = new WebSocket(url); 82 + 83 + jetstream.onmessage = (event) => { 84 + const msg = JSON.parse(event.data); 85 + if (msg.kind !== "commit") return; 86 + 87 + const { commit } = msg; 88 + const uri = `at://${msg.did}/${commit.collection}/${commit.rkey}`; 89 + 90 + if (commit.collection === POLL) { 91 + if (commit.operation === "create" && commit.record) { 92 + polls.set(uri, { 93 + uri, 94 + repo: msg.did, 95 + rkey: commit.rkey, 96 + text: commit.record.text, 97 + options: commit.record.options, 98 + createdAt: commit.record.createdAt, 99 + votes: new Map(), 100 + }); 101 + render(); 102 + } else if (commit.operation === "delete") { 103 + polls.delete(uri); 104 + render(); 105 + } 106 + } 107 + 108 + if (commit.collection === VOTE) { 109 + if (commit.operation === "create" && commit.record) { 110 + const poll = polls.get(commit.record.subject); 111 + if (poll && !poll.votes.has(uri)) { 112 + poll.votes.set(uri, commit.record.option); 113 + render(); 114 + } 115 + } else if (commit.operation === "delete") { 116 + // find and remove vote from its poll 117 + for (const poll of polls.values()) { 118 + if (poll.votes.has(uri)) { 119 + poll.votes.delete(uri); 120 + render(); 121 + break; 122 + } 123 + } 124 + } 125 + } 126 + }; 127 + 128 + jetstream.onclose = () => setTimeout(connectJetstream, 3000); 129 + }; 130 + 131 + // render 132 + const render = () => { 133 + renderNav(); 134 + 135 + const path = location.pathname; 136 + const match = path.match(/^\/poll\/([^/]+)\/([^/]+)$/); 137 + 138 + if (match) { 139 + renderPoll(match[1], match[2]); 140 + } else if (path === "/new") { 141 + renderCreate(); 142 + } else { 143 + renderHome(); 144 + } 145 + }; 146 + 147 + const renderNav = () => { 148 + if (agent) { 149 + nav.innerHTML = `<a href="/">my polls</a> · <a href="/new">new</a> · <a href="#" id="logout">logout</a>`; 150 + document.getElementById("logout")!.onclick = async (e) => { 151 + e.preventDefault(); 152 + if (currentDid) { 153 + await deleteStoredSession(currentDid as `did:${string}:${string}`); 154 + localStorage.removeItem("lastDid"); 155 + } 156 + agent = null; 157 + currentDid = null; 158 + render(); 159 + }; 160 + } else { 161 + nav.innerHTML = `<input id="handle" placeholder="handle" style="width:120px"/> <button id="login">login</button>`; 162 + document.getElementById("login")!.onclick = async () => { 163 + const handle = (document.getElementById("handle") as HTMLInputElement).value.trim(); 164 + if (!handle) return; 165 + setStatus("redirecting..."); 166 + try { 167 + const url = await createAuthorizationUrl({ 168 + scope: `atproto repo:${POLL} repo:${VOTE}`, 169 + target: { type: "account", identifier: handle }, 170 + }); 171 + location.assign(url); 172 + } catch (e) { 173 + setStatus(`error: ${e}`); 174 + } 175 + }; 176 + } 177 + }; 178 + 179 + const BACKEND_URL = import.meta.env.VITE_BACKEND_URL || "https://pollz-backend.fly.dev"; 180 + 181 + // fetch user's existing votes from their PDS 182 + const loadUserVotes = async () => { 183 + if (!agent || !currentDid) return; 184 + 185 + try { 186 + const rpc = new Client({ handler: agent }); 187 + const res = await rpc.get("com.atproto.repo.listRecords", { 188 + params: { repo: currentDid, collection: VOTE, limit: 100 }, 189 + }); 190 + 191 + if (res.ok) { 192 + for (const record of res.data.records) { 193 + const val = record.value as { subject?: string; option?: number }; 194 + if (val.subject && typeof val.option === "number") { 195 + const poll = polls.get(val.subject); 196 + if (poll) { 197 + poll.votes.set(record.uri, val.option); 198 + } 199 + } 200 + } 201 + } 202 + } catch (e) { 203 + console.error("failed to load user votes:", e); 204 + } 205 + }; 206 + 207 + const renderHome = async () => { 208 + app.innerHTML = "<p>loading polls...</p>"; 209 + 210 + try { 211 + // fetch all polls from backend 212 + const res = await fetch(`${BACKEND_URL}/api/polls`); 213 + if (!res.ok) throw new Error("failed to fetch polls"); 214 + 215 + const backendPolls = await res.json() as Array<{ 216 + uri: string; 217 + repo: string; 218 + rkey: string; 219 + text: string; 220 + options: string[]; 221 + createdAt: string; 222 + voteCount: number; 223 + }>; 224 + 225 + // merge into local state 226 + for (const p of backendPolls) { 227 + const existing = polls.get(p.uri); 228 + if (existing) { 229 + // update vote count from backend 230 + existing.voteCount = p.voteCount; 231 + } else { 232 + polls.set(p.uri, { 233 + uri: p.uri, 234 + repo: p.repo, 235 + rkey: p.rkey, 236 + text: p.text, 237 + options: p.options, 238 + createdAt: p.createdAt, 239 + votes: new Map(), 240 + voteCount: p.voteCount, 241 + }); 242 + } 243 + } 244 + 245 + // load user's votes now that polls are in memory 246 + await loadUserVotes(); 247 + 248 + const allPolls = Array.from(polls.values()) 249 + .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); 250 + 251 + const newLink = agent ? `<p><a href="/new">+ new poll</a></p>` : `<p>login to create polls</p>`; 252 + 253 + if (allPolls.length === 0) { 254 + app.innerHTML = newLink + "<p>no polls yet</p>"; 255 + } else { 256 + app.innerHTML = newLink + allPolls.map(renderPollCard).join(""); 257 + attachVoteHandlers(); 258 + } 259 + } catch (e) { 260 + console.error("renderHome error:", e); 261 + app.innerHTML = "<p>failed to load polls</p>"; 262 + } 263 + }; 264 + 265 + const renderPollCard = (p: Poll) => { 266 + // always use backend voteCount for total 267 + const total = p.voteCount ?? 0; 268 + 269 + const opts = p.options 270 + .map((opt, i) => { 271 + return ` 272 + <div class="option" data-vote="${i}" data-poll="${p.uri}"> 273 + <span class="option-text">${esc(opt)}</span> 274 + </div> 275 + `; 276 + }) 277 + .join(""); 278 + 279 + return ` 280 + <div class="poll"> 281 + <a href="/poll/${p.repo}/${p.rkey}" class="poll-question">${esc(p.text)}</a> 282 + <div class="poll-meta">${ago(p.createdAt)} · <span class="vote-count" data-poll-uri="${p.uri}">${total} vote${total === 1 ? "" : "s"}</span></div> 283 + ${opts} 284 + </div> 285 + `; 286 + }; 287 + 288 + const esc = (s: string) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;"); 289 + 290 + const ago = (date: string) => { 291 + const seconds = Math.floor((Date.now() - new Date(date).getTime()) / 1000); 292 + if (seconds < 60) return "just now"; 293 + const minutes = Math.floor(seconds / 60); 294 + if (minutes < 60) return `${minutes}m ago`; 295 + const hours = Math.floor(minutes / 60); 296 + if (hours < 24) return `${hours}h ago`; 297 + const days = Math.floor(hours / 24); 298 + if (days < 30) return `${days}d ago`; 299 + return new Date(date).toLocaleDateString(); 300 + }; 301 + 302 + const attachVoteHandlers = () => { 303 + app.querySelectorAll("[data-vote]").forEach((el) => { 304 + el.addEventListener("click", async (e) => { 305 + e.preventDefault(); 306 + const t = e.currentTarget as HTMLElement; 307 + await vote(t.dataset.poll!, parseInt(t.dataset.vote!, 10)); 308 + }); 309 + }); 310 + 311 + // attach hover handlers for vote counts 312 + app.querySelectorAll(".vote-count").forEach((el) => { 313 + el.addEventListener("mouseenter", showVotersTooltip); 314 + el.addEventListener("mouseleave", hideVotersTooltip); 315 + }); 316 + }; 317 + 318 + type Vote = { voter: string; option: number; uri: string; createdAt?: string; handle?: string }; 319 + const votersCache = new Map<string, Vote[]>(); 320 + const handleCache = new Map<string, string>(); 321 + let activeTooltip: HTMLElement | null = null; 322 + let tooltipTimeout: ReturnType<typeof setTimeout> | null = null; 323 + 324 + // resolve DID to handle 325 + const resolveHandle = async (did: string): Promise<string> => { 326 + if (handleCache.has(did)) return handleCache.get(did)!; 327 + try { 328 + const res = await fetch(`https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${did}`); 329 + if (res.ok) { 330 + const data = await res.json(); 331 + if (data.handle) { 332 + handleCache.set(did, data.handle); 333 + return data.handle; 334 + } 335 + } 336 + } catch {} 337 + return did; // fallback to DID 338 + }; 339 + 340 + const showVotersTooltip = async (e: Event) => { 341 + const el = e.target as HTMLElement; 342 + const pollUri = el.dataset.pollUri; 343 + if (!pollUri) return; 344 + 345 + // clear any pending hide 346 + if (tooltipTimeout) { 347 + clearTimeout(tooltipTimeout); 348 + tooltipTimeout = null; 349 + } 350 + 351 + // fetch voters if not cached 352 + if (!votersCache.has(pollUri)) { 353 + try { 354 + const res = await fetch(`${BACKEND_URL}/api/polls/${encodeURIComponent(pollUri)}/votes`); 355 + if (res.ok) { 356 + votersCache.set(pollUri, await res.json()); 357 + } 358 + } catch (err) { 359 + console.error("failed to fetch voters:", err); 360 + return; 361 + } 362 + } 363 + 364 + const voters = votersCache.get(pollUri); 365 + if (!voters || voters.length === 0) return; 366 + 367 + // resolve handles for all voters 368 + await Promise.all(voters.map(async (v) => { 369 + if (!v.handle) { 370 + v.handle = await resolveHandle(v.voter); 371 + } 372 + })); 373 + 374 + // get poll options for display 375 + const poll = polls.get(pollUri); 376 + const options = poll?.options || []; 377 + 378 + // remove existing tooltip if any 379 + if (activeTooltip) activeTooltip.remove(); 380 + 381 + // create tooltip 382 + const tooltip = document.createElement("div"); 383 + tooltip.className = "voters-tooltip"; 384 + tooltip.innerHTML = voters 385 + .map((v) => { 386 + const optText = options[v.option] || `option ${v.option}`; 387 + const profileUrl = `https://bsky.app/profile/${v.voter}`; 388 + const displayName = v.handle || v.voter; 389 + const timeStr = v.createdAt ? ago(v.createdAt) : ""; 390 + return `<div class="voter"><a href="${profileUrl}" target="_blank" class="voter-link">@${esc(displayName)}</a> → ${esc(optText)}${timeStr ? ` <span class="vote-time">${timeStr}</span>` : ""}</div>`; 391 + }) 392 + .join(""); 393 + 394 + // keep tooltip visible when hovering over it 395 + tooltip.addEventListener("mouseenter", () => { 396 + if (tooltipTimeout) { 397 + clearTimeout(tooltipTimeout); 398 + tooltipTimeout = null; 399 + } 400 + }); 401 + tooltip.addEventListener("mouseleave", hideVotersTooltip); 402 + 403 + // position tooltip 404 + const rect = el.getBoundingClientRect(); 405 + tooltip.style.position = "fixed"; 406 + tooltip.style.left = `${rect.left}px`; 407 + tooltip.style.top = `${rect.bottom + 4}px`; 408 + 409 + document.body.appendChild(tooltip); 410 + activeTooltip = tooltip; 411 + }; 412 + 413 + const hideVotersTooltip = () => { 414 + // delay hiding so user can move to tooltip 415 + tooltipTimeout = setTimeout(() => { 416 + if (activeTooltip) { 417 + activeTooltip.remove(); 418 + activeTooltip = null; 419 + } 420 + }, 150); 421 + }; 422 + 423 + const attachShareHandler = () => { 424 + const btn = app.querySelector(".share-btn") as HTMLButtonElement; 425 + if (!btn) return; 426 + 427 + btn.addEventListener("click", async () => { 428 + const url = btn.dataset.url!; 429 + try { 430 + await navigator.clipboard.writeText(url); 431 + btn.textContent = "copied!"; 432 + btn.classList.add("copied"); 433 + setTimeout(() => { 434 + btn.textContent = "copy link"; 435 + btn.classList.remove("copied"); 436 + }, 2000); 437 + } catch { 438 + // fallback for older browsers 439 + const input = document.createElement("input"); 440 + input.value = url; 441 + document.body.appendChild(input); 442 + input.select(); 443 + document.execCommand("copy"); 444 + document.body.removeChild(input); 445 + btn.textContent = "copied!"; 446 + btn.classList.add("copied"); 447 + setTimeout(() => { 448 + btn.textContent = "copy link"; 449 + btn.classList.remove("copied"); 450 + }, 2000); 451 + } 452 + }); 453 + }; 454 + 455 + const renderPoll = async (repo: string, rkey: string) => { 456 + const uri = `at://${repo}/${POLL}/${rkey}`; 457 + app.innerHTML = "<p>loading...</p>"; 458 + 459 + try { 460 + // fetch poll with vote counts from backend 461 + const res = await fetch(`${BACKEND_URL}/api/polls/${encodeURIComponent(uri)}`); 462 + 463 + if (res.ok) { 464 + const data = await res.json() as { 465 + uri: string; 466 + repo: string; 467 + rkey: string; 468 + text: string; 469 + options: Array<{ text: string; count: number }>; 470 + createdAt: string; 471 + }; 472 + 473 + // render poll with vote counts from backend 474 + const total = data.options.reduce((sum, o) => sum + o.count, 0); 475 + const opts = data.options 476 + .map((opt, i) => { 477 + const pct = total > 0 ? Math.round((opt.count / total) * 100) : 0; 478 + return ` 479 + <div class="option" data-vote="${i}" data-poll="${uri}"> 480 + <div class="option-bar" style="width: ${pct}%"></div> 481 + <span class="option-text">${esc(opt.text)}</span> 482 + <span class="option-count">${opt.count} (${pct}%)</span> 483 + </div>`; 484 + }) 485 + .join(""); 486 + 487 + const shareUrl = `${window.location.origin}/poll/${repo}/${rkey}`; 488 + app.innerHTML = ` 489 + <p><a href="/">&larr; back</a></p> 490 + <div class="poll-detail"> 491 + <div class="poll-header"> 492 + <h2 class="poll-question">${esc(data.text)}</h2> 493 + <button class="share-btn" data-url="${shareUrl}">copy link</button> 494 + </div> 495 + ${opts} 496 + <div class="poll-meta">${ago(data.createdAt)} · <span class="vote-count" data-poll-uri="${uri}">${total} vote${total === 1 ? "" : "s"}</span></div> 497 + </div>`; 498 + attachVoteHandlers(); 499 + attachShareHandler(); 500 + return; 501 + } 502 + 503 + // fallback to direct PDS fetch if backend doesn't have it 504 + const didDoc = await didDocumentResolver.resolve(repo as `did:${string}:${string}`); 505 + const pds = didDoc?.service?.find((s: { id: string }) => s.id === "#atproto_pds") as { serviceEndpoint?: string } | undefined; 506 + const pdsUrl = pds?.serviceEndpoint || "https://bsky.social"; 507 + 508 + const pdsClient = new Client({ 509 + handler: simpleFetchHandler({ service: pdsUrl }), 510 + }); 511 + 512 + const pdsRes = await pdsClient.get("com.atproto.repo.getRecord", { 513 + params: { repo, collection: POLL, rkey }, 514 + }); 515 + if (!pdsRes.ok) { 516 + app.innerHTML = "<p>not found</p>"; 517 + return; 518 + } 519 + const rec = pdsRes.data.value as { text: string; options: string[]; createdAt: string }; 520 + const poll = { uri: pdsRes.data.uri, repo, rkey, text: rec.text, options: rec.options, createdAt: rec.createdAt, votes: new Map() }; 521 + polls.set(uri, poll); 522 + 523 + app.innerHTML = `<p><a href="/">&larr; back</a></p>${renderPollCard(poll)}`; 524 + attachVoteHandlers(); 525 + } catch (e) { 526 + console.error("renderPoll error:", e); 527 + app.innerHTML = "<p>error loading poll</p>"; 528 + } 529 + }; 530 + 531 + const renderCreate = () => { 532 + if (!agent) { 533 + app.innerHTML = "<p>login to create</p>"; 534 + return; 535 + } 536 + app.innerHTML = ` 537 + <div class="create-form"> 538 + <input type="text" id="question" placeholder="question" /> 539 + <textarea id="options" rows="4" placeholder="options (one per line)"></textarea> 540 + <button id="create">create</button> 541 + </div> 542 + `; 543 + document.getElementById("create")!.onclick = create; 544 + }; 545 + 546 + const create = async () => { 547 + if (!agent || !currentDid) return; 548 + 549 + const text = (document.getElementById("question") as HTMLInputElement).value.trim(); 550 + const options = (document.getElementById("options") as HTMLTextAreaElement).value 551 + .split("\n") 552 + .map((s) => s.trim()) 553 + .filter(Boolean); 554 + 555 + if (!text || options.length < 2) { 556 + setStatus("need question + 2 options"); 557 + return; 558 + } 559 + 560 + setStatus("creating..."); 561 + const rpc = new Client({ handler: agent }); 562 + const res = await rpc.post("com.atproto.repo.createRecord", { 563 + input: { 564 + repo: currentDid, 565 + collection: POLL, 566 + record: { $type: POLL, text, options, createdAt: new Date().toISOString() }, 567 + }, 568 + }); 569 + 570 + if (!res.ok) { 571 + setStatus(`error: ${res.data.error}`); 572 + return; 573 + } 574 + 575 + const rkey = res.data.uri.split("/").pop()!; 576 + polls.set(res.data.uri, { 577 + uri: res.data.uri, 578 + repo: currentDid, 579 + rkey, 580 + text, 581 + options, 582 + createdAt: new Date().toISOString(), 583 + votes: new Map(), 584 + }); 585 + 586 + setStatus(""); 587 + history.pushState(null, "", "/"); 588 + render(); 589 + }; 590 + 591 + const vote = async (pollUri: string, option: number) => { 592 + if (!agent || !currentDid) { 593 + setStatus("login to vote"); 594 + return; 595 + } 596 + 597 + setStatus("voting..."); 598 + const rpc = new Client({ handler: agent }); 599 + 600 + // first, find and delete any existing votes on this poll 601 + try { 602 + const existing = await rpc.get("com.atproto.repo.listRecords", { 603 + params: { repo: currentDid, collection: VOTE, limit: 100 }, 604 + }); 605 + if (existing.ok) { 606 + for (const record of existing.data.records) { 607 + const val = record.value as { subject?: string }; 608 + if (val.subject === pollUri) { 609 + const rkey = record.uri.split("/").pop()!; 610 + await rpc.post("com.atproto.repo.deleteRecord", { 611 + input: { repo: currentDid, collection: VOTE, rkey }, 612 + }); 613 + } 614 + } 615 + } 616 + } catch (e) { 617 + console.error("error checking existing votes:", e); 618 + } 619 + 620 + const res = await rpc.post("com.atproto.repo.createRecord", { 621 + input: { 622 + repo: currentDid, 623 + collection: VOTE, 624 + record: { $type: VOTE, subject: pollUri, option, createdAt: new Date().toISOString() }, 625 + }, 626 + }); 627 + 628 + if (!res.ok) { 629 + console.error("vote error:", res.status, res.data); 630 + setStatus(`error: ${res.data.error || res.data.message || "unknown"}`); 631 + setTimeout(() => setStatus(""), 3000); 632 + return; 633 + } 634 + 635 + // update local state 636 + const poll = polls.get(pollUri); 637 + if (poll) { 638 + // remove any existing vote from this user 639 + for (const [uri, _] of poll.votes) { 640 + if (uri.startsWith(`at://${currentDid}/${VOTE}/`)) { 641 + poll.votes.delete(uri); 642 + } 643 + } 644 + poll.votes.set(res.data.uri, option); 645 + } 646 + 647 + setStatus(""); 648 + render(); 649 + }; 650 + 651 + // oauth 652 + const handleCallback = async () => { 653 + const params = new URLSearchParams(location.hash.slice(1)); 654 + if (!params.has("state")) return false; 655 + 656 + history.replaceState(null, "", "/"); 657 + setStatus("logging in..."); 658 + 659 + try { 660 + const { session } = await finalizeAuthorization(params); 661 + agent = new OAuthUserAgent(session); 662 + currentDid = session.info.sub; 663 + localStorage.setItem("lastDid", currentDid); 664 + setStatus(""); 665 + return true; 666 + } catch (e) { 667 + setStatus(`login failed: ${e}`); 668 + return false; 669 + } 670 + }; 671 + 672 + const restoreSession = async () => { 673 + const lastDid = localStorage.getItem("lastDid"); 674 + if (!lastDid) return; 675 + 676 + try { 677 + const session = await getSession(lastDid as `did:${string}:${string}`); 678 + agent = new OAuthUserAgent(session); 679 + currentDid = session.info.sub; 680 + } catch { 681 + localStorage.removeItem("lastDid"); 682 + } 683 + }; 684 + 685 + // routing 686 + window.addEventListener("popstate", render); 687 + document.addEventListener("click", (e) => { 688 + const a = (e.target as HTMLElement).closest("a"); 689 + if (a?.href.startsWith(location.origin) && !a.href.includes("#")) { 690 + e.preventDefault(); 691 + history.pushState(null, "", a.href); 692 + render(); 693 + } 694 + }); 695 + 696 + // init 697 + (async () => { 698 + await handleCallback(); 699 + await restoreSession(); 700 + connectJetstream(); 701 + render(); 702 + })();
+12
tsconfig.json
··· 1 + { 2 + "compilerOptions": { 3 + "target": "ESNext", 4 + "module": "ESNext", 5 + "moduleResolution": "bundler", 6 + "strict": true, 7 + "skipLibCheck": true, 8 + "esModuleInterop": true, 9 + "resolveJsonModule": true 10 + }, 11 + "include": ["src"] 12 + }
+36
vite.config.ts
··· 1 + import { defineConfig } from "vite"; 2 + import metadata from "./public/oauth-client-metadata.json"; 3 + 4 + const SERVER_PORT = 5173; 5 + 6 + export default defineConfig({ 7 + plugins: [ 8 + { 9 + name: "oauth", 10 + config(_conf, { command }) { 11 + if (command === "build") { 12 + process.env.VITE_OAUTH_CLIENT_ID = metadata.client_id; 13 + process.env.VITE_OAUTH_REDIRECT_URL = metadata.redirect_uris[0]; 14 + } else { 15 + // local dev: use http://localhost client ID trick 16 + const redirectUri = `http://127.0.0.1:${SERVER_PORT}/`; 17 + const clientId = 18 + `http://localhost` + 19 + `?redirect_uri=${encodeURIComponent(redirectUri)}` + 20 + `&scope=${encodeURIComponent(metadata.scope)}`; 21 + 22 + process.env.VITE_OAUTH_CLIENT_ID = clientId; 23 + process.env.VITE_OAUTH_REDIRECT_URL = redirectUri; 24 + } 25 + }, 26 + }, 27 + ], 28 + server: { 29 + host: "127.0.0.1", 30 + port: SERVER_PORT, 31 + strictPort: true, 32 + }, 33 + build: { 34 + target: "esnext", 35 + }, 36 + });