polls on atproto pollz.waow.tech
atproto zig
at main 446 lines 13 kB view raw
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="/">&larr; 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="/">&larr; 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})();