Compare commits

..

1 Commits

Author SHA1 Message Date
Patrick Charrier
a621cc4483 Implement P2P polling app with Yjs and WebRTC
Uses Yjs CRDTs for conflict-free shared state and y-webrtc for
peer-to-peer data exchange. Users can add poll options and vote/unvote,
with all changes syncing in real-time across connected peers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 04:32:06 +01:00
13 changed files with 350 additions and 917 deletions

2
.gitignore vendored
View File

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

View File

@@ -1,44 +1 @@
# 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

View File

@@ -3,11 +3,31 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Polly — P2P Polls</title>
<title>P2P Poll</title>
<link rel="stylesheet" href="/src/style.css">
</head>
<body>
<div id="app"></div>
<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>
<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="poll-options"></div>
<p id="empty-state">No options yet. Add one above!</p>
</main>
</div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

218
package-lock.json generated
View File

@@ -458,9 +458,9 @@
}
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.0.tgz",
"integrity": "sha512-WOhNW9K8bR3kf4zLxbfg6Pxu2ybOUbB2AjMDHSQx86LIF4rH4Ft7vmMwNt0loO0eonglSNy4cpD3MKXXKQu0/A==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz",
"integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==",
"cpu": [
"arm"
],
@@ -472,9 +472,9 @@
]
},
"node_modules/@rollup/rollup-android-arm64": {
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.0.tgz",
"integrity": "sha512-u6JHLll5QKRvjciE78bQXDmqRqNs5M/3GVqZeMwvmjaNODJih/WIrJlFVEihvV0MiYFmd+ZyPr9wxOVbPAG2Iw==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz",
"integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==",
"cpu": [
"arm64"
],
@@ -486,9 +486,9 @@
]
},
"node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.0.tgz",
"integrity": "sha512-qEF7CsKKzSRc20Ciu2Zw1wRrBz4g56F7r/vRwY430UPp/nt1x21Q/fpJ9N5l47WWvJlkNCPJz3QRVw008fi7yA==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz",
"integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==",
"cpu": [
"arm64"
],
@@ -500,9 +500,9 @@
]
},
"node_modules/@rollup/rollup-darwin-x64": {
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.0.tgz",
"integrity": "sha512-WADYozJ4QCnXCH4wPB+3FuGmDPoFseVCUrANmA5LWwGmC6FL14BWC7pcq+FstOZv3baGX65tZ378uT6WG8ynTw==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz",
"integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==",
"cpu": [
"x64"
],
@@ -514,9 +514,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-arm64": {
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.0.tgz",
"integrity": "sha512-6b8wGHJlDrGeSE3aH5mGNHBjA0TTkxdoNHik5EkvPHCt351XnigA4pS7Wsj/Eo9Y8RBU6f35cjN9SYmCFBtzxw==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz",
"integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==",
"cpu": [
"arm64"
],
@@ -528,9 +528,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-x64": {
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.0.tgz",
"integrity": "sha512-h25Ga0t4jaylMB8M/JKAyrvvfxGRjnPQIR8lnCayyzEjEOx2EJIlIiMbhpWxDRKGKF8jbNH01NnN663dH638mA==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz",
"integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==",
"cpu": [
"x64"
],
@@ -542,9 +542,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.0.tgz",
"integrity": "sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz",
"integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==",
"cpu": [
"arm"
],
@@ -556,9 +556,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.0.tgz",
"integrity": "sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz",
"integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==",
"cpu": [
"arm"
],
@@ -570,9 +570,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.0.tgz",
"integrity": "sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz",
"integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==",
"cpu": [
"arm64"
],
@@ -584,9 +584,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.0.tgz",
"integrity": "sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz",
"integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==",
"cpu": [
"arm64"
],
@@ -598,9 +598,9 @@
]
},
"node_modules/@rollup/rollup-linux-loong64-gnu": {
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.0.tgz",
"integrity": "sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz",
"integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==",
"cpu": [
"loong64"
],
@@ -612,9 +612,9 @@
]
},
"node_modules/@rollup/rollup-linux-loong64-musl": {
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.0.tgz",
"integrity": "sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz",
"integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==",
"cpu": [
"loong64"
],
@@ -626,9 +626,9 @@
]
},
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.0.tgz",
"integrity": "sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz",
"integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==",
"cpu": [
"ppc64"
],
@@ -640,9 +640,9 @@
]
},
"node_modules/@rollup/rollup-linux-ppc64-musl": {
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.0.tgz",
"integrity": "sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz",
"integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==",
"cpu": [
"ppc64"
],
@@ -654,9 +654,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.0.tgz",
"integrity": "sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz",
"integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==",
"cpu": [
"riscv64"
],
@@ -668,9 +668,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-musl": {
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.0.tgz",
"integrity": "sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz",
"integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==",
"cpu": [
"riscv64"
],
@@ -682,9 +682,9 @@
]
},
"node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.0.tgz",
"integrity": "sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz",
"integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==",
"cpu": [
"s390x"
],
@@ -696,9 +696,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.0.tgz",
"integrity": "sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz",
"integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==",
"cpu": [
"x64"
],
@@ -710,9 +710,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.0.tgz",
"integrity": "sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz",
"integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==",
"cpu": [
"x64"
],
@@ -724,9 +724,9 @@
]
},
"node_modules/@rollup/rollup-openbsd-x64": {
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.0.tgz",
"integrity": "sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz",
"integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==",
"cpu": [
"x64"
],
@@ -738,9 +738,9 @@
]
},
"node_modules/@rollup/rollup-openharmony-arm64": {
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.0.tgz",
"integrity": "sha512-pESDkos/PDzYwtyzB5p/UoNU/8fJo68vcXM9ZW2V0kjYayj1KaaUfi1NmTUTUpMn4UhU4gTuK8gIaFO4UGuMbA==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz",
"integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==",
"cpu": [
"arm64"
],
@@ -752,9 +752,9 @@
]
},
"node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.0.tgz",
"integrity": "sha512-hj1wFStD7B1YBeYmvY+lWXZ7ey73YGPcViMShYikqKT1GtstIKQAtfUI6yrzPjAy/O7pO0VLXGmUVWXQMaYgTQ==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz",
"integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==",
"cpu": [
"arm64"
],
@@ -766,9 +766,9 @@
]
},
"node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.0.tgz",
"integrity": "sha512-SyaIPFoxmUPlNDq5EHkTbiKzmSEmq/gOYFI/3HHJ8iS/v1mbugVa7dXUzcJGQfoytp9DJFLhHH4U3/eTy2Bq4w==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz",
"integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==",
"cpu": [
"ia32"
],
@@ -780,9 +780,9 @@
]
},
"node_modules/@rollup/rollup-win32-x64-gnu": {
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.0.tgz",
"integrity": "sha512-RdcryEfzZr+lAr5kRm2ucN9aVlCCa2QNq4hXelZxb8GG0NJSazq44Z3PCCc8wISRuCVnGs0lQJVX5Vp6fKA+IA==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz",
"integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==",
"cpu": [
"x64"
],
@@ -794,9 +794,9 @@
]
},
"node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.0.tgz",
"integrity": "sha512-PrsWNQ8BuE00O3Xsx3ALh2Df8fAj9+cvvX9AIA6o4KpATR98c9mud4XtDWVvsEuyia5U4tVSTKygawyJkjm60w==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz",
"integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==",
"cpu": [
"x64"
],
@@ -1052,9 +1052,9 @@
"license": "ISC"
},
"node_modules/picomatch": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"engines": {
@@ -1137,9 +1137,9 @@
}
},
"node_modules/rollup": {
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.0.tgz",
"integrity": "sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
"integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1153,31 +1153,31 @@
"npm": ">=8.0.0"
},
"optionalDependencies": {
"@rollup/rollup-android-arm-eabi": "4.60.0",
"@rollup/rollup-android-arm64": "4.60.0",
"@rollup/rollup-darwin-arm64": "4.60.0",
"@rollup/rollup-darwin-x64": "4.60.0",
"@rollup/rollup-freebsd-arm64": "4.60.0",
"@rollup/rollup-freebsd-x64": "4.60.0",
"@rollup/rollup-linux-arm-gnueabihf": "4.60.0",
"@rollup/rollup-linux-arm-musleabihf": "4.60.0",
"@rollup/rollup-linux-arm64-gnu": "4.60.0",
"@rollup/rollup-linux-arm64-musl": "4.60.0",
"@rollup/rollup-linux-loong64-gnu": "4.60.0",
"@rollup/rollup-linux-loong64-musl": "4.60.0",
"@rollup/rollup-linux-ppc64-gnu": "4.60.0",
"@rollup/rollup-linux-ppc64-musl": "4.60.0",
"@rollup/rollup-linux-riscv64-gnu": "4.60.0",
"@rollup/rollup-linux-riscv64-musl": "4.60.0",
"@rollup/rollup-linux-s390x-gnu": "4.60.0",
"@rollup/rollup-linux-x64-gnu": "4.60.0",
"@rollup/rollup-linux-x64-musl": "4.60.0",
"@rollup/rollup-openbsd-x64": "4.60.0",
"@rollup/rollup-openharmony-arm64": "4.60.0",
"@rollup/rollup-win32-arm64-msvc": "4.60.0",
"@rollup/rollup-win32-ia32-msvc": "4.60.0",
"@rollup/rollup-win32-x64-gnu": "4.60.0",
"@rollup/rollup-win32-x64-msvc": "4.60.0",
"@rollup/rollup-android-arm-eabi": "4.59.0",
"@rollup/rollup-android-arm64": "4.59.0",
"@rollup/rollup-darwin-arm64": "4.59.0",
"@rollup/rollup-darwin-x64": "4.59.0",
"@rollup/rollup-freebsd-arm64": "4.59.0",
"@rollup/rollup-freebsd-x64": "4.59.0",
"@rollup/rollup-linux-arm-gnueabihf": "4.59.0",
"@rollup/rollup-linux-arm-musleabihf": "4.59.0",
"@rollup/rollup-linux-arm64-gnu": "4.59.0",
"@rollup/rollup-linux-arm64-musl": "4.59.0",
"@rollup/rollup-linux-loong64-gnu": "4.59.0",
"@rollup/rollup-linux-loong64-musl": "4.59.0",
"@rollup/rollup-linux-ppc64-gnu": "4.59.0",
"@rollup/rollup-linux-ppc64-musl": "4.59.0",
"@rollup/rollup-linux-riscv64-gnu": "4.59.0",
"@rollup/rollup-linux-riscv64-musl": "4.59.0",
"@rollup/rollup-linux-s390x-gnu": "4.59.0",
"@rollup/rollup-linux-x64-gnu": "4.59.0",
"@rollup/rollup-linux-x64-musl": "4.59.0",
"@rollup/rollup-openbsd-x64": "4.59.0",
"@rollup/rollup-openharmony-arm64": "4.59.0",
"@rollup/rollup-win32-arm64-msvc": "4.59.0",
"@rollup/rollup-win32-ia32-msvc": "4.59.0",
"@rollup/rollup-win32-x64-gnu": "4.59.0",
"@rollup/rollup-win32-x64-msvc": "4.59.0",
"fsevents": "~2.3.2"
}
},
@@ -1348,9 +1348,9 @@
}
},
"node_modules/ws": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
"version": "8.19.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
"license": "MIT",
"optional": true,
"engines": {

View File

@@ -1,47 +0,0 @@
import { addOption } from '../utils/store.js';
export function AddOption() {
const wrapper = document.createElement('div');
wrapper.className = 'add-option-wrapper';
const input = document.createElement('input');
input.type = 'text';
input.className = 'add-option-input';
input.placeholder = 'Add an option…';
input.maxLength = 100;
input.setAttribute('aria-label', 'New poll option');
const btn = document.createElement('button');
btn.className = 'add-option-btn';
btn.setAttribute('aria-label', 'Add option');
// Plus icon
btn.innerHTML = `
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path d="M8 2v12M2 8h12" stroke="currentColor" stroke-width="1.75" stroke-linecap="round"/>
</svg>
<span>Add</span>
`;
wrapper.append(input, btn);
function submit() {
const name = input.value.trim();
if (!name) {
input.focus();
input.classList.add('shake');
input.addEventListener('animationend', () => input.classList.remove('shake'), { once: true });
return;
}
addOption(name);
input.value = '';
input.focus();
}
btn.addEventListener('click', submit);
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') submit();
});
return wrapper;
}

View File

@@ -1,77 +0,0 @@
import { yOptions, getEntries, getTotalVotes } from '../utils/store.js';
import { PollOption } from './PollOption.js';
export function PollList() {
const wrapper = document.createElement('div');
wrapper.className = 'poll-list-wrapper';
const meta = document.createElement('div');
meta.className = 'poll-list-meta';
const list = document.createElement('div');
list.className = 'poll-list';
const empty = document.createElement('div');
empty.className = 'poll-list-empty';
empty.innerHTML = `
<div class="empty-icon">
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="4" y="10" width="24" height="3" rx="1.5" fill="currentColor" opacity="0.15"/>
<rect x="4" y="16" width="18" height="3" rx="1.5" fill="currentColor" opacity="0.1"/>
<rect x="4" y="22" width="21" height="3" rx="1.5" fill="currentColor" opacity="0.07"/>
</svg>
</div>
<p>No options yet — add the first one above.</p>
`;
wrapper.append(meta, list, empty);
function render() {
const entries = getEntries();
const total = getTotalVotes();
// Meta line
if (entries.length > 0) {
meta.textContent = `${entries.length} option${entries.length !== 1 ? 's' : ''} · ${total} vote${total !== 1 ? 's' : ''} total`;
meta.style.display = '';
} else {
meta.style.display = 'none';
}
// Empty state
empty.style.display = entries.length === 0 ? '' : 'none';
// Diff-render: reuse existing rows when possible
const existing = new Map(
[...list.querySelectorAll('.poll-option')].map((el) => [el.dataset.id, el])
);
// Remove stale rows
existing.forEach((el, id) => {
if (!entries.find((e) => e.id === id)) el.remove();
});
// Update or insert rows in sorted order
entries.forEach((entry, i) => {
const newEl = PollOption({ ...entry, totalVotes: total });
const currentEl = list.children[i];
if (!currentEl) {
list.appendChild(newEl);
} else if (currentEl.dataset.id !== entry.id) {
list.insertBefore(newEl, currentEl);
// Remove the now-displaced old element if it was this id
const old = existing.get(entry.id);
if (old && old !== currentEl) old.remove();
} else {
// Replace in-place so vote bar animation triggers
list.replaceChild(newEl, currentEl);
}
});
}
yOptions.observeDeep(() => render());
render();
return wrapper;
}

View File

@@ -1,44 +0,0 @@
import { toggleVote, deleteOption } from '../utils/store.js';
/**
* @param {{ id: string, name: string, votes: number, voted: boolean, totalVotes: number }} entry
*/
export function PollOption({ id, name, votes, voted, totalVotes }) {
const row = document.createElement('div');
row.className = `poll-option${voted ? ' poll-option--voted' : ''}`;
row.dataset.id = id;
const pct = totalVotes > 0 ? Math.round((votes / totalVotes) * 100) : 0;
row.innerHTML = `
<div class="poll-option__bar" style="width: ${pct}%"></div>
<div class="poll-option__content">
<span class="poll-option__name">${escapeHtml(name)}</span>
<div class="poll-option__actions">
<span class="poll-option__pct">${pct}%</span>
<span class="poll-option__count">${votes} vote${votes !== 1 ? 's' : ''}</span>
<button class="poll-option__vote-btn" aria-pressed="${voted}">
${voted ? 'Voted' : 'Vote'}
</button>
<button class="poll-option__delete-btn" aria-label="Remove option">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2 2l10 10M12 2L2 12" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
</button>
</div>
</div>
`;
row.querySelector('.poll-option__vote-btn').addEventListener('click', () => toggleVote(id));
row.querySelector('.poll-option__delete-btn').addEventListener('click', () => deleteOption(id));
return row;
}
function escapeHtml(str) {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}

View File

@@ -1,34 +0,0 @@
import { ydoc, yTitle } from '../utils/store.js';
export function PollTitle() {
const wrapper = document.createElement('div');
wrapper.className = 'poll-title-wrapper';
const input = document.createElement('input');
input.type = 'text';
input.id = 'poll-title';
input.className = 'poll-title-input';
input.placeholder = 'Untitled Poll';
input.maxLength = 120;
input.setAttribute('aria-label', 'Poll title');
input.value = yTitle.toString();
wrapper.appendChild(input);
// Sync from Yjs → input (only when not focused to avoid cursor jump)
yTitle.observe(() => {
if (document.activeElement !== input) {
input.value = yTitle.toString();
}
});
// Sync from input → Yjs
input.addEventListener('input', () => {
ydoc.transact(() => {
yTitle.delete(0, yTitle.length);
yTitle.insert(0, input.value);
});
});
return wrapper;
}

View File

@@ -1,38 +0,0 @@
import { roomName } from '../utils/store.js';
export function ShareSection() {
const url = `${window.location.origin}${window.location.pathname}?room=${encodeURIComponent(roomName)}`;
const section = document.createElement('div');
section.className = 'share-section';
section.innerHTML = `
<p class="share-label">Share this poll</p>
<div class="share-row">
<code class="share-url" title="${url}">${url}</code>
<button class="share-copy-btn">Copy link</button>
</div>
`;
const copyBtn = section.querySelector('.share-copy-btn');
copyBtn.addEventListener('click', async () => {
try {
await navigator.clipboard.writeText(url);
copyBtn.textContent = 'Copied!';
copyBtn.classList.add('share-copy-btn--success');
setTimeout(() => {
copyBtn.textContent = 'Copy link';
copyBtn.classList.remove('share-copy-btn--success');
}, 2000);
} catch {
// Fallback: select the text
const range = document.createRange();
range.selectNode(section.querySelector('.share-url'));
window.getSelection().removeAllRanges();
window.getSelection().addRange(range);
}
});
return section;
}

View File

@@ -1,50 +0,0 @@
import { provider } from '../utils/store.js';
export function StatusBar() {
const el = document.createElement('div');
el.className = 'status-bar';
const dot = document.createElement('span');
dot.className = 'status-dot connecting';
const statusText = document.createElement('span');
statusText.className = 'status-text';
statusText.textContent = 'Connecting';
const divider = document.createElement('span');
divider.className = 'status-divider';
divider.textContent = '·';
const peerText = document.createElement('span');
peerText.className = 'status-peers';
el.append(dot, statusText, divider, peerText);
// --- Connection state ---
let syncTimeout = setTimeout(() => {
statusText.textContent = 'Ready';
dot.className = 'status-dot ready';
}, 3000);
provider.on('synced', ({ synced }) => {
clearTimeout(syncTimeout);
dot.className = `status-dot ${synced ? 'connected' : 'connecting'}`;
statusText.textContent = synced ? 'Connected' : 'Connecting';
});
// --- Peer count ---
function updatePeerCount() {
const total = provider.awareness.getStates().size;
const others = total - 1;
peerText.textContent = others === 0
? 'Only you'
: `${others} other${others !== 1 ? 's' : ''}`;
}
provider.awareness.on('change', updatePeerCount);
updatePeerCount();
return el;
}

View File

@@ -1,41 +1,136 @@
import { StatusBar } from './components/StatusBar.js';
import { PollTitle } from './components/PollTitle.js';
import { AddOption } from './components/AddOption.js';
import { PollList } from './components/PollList.js';
import { ShareSection } from './components/ShareSection.js';
import * as Y from 'yjs';
import { WebrtcProvider } from 'y-webrtc';
const app = document.getElementById('app');
// --- Peer ID (stable across reloads) ---
// Header: logo + status
const header = document.createElement('header');
header.className = 'app-header';
function getOrCreatePeerId() {
let id = localStorage.getItem('peer-id');
if (!id) {
id = crypto.randomUUID();
localStorage.setItem('peer-id', id);
}
return id;
}
const wordmark = document.createElement('div');
wordmark.className = 'app-wordmark';
wordmark.innerHTML = `
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<rect x="2" y="4" width="16" height="2.5" rx="1.25" fill="currentColor"/>
<rect x="2" y="8.75" width="11" height="2.5" rx="1.25" fill="currentColor" opacity="0.6"/>
<rect x="2" y="13.5" width="13" height="2.5" rx="1.25" fill="currentColor" opacity="0.35"/>
</svg>
<span>Polly</span>
`;
const peerId = getOrCreatePeerId();
header.append(wordmark, StatusBar());
// --- Room name from URL ---
// Main card
const card = document.createElement('main');
card.className = 'app-card';
function getRoomName() {
const params = new URLSearchParams(window.location.search);
return params.get('room') || 'default-poll';
}
card.append(
PollTitle(),
AddOption(),
PollList(),
);
const roomName = getRoomName();
// Footer
const footer = document.createElement('footer');
footer.className = 'app-footer';
footer.appendChild(ShareSection());
// --- Yjs setup ---
app.append(header, card, footer);
const ydoc = new Y.Doc();
const provider = new WebrtcProvider(roomName, ydoc);
const yOptions = ydoc.getMap('poll-options');
// --- 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);
}
}
// --- UI rendering ---
const container = document.getElementById('poll-options');
const emptyState = document.getElementById('empty-state');
function render() {
container.innerHTML = '';
const entries = [];
yOptions.forEach((optionMap, id) => {
entries.push({
id,
name: optionMap.get('name'),
votes: optionMap.get('votes').size,
voted: optionMap.get('votes').has(peerId),
});
});
entries.sort((a, b) => b.votes - a.votes || a.name.localeCompare(b.name));
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();
// --- Initial render ---
render();

View File

@@ -1,412 +1,144 @@
/* ── Fonts ─────────────────────────────────────────────── */
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@300;400;500;600&family=Playfair+Display:wght@500&display=swap');
/* ── Tokens ────────────────────────────────────────────── */
:root {
--bg: #F7F6F2;
--surface: #FFFFFF;
--surface-hover: #FAFAF8;
--border: #E8E5DF;
--border-focus: #1A1A1A;
--text-primary: #1A1A1A;
--text-secondary: #6B6860;
--text-muted: #AAA79F;
--accent: #1A1A1A;
--accent-text: #FFFFFF;
--vote-bar: rgba(26, 26, 26, 0.07);
--vote-bar-voted: rgba(26, 26, 26, 0.12);
--success: #2D7D46;
--danger: #C0392B;
--radius-sm: 6px;
--radius-md: 10px;
--radius-lg: 14px;
--font-display: 'Playfair Display', Georgia, serif;
--font-body: 'DM Sans', system-ui, sans-serif;
--shadow-card: 0 1px 3px rgba(0,0,0,0.06), 0 4px 16px rgba(0,0,0,0.05);
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
/* ── Reset ─────────────────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
/* ── Base ──────────────────────────────────────────────── */
html { font-size: 16px; }
body {
font-family: var(--font-body);
background: var(--bg);
color: var(--text-primary);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: #f5f5f5;
color: #333;
min-height: 100vh;
-webkit-font-smoothing: antialiased;
}
/* ── Layout ────────────────────────────────────────────── */
#app {
max-width: 580px;
max-width: 600px;
margin: 0 auto;
padding: 2rem 1.25rem 4rem;
display: flex;
flex-direction: column;
gap: 1.25rem;
padding: 2rem 1rem;
}
/* ── Header ────────────────────────────────────────────── */
.app-header {
header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 0.25rem;
}
.app-wordmark {
display: flex;
align-items: center;
gap: 0.5rem;
font-family: var(--font-display);
font-size: 1.1rem;
color: var(--text-primary);
letter-spacing: -0.01em;
margin-bottom: 2rem;
}
/* ── Status bar ────────────────────────────────────────── */
.status-bar {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.8rem;
color: var(--text-secondary);
}
.status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
transition: background 0.3s;
}
.status-dot.connecting { background: var(--text-muted); }
.status-dot.ready { background: var(--text-muted); }
.status-dot.connected { background: var(--success); }
.status-divider { color: var(--text-muted); }
/* ── Card ──────────────────────────────────────────────── */
.app-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
overflow: hidden;
}
/* ── Poll Title ────────────────────────────────────────── */
.poll-title-wrapper {
padding: 1.75rem 1.75rem 1.25rem;
border-bottom: 1px solid var(--border);
}
.poll-title-input {
width: 100%;
font-family: var(--font-display);
h1 {
font-size: 1.5rem;
font-weight: 500;
color: var(--text-primary);
background: transparent;
border: none;
outline: none;
line-height: 1.3;
letter-spacing: -0.02em;
}
.poll-title-input::placeholder { color: var(--text-muted); }
/* ── Add Option ────────────────────────────────────────── */
.add-option-wrapper {
#status {
display: flex;
gap: 0.625rem;
padding: 1.25rem 1.75rem;
border-bottom: 1px solid var(--border);
gap: 1rem;
font-size: 0.85rem;
color: #666;
}
.add-option-input {
#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: #4caf50;
}
#connection-status.connecting::before {
background: #ff9800;
}
#add-option {
display: flex;
gap: 0.5rem;
margin-bottom: 1.5rem;
}
#option-input {
flex: 1;
height: 2.5rem;
padding: 0 0.875rem;
font-family: var(--font-body);
font-size: 0.9rem;
color: var(--text-primary);
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 0.6rem 0.8rem;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 1rem;
outline: none;
transition: border-color 0.15s;
}
.add-option-input::placeholder { color: var(--text-muted); }
.add-option-input:focus { border-color: var(--border-focus); }
.add-option-input.shake {
animation: shake 0.3s ease;
#option-input:focus {
border-color: #2196f3;
}
@keyframes shake {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-4px); }
75% { transform: translateX(4px); }
}
.add-option-btn {
display: flex;
align-items: center;
gap: 0.375rem;
height: 2.5rem;
padding: 0 1rem;
font-family: var(--font-body);
font-size: 0.875rem;
font-weight: 500;
color: var(--accent-text);
background: var(--accent);
#add-btn {
padding: 0.6rem 1.2rem;
background: #2196f3;
color: white;
border: none;
border-radius: var(--radius-sm);
border-radius: 6px;
font-size: 1rem;
cursor: pointer;
transition: opacity 0.15s;
white-space: nowrap;
}
.add-option-btn:hover { opacity: 0.85; }
.add-option-btn:active { opacity: 0.7; }
/* ── Poll List ─────────────────────────────────────────── */
.poll-list-wrapper {
padding: 0.5rem 0;
#add-btn:hover {
background: #1976d2;
}
.poll-list-meta {
padding: 0.5rem 1.75rem 0.75rem;
font-size: 0.775rem;
color: var(--text-muted);
letter-spacing: 0.02em;
text-transform: uppercase;
font-weight: 500;
}
.poll-list-empty {
padding: 3rem 1.75rem;
text-align: center;
color: var(--text-muted);
font-size: 0.875rem;
}
.empty-icon {
margin-bottom: 0.75rem;
opacity: 0.6;
}
/* ── Poll Option ───────────────────────────────────────── */
.poll-option {
position: relative;
overflow: hidden;
transition: background 0.15s;
}
.poll-option:hover {
background: var(--surface-hover);
}
.poll-option__bar {
position: absolute;
inset: 0 auto 0 0;
background: var(--vote-bar);
transition: width 0.5s cubic-bezier(0.4, 0, 0.2, 1);
pointer-events: none;
}
.poll-option--voted .poll-option__bar {
background: var(--vote-bar-voted);
}
.poll-option__content {
position: relative;
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.875rem 1.75rem;
background: white;
padding: 0.8rem 1rem;
border-radius: 8px;
margin-bottom: 0.5rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
}
.poll-option__name {
.option-name {
flex: 1;
font-size: 0.9375rem;
font-weight: 400;
color: var(--text-primary);
word-break: break-word;
font-size: 1rem;
}
.poll-option--voted .poll-option__name {
font-weight: 500;
.vote-count {
font-size: 0.9rem;
font-weight: 600;
color: #666;
margin-right: 0.75rem;
min-width: 1.5rem;
text-align: center;
}
.poll-option__actions {
display: flex;
align-items: center;
gap: 0.625rem;
flex-shrink: 0;
}
.poll-option__pct {
font-size: 0.8125rem;
font-weight: 500;
color: var(--text-secondary);
min-width: 2.5rem;
text-align: right;
}
.poll-option__count {
font-size: 0.775rem;
color: var(--text-muted);
min-width: 3.5rem;
}
.poll-option__vote-btn {
height: 1.875rem;
padding: 0 0.875rem;
font-family: var(--font-body);
font-size: 0.8125rem;
font-weight: 500;
border-radius: var(--radius-sm);
.vote-btn {
padding: 0.4rem 1rem;
border: 2px solid #2196f3;
border-radius: 6px;
font-size: 0.85rem;
cursor: pointer;
transition: all 0.15s;
white-space: nowrap;
background: transparent;
color: var(--text-secondary);
border: 1px solid var(--border);
background: white;
color: #2196f3;
}
.poll-option__vote-btn:hover {
border-color: var(--accent);
color: var(--accent);
.vote-btn:hover {
background: #e3f2fd;
}
.poll-option--voted .poll-option__vote-btn {
background: var(--accent);
color: var(--accent-text);
border-color: var(--accent);
.vote-btn.voted {
background: #2196f3;
color: white;
}
.poll-option--voted .poll-option__vote-btn:hover {
opacity: 0.8;
.vote-btn.voted:hover {
background: #1976d2;
}
.poll-option__delete-btn {
display: flex;
align-items: center;
justify-content: center;
width: 1.625rem;
height: 1.625rem;
background: transparent;
border: none;
border-radius: var(--radius-sm);
color: var(--text-muted);
cursor: pointer;
opacity: 0;
transition: opacity 0.15s, color 0.15s, background 0.15s;
#empty-state {
text-align: center;
color: #999;
padding: 2rem;
}
.poll-option:hover .poll-option__delete-btn { opacity: 1; }
.poll-option__delete-btn:hover {
color: var(--danger);
background: rgba(192, 57, 43, 0.07);
}
/* ── Footer ────────────────────────────────────────────── */
.app-footer {
padding: 0 0.25rem;
}
/* ── Share Section ─────────────────────────────────────── */
.share-section {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
padding: 1.25rem 1.5rem;
}
.share-label {
font-size: 0.775rem;
font-weight: 500;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 0.625rem;
}
.share-row {
display: flex;
align-items: center;
gap: 0.625rem;
}
.share-url {
flex: 1;
font-family: 'DM Mono', 'Fira Mono', monospace;
font-size: 0.8rem;
color: var(--text-secondary);
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 0.5rem 0.75rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: block;
user-select: all;
}
.share-copy-btn {
height: 2rem;
padding: 0 0.875rem;
font-family: var(--font-body);
font-size: 0.8125rem;
font-weight: 500;
color: var(--text-secondary);
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
cursor: pointer;
transition: all 0.15s;
white-space: nowrap;
flex-shrink: 0;
}
.share-copy-btn:hover {
border-color: var(--accent);
color: var(--accent);
}
.share-copy-btn--success {
color: var(--success) !important;
border-color: var(--success) !important;
}
/* ── Responsive ────────────────────────────────────────── */
@media (max-width: 480px) {
#app { padding: 1rem 0.75rem 3rem; }
.poll-title-wrapper { padding: 1.25rem 1.25rem 1rem; }
.add-option-wrapper { padding: 1rem 1.25rem; }
.poll-option__content { padding: 0.875rem 1.25rem; }
.poll-list-meta { padding: 0.5rem 1.25rem 0.625rem; }
.poll-list-empty { padding: 2.5rem 1.25rem; }
.poll-option__count { display: none; }
.share-section { padding: 1rem 1.25rem; }
#empty-state.hidden {
display: none;
}

View File

@@ -1,79 +0,0 @@
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;
}
// --- Room name from URL ---
function getRoomName() {
const params = new URLSearchParams(window.location.search);
return params.get('room') || 'default-poll';
}
// --- Yjs setup ---
export const peerId = getOrCreatePeerId();
export const roomName = getRoomName();
export const ydoc = new Y.Doc();
export const provider = new WebrtcProvider(roomName, ydoc);
export const yOptions = ydoc.getMap('poll-options');
export const yTitle = ydoc.getText('poll-title');
// --- Data operations ---
export 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);
}
export 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);
}
}
export function deleteOption(optionId) {
yOptions.delete(optionId);
}
// --- Derived read helpers ---
export function getEntries() {
const entries = [];
yOptions.forEach((optionMap, id) => {
entries.push({
id,
name: optionMap.get('name'),
votes: optionMap.get('votes').size,
voted: optionMap.get('votes').has(peerId),
});
});
entries.sort((a, b) => b.votes - a.votes || a.name.localeCompare(b.name));
return entries;
}
export function getTotalVotes() {
let total = 0;
yOptions.forEach((optionMap) => {
total += optionMap.get('votes').size;
});
return total;
}