feat: add proposed group solution

This commit is contained in:
Patrick Charrier
2026-03-29 21:24:42 +02:00
parent 4275cbd795
commit 2462872e24
7 changed files with 1988 additions and 1 deletions

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
dist
node_modules

View File

@@ -1 +1,44 @@
# P2P Poll App
A peer-to-peer polling application where users create and vote on polls without any central server. All data syncs directly between browsers using WebRTC and CRDTs.
## Features
- **Real-time P2P sync** — poll options and votes sync instantly across all connected peers via WebRTC
- **Collaborative poll title** — editable title that syncs between all participants
- **One vote per user** — each peer gets a stable ID, enforcing one vote per person per option
- **Vote/Unvote toggle** — change your mind anytime
- **Connection status** — see when you're connected and how many peers are in the room
- **Shareable polls** — share via URL with `?room=your-poll-name`
- **No backend required** — runs entirely in the browser
## Tech Stack
- [Yjs](https://yjs.dev/) — CRDT library for conflict-free shared state
- [y-webrtc](https://github.com/yjs/y-webrtc) — WebRTC provider for peer-to-peer connections
- [Vite](https://vitejs.dev/) — Development server and build tool
- Vanilla JavaScript — no framework dependencies
## Getting Started
```bash
npm install
npm run dev
```
Open `http://localhost:5173/?room=my-poll` in multiple browser tabs to test.
To test across devices on the same network:
```bash
npm run dev -- --host
```
Then open the URL shown in the terminal on other devices.
## How It Works
1. Each browser tab creates a Yjs document and connects to other peers via WebRTC
2. Poll options and votes are stored in Yjs shared data types (Y.Map)
3. Changes propagate automatically to all connected peers using CRDTs
4. A public signaling server handles peer discovery; all poll data flows directly between browsers

47
index.html Normal file
View File

@@ -0,0 +1,47 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>P2P Poll</title>
<link rel="stylesheet" href="/src/style.css">
</head>
<body>
<div id="app">
<header>
<h1>P2P Poll</h1>
<div id="status">
<span id="connection-status" class="connecting">Connecting...</span>
<span id="peer-count">1 peer</span>
</div>
</header>
<div id="poll-title-section">
<input type="text" id="poll-title" placeholder="Click to name this poll..." maxlength="120">
</div>
<main>
<div id="add-option">
<input type="text" id="option-input" placeholder="Add a poll option..." maxlength="100">
<button id="add-btn">Add</button>
</div>
<div id="total-votes">Total votes: <span id="total-count">0</span></div>
<div id="poll-options"></div>
<p id="empty-state">No options yet. Add one above!</p>
</main>
<footer id="share-section">
<p>Share this poll:</p>
<div id="share-url-container">
<code id="share-url"></code>
<button id="copy-btn">Copy</button>
</div>
</footer>
</div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

1437
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

18
package.json Normal file
View File

@@ -0,0 +1,18 @@
{
"name": "p2p-poll",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"yjs": "^13.6.0",
"y-webrtc": "^10.3.0"
},
"devDependencies": {
"vite": "^6.0.0"
}
}

183
src/main.js Normal file
View File

@@ -0,0 +1,183 @@
import * as Y from 'yjs';
import { WebrtcProvider } from 'y-webrtc';
// --- Peer ID (stable across reloads) ---
function getOrCreatePeerId() {
let id = localStorage.getItem('peer-id');
if (!id) {
id = crypto.randomUUID();
localStorage.setItem('peer-id', id);
}
return id;
}
const peerId = getOrCreatePeerId();
// --- Room name from URL ---
function getRoomName() {
const params = new URLSearchParams(window.location.search);
return params.get('room') || 'default-poll';
}
const roomName = getRoomName();
// --- Yjs setup ---
const ydoc = new Y.Doc();
const provider = new WebrtcProvider(roomName, ydoc);
const yOptions = ydoc.getMap('poll-options');
const yTitle = ydoc.getText('poll-title');
// --- Data operations ---
function addOption(name) {
const id = crypto.randomUUID();
const optionMap = new Y.Map();
optionMap.set('name', name);
optionMap.set('votes', new Y.Map());
yOptions.set(id, optionMap);
}
function toggleVote(optionId) {
const optionMap = yOptions.get(optionId);
if (!optionMap) return;
const votes = optionMap.get('votes');
if (votes.has(peerId)) {
votes.delete(peerId);
} else {
votes.set(peerId, true);
}
}
// --- Poll title (collaborative) ---
function bindTitle() {
const titleInput = document.getElementById('poll-title');
yTitle.observe(() => {
if (document.activeElement !== titleInput) {
titleInput.value = yTitle.toString();
}
});
titleInput.addEventListener('input', () => {
ydoc.transact(() => {
yTitle.delete(0, yTitle.length);
yTitle.insert(0, titleInput.value);
});
});
titleInput.value = yTitle.toString();
}
// --- UI rendering ---
const container = document.getElementById('poll-options');
const emptyState = document.getElementById('empty-state');
const totalCountEl = document.getElementById('total-count');
function render() {
container.innerHTML = '';
const entries = [];
let totalVotes = 0;
yOptions.forEach((optionMap, id) => {
const voteCount = optionMap.get('votes').size;
totalVotes += voteCount;
entries.push({
id,
name: optionMap.get('name'),
votes: voteCount,
voted: optionMap.get('votes').has(peerId),
});
});
entries.sort((a, b) => b.votes - a.votes || a.name.localeCompare(b.name));
totalCountEl.textContent = totalVotes;
emptyState.className = entries.length > 0 ? 'hidden' : '';
for (const entry of entries) {
const div = document.createElement('div');
div.className = 'poll-option';
const nameSpan = document.createElement('span');
nameSpan.className = 'option-name';
nameSpan.textContent = entry.name;
const voteCount = document.createElement('span');
voteCount.className = 'vote-count';
voteCount.textContent = entry.votes;
const voteBtn = document.createElement('button');
voteBtn.className = entry.voted ? 'vote-btn voted' : 'vote-btn';
voteBtn.textContent = entry.voted ? 'Unvote' : 'Vote';
voteBtn.addEventListener('click', () => toggleVote(entry.id));
div.append(nameSpan, voteCount, voteBtn);
container.appendChild(div);
}
}
// Re-render on any change in the shared state
yOptions.observeDeep(() => render());
// --- Add option handlers ---
const input = document.getElementById('option-input');
const addBtn = document.getElementById('add-btn');
addBtn.addEventListener('click', () => {
const name = input.value.trim();
if (name) {
addOption(name);
input.value = '';
}
});
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') addBtn.click();
});
// --- Connection status & peer count ---
const statusEl = document.getElementById('connection-status');
const peerCountEl = document.getElementById('peer-count');
provider.on('synced', ({ synced }) => {
statusEl.textContent = synced ? 'Connected' : 'Connecting...';
statusEl.className = synced ? 'connected' : 'connecting';
});
function updatePeerCount() {
const count = provider.awareness.getStates().size;
peerCountEl.textContent = `${count} peer${count !== 1 ? 's' : ''}`;
}
provider.awareness.on('change', updatePeerCount);
updatePeerCount();
// --- Share section ---
function initShareSection() {
const shareUrl = document.getElementById('share-url');
const copyBtn = document.getElementById('copy-btn');
const url = `${window.location.origin}${window.location.pathname}?room=${roomName}`;
shareUrl.textContent = url;
copyBtn.addEventListener('click', () => {
navigator.clipboard.writeText(url).then(() => {
copyBtn.textContent = 'Copied!';
setTimeout(() => { copyBtn.textContent = 'Copy'; }, 2000);
});
});
}
// --- Initialize ---
bindTitle();
initShareSection();
render();

257
src/style.css Normal file
View File

@@ -0,0 +1,257 @@
:root {
--bg: #0b0a14;
--surface: #1f1740;
--surface-alt: #2a174f;
--text: #e8e3ff;
--text-secondary: #b8a7d2;
--text-muted: #8a7aaa;
--accent: #6bd3ff;
--accent-hover: #4fc3f7;
--accent-light: rgba(107, 211, 255, 0.1);
--border: #2a1950;
--shadow: rgba(0, 0, 0, 0.3);
--success: #4caf50;
--warning: #ff9800;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
}
#app {
max-width: 600px;
margin: 0 auto;
padding: 2rem 1rem;
}
header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
h1 {
font-size: 1.5rem;
}
#status {
display: flex;
gap: 1rem;
font-size: 0.85rem;
color: var(--text-secondary);
}
#connection-status::before {
content: "";
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 4px;
vertical-align: middle;
}
#connection-status.connected::before {
background: var(--success);
}
#connection-status.connecting::before {
background: var(--warning);
}
/* Poll title */
#poll-title-section {
margin-bottom: 1.5rem;
}
#poll-title {
width: 100%;
font-size: 1.3rem;
font-weight: 600;
border: none;
border-bottom: 2px solid transparent;
background: transparent;
color: var(--text);
padding: 0.4rem 0;
outline: none;
}
#poll-title::placeholder {
color: var(--text-muted);
}
#poll-title:hover {
border-bottom-color: var(--border);
}
#poll-title:focus {
border-bottom-color: var(--accent);
}
/* Add option */
#add-option {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
}
#option-input {
flex: 1;
padding: 0.6rem 0.8rem;
border: 1px solid var(--border);
border-radius: 6px;
font-size: 1rem;
outline: none;
background: var(--surface);
color: var(--text);
}
#option-input::placeholder {
color: var(--text-muted);
}
#option-input:focus {
border-color: var(--accent);
}
#add-btn {
padding: 0.6rem 1.2rem;
background: var(--accent);
color: var(--bg);
border: none;
border-radius: 6px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
}
#add-btn:hover {
background: var(--accent-hover);
}
/* Total votes */
#total-votes {
font-size: 0.85rem;
color: var(--text-secondary);
margin-bottom: 1rem;
font-weight: 500;
}
/* Poll options */
.poll-option {
display: flex;
align-items: center;
background: var(--surface);
padding: 0.8rem 1rem;
border-radius: 8px;
margin-bottom: 0.5rem;
border: 1px solid var(--border);
}
.option-name {
flex: 1;
font-size: 1rem;
}
.vote-count {
font-size: 0.9rem;
font-weight: 600;
color: var(--text-secondary);
margin-right: 0.75rem;
min-width: 1.5rem;
text-align: center;
}
.vote-btn {
padding: 0.4rem 1rem;
border: 2px solid var(--accent);
border-radius: 6px;
font-size: 0.85rem;
cursor: pointer;
background: transparent;
color: var(--accent);
}
.vote-btn:hover {
background: var(--accent-light);
}
.vote-btn.voted {
background: var(--accent);
color: var(--bg);
}
.vote-btn.voted:hover {
background: var(--accent-hover);
}
/* Empty state */
#empty-state {
text-align: center;
color: var(--text-muted);
padding: 2rem;
}
#empty-state.hidden {
display: none;
}
/* Share section */
#share-section {
margin-top: 2rem;
padding-top: 1.5rem;
border-top: 1px solid var(--border);
font-size: 0.85rem;
color: var(--text-secondary);
}
#share-url-container {
display: flex;
gap: 0.5rem;
margin-top: 0.5rem;
align-items: center;
}
#share-url {
flex: 1;
background: var(--surface);
padding: 0.5rem 0.75rem;
border-radius: 6px;
border: 1px solid var(--border);
font-size: 0.8rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--text);
}
#copy-btn {
padding: 0.5rem 1rem;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
cursor: pointer;
font-size: 0.8rem;
color: var(--text-secondary);
}
#copy-btn:hover {
background: var(--accent-light);
color: var(--accent);
}