import { Repo, BroadcastChannelNetworkAdapter, WebSocketClientAdapter, IndexedDBStorageAdapter, isValidAutomergeUrl, } from "@automerge/vanillajs"; const REPO = new Repo({ network: [ new BroadcastChannelNetworkAdapter(), new WebSocketClientAdapter("wss://sync.automerge.org"), ], storage: new IndexedDBStorageAdapter(), }); let HANDLE; function getVotedKey(docUrl) { return `p2p-poll-voted:${docUrl}`; } function getVotedOptions() { const key = getVotedKey(document.location.hash); return new Set(JSON.parse(localStorage.getItem(key) || "[]")); } function markVoted(opt) { const key = getVotedKey(document.location.hash); const voted = getVotedOptions(); voted.add(opt); localStorage.setItem(key, JSON.stringify([...voted])); } function unmarkVoted(opt) { const key = getVotedKey(document.location.hash); const voted = getVotedOptions(); voted.delete(opt); localStorage.setItem(key, JSON.stringify([...voted])); } const locationHash = document.location.hash.substring(1); const statusEl = document.getElementById("status"); const optionsList = document.getElementById("options-list"); const optionInput = document.getElementById("option-input"); const addBtn = document.getElementById("add-btn"); const duplicateMsg = document.getElementById("duplicate-msg"); const copyBtn = document.getElementById("copy-btn"); const timerEl = document.getElementById("timer"); const startVotingBtn = document.getElementById("start-voting-btn"); if (isValidAutomergeUrl(locationHash)) { statusEl.textContent = "Loading document…"; try { HANDLE = await REPO.find(locationHash); statusEl.textContent = ""; } catch (err) { statusEl.textContent = "Could not load document. Starting a new one."; HANDLE = REPO.create({ options: [], votes: {}, deadline: null }); document.location.hash = HANDLE.url; } } else { HANDLE = REPO.create({ options: [], votes: {}, deadline: null }); document.location.hash = HANDLE.url; } HANDLE.on("change", ({ doc }) => render(doc)); const initialDoc = HANDLE.doc(); if (initialDoc) render(initialDoc); let timerInterval = null; function render(doc) { renderOptions(doc); renderTimer(doc); } function renderOptions(doc) { const votes = doc.votes || {}; const deadline = doc.deadline ?? null; const now = Date.now(); const votingClosed = deadline !== null && now >= deadline; const votedOptions = getVotedOptions(); const sorted = [...(doc.options || [])].sort( (a, b) => (votes[b] || 0) - (votes[a] || 0), ); optionsList.innerHTML = ""; sorted.forEach((opt) => { const count = votes[opt] || 0; const alreadyVoted = votedOptions.has(opt); const item = document.createElement("div"); item.className = "option-item"; const label = document.createElement("span"); label.className = "option-label"; label.textContent = opt; const voteBtn = document.createElement("button"); voteBtn.className = alreadyVoted ? "vote-btn voted" : "vote-btn"; voteBtn.textContent = alreadyVoted ? `✓ ${count}` : `▲ ${count}`; voteBtn.disabled = votingClosed; if (votingClosed) voteBtn.title = "Voting is closed"; else if (alreadyVoted) voteBtn.title = "Click to remove your vote"; voteBtn.addEventListener("click", () => { if (alreadyVoted) { unmarkVoted(opt); HANDLE.change((d) => { if (!d.votes) d.votes = {}; d.votes[opt] = Math.max(0, (d.votes[opt] || 0) - 1); }); } else { markVoted(opt); HANDLE.change((d) => { if (!d.votes) d.votes = {}; d.votes[opt] = (d.votes[opt] || 0) + 1; }); } }); item.appendChild(label); item.appendChild(voteBtn); optionsList.appendChild(item); }); } function renderTimer(doc) { const deadline = doc?.deadline ?? null; const now = Date.now(); if (deadline === null) { timerEl.textContent = ""; startVotingBtn.hidden = false; return; } startVotingBtn.hidden = true; if (now >= deadline) { timerEl.textContent = "Voting closed."; timerEl.className = "timer closed"; clearInterval(timerInterval); timerInterval = null; return; } const remaining = Math.ceil((deadline - now) / 1000); const mins = Math.floor(remaining / 60); const secs = remaining % 60; timerEl.textContent = `Voting closes in ${mins}:${secs.toString().padStart(2, "0")}`; timerEl.className = "timer active"; if (!timerInterval) { timerInterval = setInterval(() => { const d = HANDLE.doc(); if (!d) return; renderTimer(d); renderOptions(d); if (Date.now() >= d.deadline) { clearInterval(timerInterval); timerInterval = null; } }, 1000); } } startVotingBtn.addEventListener("click", () => { HANDLE.change((d) => { d.deadline = Date.now() + 2 * 60 * 1000; }); }); addBtn.addEventListener("click", () => { const value = optionInput.value.trim(); if (!value) return; const doc = HANDLE.doc(); const valueLower = value.toLowerCase(); const isDuplicate = (doc?.options || []).some( (opt) => opt.toLowerCase() === valueLower, ); if (isDuplicate) { duplicateMsg.textContent = `"${value}" is already in the list.`; duplicateMsg.hidden = false; return; } duplicateMsg.hidden = true; HANDLE.change((d) => { if (!d.options) d.options = []; d.options.push(value); }); optionInput.value = ""; }); optionInput.addEventListener("keydown", (e) => { if (e.key === "Enter") addBtn.click(); }); optionInput.addEventListener("input", () => { duplicateMsg.hidden = true; }); copyBtn.addEventListener("click", () => { navigator.clipboard.writeText(window.location.href).then(() => { copyBtn.textContent = "Copied!"; setTimeout(() => (copyBtn.textContent = "Copy URL"), 1500); }); });