feat: implement P2P polling app with Automerge sync
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>
This commit is contained in:
214
app.js
Normal file
214
app.js
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user