diff --git a/README.md b/README.md index 0217c70..ae10ebc 100644 --- a/README.md +++ b/README.md @@ -1 +1,14 @@ -# P2P Poll App \ No newline at end of file +# P2P Poll App + +P2P Poll - Minimal GUN-based Scaffolding + +What this is +- A tiny peer-to-peer polling app implemented with vanilla JS and GUN (a decentralized, CRDT-friendly database). +- No central server for poll data. Peers connect and synchronize the poll in real-time. + +Notes +- This scaffold uses GUN’s browser-based peer-to-peer data replication. +- You can extend with: + - Persistent storage on each peer + - Better conflict resolution UI + - Optional signaling server for explicit peer discovery \ No newline at end of file diff --git a/app.js b/app.js new file mode 100644 index 0000000..0fa101a --- /dev/null +++ b/app.js @@ -0,0 +1,200 @@ +// Minimal GUN-based P2P Poll scaffold (vanilla JS) + +(function() { + // Initialize GUN (no server needed; can optionally connect to a relay server) + // If you want a local-only node for prototyping: Gun() + // If you want to use a signaling server for discovery, pass a list of peers to Gun().opt({peers: [...]}) + const gun = Gun(); + + // DOM helpers + const pollSection = document.getElementById('poll'); + const pollIdInput = document.getElementById('pollId'); + const loadPollBtn = document.getElementById('loadPollBtn'); + const createPollBtn = document.getElementById('createPollBtn'); + const pollTitleInput = document.getElementById('pollTitle'); + const setTitleBtn = document.getElementById('setTitleBtn'); + const optionsDiv = document.getElementById('options'); + const newOptionInput = document.getElementById('newOption'); + const addOptionBtn = document.getElementById('addOptionBtn'); + const totalVotesSpan = document.getElementById('totalVotes'); + const pollIdDisplay = document.getElementById('pollIdDisplay'); + + let currentPollId = null; + let pollsRef = null; // Gun node for /polls/{pollId} + let pollData = null; // local cache + + // Helpers + function renderOptionList(options) { + optionsDiv.innerHTML = ''; + const keys = Object.keys(options || {}); + if (keys.length === 0) { + optionsDiv.innerHTML = '
No options yet. Add one below.
'; + return; + } + keys.forEach((optId) => { + const opt = options[optId]; + const el = document.createElement('div'); + el.className = 'option'; + el.innerHTML = ` + ${opt.text} + + `; + optionsDiv.appendChild(el); + }); + } + + function updateTotalVotes() { + const opts = pollData && pollData.options ? pollData.options : {}; + const total = Object.values(opts).reduce((sum, o) => sum + (o.votes || 0), 0); + totalVotesSpan.textContent = total; + } + + // Load existing poll and subscribe to changes + function loadPoll(pollId) { + if (!pollId) return; + + currentPollId = pollId; + pollIdDisplay.textContent = pollId; + pollsRef = gun.get('polls').get(pollId); + + // Initialize structure if new poll + pollsRef.get('title').put('Untitled Poll'); + pollsRef.get('options').put({}); // ensure map exists + + // Real-time updates: listen to changes + pollsRef.get('title').on((title) => { + pollTitleInput.value = title || ''; + }); + + pollsRef.on((data) => { + // data is the entire poll node if subscribed; but we explore explicit fields + }); + + // For options map + pollsRef.get('options').on((opts) => { + // opts is the entire options map; convert to plain object + const optionsObj = toPlainObject(opts); + pollData = { + title: null, + options: optionsObj + }; + renderOptionList(optionsObj); + updateTotalVotes(); + }); + + // Also listen for title changes separately (in case some clients push via title path) + pollsRef.get('title').on((title) => { + pollTitleInput.value = title || ''; + }); + + // Show UI + document.getElementById('setup').style.display = 'none'; + pollSection.style.display = 'block'; + } + + // Create a new poll + function createPoll() { + const pollId = pollIdInput.value.trim(); + if (!pollId) { + alert('Please enter a poll ID to create.'); + return; + } + currentPollId = pollId; + // Initialize poll + pollsRef = gun.get('polls').get(pollId); + pollsRef.put({ + title: 'New Poll', + }); + pollsRef.get('options').put({}); // ensure map exists + // reflect in UI + pollIdDisplay.textContent = pollId; + pollTitleInput.value = 'New Poll'; + pollSection.style.display = 'block'; + document.getElementById('setup').style.display = 'none'; + } + + // Add option + function addOption() { + if (!pollsRef) { + alert('Load or create a poll first.'); + return; + } + const text = newOptionInput.value.trim(); + if (!text) return; + const optionId = 'opt_' + Date.now(); + pollsRef.get('options').get(optionId).put({ text, votes: 0 }); + newOptionInput.value = ''; + } + + // Set title + function setTitle() { + if (!pollsRef) return; + const title = pollTitleInput.value.trim() || 'Untitled'; + pollsRef.get('title').put(title); + } + + // Vote handler (delegated) + function handleVoteClick(e) { + if (e.target.classList.contains('voteBtn')) { + const optId = e.target.getAttribute('data-optid'); + if (!optId || !pollsRef) return; + const optRef = pollsRef.get('options').get(optId); + // Increment votes locally + optRef.get('votes').once((v) => { + const current = v || 0; + optRef.put({ votes: current + 1 }); + }); + // UI will update via the on() listener on options map + } + } + + // Utilities + function toPlainObject(obj) { + // Gun emits special proxy-like objects; convert to plain object + if (!obj) return {}; + // If it's already a plain object, return + if (typeof obj !== 'object') return {}; + // Gun events deliver object-like; attempt shallow copy + try { + const copy = JSON.parse(JSON.stringify(obj)); + return copy; + } catch (e) { + // Fallback: return as-is if cannot stringify + return obj; + } + } + + // Event bindings + loadPollBtn.addEventListener('click', () => { + const id = pollIdInput.value.trim(); + if (!id) { + alert('Enter a Poll ID to load.'); + return; + } + loadPoll(id); + }); + + createPollBtn.addEventListener('click', () => { + createPoll(); + }); + + addOptionBtn.addEventListener('click', addOption); + newOptionInput.addEventListener('keydown', (ev) => { + if (ev.key === 'Enter') addOption(); + }); + + setTitleBtn.addEventListener('click', setTitle); + + // Delegate vote clicks + optionsDiv.addEventListener('click', handleVoteClick); + + // Initialize: optionally auto-create a poll if URL param present + // Example: ?pollId=my-poll-123 + const urlParams = new URLSearchParams(window.location.search); + const autoPoll = urlParams.get('pollId'); + if (autoPoll) { + pollIdInput.value = autoPoll; + loadPoll(autoPoll); + } + +})(); \ No newline at end of file diff --git a/index.html b/index.html new file mode 100644 index 0000000..fbe6ed0 --- /dev/null +++ b/index.html @@ -0,0 +1,55 @@ + + + + + +Tip: You can run two instances and connect by using the same Poll ID.
+