polls on atproto
pollz.waow.tech
atproto
zig
1import {
2 POLL,
3 agent,
4 currentDid,
5 setAgent,
6 setCurrentDid,
7 polls,
8 login,
9 logout,
10 handleCallback,
11 restoreSession,
12 fetchPolls,
13 fetchPoll,
14 fetchVoters,
15 createPoll,
16 vote,
17 resolveHandle,
18 fetchPollFromPDS,
19 type Poll,
20} from "./lib/api";
21import { esc, ago } from "./lib/utils";
22
23const app = document.getElementById("app")!;
24const nav = document.getElementById("nav")!;
25const status = document.getElementById("status")!;
26
27const setStatus = (msg: string) => (status.textContent = msg);
28
29const showToast = (msg: string) => {
30 const existing = document.querySelector(".toast");
31 if (existing) existing.remove();
32
33 const toast = document.createElement("div");
34 toast.className = "toast";
35 toast.textContent = msg;
36 document.body.appendChild(toast);
37
38 setTimeout(() => toast.remove(), 3000);
39};
40
41// track if a vote is in progress to prevent double-clicks
42let votingInProgress = false;
43
44// render
45const render = () => {
46 renderNav();
47
48 const path = location.pathname;
49 const match = path.match(/^\/poll\/([^/]+)\/([^/]+)$/);
50
51 if (match) {
52 renderPollPage(match[1], match[2]);
53 } else if (path === "/new") {
54 renderCreate();
55 } else if (path === "/mine") {
56 renderHome(true);
57 } else {
58 renderHome(false);
59 }
60};
61
62const renderNav = () => {
63 if (agent) {
64 nav.innerHTML = `<a href="/">all</a> · <a href="/mine">mine</a> · <a href="/new">new</a> · <a href="#" id="logout">logout</a>`;
65 document.getElementById("logout")!.onclick = async (e) => {
66 e.preventDefault();
67 await logout();
68 setAgent(null);
69 setCurrentDid(null);
70 render();
71 };
72 } else {
73 nav.innerHTML = `<input id="handle" placeholder="handle" style="width:120px"/> <button id="login">login</button>`;
74 document.getElementById("login")!.onclick = async () => {
75 const handle = (document.getElementById("handle") as HTMLInputElement).value.trim();
76 if (!handle) return;
77 setStatus("redirecting...");
78 try {
79 await login(handle);
80 } catch (e) {
81 setStatus(`error: ${e}`);
82 }
83 };
84 }
85};
86
87const renderHome = async (mineOnly: boolean = false) => {
88 app.innerHTML = "<p>loading polls...</p>";
89
90 try {
91 await fetchPolls();
92
93 let filteredPolls = Array.from(polls.values());
94 if (mineOnly && currentDid) {
95 filteredPolls = filteredPolls.filter((p) => p.repo === currentDid);
96 }
97 filteredPolls.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
98
99 const newLink = agent ? `<p><a href="/new">+ new poll</a></p>` : `<p>login to create polls</p>`;
100 const heading = mineOnly ? `<p><strong>my polls</strong></p>` : "";
101
102 if (filteredPolls.length === 0) {
103 const msg = mineOnly ? "you haven't created any polls yet" : "no polls yet";
104 app.innerHTML = newLink + heading + `<p>${msg}</p>`;
105 } else {
106 app.innerHTML = newLink + heading + filteredPolls.map(renderPollCard).join("");
107 attachVoteHandlers();
108 }
109 } catch (e) {
110 console.error("renderHome error:", e);
111 app.innerHTML = "<p>failed to load polls</p>";
112 }
113};
114
115const renderPollCard = (p: Poll) => {
116 const total = p.voteCount ?? 0;
117 const disabled = votingInProgress ? " disabled" : "";
118
119 const opts = p.options
120 .map((opt, i) => `
121 <div class="option${disabled}" data-vote="${i}" data-poll="${p.uri}">
122 <span class="option-text">${esc(opt)}</span>
123 </div>
124 `)
125 .join("");
126
127 return `
128 <div class="poll">
129 <a href="/poll/${p.repo}/${p.rkey}" class="poll-question">${esc(p.text)}</a>
130 <div class="poll-meta">${ago(p.createdAt)} · <span class="vote-count" data-poll-uri="${p.uri}">${total} vote${total === 1 ? "" : "s"}</span></div>
131 ${opts}
132 </div>
133 `;
134};
135
136// voters tooltip
137type VoteInfo = { voter: string; option: number; uri: string; createdAt?: string; handle?: string };
138const votersCache = new Map<string, VoteInfo[]>();
139const pollOptionsCache = new Map<string, string[]>(); // for tooltip option names
140let activeTooltip: HTMLElement | null = null;
141let tooltipTimeout: ReturnType<typeof setTimeout> | null = null;
142
143const showVotersTooltip = async (e: Event) => {
144 const el = e.target as HTMLElement;
145 const pollUri = el.dataset.pollUri;
146 if (!pollUri) return;
147
148 if (tooltipTimeout) {
149 clearTimeout(tooltipTimeout);
150 tooltipTimeout = null;
151 }
152
153 if (!votersCache.has(pollUri)) {
154 try {
155 const voters = await fetchVoters(pollUri);
156 votersCache.set(pollUri, voters);
157 } catch (err) {
158 console.error("failed to fetch voters:", err);
159 return;
160 }
161 }
162
163 const voters = votersCache.get(pollUri);
164 if (!voters || voters.length === 0) return;
165
166 await Promise.all(voters.map(async (v) => {
167 if (!v.handle) {
168 v.handle = await resolveHandle(v.voter);
169 }
170 }));
171
172 const poll = polls.get(pollUri);
173 const options = poll?.options || pollOptionsCache.get(pollUri) || [];
174
175 if (activeTooltip) activeTooltip.remove();
176
177 const tooltip = document.createElement("div");
178 tooltip.className = "voters-tooltip";
179 tooltip.innerHTML = voters
180 .map((v) => {
181 const optText = options[v.option] || `option ${v.option}`;
182 const profileUrl = `https://bsky.app/profile/${v.voter}`;
183 const displayName = v.handle || v.voter;
184 const timeStr = v.createdAt ? ago(v.createdAt) : "";
185 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>`;
186 })
187 .join("");
188
189 tooltip.addEventListener("mouseenter", () => {
190 if (tooltipTimeout) {
191 clearTimeout(tooltipTimeout);
192 tooltipTimeout = null;
193 }
194 });
195 tooltip.addEventListener("mouseleave", hideVotersTooltip);
196
197 const rect = el.getBoundingClientRect();
198 tooltip.style.position = "fixed";
199 tooltip.style.left = `${rect.left}px`;
200 tooltip.style.top = `${rect.bottom + 4}px`;
201
202 document.body.appendChild(tooltip);
203 activeTooltip = tooltip;
204};
205
206const hideVotersTooltip = () => {
207 tooltipTimeout = setTimeout(() => {
208 if (activeTooltip) {
209 activeTooltip.remove();
210 activeTooltip = null;
211 }
212 }, 150);
213};
214
215const attachVoteHandlers = () => {
216 app.querySelectorAll("[data-vote]").forEach((el) => {
217 el.addEventListener("click", async (e) => {
218 e.preventDefault();
219 const t = e.currentTarget as HTMLElement;
220 await handleVote(t.dataset.poll!, parseInt(t.dataset.vote!, 10));
221 });
222 });
223
224 app.querySelectorAll(".vote-count").forEach((el) => {
225 el.addEventListener("mouseenter", showVotersTooltip);
226 el.addEventListener("mouseleave", hideVotersTooltip);
227 });
228};
229
230const handleVote = async (pollUri: string, option: number) => {
231 if (!agent || !currentDid) {
232 showToast("login to vote");
233 return;
234 }
235
236 if (votingInProgress) {
237 return;
238 }
239
240 votingInProgress = true;
241 setStatus("voting...");
242
243 // disable all vote options visually
244 app.querySelectorAll("[data-vote]").forEach((el) => {
245 el.classList.add("disabled");
246 });
247
248 try {
249 await vote(pollUri, option);
250 setStatus("confirming...");
251
252 // poll backend until vote is confirmed (tap needs time to process)
253 const maxWait = 10000;
254 const pollInterval = 500;
255 const start = Date.now();
256
257 while (Date.now() - start < maxWait) {
258 const voters = await fetchVoters(pollUri);
259 const myVote = voters.find(v => v.voter === currentDid);
260 if (myVote && myVote.option === option) {
261 break;
262 }
263 await new Promise(r => setTimeout(r, pollInterval));
264 }
265
266 // clear voters cache so tooltip shows fresh data
267 votersCache.delete(pollUri);
268
269 setStatus("");
270 render();
271 } catch (e) {
272 console.error("vote error:", e);
273 setStatus(`error: ${e}`);
274 setTimeout(() => {
275 setStatus("");
276 render();
277 }, 2000);
278 } finally {
279 votingInProgress = false;
280 }
281};
282
283const attachShareHandler = () => {
284 const btn = app.querySelector(".share-btn") as HTMLButtonElement;
285 if (!btn) return;
286
287 btn.addEventListener("click", async () => {
288 const url = btn.dataset.url!;
289 try {
290 await navigator.clipboard.writeText(url);
291 btn.textContent = "copied!";
292 btn.classList.add("copied");
293 setTimeout(() => {
294 btn.textContent = "copy link";
295 btn.classList.remove("copied");
296 }, 2000);
297 } catch {
298 const input = document.createElement("input");
299 input.value = url;
300 document.body.appendChild(input);
301 input.select();
302 document.execCommand("copy");
303 document.body.removeChild(input);
304 btn.textContent = "copied!";
305 btn.classList.add("copied");
306 setTimeout(() => {
307 btn.textContent = "copy link";
308 btn.classList.remove("copied");
309 }, 2000);
310 }
311 });
312};
313
314const renderPollPage = async (repo: string, rkey: string) => {
315 const uri = `at://${repo}/${POLL}/${rkey}`;
316 app.innerHTML = "<p>loading...</p>";
317
318 try {
319 const data = await fetchPoll(uri);
320
321 if (data) {
322 // cache options for tooltip
323 pollOptionsCache.set(uri, data.options.map(o => o.text));
324 const total = data.options.reduce((sum, o) => sum + o.count, 0);
325 const disabled = votingInProgress ? " disabled" : "";
326
327 const opts = data.options
328 .map((opt, i) => {
329 const pct = total > 0 ? Math.round((opt.count / total) * 100) : 0;
330 return `
331 <div class="option${disabled}" data-vote="${i}" data-poll="${uri}">
332 <div class="option-bar" style="width: ${pct}%"></div>
333 <span class="option-text">${esc(opt.text)}</span>
334 <span class="option-count">${opt.count} (${pct}%)</span>
335 </div>`;
336 })
337 .join("");
338
339 const shareUrl = `${window.location.origin}/poll/${repo}/${rkey}`;
340 app.innerHTML = `
341 <p><a href="/">← back</a></p>
342 <div class="poll-detail">
343 <div class="poll-header">
344 <h2 class="poll-question">${esc(data.text)}</h2>
345 <button class="share-btn" data-url="${shareUrl}">copy link</button>
346 </div>
347 ${opts}
348 <div class="poll-meta">${ago(data.createdAt)} · <span class="vote-count" data-poll-uri="${uri}">${total} vote${total === 1 ? "" : "s"}</span></div>
349 </div>`;
350 attachVoteHandlers();
351 attachShareHandler();
352 return;
353 }
354
355 // fallback to direct PDS fetch if backend doesn't have it
356 const pdsData = await fetchPollFromPDS(repo, rkey);
357 if (!pdsData) {
358 app.innerHTML = "<p>not found</p>";
359 return;
360 }
361
362 const poll: Poll = { ...pdsData };
363 polls.set(uri, poll);
364
365 app.innerHTML = `<p><a href="/">← back</a></p>${renderPollCard(poll)}`;
366 attachVoteHandlers();
367 } catch (e) {
368 console.error("renderPoll error:", e);
369 app.innerHTML = "<p>error loading poll</p>";
370 }
371};
372
373const renderCreate = () => {
374 if (!agent) {
375 app.innerHTML = "<p>login to create</p>";
376 return;
377 }
378 app.innerHTML = `
379 <div class="create-form">
380 <input type="text" id="question" placeholder="question" />
381 <textarea id="options" rows="4" placeholder="options (one per line)"></textarea>
382 <button id="create">create</button>
383 </div>
384 `;
385 document.getElementById("create")!.onclick = handleCreate;
386};
387
388const handleCreate = async () => {
389 if (!agent || !currentDid) return;
390
391 const text = (document.getElementById("question") as HTMLInputElement).value.trim();
392 const options = (document.getElementById("options") as HTMLTextAreaElement).value
393 .split("\n")
394 .map((s) => s.trim())
395 .filter(Boolean);
396
397 if (!text || options.length < 2) {
398 setStatus("need question + 2 options");
399 return;
400 }
401
402 setStatus("creating...");
403 try {
404 await createPoll(text, options);
405 setStatus("");
406 history.pushState(null, "", "/");
407 render();
408 } catch (e) {
409 setStatus(`error: ${e}`);
410 }
411};
412
413// oauth callback handler
414const handleOAuthCallback = async () => {
415 const params = new URLSearchParams(location.hash.slice(1));
416 if (!params.has("state")) return false;
417
418 setStatus("logging in...");
419
420 try {
421 const success = await handleCallback();
422 setStatus("");
423 return success;
424 } catch (e) {
425 setStatus(`login failed: ${e}`);
426 return false;
427 }
428};
429
430// routing
431window.addEventListener("popstate", render);
432document.addEventListener("click", (e) => {
433 const a = (e.target as HTMLElement).closest("a");
434 if (a?.href.startsWith(location.origin) && !a.href.includes("#")) {
435 e.preventDefault();
436 history.pushState(null, "", a.href);
437 render();
438 }
439});
440
441// init
442(async () => {
443 await handleOAuthCallback();
444 await restoreSession();
445 render();
446})();