Core functionality using Automerge CRDT for real-time P2P sync: - Creates or loads a shared document via URL hash - Syncs over BroadcastChannel (local tabs) and WebSocket (wss://sync.automerge.org) - Persists state to IndexedDB - Add poll options with case-insensitive duplicate detection - Toggle-vote per option, tracked per-document in localStorage - Options sorted by vote count descending - 2-minute countdown timer that disables voting on expiry - Copy-to-clipboard for shareable URL Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
215 lines
5.7 KiB
JavaScript
215 lines
5.7 KiB
JavaScript
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);
|
|
});
|
|
});
|