Compare commits

..

7 Commits

Author SHA1 Message Date
78d872c83e * fix 2026-05-06 23:18:08 +02:00
5f36ff0b7d + create user, login, logout 2026-05-06 23:17:50 +02:00
424799692a - remove unnecessary / deprecated code 2026-05-06 19:34:13 +02:00
5ecb5f1076 * fix keep old state and only update on legal actions 2026-05-06 00:12:03 +02:00
043e813864 * enforceAppendOnly for options, votes and deadlines 2026-05-04 23:02:54 +02:00
2abc0f8930 * fix WebrtcProvider use localhost for dev 2026-05-04 22:56:51 +02:00
Patrick Charrier
32e39384d5 feat: add combined codebase 2026-04-15 01:23:33 +02:00
31 changed files with 1704 additions and 724 deletions

4
.gitignore vendored
View File

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

102
PLAN.md Normal file
View File

@@ -0,0 +1,102 @@
# Plan: Combined P2P Polling App
## Overview
Merge the three subfolder projects into a single TypeScript + Yjs + Vite application at the repo root, combining the best features from each.
## Technology Choices
- **Language:** TypeScript (from project 3) with strict mode
- **CRDT:** Yjs + y-webrtc + y-indexeddb (from projects 1 & 3)
- **Build:** Vite (shared by all)
- **Package manager:** npm
## Feature Superset
From **project 1** (group-efa16e66):
- Collaborative title editing via Y.Text (real-time character-level sync)
- Delete options
- Diff-rendering for poll list (reuse DOM elements)
- Peer count display via awareness
- Share section with copy-to-clipboard
From **project 2** (proposal-8835ffc9):
- Deadline/timer system (2-minute voting window with countdown)
- Duplicate detection (case-insensitive)
From **project 3** (proposal-88461784):
- TypeScript types & strict mode
- Modular architecture (state, sync, render, identity, app layers)
- IndexedDB persistence (offline support)
- Online/offline connection tracking
- Input validation (max lengths, empty checks)
- HTML escaping for XSS prevention
- ViewModel pattern for clean render layer
## Data Model (Yjs)
```
Y.Doc
├── poll-meta (Y.Map<string>)
│ └── title → Y.Text (collaborative editing from project 1)
├── poll-options (Y.Map<OptionRecord>)
│ └── [optionId] → { id, label, createdAt, createdBy }
├── poll-votes (Y.Map<string>)
│ └── [userId] → optionId (single vote per user)
└── poll-deadline (Y.Map<any>)
└── deadline → number | null (timestamp, from project 2)
```
## Architecture & File Structure
```
/
├── index.html
├── package.json
├── tsconfig.json
├── vite.config.ts (if needed for any plugins)
├── src/
│ ├── main.ts (entry: mount app)
│ ├── app.ts (orchestrator: init sync, bind events, manage state)
│ ├── identity.ts (getUserId with localStorage persistence)
│ ├── state.ts (types, pure functions, ViewModel creation)
│ ├── sync.ts (Yjs doc, WebRTC provider, IndexedDB, connection status)
│ ├── render.ts (DOM rendering with escapeHtml, diff-rendering for options)
│ ├── components/
│ │ ├── PollTitle.ts (collaborative title input bound to Y.Text)
│ │ ├── PollList.ts (diff-rendered option list with sorting)
│ │ ├── PollOption.ts (single option: vote bar, vote/delete buttons)
│ │ ├── AddOption.ts (input + submit with validation & duplicate check)
│ │ ├── StatusBar.ts (connection status + peer count)
│ │ ├── ShareSection.ts (copy URL to clipboard)
│ │ └── DeadlineTimer.ts (set deadline + countdown display, from project 2)
│ └── styles.css (merged: project 1's design tokens + project 3's glassmorphism)
```
## Implementation Steps
### Step 1: Scaffold root project
- Create `package.json` with dependencies: yjs, y-webrtc, y-indexeddb, vite, typescript
- Create `tsconfig.json` (strict, ES2022, bundler resolution)
- Create `index.html` entry point
- Create `vite.config.ts` if needed
### Step 2: Core layer — identity.ts, state.ts, sync.ts
- `identity.ts`: port from project 3 (getUserId)
- `state.ts`: port types from project 3, add deadline types, add ViewModel with deadline/timer info, add vote percentage calculation from project 1
- `sync.ts`: port from project 3, add Y.Text for title (from project 1), add poll-deadline map, add awareness tracking for peer count (from project 1)
### Step 3: App orchestrator — app.ts, main.ts
- `app.ts`: port from project 3, add deadline handlers, add delete option handler, wire up all components
- `main.ts`: minimal entry that calls initApp
### Step 4: Components
- `PollTitle.ts`: port collaborative Y.Text editing from project 1, add TypeScript types
- `AddOption.ts`: merge project 1 (UI/animation) + project 2 (duplicate detection) + project 3 (validation)
- `PollOption.ts`: port from project 1 (vote bar, percentage, delete button), add TypeScript
- `PollList.ts`: port diff-rendering from project 1, add TypeScript
- `StatusBar.ts`: merge project 1 (peer count) + project 3 (online/offline status)
- `ShareSection.ts`: port from project 1, add TypeScript
- `DeadlineTimer.ts`: new component porting project 2's deadline/countdown logic to Yjs
### Step 5: Styling
- Merge CSS: use project 1's design tokens and typography as base, incorporate project 3's glassmorphism panel effects, add timer-specific styles from project 2
### Step 6: Cleanup
- Remove three subfolders (after confirming with user)
- Update root README.md

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
# P2P Poll App

View File

@@ -1,13 +1,12 @@
<!DOCTYPE html>
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Polly — P2P Polls</title>
<link rel="stylesheet" href="/src/style.css">
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Polly — P2P Polls</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

524
package-lock.json generated
View File

@@ -1,24 +1,27 @@
{
"name": "p2p-poll",
"name": "polly-p2p-poll",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "p2p-poll",
"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.0"
"yjs": "^13.6.27"
},
"devDependencies": {
"vite": "^6.0.0"
"typescript": "^5.9.2",
"vite": "^7.1.5"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
"integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz",
"integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==",
"cpu": [
"ppc64"
],
@@ -33,9 +36,9 @@
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
"integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz",
"integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==",
"cpu": [
"arm"
],
@@ -50,9 +53,9 @@
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
"integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz",
"integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==",
"cpu": [
"arm64"
],
@@ -67,9 +70,9 @@
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
"integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz",
"integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==",
"cpu": [
"x64"
],
@@ -84,9 +87,9 @@
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
"integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz",
"integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==",
"cpu": [
"arm64"
],
@@ -101,9 +104,9 @@
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
"integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz",
"integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==",
"cpu": [
"x64"
],
@@ -118,9 +121,9 @@
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
"integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz",
"integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==",
"cpu": [
"arm64"
],
@@ -135,9 +138,9 @@
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
"integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz",
"integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==",
"cpu": [
"x64"
],
@@ -152,9 +155,9 @@
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
"integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz",
"integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==",
"cpu": [
"arm"
],
@@ -169,9 +172,9 @@
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
"integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz",
"integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==",
"cpu": [
"arm64"
],
@@ -186,9 +189,9 @@
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
"integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz",
"integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==",
"cpu": [
"ia32"
],
@@ -203,9 +206,9 @@
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
"integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz",
"integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==",
"cpu": [
"loong64"
],
@@ -220,9 +223,9 @@
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
"integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz",
"integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==",
"cpu": [
"mips64el"
],
@@ -237,9 +240,9 @@
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
"integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz",
"integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==",
"cpu": [
"ppc64"
],
@@ -254,9 +257,9 @@
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
"integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz",
"integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==",
"cpu": [
"riscv64"
],
@@ -271,9 +274,9 @@
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
"integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz",
"integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==",
"cpu": [
"s390x"
],
@@ -288,9 +291,9 @@
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
"integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz",
"integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==",
"cpu": [
"x64"
],
@@ -305,9 +308,9 @@
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
"integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz",
"integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==",
"cpu": [
"arm64"
],
@@ -322,9 +325,9 @@
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
"integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz",
"integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==",
"cpu": [
"x64"
],
@@ -339,9 +342,9 @@
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
"integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz",
"integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==",
"cpu": [
"arm64"
],
@@ -356,9 +359,9 @@
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
"integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz",
"integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==",
"cpu": [
"x64"
],
@@ -373,9 +376,9 @@
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
"integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz",
"integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==",
"cpu": [
"arm64"
],
@@ -390,9 +393,9 @@
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
"integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz",
"integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==",
"cpu": [
"x64"
],
@@ -407,9 +410,9 @@
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
"integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz",
"integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==",
"cpu": [
"arm64"
],
@@ -424,9 +427,9 @@
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
"integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz",
"integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==",
"cpu": [
"ia32"
],
@@ -441,9 +444,9 @@
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
"integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz",
"integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==",
"cpu": [
"x64"
],
@@ -458,9 +461,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.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz",
"integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==",
"cpu": [
"arm"
],
@@ -472,9 +475,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.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz",
"integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==",
"cpu": [
"arm64"
],
@@ -486,9 +489,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.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz",
"integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==",
"cpu": [
"arm64"
],
@@ -500,9 +503,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.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz",
"integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==",
"cpu": [
"x64"
],
@@ -514,9 +517,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.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz",
"integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==",
"cpu": [
"arm64"
],
@@ -528,9 +531,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.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz",
"integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==",
"cpu": [
"x64"
],
@@ -542,9 +545,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.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz",
"integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==",
"cpu": [
"arm"
],
@@ -556,9 +559,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.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz",
"integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==",
"cpu": [
"arm"
],
@@ -570,9 +573,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.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz",
"integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==",
"cpu": [
"arm64"
],
@@ -584,9 +587,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.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz",
"integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==",
"cpu": [
"arm64"
],
@@ -598,9 +601,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.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz",
"integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==",
"cpu": [
"loong64"
],
@@ -612,9 +615,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.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz",
"integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==",
"cpu": [
"loong64"
],
@@ -626,9 +629,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.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz",
"integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==",
"cpu": [
"ppc64"
],
@@ -640,9 +643,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.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz",
"integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==",
"cpu": [
"ppc64"
],
@@ -654,9 +657,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.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz",
"integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==",
"cpu": [
"riscv64"
],
@@ -668,9 +671,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.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz",
"integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==",
"cpu": [
"riscv64"
],
@@ -682,9 +685,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.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz",
"integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==",
"cpu": [
"s390x"
],
@@ -696,9 +699,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.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz",
"integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==",
"cpu": [
"x64"
],
@@ -710,9 +713,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.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz",
"integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==",
"cpu": [
"x64"
],
@@ -724,9 +727,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.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz",
"integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==",
"cpu": [
"x64"
],
@@ -738,9 +741,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.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz",
"integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==",
"cpu": [
"arm64"
],
@@ -752,9 +755,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.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz",
"integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==",
"cpu": [
"arm64"
],
@@ -766,9 +769,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.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz",
"integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==",
"cpu": [
"ia32"
],
@@ -780,9 +783,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.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz",
"integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==",
"cpu": [
"x64"
],
@@ -794,9 +797,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.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz",
"integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==",
"cpu": [
"x64"
],
@@ -882,9 +885,9 @@
"license": "MIT"
},
"node_modules/esbuild": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
"integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz",
"integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
@@ -895,32 +898,32 @@
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.25.12",
"@esbuild/android-arm": "0.25.12",
"@esbuild/android-arm64": "0.25.12",
"@esbuild/android-x64": "0.25.12",
"@esbuild/darwin-arm64": "0.25.12",
"@esbuild/darwin-x64": "0.25.12",
"@esbuild/freebsd-arm64": "0.25.12",
"@esbuild/freebsd-x64": "0.25.12",
"@esbuild/linux-arm": "0.25.12",
"@esbuild/linux-arm64": "0.25.12",
"@esbuild/linux-ia32": "0.25.12",
"@esbuild/linux-loong64": "0.25.12",
"@esbuild/linux-mips64el": "0.25.12",
"@esbuild/linux-ppc64": "0.25.12",
"@esbuild/linux-riscv64": "0.25.12",
"@esbuild/linux-s390x": "0.25.12",
"@esbuild/linux-x64": "0.25.12",
"@esbuild/netbsd-arm64": "0.25.12",
"@esbuild/netbsd-x64": "0.25.12",
"@esbuild/openbsd-arm64": "0.25.12",
"@esbuild/openbsd-x64": "0.25.12",
"@esbuild/openharmony-arm64": "0.25.12",
"@esbuild/sunos-x64": "0.25.12",
"@esbuild/win32-arm64": "0.25.12",
"@esbuild/win32-ia32": "0.25.12",
"@esbuild/win32-x64": "0.25.12"
"@esbuild/aix-ppc64": "0.27.7",
"@esbuild/android-arm": "0.27.7",
"@esbuild/android-arm64": "0.27.7",
"@esbuild/android-x64": "0.27.7",
"@esbuild/darwin-arm64": "0.27.7",
"@esbuild/darwin-x64": "0.27.7",
"@esbuild/freebsd-arm64": "0.27.7",
"@esbuild/freebsd-x64": "0.27.7",
"@esbuild/linux-arm": "0.27.7",
"@esbuild/linux-arm64": "0.27.7",
"@esbuild/linux-ia32": "0.27.7",
"@esbuild/linux-loong64": "0.27.7",
"@esbuild/linux-mips64el": "0.27.7",
"@esbuild/linux-ppc64": "0.27.7",
"@esbuild/linux-riscv64": "0.27.7",
"@esbuild/linux-s390x": "0.27.7",
"@esbuild/linux-x64": "0.27.7",
"@esbuild/netbsd-arm64": "0.27.7",
"@esbuild/netbsd-x64": "0.27.7",
"@esbuild/openbsd-arm64": "0.27.7",
"@esbuild/openbsd-x64": "0.27.7",
"@esbuild/openharmony-arm64": "0.27.7",
"@esbuild/sunos-x64": "0.27.7",
"@esbuild/win32-arm64": "0.27.7",
"@esbuild/win32-ia32": "0.27.7",
"@esbuild/win32-x64": "0.27.7"
}
},
"node_modules/fdir": {
@@ -1065,9 +1068,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.8",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
"integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
"version": "8.5.9",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz",
"integrity": "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==",
"dev": true,
"funding": [
{
@@ -1137,9 +1140,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.60.1",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz",
"integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1153,31 +1156,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.60.1",
"@rollup/rollup-android-arm64": "4.60.1",
"@rollup/rollup-darwin-arm64": "4.60.1",
"@rollup/rollup-darwin-x64": "4.60.1",
"@rollup/rollup-freebsd-arm64": "4.60.1",
"@rollup/rollup-freebsd-x64": "4.60.1",
"@rollup/rollup-linux-arm-gnueabihf": "4.60.1",
"@rollup/rollup-linux-arm-musleabihf": "4.60.1",
"@rollup/rollup-linux-arm64-gnu": "4.60.1",
"@rollup/rollup-linux-arm64-musl": "4.60.1",
"@rollup/rollup-linux-loong64-gnu": "4.60.1",
"@rollup/rollup-linux-loong64-musl": "4.60.1",
"@rollup/rollup-linux-ppc64-gnu": "4.60.1",
"@rollup/rollup-linux-ppc64-musl": "4.60.1",
"@rollup/rollup-linux-riscv64-gnu": "4.60.1",
"@rollup/rollup-linux-riscv64-musl": "4.60.1",
"@rollup/rollup-linux-s390x-gnu": "4.60.1",
"@rollup/rollup-linux-x64-gnu": "4.60.1",
"@rollup/rollup-linux-x64-musl": "4.60.1",
"@rollup/rollup-openbsd-x64": "4.60.1",
"@rollup/rollup-openharmony-arm64": "4.60.1",
"@rollup/rollup-win32-arm64-msvc": "4.60.1",
"@rollup/rollup-win32-ia32-msvc": "4.60.1",
"@rollup/rollup-win32-x64-gnu": "4.60.1",
"@rollup/rollup-win32-x64-msvc": "4.60.1",
"fsevents": "~2.3.2"
}
},
@@ -1250,14 +1253,14 @@
}
},
"node_modules/tinyglobby": {
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
"integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
"version": "0.2.16",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
"integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==",
"dev": true,
"license": "MIT",
"dependencies": {
"fdir": "^6.5.0",
"picomatch": "^4.0.3"
"picomatch": "^4.0.4"
},
"engines": {
"node": ">=12.0.0"
@@ -1266,31 +1269,58 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"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": "6.4.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
"version": "7.3.2",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz",
"integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.4.4",
"picomatch": "^4.0.2",
"postcss": "^8.5.3",
"rollup": "^4.34.9",
"tinyglobby": "^0.2.13"
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
"picomatch": "^4.0.3",
"postcss": "^8.5.6",
"rollup": "^4.43.0",
"tinyglobby": "^0.2.15"
},
"bin": {
"vite": "bin/vite.js"
},
"engines": {
"node": "^18.0.0 || ^20.0.0 || >=22.0.0"
"node": "^20.19.0 || >=22.12.0"
},
"funding": {
"url": "https://github.com/vitejs/vite?sponsor=1"
@@ -1299,14 +1329,14 @@
"fsevents": "~2.3.3"
},
"peerDependencies": {
"@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
"@types/node": "^20.19.0 || >=22.12.0",
"jiti": ">=1.21.0",
"less": "*",
"less": "^4.0.0",
"lightningcss": "^1.21.0",
"sass": "*",
"sass-embedded": "*",
"stylus": "*",
"sugarss": "*",
"sass": "^1.70.0",
"sass-embedded": "^1.70.0",
"stylus": ">=0.54.8",
"sugarss": "^5.0.0",
"terser": "^5.16.0",
"tsx": "^4.8.1",
"yaml": "^2.4.2"
@@ -1369,6 +1399,26 @@
}
}
},
"node_modules/y-indexeddb": {
"version": "9.0.12",
"resolved": "https://registry.npmjs.org/y-indexeddb/-/y-indexeddb-9.0.12.tgz",
"integrity": "sha512-9oCFRSPPzBK7/w5vOkJBaVCQZKHXB/v6SIT+WYhnJxlEC61juqG0hBrAf+y3gmSMLFLwICNH9nQ53uscuse6Hg==",
"license": "MIT",
"dependencies": {
"lib0": "^0.2.74"
},
"engines": {
"node": ">=16.0.0",
"npm": ">=8.0.0"
},
"funding": {
"type": "GitHub Sponsors ❤",
"url": "https://github.com/sponsors/dmonad"
},
"peerDependencies": {
"yjs": "^13.0.0"
}
},
"node_modules/y-protocols": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/y-protocols/-/y-protocols-1.0.7.tgz",

View File

@@ -1,18 +1,21 @@
{
"name": "p2p-poll",
"private": true,
"name": "polly-p2p-poll",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"dev": "PORT=4444 npx y-webrtc & vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview"
},
"dependencies": {
"yjs": "^13.6.0",
"y-webrtc": "^10.3.0"
"y-indexeddb": "^9.0.12",
"y-webrtc": "^10.3.0",
"yjs": "^13.6.27",
"uuid": "^13.0.0"
},
"devDependencies": {
"vite": "^6.0.0"
"typescript": "^5.9.2",
"vite": "^7.1.5"
}
}

193
src/app.ts Normal file
View File

@@ -0,0 +1,193 @@
import {
User,
addOption,
toggleVote,
setDeadline,
clearDeadline,
getDeadline,
} from "./state";
import { v4 as uuidv4 } from 'uuid';
import { initSync } from "./sync";
import { initUserSync } from "./userSync";
import { StatusBar } from "./components/StatusBar";
import { PollTitle } from "./components/PollTitle";
import { AddOption } from "./components/AddOption";
import { PollList } from "./components/PollList";
import { ShareSection } from "./components/ShareSection";
import { DeadlineTimer } from "./components/DeadlineTimer";
import { generateUserKeyPair, exportPrivateKey, savePrivateKeyToFile, exportPublicKey, stringToCryptoKey } from "./crypto";
const ROOM_PARAM = "room";
function createRoomId(): string {
if (typeof crypto.randomUUID === "function") {
return `poll-${crypto.randomUUID().slice(0, 8)}`;
}
return `poll-${Math.random().toString(36).slice(2, 10)}`;
}
function ensureRoomId(): string {
const url = new URL(window.location.href);
let roomId = url.searchParams.get(ROOM_PARAM)?.trim();
if (!roomId) {
roomId = createRoomId();
url.searchParams.set(ROOM_PARAM, roomId);
window.history.replaceState({}, "", url);
}
return roomId;
}
export function initApp(container: HTMLElement): () => void {
const roomId = ensureRoomId();
const sync = initSync(roomId);
const userSync = initUserSync();
let user : User | undefined = undefined;
const shareUrl = window.location.href;
// --- Actions ---
const actions = {
addOption: (label: string) => {
if (!user || isVotingClosed()) return;
return addOption(sync.options, label, user.userid);
},
toggleVote: (optionId: string) => {
if (!user || isVotingClosed()) return;
toggleVote(sync.votes, user.userid, optionId);
},
startDeadline: (durationMs: number) => {
setDeadline(sync.deadlineMap, durationMs);
},
clearDeadline: () => {
clearDeadline(sync.deadlineMap);
},
onLoginLogout: async (event: Event) => {
if(user){
user = undefined
return false;
} else {
const target = event.target as HTMLInputElement;
const file = target.files?.[0];
if (file) {
try {
const content = await file.text();
console.log("File loaded: ");
if (file.name && content) {
try {
const uuid = file.name.replace(".pem", "");
// Standardize the string for the importer
const pkBase64 = content.replace(/-----BEGIN PRIVATE KEY-----|-----END PRIVATE KEY-----/g, "").replace(/\s+/g, "");
const key = await stringToCryptoKey(pkBase64, "private");
user = {
userid: uuid,
private_key: key,
public_key: undefined, // Note: You might need to import a pub key too!
};
console.log("Login successful for:", uuid);
return true;
} catch (err) {
console.error("Crypto Import Error:", err);
alert("The file content is not a valid Private Key.");
}
}
} catch (e) {
console.error("Failed to read file", e);
}
}
return false;
}
},
onCreateUser: async (event: Event) => {
try {
const keypair = await generateUserKeyPair();
console.log('keypair:', keypair);
const uuid = uuidv4();
user = {
userid: uuid,
private_key: keypair.privateKey,
public_key: keypair.publicKey,
};
const prvKeyString = await exportPrivateKey(keypair.privateKey);
savePrivateKeyToFile(prvKeyString,uuid+".pem")
const pubKeyString = await exportPublicKey(keypair.publicKey);
userSync.users.set(user.userid,pubKeyString)
return true;
} catch (err) {
user = undefined
console.error("Failed to create new User!", err);
}
return false;
}
};
function isVotingClosed() {
const deadline = getDeadline(sync.deadlineMap);
const votingClosed = deadline !== null && Date.now() >= deadline;
return votingClosed;
}
// --- Build UI ---
// Header
const header = document.createElement("header");
header.className = "app-header";
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 statusBar = StatusBar(sync.provider,userSync.provider,actions.onLoginLogout,actions.onCreateUser);
header.append(wordmark, statusBar);
// Main card
const card = document.createElement("main");
card.className = "app-card";
const pollTitle = PollTitle(sync.doc, sync.yTitle);
const addOptionComponent = AddOption((label: string) => {
const result = actions.addOption(label);
if (result && !result.ok) return result.error;
return null;
});
const pollList = PollList(sync.options, sync.votes, user, isVotingClosed, actions.toggleVote);
const deadlineTimer = DeadlineTimer(
sync.deadlineMap,
actions.startDeadline,
actions.clearDeadline,
);
card.append(pollTitle, addOptionComponent, deadlineTimer, pollList);
// Footer
const footer = document.createElement("footer");
footer.className = "app-footer";
footer.appendChild(ShareSection(roomId));
container.append(header, card, footer);
// --- Cleanup ---
return () => {
sync.destroy();
};
}

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

@@ -0,0 +1,66 @@
export function AddOption(
onSubmit: (label: string) => string | null,
): HTMLElement {
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\u2026";
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");
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>
`;
const feedback = document.createElement("div");
feedback.className = "add-option-feedback";
feedback.setAttribute("aria-live", "polite");
wrapper.append(input, btn, feedback);
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;
}
const error = onSubmit(name);
if (error) {
feedback.textContent = error;
feedback.style.display = "";
setTimeout(() => {
feedback.textContent = "";
feedback.style.display = "none";
}, 3000);
return;
}
input.value = "";
feedback.textContent = "";
feedback.style.display = "none";
input.focus();
}
btn.addEventListener("click", submit);
input.addEventListener("keydown", (e) => {
if (e.key === "Enter") submit();
});
input.addEventListener("input", () => {
feedback.textContent = "";
feedback.style.display = "none";
});
return wrapper;
}

View File

@@ -0,0 +1,87 @@
import * as Y from "yjs";
import { getDeadline } from "../state";
import { enforceAppendOnly } from "../yDocUtil";
const DEADLINE_DURATION_MS = 2 * 60 * 1000; // 2 minutes
export function DeadlineTimer(
deadlineMap: Y.Map<unknown>,
onStartDeadline: (durationMs: number) => void,
onClearDeadline: () => void,
): HTMLElement {
const wrapper = document.createElement("div");
wrapper.className = "deadline-wrapper";
const timerEl = document.createElement("span");
timerEl.className = "deadline-timer";
const startBtn = document.createElement("button");
startBtn.className = "deadline-btn";
startBtn.textContent = "Start 2-min vote";
startBtn.setAttribute("aria-label", "Start a 2-minute voting deadline");
const clearBtn = document.createElement("button");
clearBtn.className = "deadline-btn deadline-btn--clear";
clearBtn.textContent = "Clear";
clearBtn.setAttribute("aria-label", "Remove voting deadline");
wrapper.append(timerEl, startBtn, clearBtn);
let interval: ReturnType<typeof setInterval> | undefined;
function render() {
const deadline = getDeadline(deadlineMap);
const now = Date.now();
if (deadline === null) {
// No deadline set
timerEl.textContent = "";
timerEl.className = "deadline-timer";
startBtn.hidden = false;
clearBtn.hidden = true;
if (interval) {
clearInterval(interval);
interval = undefined;
}
return;
}
startBtn.hidden = true;
clearBtn.hidden = false;
if (now >= deadline) {
// Voting closed
timerEl.textContent = "Voting closed";
timerEl.className = "deadline-timer deadline-timer--closed";
if (interval) {
clearInterval(interval);
interval = undefined;
}
return;
}
// Counting down
const remaining = Math.ceil((deadline - now) / 1000);
const mins = Math.floor(remaining / 60);
const secs = remaining % 60;
timerEl.textContent = `Voting closes in ${mins}:${secs.toString().padStart(2, "0")}`;
timerEl.className = "deadline-timer deadline-timer--active";
if (!interval) {
interval = setInterval(() => render(), 1000);
}
}
startBtn.addEventListener("click", () => {
onStartDeadline(DEADLINE_DURATION_MS);
});
clearBtn.addEventListener("click", () => {
onClearDeadline();
});
deadlineMap.observe(enforceAppendOnly(deadlineMap,render));
render();
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;
}

139
src/components/PollList.ts Normal file
View File

@@ -0,0 +1,139 @@
import * as Y from "yjs";
import type { OptionRecord } from "../state";
import { PollOption } from "./PollOption";
import { enforceAppendOnly } from "../yDocUtil";
import { User } from "../state";
export function PollList(
yOptions: Y.Map<OptionRecord>,
yVotes: Y.Map<string>,
user: User | undefined,
isVotingClosed: () => boolean,
onVote: (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";
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 getEntries() {
const entries: Array<{
id: string;
name: string;
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);
}
let myVote = null;
if (user) {
myVote = currentVotes[user.userid]
}
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));
}
return entries;
}
function getTotalVotes(): number {
return yVotes.size;
}
function render() {
const entries = getEntries();
const total = getTotalVotes();
const votingClosed = isVotingClosed();
// Meta line
if (entries.length > 0) {
meta.textContent = `${entries.length} option${entries.length !== 1 ? "s" : ""} \u00b7 ${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<HTMLElement>(".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,
votingClosed,
onVote,
});
const currentEl = list.children[i] as HTMLElement | undefined;
if (!currentEl) {
list.appendChild(newEl);
} else if (currentEl.dataset.id !== entry.id) {
list.insertBefore(newEl, currentEl);
const old = existing.get(entry.id);
if (old && old !== currentEl) old.remove();
} else {
list.replaceChild(newEl, currentEl);
}
});
}
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()
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

@@ -0,0 +1,39 @@
import { escapeHtml } from "../state";
export interface PollOptionProps {
id: string;
name: string;
votes: number;
voted: boolean;
totalVotes: number;
votingClosed: boolean;
onVote: (id: string) => void;
}
export function PollOption(props: PollOptionProps): HTMLElement {
const { id, name, votes, voted, totalVotes, votingClosed, onVote } = props;
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}"${votingClosed ? " disabled" : ""}>
${voted ? "Voted" : "Vote"}
</button>
</div>
</div>
`;
row.querySelector(".poll-option__vote-btn")!.addEventListener("click", () => onVote(id));
return row;
}

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

@@ -0,0 +1,34 @@
import * as Y from "yjs";
export function PollTitle(ydoc: Y.Doc, yTitle: Y.Text): HTMLElement {
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

@@ -0,0 +1,39 @@
export function ShareSection(roomName: string): HTMLElement {
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<HTMLButtonElement>(".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();
const urlEl = section.querySelector(".share-url");
if (urlEl) {
range.selectNode(urlEl);
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;
}

174
src/components/StatusBar.ts Normal file
View File

@@ -0,0 +1,174 @@
import type { WebrtcProvider } from "y-webrtc";
export function StatusBar(provider: WebrtcProvider, user_provider: WebrtcProvider, onLoginLogout: (event: Event) => Promise<boolean>, onCreateUser: (event: Event) => Promise<boolean>): HTMLElement {
const el = document.createElement("div");
el.className = "status-bar";
const statusPanel=document.createElement("div");
statusPanel.className = "status-bar";
const divider = document.createElement("span");
divider.className = "status-divider";
divider.textContent = "\u00b7";
function getProviderStatus(){
const dot = document.createElement("span");
dot.className = "status-dot connecting";
const statusText = document.createElement("span");
statusText.className = "status-text";
statusText.textContent = "Connecting";
const peerText = document.createElement("span");
peerText.className = "status-peers";
return { dot: dot, statusText: statusText, peerText: peerText}
}
const providerStatusPanel=document.createElement("div");
providerStatusPanel.className = "provider-status-container";
const pollProviderText = document.createElement("span");
pollProviderText.className = "status-text";
pollProviderText.textContent = "Polls: ";
const pollProviderElements = getProviderStatus()
const pollProviderStatusPanel=document.createElement("div");
pollProviderStatusPanel.className = "status-bar";
pollProviderStatusPanel.append(pollProviderText,pollProviderElements.dot, pollProviderElements.statusText, divider, pollProviderElements.peerText);
const userProviderText = document.createElement("span");
userProviderText.className = "status-text";
userProviderText.textContent = "Users: ";
const userProviderElements = getProviderStatus()
const userProviderStatusPanel=document.createElement("div");
userProviderStatusPanel.className = "status-bar";
userProviderStatusPanel.append(userProviderText,userProviderElements.dot, userProviderElements.statusText, divider, userProviderElements.peerText);
providerStatusPanel.append(userProviderStatusPanel,pollProviderStatusPanel)
const userButtons = document.createElement("div");
userButtons.className = "status-bar";
const loginLabel = document.createElement("label");
loginLabel.setAttribute("title", "Select Key File");
const loginSpan = document.createElement("span");
loginSpan.className = "add-option-btn"
loginSpan.textContent = "Login";
const loginInput = document.createElement("input");
loginInput.type = "file"
loginInput.accept = ".pem"
loginInput.hidden = true
loginLabel.append(loginSpan,loginInput)
const logoutButton = document.createElement("button");
logoutButton.className = "add-option-btn";
logoutButton.setAttribute("aria-label", "Logout");
logoutButton.innerHTML="<span>Logout</span>"
logoutButton.style.display = "none";
const createUserButton = document.createElement("button");
createUserButton.className = "add-option-btn";
createUserButton.setAttribute("aria-label", "Create User");
createUserButton.innerHTML="<span>Create User</span>"
async function onLoginLogoutResult(event: Event, loginLogout: (event: Event) => Promise<boolean>){
if(await loginLogout(event)){
console.log('created / logged in')
loginLabel.style.display = "none";
logoutButton.style.display = "block";
createUserButton.style.display = "none";
} else {
console.log('logged out')
loginLabel.style.display = "block";
logoutButton.style.display = "none";
createUserButton.hidden = false;
createUserButton.style.display = "block";
}
}
loginLabel.addEventListener("change", (e) => onLoginLogoutResult(e,onLoginLogout));
logoutButton.addEventListener("click", (e) => onLoginLogoutResult(e,onLoginLogout));
createUserButton.addEventListener("click", (e) => onLoginLogoutResult(e,onCreateUser));
userButtons.append(loginLabel,logoutButton,createUserButton)
el.append(providerStatusPanel, divider, userButtons);
// --- Connection state ---
let syncTimeout: ReturnType<typeof setTimeout> | undefined = setTimeout(() => {
pollProviderElements.statusText.textContent = "Ready";
pollProviderElements.dot.className = "status-dot ready";
}, 3000);
provider.on("synced", ({ synced }: { synced: boolean }) => {
if (syncTimeout) {
clearTimeout(syncTimeout);
syncTimeout = undefined;
}
pollProviderElements.dot.className = `status-dot ${synced ? "connected" : "connecting"}`;
pollProviderElements.statusText.textContent = synced ? "Connected" : "Connecting";
});
let syncTimeout2: ReturnType<typeof setTimeout> | undefined = setTimeout(() => {
userProviderElements.statusText.textContent = "Ready";
userProviderElements.dot.className = "status-dot ready";
}, 3000);
user_provider.on("synced", ({ synced }: { synced: boolean }) => {
if (syncTimeout2) {
clearTimeout(syncTimeout2);
syncTimeout2 = undefined;
}
userProviderElements.dot.className = `status-dot ${synced ? "connected" : "connecting"}`;
userProviderElements.statusText.textContent = synced ? "Connected" : "Connecting";
});
// Online/offline awareness
const handleOffline = () => {
pollProviderElements.dot.className = "status-dot connecting";
pollProviderElements.statusText.textContent = "Offline";
userProviderElements.dot.className = "status-dot connecting";
userProviderElements.statusText.textContent = "Offline";
};
const handleOnline = () => {
pollProviderElements.dot.className = "status-dot connecting";
pollProviderElements.statusText.textContent = "Reconnecting";
userProviderElements.dot.className = "status-dot connecting";
userProviderElements.statusText.textContent = "Reconnecting";
};
window.addEventListener("offline", handleOffline);
window.addEventListener("online", handleOnline);
// --- Peer count ---
function updatePeerCount() {
const total = provider.awareness.getStates().size;
const others = total - 1;
pollProviderElements.peerText.textContent =
others === 0
? "Only you"
: `${others} other${others !== 1 ? "s" : ""}`;
const total2 = user_provider.awareness.getStates().size;
const others2 = total2 - 1;
userProviderElements.peerText.textContent =
others2 === 0
? "Only you"
: `${others2} other${others2 !== 1 ? "s" : ""}`;
}
provider.awareness.on("change", updatePeerCount);
updatePeerCount();
return el;
}

119
src/crypto.ts Normal file
View File

@@ -0,0 +1,119 @@
export const generateUserKeyPair = async () => {
return await window.crypto.subtle.generateKey(
{
name: "RSASSA-PKCS1-v1_5",
modulusLength: 2048,
publicExponent: new Uint8Array([1, 0, 1]), // 65537
hash: "SHA-256",
},
true, // extractable
["sign", "verify"]
);
};
export const signData = async (data: any, privateKey: CryptoKey) => {
const encoder = new TextEncoder();
const encodedData = encoder.encode(JSON.stringify(data));
const signature = await window.crypto.subtle.sign(
"RSASSA-PKCS1-v1_5",
privateKey,
encodedData
);
// Convert to Base64 or Hex to store in Yjs easily
return btoa(String.fromCharCode(...new Uint8Array(signature)));
};
// Helper to convert ArrayBuffer to Base64 string
const bufferToBase64 = (buf: ArrayBuffer) =>
window.btoa(String.fromCharCode(...new Uint8Array(buf)));
export const exportPublicKey = async (key: CryptoKey) => {
// Export Public Key
const exportedPublic = await window.crypto.subtle.exportKey("spki", key);
const publicKeyString = bufferToBase64(exportedPublic);
return publicKeyString;
};
export const exportPrivateKey = async (key: CryptoKey) => {
// Export Private Key
const exportedPrivate = await window.crypto.subtle.exportKey("pkcs8", key);
const privateKeyString = bufferToBase64(exportedPrivate);
return privateKeyString;
};
/**
* Converts a Base64 string back into a usable CryptoKey object
* @param keyStr The Base64 string (without PEM headers)
* @param type 'public' or 'private'
*/
export const stringToCryptoKey = async (keyStr: string, type: 'public' | 'private'): Promise<CryptoKey> => {
// 1. Convert Base64 string to a Uint8Array (binary)
const binaryString = window.atob(keyStr);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
// 2. Identify the format based on the key type
// Public keys usually use 'spki', Private keys use 'pkcs8'
const format = type === 'public' ? 'spki' : 'pkcs8';
const usages: KeyUsage[] = type === 'public' ? ['verify'] : ['sign'];
// 3. Import the key
return await window.crypto.subtle.importKey(
format,
bytes.buffer,
{
name: "RSASSA-PKCS1-v1_5",
hash: "SHA-256",
},
true, // extractable (set to false if you want to lock it in memory)
usages
);
};
export const savePrivateKeyToFile = (privateKeyStr: string, filename: string) => {
// Optional: Wrap in PEM headers for standard formatting
const pemHeader = "-----BEGIN PRIVATE KEY-----\n";
const pemFooter = "\n-----END PRIVATE KEY-----";
const fileContent = pemHeader + privateKeyStr + pemFooter;
const blob = new Blob([fileContent], { type: "text/plain" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
// Cleanup
document.body.removeChild(link);
URL.revokeObjectURL(url);
};
export const loadPrivateKeyFromFile = async (file: File): Promise<string> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (e) => {
const content = e.target?.result as string;
// Clean up the string by removing PEM headers and newlines
const cleanKey = content
.replace("-----BEGIN PRIVATE KEY-----", "")
.replace("-----END PRIVATE KEY-----", "")
.replace(/\s+/g, ""); // Removes all whitespace/newlines
resolve(cleanKey);
};
reader.onerror = () => reject("Error reading file");
reader.readAsText(file);
});
};

17
src/identity.ts Normal file
View File

@@ -0,0 +1,17 @@
const USER_ID_KEY = "polly:user-id";
function createUserId(): string {
if (typeof crypto.randomUUID === "function") {
return crypto.randomUUID();
}
return `user-${Math.random().toString(36).slice(2, 10)}`;
}
export function getUserId(): string {
const existing = localStorage.getItem(USER_ID_KEY);
if (existing) return existing;
const next = createUserId();
localStorage.setItem(USER_ID_KEY, next);
return next;
}

View File

@@ -1,41 +0,0 @@
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';
const app = document.getElementById('app');
// Header: logo + status
const header = document.createElement('header');
header.className = 'app-header';
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>
`;
header.append(wordmark, StatusBar());
// Main card
const card = document.createElement('main');
card.className = 'app-card';
card.append(
PollTitle(),
AddOption(),
PollList(),
);
// Footer
const footer = document.createElement('footer');
footer.className = 'app-footer';
footer.appendChild(ShareSection());
app.append(header, card, footer);

9
src/main.ts Normal file
View File

@@ -0,0 +1,9 @@
import "./styles.css";
import { initApp } from "./app";
const container = document.querySelector<HTMLElement>("#app");
if (!container) {
throw new Error("App container not found.");
}
initApp(container);

110
src/state.ts Normal file
View File

@@ -0,0 +1,110 @@
import * as Y from "yjs";
// --- Types ---
export type ConnectionStatus = "connecting" | "connected" | "offline";
export interface User{
userid: string,
private_key: CryptoKey,
public_key: CryptoKey | undefined,
}
export interface OptionRecord {
id: string;
label: string;
createdAt: number;
createdBy: string;
}
// --- Helpers ---
export function createOptionId(): string {
if (typeof crypto.randomUUID === "function") {
return crypto.randomUUID();
}
return `option-${Math.random().toString(36).slice(2, 10)}`;
}
function normalizeLabel(label: string): string {
return label.trim().replace(/\s+/g, " ");
}
export function escapeHtml(value: string): string {
return value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
// --- Title ---
export function getPollTitle(yTitle: Y.Text): string {
const title = yTitle.toString();
return title || "Untitled Poll";
}
// --- Options ---
export function addOption(
options: Y.Map<OptionRecord>,
rawLabel: string,
userId: string,
): { ok: true; optionId: string } | { ok: false; error: string } {
const label = normalizeLabel(rawLabel);
if (!label) {
return { ok: false, error: "Option cannot be empty." };
}
const normalizedTarget = label.toLocaleLowerCase();
const duplicate = Array.from(options.values()).some(
(option) => option.label.trim().toLocaleLowerCase() === normalizedTarget,
);
if (duplicate) {
return { ok: false, error: "That option already exists." };
}
const option: OptionRecord = {
id: createOptionId(),
label,
createdAt: Date.now(),
createdBy: userId,
};
options.set(option.id, option);
return { ok: true, optionId: option.id };
}
export function toggleVote(
votes: Y.Map<string>,
userId: string,
optionId: string,
): void {
const current = votes.get(userId);
if (current === optionId) {
votes.delete(userId);
} else {
votes.set(userId, optionId);
}
}
// --- Deadline ---
export function setDeadline(
deadlineMap: Y.Map<unknown>,
durationMs: number,
): void {
deadlineMap.set("deadline", Date.now() + durationMs);
}
export function clearDeadline(deadlineMap: Y.Map<unknown>): void {
deadlineMap.delete("deadline");
}
export function getDeadline(deadlineMap: Y.Map<unknown>): number | null {
const val = deadlineMap.get("deadline");
return typeof val === "number" ? val : null;
}

View File

@@ -21,6 +21,7 @@
--success: #2D7D46;
--danger: #C0392B;
--warning: #8c5300;
--radius-sm: 6px;
--radius-md: 10px;
@@ -46,6 +47,9 @@ body {
-webkit-font-smoothing: antialiased;
}
button, input { font: inherit; }
button { cursor: pointer; }
/* ── Layout ────────────────────────────────────────────── */
#app {
max-width: 580px;
@@ -82,6 +86,14 @@ body {
font-size: 0.8rem;
color: var(--text-secondary);
}
.provider-status-container {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.4rem;
font-size: 0.8rem;
color: var(--text-secondary);
}
.status-dot {
width: 6px;
@@ -130,6 +142,7 @@ body {
/* ── Add Option ────────────────────────────────────────── */
.add-option-wrapper {
display: flex;
flex-wrap: wrap;
gap: 0.625rem;
padding: 1.25rem 1.75rem;
border-bottom: 1px solid var(--border);
@@ -137,9 +150,9 @@ body {
.add-option-input {
flex: 1;
min-width: 0;
height: 2.5rem;
padding: 0 0.875rem;
font-family: var(--font-body);
font-size: 0.9rem;
color: var(--text-primary);
background: var(--bg);
@@ -168,14 +181,12 @@ body {
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);
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
transition: opacity 0.15s;
white-space: nowrap;
}
@@ -183,6 +194,60 @@ body {
.add-option-btn:hover { opacity: 0.85; }
.add-option-btn:active { opacity: 0.7; }
.add-option-feedback {
width: 100%;
font-size: 0.8rem;
color: var(--danger);
display: none;
}
/* ── Deadline Timer ────────────────────────────────────── */
.deadline-wrapper {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1.75rem;
border-bottom: 1px solid var(--border);
font-size: 0.85rem;
}
.deadline-timer {
flex: 1;
font-weight: 500;
}
.deadline-timer--active {
color: var(--success);
}
.deadline-timer--closed {
color: var(--danger);
font-weight: 600;
}
.deadline-btn {
height: 2rem;
padding: 0 0.875rem;
font-size: 0.8rem;
font-weight: 500;
color: var(--text-secondary);
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
transition: all 0.15s;
white-space: nowrap;
}
.deadline-btn:hover {
border-color: var(--accent);
color: var(--accent);
}
.deadline-btn--clear:hover {
border-color: var(--danger);
color: var(--danger);
}
/* ── Poll List ─────────────────────────────────────────── */
.poll-list-wrapper {
padding: 0.5rem 0;
@@ -276,31 +341,33 @@ body {
.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);
cursor: pointer;
transition: all 0.15s;
white-space: nowrap;
background: transparent;
color: var(--text-secondary);
border: 1px solid var(--border);
}
.poll-option__vote-btn:hover {
.poll-option__vote-btn:hover:not(:disabled) {
border-color: var(--accent);
color: var(--accent);
}
.poll-option__vote-btn:disabled {
opacity: 0.5;
cursor: default;
}
.poll-option--voted .poll-option__vote-btn {
background: var(--accent);
color: var(--accent-text);
border-color: var(--accent);
}
.poll-option--voted .poll-option__vote-btn:hover {
.poll-option--voted .poll-option__vote-btn:hover:not(:disabled) {
opacity: 0.8;
}
@@ -314,7 +381,6 @@ body {
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;
}
@@ -373,14 +439,12 @@ body {
.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;
@@ -405,8 +469,9 @@ body {
.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; }
.deadline-wrapper { padding: 0.75rem 1.25rem; }
.poll-option__count { display: none; }
.share-section { padding: 1rem 1.25rem; }
}
}

79
src/sync.ts Normal file
View File

@@ -0,0 +1,79 @@
import { IndexeddbPersistence } from "y-indexeddb";
import { WebrtcProvider } from "y-webrtc";
import * as Y from "yjs";
import type { ConnectionStatus, OptionRecord } from "./state";
export interface AppSync {
doc: Y.Doc;
yTitle: Y.Text;
options: Y.Map<OptionRecord>;
votes: Y.Map<string>;
deadlineMap: Y.Map<unknown>;
provider: WebrtcProvider;
persistence: IndexeddbPersistence;
getConnectionStatus: () => ConnectionStatus;
getPeerCount: () => number;
destroy: () => void;
}
export function initSync(roomId: string): AppSync {
const doc = new Y.Doc();
const yTitle = doc.getText("poll-title");
const options = doc.getMap<OptionRecord>("poll-options");
const votes = doc.getMap<string>("poll-votes");
const deadlineMap = doc.getMap<unknown>("poll-deadline");
let connectionStatus: ConnectionStatus = navigator.onLine
? "connecting"
: "offline";
const provider = new WebrtcProvider(roomId, doc,{
signaling: ["ws://localhost:4444", "ws://lynxpi.ddns.net:4444"]
});
const persistence = new IndexeddbPersistence(roomId, doc);
const syncConnectionStatus = (status: ConnectionStatus) => {
connectionStatus = navigator.onLine ? status : "offline";
};
const handleOnline = () => {
syncConnectionStatus(provider.connected ? "connected" : "connecting");
};
const handleOffline = () => {
connectionStatus = "offline";
};
provider.on("status", (event: { connected: boolean }) => {
syncConnectionStatus(event.connected ? "connected" : "connecting");
});
provider.on("synced", ({ synced }: { synced: boolean }) => {
if (synced) syncConnectionStatus("connected");
});
window.addEventListener("online", handleOnline);
window.addEventListener("offline", handleOffline);
return {
doc,
yTitle,
options,
votes,
deadlineMap,
provider,
persistence,
getConnectionStatus: () => connectionStatus,
getPeerCount: () => {
const total = provider.awareness.getStates().size;
return Math.max(0, total - 1);
},
destroy: () => {
window.removeEventListener("online", handleOnline);
window.removeEventListener("offline", handleOffline);
persistence.destroy();
provider.destroy();
doc.destroy();
},
};
}

70
src/userSync.ts Normal file
View File

@@ -0,0 +1,70 @@
import { IndexeddbPersistence } from "y-indexeddb";
import { WebrtcProvider } from "y-webrtc";
import * as Y from "yjs";
import type { ConnectionStatus, OptionRecord } from "./state";
export interface UserSync {
doc: Y.Doc;
users: Y.Map<string>;
provider: WebrtcProvider;
persistence: IndexeddbPersistence;
getConnectionStatus: () => ConnectionStatus;
getPeerCount: () => number;
destroy: () => void;
}
export function initUserSync(): UserSync {
const doc = new Y.Doc();
const users = doc.getMap<string>("users");
let connectionStatus: ConnectionStatus = navigator.onLine
? "connecting"
: "offline";
const provider = new WebrtcProvider("users", doc,{
signaling: ["ws://localhost:4444", "ws://lynxpi.ddns.net:4444"]
});
const persistence = new IndexeddbPersistence("users", doc);
const syncConnectionStatus = (status: ConnectionStatus) => {
connectionStatus = navigator.onLine ? status : "offline";
};
const handleOnline = () => {
syncConnectionStatus(provider.connected ? "connected" : "connecting");
};
const handleOffline = () => {
connectionStatus = "offline";
};
provider.on("status", (event: { connected: boolean }) => {
syncConnectionStatus(event.connected ? "connected" : "connecting");
});
provider.on("synced", ({ synced }: { synced: boolean }) => {
if (synced) syncConnectionStatus("connected");
});
window.addEventListener("online", handleOnline);
window.addEventListener("offline", handleOffline);
return {
doc,
users,
provider,
persistence,
getConnectionStatus: () => connectionStatus,
getPeerCount: () => {
const total = provider.awareness.getStates().size;
return Math.max(0, total - 1);
},
destroy: () => {
window.removeEventListener("online", handleOnline);
window.removeEventListener("offline", handleOffline);
persistence.destroy();
provider.destroy();
doc.destroy();
},
};
}

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;
}

24
src/yDocUtil.ts Normal file
View File

@@ -0,0 +1,24 @@
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();
}
};
}

15
tsconfig.json Normal file
View File

@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["DOM", "DOM.Iterable", "ES2022"],
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"skipLibCheck": true,
"allowSyntheticDefaultImports": true,
"useDefineForClassFields": true
},
"include": ["src"]
}