From 06afbc46f618d9193fcfea7a638f0127ad431f35 Mon Sep 17 00:00:00 2001 From: Peter Thomas Date: Sat, 7 Mar 2026 19:34:19 +0100 Subject: [PATCH] Add initial implementation of P2P Poll app with HTML, CSS, and JavaScript --- README.md | 15 +++- app.js | 200 +++++++++++++++++++++++++++++++++++++++++++++++++++++ index.html | 55 +++++++++++++++ styles.css | 104 ++++++++++++++++++++++++++++ 4 files changed, 373 insertions(+), 1 deletion(-) create mode 100644 app.js create mode 100644 index.html create mode 100644 styles.css 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 @@ + + + + + + P2P Poll + + + + + +
+

P2P Poll

+ +
+

Start or Join Poll

+
+ + +
+ + +

Tip: You can run two instances and connect by using the same Poll ID.

+
+ + +
+ + + + \ No newline at end of file diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..dde2260 --- /dev/null +++ b/styles.css @@ -0,0 +1,104 @@ +:root { + --bg: #0f1220; + --card: #1a1740; + --text: #e8e3ff; + --muted: #b8a7d2; + --accent: #6bd3ff; +} + +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + padding: 0; + font-family: sans-serif; + background: #0b0a14; + color: #fff; + height: 100%; +} + +.container { + max-width: 700px; + margin: 40px auto; + padding: 0 16px; +} + +.card { + background: #1f1740; + border-radius: 12px; + padding: 16px; + margin-bottom: 14px; + border: 1px solid #2a1950; +} + +.field { + margin-bottom: 12px; + display: flex; + gap: 8px; + align-items: center; +} + +label { + min-width: 180px; +} + +input[type="text"] { + flex: 1; + padding: 8px; + border-radius: 6px; + border: 1px solid #5a2d8a; + background: #2a174f; + color: #fff; +} + +button { + padding: 8px 12px; + border-radius: 6px; + border: none; + background: #6bd3ff; + color: #04152a; + font-weight: bold; + cursor: pointer; +} + +button:hover { + opacity: 0.95; +} + +.options { + display: flex; + flex-direction: column; + gap: 8px; + margin-top: 6px; +} + +.option { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px; + background: #2a174f; + border: 1px solid #40217a; + border-radius: 6px; +} + +.option .text { + flex: 1; +} + +.share { + font-size: 0.9em; + color: var(--muted); +} + +.note { + color: #c8b5ff; + font-size: 0.9em; +} + +.total { + font-weight: bold; +} \ No newline at end of file -- 2.49.1