Compare commits
6 Commits
5ecb5f1076
...
group-26d3
| Author | SHA1 | Date | |
|---|---|---|---|
| d0fe9f49aa | |||
| ba76c5df49 | |||
| 1ef970fef3 | |||
| 78d872c83e | |||
| 5f36ff0b7d | |||
| 424799692a |
57
README.md
57
README.md
@@ -1 +1,56 @@
|
||||
# P2P Poll App
|
||||
# Polly - P2P Poll App
|
||||
|
||||
A lightweight, real-time collaborative polling application that uses [Yjs](https://yjs.dev/) and [WebRTC](https://de.wikipedia.org/wiki/WebRTC) to allow multiple users to create options, vote, and see live results without a centralized database or back-end server.
|
||||
|
||||
### 🚀 Features
|
||||
|
||||
- Real-time Collaboration: Instant synchronization of poll titles, options, and votes across all connected peers using [CRDTs (Conflict-free Replicated Data Types)](https://en.wikipedia.org/wiki/Conflict-free_replicated_data_type).
|
||||
- P2P Connectivity: Uses WebRTC via [y-webrtc](https://github.com/yjs/y-webrtc) for direct browser-to-browser communication.
|
||||
- Dynamic Voting: * Add new options on the fly.
|
||||
- Live-updating progress bars and vote tallies.
|
||||
- Automatic sorting of options by vote count.
|
||||
- Voting Deadline: A shared countdown timer (2 minutes) that locks the poll for all participants once expired.
|
||||
- Awareness & Presence: A status bar showing connection health and the number of active peers currently in the room.
|
||||
- Local Persistence: Uses [y-indexeddb](https://github.com/yjs/y-indexeddb) to save the poll state locally in your browser, ensuring data isn't lost if you refresh or lose connection.
|
||||
- No Setup Required: Unique "rooms" are created via URL parameters, making it easy to share a link and start a poll instantly.
|
||||
|
||||
### 🛠 Tech Stack
|
||||
|
||||
- Language: TypeScript
|
||||
- State Management: Yjs (Shared data types: Y.Doc, Y.Map, Y.Text)
|
||||
- Networking: y-webrtc (WebRTC provider for Yjs)
|
||||
- UI: Vanilla DOM manipulation (No heavy frameworks like React or Vue)
|
||||
|
||||
### 💡 How It Works
|
||||
|
||||
- Room Creation: When you open the app, it checks for a ?room= parameter. If none exists, it generates a unique ID and updates the URL.
|
||||
- State Synchronization: The y-webrtc provider connects users with the same room ID. Any change to sync.options or sync.votes is propagated to all users.
|
||||
- Local Reactivity: Components use .observe() and .observeDeep() on Yjs types to trigger a re-render of the UI whenever the shared state changes.
|
||||
- Voting: Votes are stored in a Y.Map<string> where the key is the User ID and the value is the Option ID. This ensures each user can only have one active vote at a time.
|
||||
|
||||
You can simulate a second user by opening an incognito Tab.
|
||||
|
||||
### 🔧 Installation, Development and Deployment
|
||||
|
||||
- Install dependencies:
|
||||
|
||||
```npm install yjs y-webrtc y-indexeddb```
|
||||
|
||||
- Development:
|
||||
|
||||
```npm run dev```
|
||||
|
||||
- Deployment:
|
||||
- The code currently uses an Y-Webrtc-Signaling-Server at localhost:4444 that starts with `npm run dev` for development.
|
||||
- To deploy the App, you need to set up a publicly available signaling server and set the address in the `synx.ts`. E.g. with Docker using the [funnyzak/y-webrtc-signaling](https://hub.docker.com/r/funnyzak/y-webrtc-signaling) image:
|
||||
|
||||
```version: '3.1'
|
||||
services:
|
||||
y-webrtc-signaling:
|
||||
container_name: y-webrtc-signaling
|
||||
image: funnyzak/y-webrtc-signaling:latest
|
||||
restart: always
|
||||
network_mode: bridge
|
||||
ports:
|
||||
- "4444:4444"
|
||||
dns: 8.8.8.8```
|
||||
14
package-lock.json
generated
14
package-lock.json
generated
@@ -8,6 +8,7 @@
|
||||
"name": "polly-p2p-poll",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"uuid": "^13.0.0",
|
||||
"y-indexeddb": "^9.0.12",
|
||||
"y-webrtc": "^10.3.0",
|
||||
"yjs": "^13.6.27"
|
||||
@@ -1288,6 +1289,19 @@
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "13.0.2",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.2.tgz",
|
||||
"integrity": "sha512-vzi9uRZ926x4XV73S/4qQaTwPXM2JBj6/6lI/byHH1jOpCzb0zDbfytgA9LcN/hzb2l7WQSQnxITOVx5un/wGw==",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/broofa",
|
||||
"https://github.com/sponsors/ctavan"
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"uuid": "dist-node/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "7.3.2",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz",
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
"dependencies": {
|
||||
"y-indexeddb": "^9.0.12",
|
||||
"y-webrtc": "^10.3.0",
|
||||
"yjs": "^13.6.27"
|
||||
"yjs": "^13.6.27",
|
||||
"uuid": "^13.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.9.2",
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import * as Y from "yjs";
|
||||
import { getDeadline } from "../state";
|
||||
import { enforceAppendOnly } from "../yDocUtil";
|
||||
|
||||
const DEADLINE_DURATION_MS = 2 * 60 * 1000; // 2 minutes
|
||||
|
||||
@@ -80,7 +79,7 @@ export function DeadlineTimer(
|
||||
onClearDeadline();
|
||||
});
|
||||
|
||||
deadlineMap.observe(enforceAppendOnly(deadlineMap,render));
|
||||
deadlineMap.observe(() => render());
|
||||
render();
|
||||
|
||||
return wrapper;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import * as Y from "yjs";
|
||||
import type { OptionRecord } from "../state";
|
||||
import { PollOption } from "./PollOption";
|
||||
import { enforceAppendOnly } from "../yDocUtil";
|
||||
|
||||
export function PollList(
|
||||
yOptions: Y.Map<OptionRecord>,
|
||||
@@ -11,10 +10,6 @@ export function PollList(
|
||||
onVote: (optionId: string) => void,
|
||||
onDelete: (optionId: string) => void,
|
||||
): HTMLElement {
|
||||
|
||||
var currentOptions : { [x: string]: any; } | undefined = undefined
|
||||
var currentVotes : { [x: string]: any; } | undefined = undefined
|
||||
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.className = "poll-list-wrapper";
|
||||
|
||||
@@ -46,29 +41,25 @@ export function PollList(
|
||||
votes: number;
|
||||
voted: boolean;
|
||||
}> = [];
|
||||
if (currentOptions && currentVotes){
|
||||
|
||||
// Tally votes per option
|
||||
const tally = new Map<string, number>();
|
||||
for (const optionId of Object.values(currentVotes)) {
|
||||
tally.set(optionId, (tally.get(optionId) ?? 0) + 1);
|
||||
}
|
||||
|
||||
const myVote = currentVotes[userId] ?? null;
|
||||
|
||||
Object.entries(currentOptions).forEach(([id,record]) => {
|
||||
console.log(`${record}: ${id}`)
|
||||
entries.push({
|
||||
id,
|
||||
name: record.label,
|
||||
votes: tally.get(id) ?? 0,
|
||||
voted: myVote === id,
|
||||
});
|
||||
});
|
||||
|
||||
entries.sort((a, b) => b.votes - a.votes || a.name.localeCompare(b.name));
|
||||
// Tally votes per option
|
||||
const tally = new Map<string, number>();
|
||||
for (const optionId of yVotes.values()) {
|
||||
tally.set(optionId, (tally.get(optionId) ?? 0) + 1);
|
||||
}
|
||||
|
||||
const myVote = yVotes.get(userId) ?? null;
|
||||
|
||||
yOptions.forEach((record, id) => {
|
||||
entries.push({
|
||||
id,
|
||||
name: record.label,
|
||||
votes: tally.get(id) ?? 0,
|
||||
voted: myVote === id,
|
||||
});
|
||||
});
|
||||
|
||||
entries.sort((a, b) => b.votes - a.votes || a.name.localeCompare(b.name));
|
||||
return entries;
|
||||
}
|
||||
|
||||
@@ -127,10 +118,9 @@ export function PollList(
|
||||
}
|
||||
});
|
||||
}
|
||||
yOptions.observe(enforceAppendOnly(yOptions,(update : { [x: string]: any; }) => {currentOptions = update}, render));
|
||||
yVotes.observe(enforceAppendOnly(yVotes,(update : { [x: string]: any; }) => {currentVotes = update},render));
|
||||
currentOptions=yOptions.toJSON()
|
||||
currentVotes=yVotes.toJSON()
|
||||
|
||||
yOptions.observeDeep(() => render());
|
||||
yVotes.observe(() => render());
|
||||
render();
|
||||
|
||||
return wrapper;
|
||||
|
||||
@@ -29,7 +29,7 @@ export function initSync(roomId: string): AppSync {
|
||||
: "offline";
|
||||
|
||||
const provider = new WebrtcProvider(roomId, doc,{
|
||||
signaling: ["ws://localhost:4444", "ws://lynxpi.ddns.net:4444"]
|
||||
signaling: ["ws://localhost:4444"]
|
||||
});
|
||||
const persistence = new IndexeddbPersistence(roomId, doc);
|
||||
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import * as Y from "yjs";
|
||||
|
||||
/**
|
||||
* Enforces append-only logic on a Y.Map.
|
||||
* Reverts any 'update' or 'delete' actions detected in the observer.
|
||||
*/
|
||||
export function enforceAppendOnly(yMap: Y.Map<any>,update: (update : { [x: string]: any; }) => void, render: () => void) {
|
||||
return (event: Y.YMapEvent<any>, transaction: Y.Transaction) => {
|
||||
var isOperationIllegal = false
|
||||
event.keys.forEach((change, key) => {
|
||||
const { action, oldValue } = change;
|
||||
|
||||
if (action === 'update' || action === 'delete') {
|
||||
isOperationIllegal = true
|
||||
console.log("Illegal Operation: "+action)
|
||||
}
|
||||
});
|
||||
if(!isOperationIllegal) {
|
||||
console.log("Updating Map!")
|
||||
update(yMap.toJSON())
|
||||
render();
|
||||
}
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user