diff --git a/app.js b/app.js new file mode 100644 index 0000000..56bd344 --- /dev/null +++ b/app.js @@ -0,0 +1,214 @@ +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); + }); +});