From 32e39384d563fd6cffffe1b09b8d93395527ba23 Mon Sep 17 00:00:00 2001 From: Patrick Charrier Date: Wed, 15 Apr 2026 01:23:33 +0200 Subject: [PATCH 01/10] feat: add combined codebase --- .gitignore | 2 + PLAN.md | 102 +++ index.html | 12 + package-lock.json | 1473 +++++++++++++++++++++++++++++++ package.json | 20 + src/app.ts | 139 +++ src/components/AddOption.ts | 66 ++ src/components/DeadlineTimer.ts | 86 ++ src/components/PollList.ts | 127 +++ src/components/PollOption.ts | 46 + src/components/PollTitle.ts | 34 + src/components/ShareSection.ts | 39 + src/components/StatusBar.ts | 67 ++ src/identity.ts | 17 + src/main.ts | 9 + src/state.ts | 207 +++++ src/styles.css | 469 ++++++++++ src/sync.ts | 77 ++ tsconfig.json | 15 + 19 files changed, 3007 insertions(+) create mode 100644 .gitignore create mode 100644 PLAN.md create mode 100644 index.html create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/app.ts create mode 100644 src/components/AddOption.ts create mode 100644 src/components/DeadlineTimer.ts create mode 100644 src/components/PollList.ts create mode 100644 src/components/PollOption.ts create mode 100644 src/components/PollTitle.ts create mode 100644 src/components/ShareSection.ts create mode 100644 src/components/StatusBar.ts create mode 100644 src/identity.ts create mode 100644 src/main.ts create mode 100644 src/state.ts create mode 100644 src/styles.css create mode 100644 src/sync.ts create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..76add87 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +node_modules +dist \ No newline at end of file diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..d60243a --- /dev/null +++ b/PLAN.md @@ -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) +│ └── title → Y.Text (collaborative editing from project 1) +├── poll-options (Y.Map) +│ └── [optionId] → { id, label, createdAt, createdBy } +├── poll-votes (Y.Map) +│ └── [userId] → optionId (single vote per user) +└── poll-deadline (Y.Map) + └── 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 diff --git a/index.html b/index.html new file mode 100644 index 0000000..74a58e4 --- /dev/null +++ b/index.html @@ -0,0 +1,12 @@ + + + + + + Polly — P2P Polls + + +
+ + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..f4d1151 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1473 @@ +{ + "name": "polly-p2p-poll", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "polly-p2p-poll", + "version": "1.0.0", + "dependencies": { + "y-indexeddb": "^9.0.12", + "y-webrtc": "^10.3.0", + "yjs": "^13.6.27" + }, + "devDependencies": { + "typescript": "^5.9.2", + "vite": "^7.1.5" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/err-code": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/err-code/-/err-code-3.0.1.tgz", + "integrity": "sha512-GiaH0KJUewYok+eeY05IIgjtAe4Yltygk9Wqp1V5yVWLdhf0hYZchRjNIT9bb0mSwRcIusT3cx7PJUf3zEIfUA==", + "license": "MIT" + }, + "node_modules/esbuild": { + "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", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@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": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-browser-rtc": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-browser-rtc/-/get-browser-rtc-1.1.0.tgz", + "integrity": "sha512-MghbMJ61EJrRsDe7w1Bvqt3ZsBuqhce5nrn/XAwgwOXhcsz53/ltdxOse1h/8eKXj5slzxdsz56g5rzOFSGwfQ==", + "license": "MIT" + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/isomorphic.js": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.2.5.tgz", + "integrity": "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==", + "license": "MIT", + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + } + }, + "node_modules/lib0": { + "version": "0.2.117", + "resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.117.tgz", + "integrity": "sha512-DeXj9X5xDCjgKLU/7RR+/HQEVzuuEUiwldwOGsHK/sfAfELGWEyTcf0x+uOvCvK3O2zPmZePXWL85vtia6GyZw==", + "license": "MIT", + "dependencies": { + "isomorphic.js": "^0.2.4" + }, + "bin": { + "0ecdsa-generate-keypair": "bin/0ecdsa-generate-keypair.js", + "0gentesthtml": "bin/gentesthtml.js", + "0serve": "bin/0serve.js" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "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": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "^5.1.0" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/rollup": { + "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": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@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" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-peer": { + "version": "9.11.1", + "resolved": "https://registry.npmjs.org/simple-peer/-/simple-peer-9.11.1.tgz", + "integrity": "sha512-D1SaWpOW8afq1CZGWB8xTfrT3FekjQmPValrqncJMX7QFl8YwhrPTZvMCANLtgBwwdS+7zURyqxDDEmY558tTw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "buffer": "^6.0.3", + "debug": "^4.3.2", + "err-code": "^3.0.1", + "get-browser-rtc": "^1.1.0", + "queue-microtask": "^1.2.3", + "randombytes": "^2.1.0", + "readable-stream": "^3.6.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/tinyglobby": { + "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.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "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/vite": { + "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.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": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "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" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "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", + "integrity": "sha512-YSVsLoXxO67J6eE/nV4AtFtT3QEotZf5sK5BHxFBXso7VDUT3Tx07IfA6hsu5Q5OmBdMkQVmFZ9QOA7fikWvnw==", + "license": "MIT", + "dependencies": { + "lib0": "^0.2.85" + }, + "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-webrtc": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/y-webrtc/-/y-webrtc-10.3.0.tgz", + "integrity": "sha512-KalJr7dCgUgyVFxoG3CQYbpS0O2qybegD0vI4bYnYHI0MOwoVbucED3RZ5f2o1a5HZb1qEssUKS0H/Upc6p1lA==", + "license": "MIT", + "dependencies": { + "lib0": "^0.2.42", + "simple-peer": "^9.11.0", + "y-protocols": "^1.0.6" + }, + "bin": { + "y-webrtc-signaling": "bin/server.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + }, + "optionalDependencies": { + "ws": "^8.14.2" + }, + "peerDependencies": { + "yjs": "^13.6.8" + } + }, + "node_modules/yjs": { + "version": "13.6.30", + "resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.30.tgz", + "integrity": "sha512-vv/9h42eCMC81ZHDFswuu/MKzkl/vyq1BhaNGfHyOonwlG4CJbQF4oiBBJPvfdeCt/PlVDWh7Nov9D34YY09uQ==", + "license": "MIT", + "dependencies": { + "lib0": "^0.2.99" + }, + "engines": { + "node": ">=16.0.0", + "npm": ">=8.0.0" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..565fdee --- /dev/null +++ b/package.json @@ -0,0 +1,20 @@ +{ + "name": "polly-p2p-poll", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc --noEmit && vite build", + "preview": "vite preview" + }, + "dependencies": { + "y-indexeddb": "^9.0.12", + "y-webrtc": "^10.3.0", + "yjs": "^13.6.27" + }, + "devDependencies": { + "typescript": "^5.9.2", + "vite": "^7.1.5" + } +} diff --git a/src/app.ts b/src/app.ts new file mode 100644 index 0000000..bf00075 --- /dev/null +++ b/src/app.ts @@ -0,0 +1,139 @@ +import { getUserId } from "./identity"; +import { + addOption, + toggleVote, + deleteOption, + setDeadline, + clearDeadline, + createViewModel, +} from "./state"; +import { initSync } from "./sync"; +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"; + +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 userId = getUserId(); + const sync = initSync(roomId); + + const shareUrl = window.location.href; + + // --- Actions --- + + const actions = { + addOption: (label: string) => { + const vm = createViewModel(getViewModelParams()); + if (vm.votingClosed) return; + return addOption(sync.options, label, userId); + }, + toggleVote: (optionId: string) => { + const vm = createViewModel(getViewModelParams()); + if (vm.votingClosed) return; + toggleVote(sync.votes, userId, optionId); + }, + deleteOption: (optionId: string) => { + deleteOption(sync.options, sync.votes, optionId); + }, + startDeadline: (durationMs: number) => { + setDeadline(sync.deadlineMap, durationMs); + }, + clearDeadline: () => { + clearDeadline(sync.deadlineMap); + }, + }; + + function getViewModelParams() { + return { + yTitle: sync.yTitle, + options: sync.options, + votes: sync.votes, + deadlineMap: sync.deadlineMap, + roomId, + shareUrl, + connectionStatus: sync.getConnectionStatus(), + peerCount: sync.getPeerCount(), + userId, + }; + } + + // --- Build UI --- + + // Header + const header = document.createElement("header"); + header.className = "app-header"; + + const wordmark = document.createElement("div"); + wordmark.className = "app-wordmark"; + wordmark.innerHTML = ` + + Polly + `; + + const statusBar = StatusBar(sync.provider); + 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, userId, () => { + const vm = createViewModel(getViewModelParams()); + return vm.votingClosed; + }, actions.toggleVote, actions.deleteOption); + 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(); + }; +} diff --git a/src/components/AddOption.ts b/src/components/AddOption.ts new file mode 100644 index 0000000..5616ea9 --- /dev/null +++ b/src/components/AddOption.ts @@ -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 = ` + + Add + `; + + 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; +} diff --git a/src/components/DeadlineTimer.ts b/src/components/DeadlineTimer.ts new file mode 100644 index 0000000..9ef1cb9 --- /dev/null +++ b/src/components/DeadlineTimer.ts @@ -0,0 +1,86 @@ +import * as Y from "yjs"; +import { getDeadline } from "../state"; + +const DEADLINE_DURATION_MS = 2 * 60 * 1000; // 2 minutes + +export function DeadlineTimer( + deadlineMap: Y.Map, + 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 | 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(() => render()); + render(); + + return wrapper; +} diff --git a/src/components/PollList.ts b/src/components/PollList.ts new file mode 100644 index 0000000..dfd4458 --- /dev/null +++ b/src/components/PollList.ts @@ -0,0 +1,127 @@ +import * as Y from "yjs"; +import type { OptionRecord } from "../state"; +import { PollOption } from "./PollOption"; + +export function PollList( + yOptions: Y.Map, + yVotes: Y.Map, + userId: string, + isVotingClosed: () => boolean, + onVote: (optionId: string) => void, + onDelete: (optionId: string) => void, +): HTMLElement { + 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 = ` +
+ + + + + +
+

No options yet — add the first one above.

+ `; + + wrapper.append(meta, list, empty); + + function getEntries() { + const entries: Array<{ + id: string; + name: string; + votes: number; + voted: boolean; + }> = []; + + // Tally votes per option + const tally = new Map(); + for (const optionId of yVotes.values()) { + tally.set(optionId, (tally.get(optionId) ?? 0) + 1); + } + + const myVote = yVotes.get(userId) ?? null; + + yOptions.forEach((record, id) => { + entries.push({ + id, + name: record.label, + votes: tally.get(id) ?? 0, + voted: myVote === id, + }); + }); + + entries.sort((a, b) => b.votes - a.votes || a.name.localeCompare(b.name)); + return entries; + } + + 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(".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, + onDelete, + }); + 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.observeDeep(() => render()); + yVotes.observe(() => render()); + render(); + + return wrapper; +} diff --git a/src/components/PollOption.ts b/src/components/PollOption.ts new file mode 100644 index 0000000..c10ef22 --- /dev/null +++ b/src/components/PollOption.ts @@ -0,0 +1,46 @@ +import { escapeHtml } from "../state"; + +export interface PollOptionProps { + id: string; + name: string; + votes: number; + voted: boolean; + totalVotes: number; + votingClosed: boolean; + onVote: (id: string) => void; + onDelete: (id: string) => void; +} + +export function PollOption(props: PollOptionProps): HTMLElement { + const { id, name, votes, voted, totalVotes, votingClosed, onVote, onDelete } = 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 = ` +
+
+ ${escapeHtml(name)} +
+ ${pct}% + ${votes} vote${votes !== 1 ? "s" : ""} + + +
+
+ `; + + row.querySelector(".poll-option__vote-btn")!.addEventListener("click", () => onVote(id)); + row.querySelector(".poll-option__delete-btn")!.addEventListener("click", () => onDelete(id)); + + return row; +} diff --git a/src/components/PollTitle.ts b/src/components/PollTitle.ts new file mode 100644 index 0000000..ae8e4ac --- /dev/null +++ b/src/components/PollTitle.ts @@ -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; +} diff --git a/src/components/ShareSection.ts b/src/components/ShareSection.ts new file mode 100644 index 0000000..a4e3854 --- /dev/null +++ b/src/components/ShareSection.ts @@ -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 = ` + + + `; + + 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(); + const urlEl = section.querySelector(".share-url"); + if (urlEl) { + range.selectNode(urlEl); + window.getSelection()?.removeAllRanges(); + window.getSelection()?.addRange(range); + } + } + }); + + return section; +} diff --git a/src/components/StatusBar.ts b/src/components/StatusBar.ts new file mode 100644 index 0000000..d981c0a --- /dev/null +++ b/src/components/StatusBar.ts @@ -0,0 +1,67 @@ +import type { WebrtcProvider } from "y-webrtc"; + +export function StatusBar(provider: WebrtcProvider): HTMLElement { + 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 = "\u00b7"; + + const peerText = document.createElement("span"); + peerText.className = "status-peers"; + + el.append(dot, statusText, divider, peerText); + + // --- Connection state --- + + let syncTimeout: ReturnType | undefined = setTimeout(() => { + statusText.textContent = "Ready"; + dot.className = "status-dot ready"; + }, 3000); + + provider.on("synced", ({ synced }: { synced: boolean }) => { + if (syncTimeout) { + clearTimeout(syncTimeout); + syncTimeout = undefined; + } + dot.className = `status-dot ${synced ? "connected" : "connecting"}`; + statusText.textContent = synced ? "Connected" : "Connecting"; + }); + + // Online/offline awareness + const handleOffline = () => { + dot.className = "status-dot connecting"; + statusText.textContent = "Offline"; + }; + const handleOnline = () => { + dot.className = "status-dot connecting"; + 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; + peerText.textContent = + others === 0 + ? "Only you" + : `${others} other${others !== 1 ? "s" : ""}`; + } + + provider.awareness.on("change", updatePeerCount); + updatePeerCount(); + + return el; +} diff --git a/src/identity.ts b/src/identity.ts new file mode 100644 index 0000000..f6c2d64 --- /dev/null +++ b/src/identity.ts @@ -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; +} diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..beb8deb --- /dev/null +++ b/src/main.ts @@ -0,0 +1,9 @@ +import "./styles.css"; +import { initApp } from "./app"; + +const container = document.querySelector("#app"); +if (!container) { + throw new Error("App container not found."); +} + +initApp(container); diff --git a/src/state.ts b/src/state.ts new file mode 100644 index 0000000..65fd560 --- /dev/null +++ b/src/state.ts @@ -0,0 +1,207 @@ +import * as Y from "yjs"; + +// --- Types --- + +export type ConnectionStatus = "connecting" | "connected" | "offline"; + +export interface OptionRecord { + id: string; + label: string; + createdAt: number; + createdBy: string; +} + +export interface PollOptionViewModel extends OptionRecord { + voteCount: number; + isVotedByMe: boolean; + percentage: number; +} + +export interface PollViewModel { + title: string; + roomId: string; + shareUrl: string; + connectionStatus: ConnectionStatus; + peerCount: number; + options: PollOptionViewModel[]; + totalVotes: number; + myVoteOptionId: string | null; + deadline: number | null; + votingClosed: boolean; +} + +// --- 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("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +// --- Title --- + +export function getPollTitle(yTitle: Y.Text): string { + const title = yTitle.toString(); + return title || "Untitled Poll"; +} + +// --- Options --- + +export function addOption( + options: Y.Map, + 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, + userId: string, + optionId: string, +): void { + const current = votes.get(userId); + if (current === optionId) { + votes.delete(userId); + } else { + votes.set(userId, optionId); + } +} + +export function deleteOption( + options: Y.Map, + votes: Y.Map, + optionId: string, +): void { + options.delete(optionId); + // Clean up votes pointing to this option + for (const [userId, votedOptionId] of votes.entries()) { + if (votedOptionId === optionId) { + votes.delete(userId); + } + } +} + +// --- Deadline --- + +export function setDeadline( + deadlineMap: Y.Map, + durationMs: number, +): void { + deadlineMap.set("deadline", Date.now() + durationMs); +} + +export function clearDeadline(deadlineMap: Y.Map): void { + deadlineMap.delete("deadline"); +} + +export function getDeadline(deadlineMap: Y.Map): number | null { + const val = deadlineMap.get("deadline"); + return typeof val === "number" ? val : null; +} + +// --- ViewModel --- + +export function createViewModel(params: { + yTitle: Y.Text; + options: Y.Map; + votes: Y.Map; + deadlineMap: Y.Map; + roomId: string; + shareUrl: string; + connectionStatus: ConnectionStatus; + peerCount: number; + userId: string; +}): PollViewModel { + const { + yTitle, + options, + votes, + deadlineMap, + roomId, + shareUrl, + connectionStatus, + peerCount, + userId, + } = params; + + // Tally votes per option + const tally = new Map(); + for (const optionId of votes.values()) { + tally.set(optionId, (tally.get(optionId) ?? 0) + 1); + } + + let totalVotes = 0; + for (const count of tally.values()) { + totalVotes += count; + } + + const myVoteOptionId = votes.get(userId) ?? null; + const deadline = getDeadline(deadlineMap); + const votingClosed = deadline !== null && Date.now() >= deadline; + + // Sort by votes desc, then alphabetically + const sortedOptions = Array.from(options.values()).sort((a, b) => { + const aVotes = tally.get(a.id) ?? 0; + const bVotes = tally.get(b.id) ?? 0; + if (bVotes !== aVotes) return bVotes - aVotes; + return a.label.localeCompare(b.label); + }); + + return { + title: getPollTitle(yTitle), + roomId, + shareUrl, + connectionStatus, + peerCount, + myVoteOptionId, + totalVotes, + deadline, + votingClosed, + options: sortedOptions.map((option) => ({ + ...option, + voteCount: tally.get(option.id) ?? 0, + isVotedByMe: myVoteOptionId === option.id, + percentage: + totalVotes > 0 + ? Math.round(((tally.get(option.id) ?? 0) / totalVotes) * 100) + : 0, + })), + }; +} diff --git a/src/styles.css b/src/styles.css new file mode 100644 index 0000000..965045c --- /dev/null +++ b/src/styles.css @@ -0,0 +1,469 @@ +/* ── Fonts ─────────────────────────────────────────────── */ +@import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@300;400;500;600&family=Playfair+Display:wght@500&display=swap'); + +/* ── Tokens ────────────────────────────────────────────── */ +:root { + --bg: #F7F6F2; + --surface: #FFFFFF; + --surface-hover: #FAFAF8; + --border: #E8E5DF; + --border-focus: #1A1A1A; + + --text-primary: #1A1A1A; + --text-secondary: #6B6860; + --text-muted: #AAA79F; + + --accent: #1A1A1A; + --accent-text: #FFFFFF; + + --vote-bar: rgba(26, 26, 26, 0.07); + --vote-bar-voted: rgba(26, 26, 26, 0.12); + + --success: #2D7D46; + --danger: #C0392B; + --warning: #8c5300; + + --radius-sm: 6px; + --radius-md: 10px; + --radius-lg: 14px; + + --font-display: 'Playfair Display', Georgia, serif; + --font-body: 'DM Sans', system-ui, sans-serif; + + --shadow-card: 0 1px 3px rgba(0,0,0,0.06), 0 4px 16px rgba(0,0,0,0.05); +} + +/* ── Reset ─────────────────────────────────────────────── */ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +/* ── Base ──────────────────────────────────────────────── */ +html { font-size: 16px; } + +body { + font-family: var(--font-body); + background: var(--bg); + color: var(--text-primary); + min-height: 100vh; + -webkit-font-smoothing: antialiased; +} + +button, input { font: inherit; } +button { cursor: pointer; } + +/* ── Layout ────────────────────────────────────────────── */ +#app { + max-width: 580px; + margin: 0 auto; + padding: 2rem 1.25rem 4rem; + display: flex; + flex-direction: column; + gap: 1.25rem; +} + +/* ── Header ────────────────────────────────────────────── */ +.app-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 0.25rem; +} + +.app-wordmark { + display: flex; + align-items: center; + gap: 0.5rem; + font-family: var(--font-display); + font-size: 1.1rem; + color: var(--text-primary); + letter-spacing: -0.01em; +} + +/* ── Status bar ────────────────────────────────────────── */ +.status-bar { + display: flex; + align-items: center; + gap: 0.4rem; + font-size: 0.8rem; + color: var(--text-secondary); +} + +.status-dot { + width: 6px; + height: 6px; + border-radius: 50%; + flex-shrink: 0; + transition: background 0.3s; +} + +.status-dot.connecting { background: var(--text-muted); } +.status-dot.ready { background: var(--text-muted); } +.status-dot.connected { background: var(--success); } + +.status-divider { color: var(--text-muted); } + +/* ── Card ──────────────────────────────────────────────── */ +.app-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-card); + overflow: hidden; +} + +/* ── Poll Title ────────────────────────────────────────── */ +.poll-title-wrapper { + padding: 1.75rem 1.75rem 1.25rem; + border-bottom: 1px solid var(--border); +} + +.poll-title-input { + width: 100%; + font-family: var(--font-display); + font-size: 1.5rem; + font-weight: 500; + color: var(--text-primary); + background: transparent; + border: none; + outline: none; + line-height: 1.3; + letter-spacing: -0.02em; +} + +.poll-title-input::placeholder { color: var(--text-muted); } + +/* ── Add Option ────────────────────────────────────────── */ +.add-option-wrapper { + display: flex; + flex-wrap: wrap; + gap: 0.625rem; + padding: 1.25rem 1.75rem; + border-bottom: 1px solid var(--border); +} + +.add-option-input { + flex: 1; + min-width: 0; + height: 2.5rem; + padding: 0 0.875rem; + font-size: 0.9rem; + color: var(--text-primary); + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + outline: none; + transition: border-color 0.15s; +} + +.add-option-input::placeholder { color: var(--text-muted); } +.add-option-input:focus { border-color: var(--border-focus); } + +.add-option-input.shake { + animation: shake 0.3s ease; +} + +@keyframes shake { + 0%, 100% { transform: translateX(0); } + 25% { transform: translateX(-4px); } + 75% { transform: translateX(4px); } +} + +.add-option-btn { + display: flex; + align-items: center; + gap: 0.375rem; + height: 2.5rem; + padding: 0 1rem; + font-size: 0.875rem; + font-weight: 500; + color: var(--accent-text); + background: var(--accent); + border: none; + border-radius: var(--radius-sm); + transition: opacity 0.15s; + white-space: nowrap; +} + +.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; +} + +.poll-list-meta { + padding: 0.5rem 1.75rem 0.75rem; + font-size: 0.775rem; + color: var(--text-muted); + letter-spacing: 0.02em; + text-transform: uppercase; + font-weight: 500; +} + +.poll-list-empty { + padding: 3rem 1.75rem; + text-align: center; + color: var(--text-muted); + font-size: 0.875rem; +} + +.empty-icon { + margin-bottom: 0.75rem; + opacity: 0.6; +} + +/* ── Poll Option ───────────────────────────────────────── */ +.poll-option { + position: relative; + overflow: hidden; + transition: background 0.15s; +} + +.poll-option:hover { + background: var(--surface-hover); +} + +.poll-option__bar { + position: absolute; + inset: 0 auto 0 0; + background: var(--vote-bar); + transition: width 0.5s cubic-bezier(0.4, 0, 0.2, 1); + pointer-events: none; +} + +.poll-option--voted .poll-option__bar { + background: var(--vote-bar-voted); +} + +.poll-option__content { + position: relative; + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.875rem 1.75rem; +} + +.poll-option__name { + flex: 1; + font-size: 0.9375rem; + font-weight: 400; + color: var(--text-primary); + word-break: break-word; +} + +.poll-option--voted .poll-option__name { + font-weight: 500; +} + +.poll-option__actions { + display: flex; + align-items: center; + gap: 0.625rem; + flex-shrink: 0; +} + +.poll-option__pct { + font-size: 0.8125rem; + font-weight: 500; + color: var(--text-secondary); + min-width: 2.5rem; + text-align: right; +} + +.poll-option__count { + font-size: 0.775rem; + color: var(--text-muted); + min-width: 3.5rem; +} + +.poll-option__vote-btn { + height: 1.875rem; + padding: 0 0.875rem; + font-size: 0.8125rem; + font-weight: 500; + border-radius: var(--radius-sm); + transition: all 0.15s; + white-space: nowrap; + background: transparent; + color: var(--text-secondary); + border: 1px solid var(--border); +} + +.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:not(:disabled) { + opacity: 0.8; +} + +.poll-option__delete-btn { + display: flex; + align-items: center; + justify-content: center; + width: 1.625rem; + height: 1.625rem; + background: transparent; + border: none; + border-radius: var(--radius-sm); + color: var(--text-muted); + opacity: 0; + transition: opacity 0.15s, color 0.15s, background 0.15s; +} + +.poll-option:hover .poll-option__delete-btn { opacity: 1; } +.poll-option__delete-btn:hover { + color: var(--danger); + background: rgba(192, 57, 43, 0.07); +} + +/* ── Footer ────────────────────────────────────────────── */ +.app-footer { + padding: 0 0.25rem; +} + +/* ── Share Section ─────────────────────────────────────── */ +.share-section { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-card); + padding: 1.25rem 1.5rem; +} + +.share-label { + font-size: 0.775rem; + font-weight: 500; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.05em; + margin-bottom: 0.625rem; +} + +.share-row { + display: flex; + align-items: center; + gap: 0.625rem; +} + +.share-url { + flex: 1; + font-family: 'DM Mono', 'Fira Mono', monospace; + font-size: 0.8rem; + color: var(--text-secondary); + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 0.5rem 0.75rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + display: block; + user-select: all; +} + +.share-copy-btn { + height: 2rem; + padding: 0 0.875rem; + font-size: 0.8125rem; + 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; + flex-shrink: 0; +} + +.share-copy-btn:hover { + border-color: var(--accent); + color: var(--accent); +} + +.share-copy-btn--success { + color: var(--success) !important; + border-color: var(--success) !important; +} + +/* ── Responsive ────────────────────────────────────────── */ +@media (max-width: 480px) { + #app { padding: 1rem 0.75rem 3rem; } + + .poll-title-wrapper { padding: 1.25rem 1.25rem 1rem; } + .add-option-wrapper { padding: 1rem 1.25rem; } + .poll-option__content { padding: 0.875rem 1.25rem; } + .poll-list-meta { padding: 0.5rem 1.25rem 0.625rem; } + .poll-list-empty { padding: 2.5rem 1.25rem; } + .deadline-wrapper { padding: 0.75rem 1.25rem; } + + .poll-option__count { display: none; } + + .share-section { padding: 1rem 1.25rem; } +} diff --git a/src/sync.ts b/src/sync.ts new file mode 100644 index 0000000..8cf0d8f --- /dev/null +++ b/src/sync.ts @@ -0,0 +1,77 @@ +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; + votes: Y.Map; + deadlineMap: Y.Map; + 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("poll-options"); + const votes = doc.getMap("poll-votes"); + const deadlineMap = doc.getMap("poll-deadline"); + + let connectionStatus: ConnectionStatus = navigator.onLine + ? "connecting" + : "offline"; + + const provider = new WebrtcProvider(roomId, doc); + 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(); + }, + }; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..2746931 --- /dev/null +++ b/tsconfig.json @@ -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"] +} -- 2.49.1 From 2abc0f8930af2941a602062732f9e23a255407d0 Mon Sep 17 00:00:00 2001 From: 1ynx Date: Mon, 4 May 2026 22:56:51 +0200 Subject: [PATCH 02/10] * fix WebrtcProvider use localhost for dev --- package.json | 2 +- src/sync.ts | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 565fdee..bc36fd7 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "private": true, "type": "module", "scripts": { - "dev": "vite", + "dev": "PORT=4444 npx y-webrtc & vite", "build": "tsc --noEmit && vite build", "preview": "vite preview" }, diff --git a/src/sync.ts b/src/sync.ts index 8cf0d8f..ec65fcf 100644 --- a/src/sync.ts +++ b/src/sync.ts @@ -28,7 +28,9 @@ export function initSync(roomId: string): AppSync { ? "connecting" : "offline"; - const provider = new WebrtcProvider(roomId, doc); + 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) => { -- 2.49.1 From 043e813864d06ab0f46668ed464db7cf99123d7a Mon Sep 17 00:00:00 2001 From: 1ynx Date: Mon, 4 May 2026 23:02:54 +0200 Subject: [PATCH 03/10] * enforceAppendOnly for options, votes and deadlines --- src/components/DeadlineTimer.ts | 3 ++- src/components/PollList.ts | 5 +++-- src/yDocUtil.ts | 33 +++++++++++++++++++++++++++++++++ 3 files changed, 38 insertions(+), 3 deletions(-) create mode 100644 src/yDocUtil.ts diff --git a/src/components/DeadlineTimer.ts b/src/components/DeadlineTimer.ts index 9ef1cb9..72e1072 100644 --- a/src/components/DeadlineTimer.ts +++ b/src/components/DeadlineTimer.ts @@ -1,5 +1,6 @@ import * as Y from "yjs"; import { getDeadline } from "../state"; +import { enforceAppendOnly } from "../yDocUtil"; const DEADLINE_DURATION_MS = 2 * 60 * 1000; // 2 minutes @@ -79,7 +80,7 @@ export function DeadlineTimer( onClearDeadline(); }); - deadlineMap.observe(() => render()); + deadlineMap.observe(enforceAppendOnly(deadlineMap,render)); render(); return wrapper; diff --git a/src/components/PollList.ts b/src/components/PollList.ts index dfd4458..be12ce3 100644 --- a/src/components/PollList.ts +++ b/src/components/PollList.ts @@ -1,6 +1,7 @@ import * as Y from "yjs"; import type { OptionRecord } from "../state"; import { PollOption } from "./PollOption"; +import { enforceAppendOnly } from "../yDocUtil"; export function PollList( yOptions: Y.Map, @@ -119,8 +120,8 @@ export function PollList( }); } - yOptions.observeDeep(() => render()); - yVotes.observe(() => render()); + yOptions.observe(enforceAppendOnly(yOptions,render)); + yVotes.observe(enforceAppendOnly(yVotes,render)); render(); return wrapper; diff --git a/src/yDocUtil.ts b/src/yDocUtil.ts new file mode 100644 index 0000000..4e750d9 --- /dev/null +++ b/src/yDocUtil.ts @@ -0,0 +1,33 @@ +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,render: () => void) { + return (event: Y.YMapEvent, transaction: Y.Transaction) => { + // Avoid infinite loops: check if this change was + // triggered by our own 'undo' logic. + if (transaction.origin === 'revert-logic') return; + + event.keys.forEach((change, key) => { + const { action, oldValue } = change; + + if (action === 'update' || action === 'delete') { + // Use the transaction to undo the illegal operation + yMap.doc?.transact(() => { + if (action === 'update' && oldValue !== undefined) { + // Revert to previous value + yMap.set(key, oldValue); + } else if (action === 'delete' && oldValue !== undefined) { + // Restore the deleted key + yMap.set(key, oldValue); + } + console.warn(`Illegal ${action} attempt on key: "${key}". Reverted.`); + }, 'revert-logic'); + } + }); + + render(); + }; +} \ No newline at end of file -- 2.49.1 From 5ecb5f10760a73b558eca7f9a472492ed07de4f8 Mon Sep 17 00:00:00 2001 From: 1ynx Date: Wed, 6 May 2026 00:12:03 +0200 Subject: [PATCH 04/10] * fix keep old state and only update on legal actions --- src/components/PollList.ts | 47 +++++++++++++++++++++++--------------- src/yDocUtil.ts | 27 ++++++++-------------- 2 files changed, 37 insertions(+), 37 deletions(-) diff --git a/src/components/PollList.ts b/src/components/PollList.ts index be12ce3..5c99505 100644 --- a/src/components/PollList.ts +++ b/src/components/PollList.ts @@ -11,6 +11,10 @@ export function PollList( onVote: (optionId: string) => void, onDelete: (optionId: string) => void, ): HTMLElement { + + var currentOptions : { [x: string]: any; } | undefined = undefined + var currentVotes : { [x: string]: any; } | undefined = undefined + const wrapper = document.createElement("div"); wrapper.className = "poll-list-wrapper"; @@ -42,25 +46,29 @@ export function PollList( votes: number; voted: boolean; }> = []; + if (currentOptions && currentVotes){ - // Tally votes per option - const tally = new Map(); - for (const optionId of yVotes.values()) { - tally.set(optionId, (tally.get(optionId) ?? 0) + 1); + // Tally votes per option + const tally = new Map(); + for (const optionId of Object.values(currentVotes)) { + tally.set(optionId, (tally.get(optionId) ?? 0) + 1); + } + + const myVote = currentVotes[userId] ?? null; + + Object.entries(currentOptions).forEach(([id,record]) => { + console.log(`${record}: ${id}`) + entries.push({ + id, + name: record.label, + votes: tally.get(id) ?? 0, + voted: myVote === id, + }); + }); + + entries.sort((a, b) => b.votes - a.votes || a.name.localeCompare(b.name)); } - const myVote = yVotes.get(userId) ?? null; - - yOptions.forEach((record, id) => { - entries.push({ - id, - name: record.label, - votes: tally.get(id) ?? 0, - voted: myVote === id, - }); - }); - - entries.sort((a, b) => b.votes - a.votes || a.name.localeCompare(b.name)); return entries; } @@ -119,9 +127,10 @@ export function PollList( } }); } - - yOptions.observe(enforceAppendOnly(yOptions,render)); - yVotes.observe(enforceAppendOnly(yVotes,render)); + 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; diff --git a/src/yDocUtil.ts b/src/yDocUtil.ts index 4e750d9..66a687d 100644 --- a/src/yDocUtil.ts +++ b/src/yDocUtil.ts @@ -4,30 +4,21 @@ 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,render: () => void) { +export function enforceAppendOnly(yMap: Y.Map,update: (update : { [x: string]: any; }) => void, render: () => void) { return (event: Y.YMapEvent, transaction: Y.Transaction) => { - // Avoid infinite loops: check if this change was - // triggered by our own 'undo' logic. - if (transaction.origin === 'revert-logic') return; - + var isOperationIllegal = false event.keys.forEach((change, key) => { const { action, oldValue } = change; if (action === 'update' || action === 'delete') { - // Use the transaction to undo the illegal operation - yMap.doc?.transact(() => { - if (action === 'update' && oldValue !== undefined) { - // Revert to previous value - yMap.set(key, oldValue); - } else if (action === 'delete' && oldValue !== undefined) { - // Restore the deleted key - yMap.set(key, oldValue); - } - console.warn(`Illegal ${action} attempt on key: "${key}". Reverted.`); - }, 'revert-logic'); + isOperationIllegal = true + console.log("Illegal Operation: "+action) } }); - - render(); + if(!isOperationIllegal) { + console.log("Updating Map!") + update(yMap.toJSON()) + render(); + } }; } \ No newline at end of file -- 2.49.1 From 424799692a1412269907e74360b346abd24f450a Mon Sep 17 00:00:00 2001 From: 1ynx Date: Wed, 6 May 2026 19:34:13 +0200 Subject: [PATCH 05/10] - remove unnecessary / deprecated code --- src/app.ts | 33 +++-------- src/components/PollList.ts | 2 - src/components/PollOption.ts | 9 +-- src/state.ts | 103 ----------------------------------- 4 files changed, 9 insertions(+), 138 deletions(-) diff --git a/src/app.ts b/src/app.ts index bf00075..b6ad391 100644 --- a/src/app.ts +++ b/src/app.ts @@ -2,10 +2,9 @@ import { getUserId } from "./identity"; import { addOption, toggleVote, - deleteOption, setDeadline, clearDeadline, - createViewModel, + getDeadline, } from "./state"; import { initSync } from "./sync"; import { StatusBar } from "./components/StatusBar"; @@ -48,18 +47,13 @@ export function initApp(container: HTMLElement): () => void { const actions = { addOption: (label: string) => { - const vm = createViewModel(getViewModelParams()); - if (vm.votingClosed) return; + if (isVotingClosed()) return; return addOption(sync.options, label, userId); }, toggleVote: (optionId: string) => { - const vm = createViewModel(getViewModelParams()); - if (vm.votingClosed) return; + if (isVotingClosed()) return; toggleVote(sync.votes, userId, optionId); }, - deleteOption: (optionId: string) => { - deleteOption(sync.options, sync.votes, optionId); - }, startDeadline: (durationMs: number) => { setDeadline(sync.deadlineMap, durationMs); }, @@ -68,18 +62,10 @@ export function initApp(container: HTMLElement): () => void { }, }; - function getViewModelParams() { - return { - yTitle: sync.yTitle, - options: sync.options, - votes: sync.votes, - deadlineMap: sync.deadlineMap, - roomId, - shareUrl, - connectionStatus: sync.getConnectionStatus(), - peerCount: sync.getPeerCount(), - userId, - }; + function isVotingClosed() { + const deadline = getDeadline(sync.deadlineMap); + const votingClosed = deadline !== null && Date.now() >= deadline; + return votingClosed; } // --- Build UI --- @@ -112,10 +98,7 @@ export function initApp(container: HTMLElement): () => void { if (result && !result.ok) return result.error; return null; }); - const pollList = PollList(sync.options, sync.votes, userId, () => { - const vm = createViewModel(getViewModelParams()); - return vm.votingClosed; - }, actions.toggleVote, actions.deleteOption); + const pollList = PollList(sync.options, sync.votes, userId, isVotingClosed, actions.toggleVote); const deadlineTimer = DeadlineTimer( sync.deadlineMap, actions.startDeadline, diff --git a/src/components/PollList.ts b/src/components/PollList.ts index 5c99505..4828495 100644 --- a/src/components/PollList.ts +++ b/src/components/PollList.ts @@ -9,7 +9,6 @@ export function PollList( userId: string, isVotingClosed: () => boolean, onVote: (optionId: string) => void, - onDelete: (optionId: string) => void, ): HTMLElement { var currentOptions : { [x: string]: any; } | undefined = undefined @@ -112,7 +111,6 @@ export function PollList( totalVotes: total, votingClosed, onVote, - onDelete, }); const currentEl = list.children[i] as HTMLElement | undefined; diff --git a/src/components/PollOption.ts b/src/components/PollOption.ts index c10ef22..70eff3b 100644 --- a/src/components/PollOption.ts +++ b/src/components/PollOption.ts @@ -8,11 +8,10 @@ export interface PollOptionProps { totalVotes: number; votingClosed: boolean; onVote: (id: string) => void; - onDelete: (id: string) => void; } export function PollOption(props: PollOptionProps): HTMLElement { - const { id, name, votes, voted, totalVotes, votingClosed, onVote, onDelete } = props; + const { id, name, votes, voted, totalVotes, votingClosed, onVote } = props; const row = document.createElement("div"); row.className = `poll-option${voted ? " poll-option--voted" : ""}`; @@ -30,17 +29,11 @@ export function PollOption(props: PollOptionProps): HTMLElement { - `; row.querySelector(".poll-option__vote-btn")!.addEventListener("click", () => onVote(id)); - row.querySelector(".poll-option__delete-btn")!.addEventListener("click", () => onDelete(id)); return row; } diff --git a/src/state.ts b/src/state.ts index 65fd560..0d08971 100644 --- a/src/state.ts +++ b/src/state.ts @@ -11,25 +11,6 @@ export interface OptionRecord { createdBy: string; } -export interface PollOptionViewModel extends OptionRecord { - voteCount: number; - isVotedByMe: boolean; - percentage: number; -} - -export interface PollViewModel { - title: string; - roomId: string; - shareUrl: string; - connectionStatus: ConnectionStatus; - peerCount: number; - options: PollOptionViewModel[]; - totalVotes: number; - myVoteOptionId: string | null; - deadline: number | null; - votingClosed: boolean; -} - // --- Helpers --- export function createOptionId(): string { @@ -104,20 +85,6 @@ export function toggleVote( } } -export function deleteOption( - options: Y.Map, - votes: Y.Map, - optionId: string, -): void { - options.delete(optionId); - // Clean up votes pointing to this option - for (const [userId, votedOptionId] of votes.entries()) { - if (votedOptionId === optionId) { - votes.delete(userId); - } - } -} - // --- Deadline --- export function setDeadline( @@ -135,73 +102,3 @@ export function getDeadline(deadlineMap: Y.Map): number | null { const val = deadlineMap.get("deadline"); return typeof val === "number" ? val : null; } - -// --- ViewModel --- - -export function createViewModel(params: { - yTitle: Y.Text; - options: Y.Map; - votes: Y.Map; - deadlineMap: Y.Map; - roomId: string; - shareUrl: string; - connectionStatus: ConnectionStatus; - peerCount: number; - userId: string; -}): PollViewModel { - const { - yTitle, - options, - votes, - deadlineMap, - roomId, - shareUrl, - connectionStatus, - peerCount, - userId, - } = params; - - // Tally votes per option - const tally = new Map(); - for (const optionId of votes.values()) { - tally.set(optionId, (tally.get(optionId) ?? 0) + 1); - } - - let totalVotes = 0; - for (const count of tally.values()) { - totalVotes += count; - } - - const myVoteOptionId = votes.get(userId) ?? null; - const deadline = getDeadline(deadlineMap); - const votingClosed = deadline !== null && Date.now() >= deadline; - - // Sort by votes desc, then alphabetically - const sortedOptions = Array.from(options.values()).sort((a, b) => { - const aVotes = tally.get(a.id) ?? 0; - const bVotes = tally.get(b.id) ?? 0; - if (bVotes !== aVotes) return bVotes - aVotes; - return a.label.localeCompare(b.label); - }); - - return { - title: getPollTitle(yTitle), - roomId, - shareUrl, - connectionStatus, - peerCount, - myVoteOptionId, - totalVotes, - deadline, - votingClosed, - options: sortedOptions.map((option) => ({ - ...option, - voteCount: tally.get(option.id) ?? 0, - isVotedByMe: myVoteOptionId === option.id, - percentage: - totalVotes > 0 - ? Math.round(((tally.get(option.id) ?? 0) / totalVotes) * 100) - : 0, - })), - }; -} -- 2.49.1 From 5f36ff0b7d8daa00abad428d1cc2d04da8c7ef9f Mon Sep 17 00:00:00 2001 From: 1ynx Date: Wed, 6 May 2026 23:17:50 +0200 Subject: [PATCH 06/10] + create user, login, logout --- package-lock.json | 14 ++++ package.json | 3 +- src/app.ts | 86 +++++++++++++++++++-- src/components/PollList.ts | 8 +- src/components/StatusBar.ts | 145 +++++++++++++++++++++++++++++++----- src/crypto.ts | 119 +++++++++++++++++++++++++++++ src/state.ts | 6 ++ src/styles.css | 8 ++ src/userSync.ts | 70 +++++++++++++++++ 9 files changed, 430 insertions(+), 29 deletions(-) create mode 100644 src/crypto.ts create mode 100644 src/userSync.ts diff --git a/package-lock.json b/package-lock.json index f4d1151..8166a4e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "polly-p2p-poll", "version": "1.0.0", "dependencies": { + "uuid": "^13.0.0", "y-indexeddb": "^9.0.12", "y-webrtc": "^10.3.0", "yjs": "^13.6.27" @@ -1288,6 +1289,19 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, + "node_modules/uuid": { + "version": "13.0.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.2.tgz", + "integrity": "sha512-vzi9uRZ926x4XV73S/4qQaTwPXM2JBj6/6lI/byHH1jOpCzb0zDbfytgA9LcN/hzb2l7WQSQnxITOVx5un/wGw==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, "node_modules/vite": { "version": "7.3.2", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", diff --git a/package.json b/package.json index bc36fd7..5d9deba 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,8 @@ "dependencies": { "y-indexeddb": "^9.0.12", "y-webrtc": "^10.3.0", - "yjs": "^13.6.27" + "yjs": "^13.6.27", + "uuid": "^13.0.0" }, "devDependencies": { "typescript": "^5.9.2", diff --git a/src/app.ts b/src/app.ts index b6ad391..6b89ada 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,18 +1,22 @@ import { getUserId } from "./identity"; 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"; @@ -38,8 +42,9 @@ function ensureRoomId(): string { export function initApp(container: HTMLElement): () => void { const roomId = ensureRoomId(); - const userId = getUserId(); const sync = initSync(roomId); + const userSync = initUserSync(); + let user : User | undefined = undefined; const shareUrl = window.location.href; @@ -47,12 +52,12 @@ export function initApp(container: HTMLElement): () => void { const actions = { addOption: (label: string) => { - if (isVotingClosed()) return; - return addOption(sync.options, label, userId); + if (!user || isVotingClosed()) return; + return addOption(sync.options, label, user.userid); }, toggleVote: (optionId: string) => { - if (isVotingClosed()) return; - toggleVote(sync.votes, userId, optionId); + if (!user || isVotingClosed()) return; + toggleVote(sync.votes, user.userid, optionId); }, startDeadline: (durationMs: number) => { setDeadline(sync.deadlineMap, durationMs); @@ -60,6 +65,73 @@ export function initApp(container: HTMLElement): () => void { 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() { @@ -85,7 +157,7 @@ export function initApp(container: HTMLElement): () => void { Polly `; - const statusBar = StatusBar(sync.provider); + const statusBar = StatusBar(sync.provider,userSync.provider,actions.onLoginLogout,actions.onCreateUser); header.append(wordmark, statusBar); // Main card @@ -98,7 +170,7 @@ export function initApp(container: HTMLElement): () => void { if (result && !result.ok) return result.error; return null; }); - const pollList = PollList(sync.options, sync.votes, userId, isVotingClosed, actions.toggleVote); + const pollList = PollList(sync.options, sync.votes, user, isVotingClosed, actions.toggleVote); const deadlineTimer = DeadlineTimer( sync.deadlineMap, actions.startDeadline, diff --git a/src/components/PollList.ts b/src/components/PollList.ts index 4828495..a4f909d 100644 --- a/src/components/PollList.ts +++ b/src/components/PollList.ts @@ -2,11 +2,12 @@ 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, yVotes: Y.Map, - userId: string, + user: User | undefined, isVotingClosed: () => boolean, onVote: (optionId: string) => void, ): HTMLElement { @@ -53,7 +54,10 @@ export function PollList( tally.set(optionId, (tally.get(optionId) ?? 0) + 1); } - const myVote = currentVotes[userId] ?? null; + let myVote = null; + if (user) { + myVote = currentVotes[user.userid] + } Object.entries(currentOptions).forEach(([id,record]) => { console.log(`${record}: ${id}`) diff --git a/src/components/StatusBar.ts b/src/components/StatusBar.ts index d981c0a..52d9950 100644 --- a/src/components/StatusBar.ts +++ b/src/components/StatusBar.ts @@ -1,30 +1,110 @@ import type { WebrtcProvider } from "y-webrtc"; -export function StatusBar(provider: WebrtcProvider): HTMLElement { +export function StatusBar(provider: WebrtcProvider, user_provider: WebrtcProvider, onLoginLogout: (event: Event) => Promise, onCreateUser: (event: Event) => Promise): HTMLElement { 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 statusPanel=document.createElement("div"); + statusPanel.className = "status-bar"; const divider = document.createElement("span"); divider.className = "status-divider"; divider.textContent = "\u00b7"; - const peerText = document.createElement("span"); - peerText.className = "status-peers"; + function getProviderStatus(){ - el.append(dot, statusText, divider, peerText); + 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="Logout" + logoutButton.style.display = "none"; + + const createUserButton = document.createElement("button"); + createUserButton.className = "add-option-btn"; + createUserButton.setAttribute("aria-label", "Create User"); + createUserButton.innerHTML="Create User" + + async function onLoginLogoutResult(event: Event, loginLogout: (event: Event) => Promise){ + 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 | undefined = setTimeout(() => { - statusText.textContent = "Ready"; - dot.className = "status-dot ready"; + pollProviderElements.statusText.textContent = "Ready"; + pollProviderElements.dot.className = "status-dot ready"; }, 3000); provider.on("synced", ({ synced }: { synced: boolean }) => { @@ -32,18 +112,37 @@ export function StatusBar(provider: WebrtcProvider): HTMLElement { clearTimeout(syncTimeout); syncTimeout = undefined; } - dot.className = `status-dot ${synced ? "connected" : "connecting"}`; - statusText.textContent = synced ? "Connected" : "Connecting"; + pollProviderElements.dot.className = `status-dot ${synced ? "connected" : "connecting"}`; + pollProviderElements.statusText.textContent = synced ? "Connected" : "Connecting"; + }); + + + let syncTimeout2: ReturnType | 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 = () => { - dot.className = "status-dot connecting"; - statusText.textContent = "Offline"; + pollProviderElements.dot.className = "status-dot connecting"; + pollProviderElements.statusText.textContent = "Offline"; + userProviderElements.dot.className = "status-dot connecting"; + userProviderElements.statusText.textContent = "Offline"; }; const handleOnline = () => { - dot.className = "status-dot connecting"; - statusText.textContent = "Reconnecting"; + pollProviderElements.dot.className = "status-dot connecting"; + pollProviderElements.statusText.textContent = "Reconnecting"; + userProviderElements.dot.className = "status-dot connecting"; + userProviderElements.statusText.textContent = "Reconnecting"; }; window.addEventListener("offline", handleOffline); @@ -54,10 +153,18 @@ export function StatusBar(provider: WebrtcProvider): HTMLElement { function updatePeerCount() { const total = provider.awareness.getStates().size; const others = total - 1; - peerText.textContent = + 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); diff --git a/src/crypto.ts b/src/crypto.ts new file mode 100644 index 0000000..91d9b0f --- /dev/null +++ b/src/crypto.ts @@ -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 => { + // 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 => { + 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); + }); +}; \ No newline at end of file diff --git a/src/state.ts b/src/state.ts index 0d08971..fc20f17 100644 --- a/src/state.ts +++ b/src/state.ts @@ -4,6 +4,12 @@ import * as Y from "yjs"; 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; diff --git a/src/styles.css b/src/styles.css index 965045c..e213662 100644 --- a/src/styles.css +++ b/src/styles.css @@ -86,6 +86,14 @@ button { cursor: pointer; } 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; diff --git a/src/userSync.ts b/src/userSync.ts new file mode 100644 index 0000000..5ba0f51 --- /dev/null +++ b/src/userSync.ts @@ -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; + provider: WebrtcProvider; + persistence: IndexeddbPersistence; + getConnectionStatus: () => ConnectionStatus; + getPeerCount: () => number; + destroy: () => void; +} + +export function initUserSync(): UserSync { + const doc = new Y.Doc(); + const users = doc.getMap("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(); + }, + }; +} -- 2.49.1 From 78d872c83ea90369148060117d848d492d134fbd Mon Sep 17 00:00:00 2001 From: 1ynx Date: Wed, 6 May 2026 23:18:08 +0200 Subject: [PATCH 07/10] * fix --- src/app.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/app.ts b/src/app.ts index 6b89ada..b396169 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,4 +1,3 @@ -import { getUserId } from "./identity"; import { User, addOption, -- 2.49.1 From 1ef970fef3466c8685c055aec5f98fb7ff2e0679 Mon Sep 17 00:00:00 2001 From: 1ynx Date: Sun, 10 May 2026 15:11:24 +0200 Subject: [PATCH 08/10] * revert changes --- src/app.ts | 114 +++++++------------------ src/components/DeadlineTimer.ts | 3 +- src/components/PollList.ts | 56 +++++------- src/components/PollOption.ts | 9 +- src/components/StatusBar.ts | 145 +++++--------------------------- src/crypto.ts | 119 -------------------------- src/state.ts | 109 ++++++++++++++++++++++-- src/styles.css | 8 -- src/userSync.ts | 70 --------------- src/yDocUtil.ts | 24 ------ 10 files changed, 183 insertions(+), 474 deletions(-) delete mode 100644 src/crypto.ts delete mode 100644 src/userSync.ts delete mode 100644 src/yDocUtil.ts diff --git a/src/app.ts b/src/app.ts index b396169..bf00075 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,21 +1,19 @@ +import { getUserId } from "./identity"; import { - User, addOption, toggleVote, + deleteOption, setDeadline, clearDeadline, - getDeadline, + createViewModel, } 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"; @@ -41,9 +39,8 @@ function ensureRoomId(): string { export function initApp(container: HTMLElement): () => void { const roomId = ensureRoomId(); + const userId = getUserId(); const sync = initSync(roomId); - const userSync = initUserSync(); - let user : User | undefined = undefined; const shareUrl = window.location.href; @@ -51,12 +48,17 @@ export function initApp(container: HTMLElement): () => void { const actions = { addOption: (label: string) => { - if (!user || isVotingClosed()) return; - return addOption(sync.options, label, user.userid); + const vm = createViewModel(getViewModelParams()); + if (vm.votingClosed) return; + return addOption(sync.options, label, userId); }, toggleVote: (optionId: string) => { - if (!user || isVotingClosed()) return; - toggleVote(sync.votes, user.userid, optionId); + const vm = createViewModel(getViewModelParams()); + if (vm.votingClosed) return; + toggleVote(sync.votes, userId, optionId); + }, + deleteOption: (optionId: string) => { + deleteOption(sync.options, sync.votes, optionId); }, startDeadline: (durationMs: number) => { setDeadline(sync.deadlineMap, durationMs); @@ -64,79 +66,20 @@ export function initApp(container: HTMLElement): () => void { 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; + function getViewModelParams() { + return { + yTitle: sync.yTitle, + options: sync.options, + votes: sync.votes, + deadlineMap: sync.deadlineMap, + roomId, + shareUrl, + connectionStatus: sync.getConnectionStatus(), + peerCount: sync.getPeerCount(), + userId, + }; } // --- Build UI --- @@ -156,7 +99,7 @@ export function initApp(container: HTMLElement): () => void { Polly `; - const statusBar = StatusBar(sync.provider,userSync.provider,actions.onLoginLogout,actions.onCreateUser); + const statusBar = StatusBar(sync.provider); header.append(wordmark, statusBar); // Main card @@ -169,7 +112,10 @@ export function initApp(container: HTMLElement): () => void { if (result && !result.ok) return result.error; return null; }); - const pollList = PollList(sync.options, sync.votes, user, isVotingClosed, actions.toggleVote); + const pollList = PollList(sync.options, sync.votes, userId, () => { + const vm = createViewModel(getViewModelParams()); + return vm.votingClosed; + }, actions.toggleVote, actions.deleteOption); const deadlineTimer = DeadlineTimer( sync.deadlineMap, actions.startDeadline, diff --git a/src/components/DeadlineTimer.ts b/src/components/DeadlineTimer.ts index 72e1072..9ef1cb9 100644 --- a/src/components/DeadlineTimer.ts +++ b/src/components/DeadlineTimer.ts @@ -1,6 +1,5 @@ import * as Y from "yjs"; import { getDeadline } from "../state"; -import { enforceAppendOnly } from "../yDocUtil"; const DEADLINE_DURATION_MS = 2 * 60 * 1000; // 2 minutes @@ -80,7 +79,7 @@ export function DeadlineTimer( onClearDeadline(); }); - deadlineMap.observe(enforceAppendOnly(deadlineMap,render)); + deadlineMap.observe(() => render()); render(); return wrapper; diff --git a/src/components/PollList.ts b/src/components/PollList.ts index a4f909d..dfd4458 100644 --- a/src/components/PollList.ts +++ b/src/components/PollList.ts @@ -1,20 +1,15 @@ 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, yVotes: Y.Map, - user: User | undefined, + userId: string, isVotingClosed: () => boolean, onVote: (optionId: string) => void, + onDelete: (optionId: string) => void, ): HTMLElement { - - var currentOptions : { [x: string]: any; } | undefined = undefined - var currentVotes : { [x: string]: any; } | undefined = undefined - const wrapper = document.createElement("div"); wrapper.className = "poll-list-wrapper"; @@ -46,32 +41,25 @@ export function PollList( votes: number; voted: boolean; }> = []; - if (currentOptions && currentVotes){ - // Tally votes per option - const tally = new Map(); - 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)); + // Tally votes per option + const tally = new Map(); + for (const optionId of yVotes.values()) { + tally.set(optionId, (tally.get(optionId) ?? 0) + 1); } + const myVote = yVotes.get(userId) ?? null; + + yOptions.forEach((record, id) => { + entries.push({ + id, + name: record.label, + votes: tally.get(id) ?? 0, + voted: myVote === id, + }); + }); + + entries.sort((a, b) => b.votes - a.votes || a.name.localeCompare(b.name)); return entries; } @@ -115,6 +103,7 @@ export function PollList( totalVotes: total, votingClosed, onVote, + onDelete, }); const currentEl = list.children[i] as HTMLElement | undefined; @@ -129,10 +118,9 @@ export function PollList( } }); } - yOptions.observe(enforceAppendOnly(yOptions,(update : { [x: string]: any; }) => {currentOptions = update}, render)); - yVotes.observe(enforceAppendOnly(yVotes,(update : { [x: string]: any; }) => {currentVotes = update},render)); - currentOptions=yOptions.toJSON() - currentVotes=yVotes.toJSON() + + yOptions.observeDeep(() => render()); + yVotes.observe(() => render()); render(); return wrapper; diff --git a/src/components/PollOption.ts b/src/components/PollOption.ts index 70eff3b..c10ef22 100644 --- a/src/components/PollOption.ts +++ b/src/components/PollOption.ts @@ -8,10 +8,11 @@ export interface PollOptionProps { totalVotes: number; votingClosed: boolean; onVote: (id: string) => void; + onDelete: (id: string) => void; } export function PollOption(props: PollOptionProps): HTMLElement { - const { id, name, votes, voted, totalVotes, votingClosed, onVote } = props; + const { id, name, votes, voted, totalVotes, votingClosed, onVote, onDelete } = props; const row = document.createElement("div"); row.className = `poll-option${voted ? " poll-option--voted" : ""}`; @@ -29,11 +30,17 @@ export function PollOption(props: PollOptionProps): HTMLElement { + `; row.querySelector(".poll-option__vote-btn")!.addEventListener("click", () => onVote(id)); + row.querySelector(".poll-option__delete-btn")!.addEventListener("click", () => onDelete(id)); return row; } diff --git a/src/components/StatusBar.ts b/src/components/StatusBar.ts index 52d9950..d981c0a 100644 --- a/src/components/StatusBar.ts +++ b/src/components/StatusBar.ts @@ -1,110 +1,30 @@ import type { WebrtcProvider } from "y-webrtc"; -export function StatusBar(provider: WebrtcProvider, user_provider: WebrtcProvider, onLoginLogout: (event: Event) => Promise, onCreateUser: (event: Event) => Promise): HTMLElement { +export function StatusBar(provider: WebrtcProvider): HTMLElement { const el = document.createElement("div"); el.className = "status-bar"; - const statusPanel=document.createElement("div"); - statusPanel.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 = "\u00b7"; - function getProviderStatus(){ + const peerText = document.createElement("span"); + peerText.className = "status-peers"; - 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="Logout" - logoutButton.style.display = "none"; - - const createUserButton = document.createElement("button"); - createUserButton.className = "add-option-btn"; - createUserButton.setAttribute("aria-label", "Create User"); - createUserButton.innerHTML="Create User" - - async function onLoginLogoutResult(event: Event, loginLogout: (event: Event) => Promise){ - 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); + el.append(dot, statusText, divider, peerText); // --- Connection state --- let syncTimeout: ReturnType | undefined = setTimeout(() => { - pollProviderElements.statusText.textContent = "Ready"; - pollProviderElements.dot.className = "status-dot ready"; + statusText.textContent = "Ready"; + dot.className = "status-dot ready"; }, 3000); provider.on("synced", ({ synced }: { synced: boolean }) => { @@ -112,37 +32,18 @@ export function StatusBar(provider: WebrtcProvider, user_provider: WebrtcProvide clearTimeout(syncTimeout); syncTimeout = undefined; } - pollProviderElements.dot.className = `status-dot ${synced ? "connected" : "connecting"}`; - pollProviderElements.statusText.textContent = synced ? "Connected" : "Connecting"; - }); - - - let syncTimeout2: ReturnType | 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"; + dot.className = `status-dot ${synced ? "connected" : "connecting"}`; + 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"; + dot.className = "status-dot connecting"; + 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"; + dot.className = "status-dot connecting"; + statusText.textContent = "Reconnecting"; }; window.addEventListener("offline", handleOffline); @@ -153,18 +54,10 @@ export function StatusBar(provider: WebrtcProvider, user_provider: WebrtcProvide function updatePeerCount() { const total = provider.awareness.getStates().size; const others = total - 1; - pollProviderElements.peerText.textContent = + 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); diff --git a/src/crypto.ts b/src/crypto.ts deleted file mode 100644 index 91d9b0f..0000000 --- a/src/crypto.ts +++ /dev/null @@ -1,119 +0,0 @@ -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 => { - // 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 => { - 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); - }); -}; \ No newline at end of file diff --git a/src/state.ts b/src/state.ts index fc20f17..65fd560 100644 --- a/src/state.ts +++ b/src/state.ts @@ -4,12 +4,6 @@ import * as Y from "yjs"; 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; @@ -17,6 +11,25 @@ export interface OptionRecord { createdBy: string; } +export interface PollOptionViewModel extends OptionRecord { + voteCount: number; + isVotedByMe: boolean; + percentage: number; +} + +export interface PollViewModel { + title: string; + roomId: string; + shareUrl: string; + connectionStatus: ConnectionStatus; + peerCount: number; + options: PollOptionViewModel[]; + totalVotes: number; + myVoteOptionId: string | null; + deadline: number | null; + votingClosed: boolean; +} + // --- Helpers --- export function createOptionId(): string { @@ -91,6 +104,20 @@ export function toggleVote( } } +export function deleteOption( + options: Y.Map, + votes: Y.Map, + optionId: string, +): void { + options.delete(optionId); + // Clean up votes pointing to this option + for (const [userId, votedOptionId] of votes.entries()) { + if (votedOptionId === optionId) { + votes.delete(userId); + } + } +} + // --- Deadline --- export function setDeadline( @@ -108,3 +135,73 @@ export function getDeadline(deadlineMap: Y.Map): number | null { const val = deadlineMap.get("deadline"); return typeof val === "number" ? val : null; } + +// --- ViewModel --- + +export function createViewModel(params: { + yTitle: Y.Text; + options: Y.Map; + votes: Y.Map; + deadlineMap: Y.Map; + roomId: string; + shareUrl: string; + connectionStatus: ConnectionStatus; + peerCount: number; + userId: string; +}): PollViewModel { + const { + yTitle, + options, + votes, + deadlineMap, + roomId, + shareUrl, + connectionStatus, + peerCount, + userId, + } = params; + + // Tally votes per option + const tally = new Map(); + for (const optionId of votes.values()) { + tally.set(optionId, (tally.get(optionId) ?? 0) + 1); + } + + let totalVotes = 0; + for (const count of tally.values()) { + totalVotes += count; + } + + const myVoteOptionId = votes.get(userId) ?? null; + const deadline = getDeadline(deadlineMap); + const votingClosed = deadline !== null && Date.now() >= deadline; + + // Sort by votes desc, then alphabetically + const sortedOptions = Array.from(options.values()).sort((a, b) => { + const aVotes = tally.get(a.id) ?? 0; + const bVotes = tally.get(b.id) ?? 0; + if (bVotes !== aVotes) return bVotes - aVotes; + return a.label.localeCompare(b.label); + }); + + return { + title: getPollTitle(yTitle), + roomId, + shareUrl, + connectionStatus, + peerCount, + myVoteOptionId, + totalVotes, + deadline, + votingClosed, + options: sortedOptions.map((option) => ({ + ...option, + voteCount: tally.get(option.id) ?? 0, + isVotedByMe: myVoteOptionId === option.id, + percentage: + totalVotes > 0 + ? Math.round(((tally.get(option.id) ?? 0) / totalVotes) * 100) + : 0, + })), + }; +} diff --git a/src/styles.css b/src/styles.css index e213662..965045c 100644 --- a/src/styles.css +++ b/src/styles.css @@ -86,14 +86,6 @@ button { cursor: pointer; } 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; diff --git a/src/userSync.ts b/src/userSync.ts deleted file mode 100644 index 5ba0f51..0000000 --- a/src/userSync.ts +++ /dev/null @@ -1,70 +0,0 @@ -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; - provider: WebrtcProvider; - persistence: IndexeddbPersistence; - getConnectionStatus: () => ConnectionStatus; - getPeerCount: () => number; - destroy: () => void; -} - -export function initUserSync(): UserSync { - const doc = new Y.Doc(); - const users = doc.getMap("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(); - }, - }; -} diff --git a/src/yDocUtil.ts b/src/yDocUtil.ts deleted file mode 100644 index 66a687d..0000000 --- a/src/yDocUtil.ts +++ /dev/null @@ -1,24 +0,0 @@ -import * as Y from "yjs"; - -/** - * Enforces append-only logic on a Y.Map. - * Reverts any 'update' or 'delete' actions detected in the observer. - */ -export function enforceAppendOnly(yMap: Y.Map,update: (update : { [x: string]: any; }) => void, render: () => void) { - return (event: Y.YMapEvent, 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(); - } - }; -} \ No newline at end of file -- 2.49.1 From ba76c5df4936bd7692d776287ea734642dc7b52f Mon Sep 17 00:00:00 2001 From: 1ynx Date: Sun, 10 May 2026 20:40:14 +0200 Subject: [PATCH 09/10] * update readme --- README.md | 57 ++++++++++++++++++++++++++++++++++++++++++++++++++++- src/sync.ts | 2 +- 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 0217c70..dcdcfd9 100644 --- a/README.md +++ b/README.md @@ -1 +1,56 @@ -# P2P Poll App \ No newline at end of file +# Polly - P2P Poll App + +A lightweight, real-time collaborative polling application that uses Yjs and WebRTC to allow multiple users to create options, vote, and see live results without a centralized database or back-end server. + +### 🚀 Features + +- Real-time Collaboration: Instant synchronization of poll titles, options, and votes across all connected peers using CRDTs (Conflict-free Replicated Data Types). +- P2P Connectivity: Uses WebRTC via y-webrtc for direct browser-to-browser communication. +- Dynamic Voting: * Add new options on the fly. + - Live-updating progress bars and vote tallies. + - Automatic sorting of options by vote count. +- Voting Deadline: A shared countdown timer (2 minutes) that locks the poll for all participants once expired. +- Awareness & Presence: A status bar showing connection health and the number of active peers currently in the room. +- Local Persistence: Uses y-indexeddb to save the poll state locally in your browser, ensuring data isn't lost if you refresh or lose connection. +- No Setup Required: Unique "rooms" are created via URL parameters, making it easy to share a link and start a poll instantly. + +### 🛠 Tech Stack + +- Language: TypeScript +- State Management: Yjs (Shared data types: Y.Doc, Y.Map, Y.Text) +- Networking: y-webrtc (WebRTC provider for Yjs) +- UI: Vanilla DOM manipulation (No heavy frameworks like React or Vue) + +### 💡 How It Works + +- Room Creation: When you open the app, it checks for a ?room= parameter. If none exists, it generates a unique ID and updates the URL. +- State Synchronization: The y-webrtc provider connects users with the same room ID. Any change to sync.options or sync.votes is propagated to all users. +- Local Reactivity: Components use .observe() and .observeDeep() on Yjs types to trigger a re-render of the UI whenever the shared state changes. +- Voting: Votes are stored in a Y.Map where the key is the User ID and the value is the Option ID. This ensures each user can only have one active vote at a time. + +You can simulate a second user by opening an incognito Tab. + +### 🔧 Installation, Development and Deployment + +- Install dependencies: + + ```npm install yjs y-webrtc y-indexeddb``` + +- Development: + + ```npm run dev``` + +- Deployment: + - The code currently uses an Y-Webrtc-Signaling-Server at localhost that starts with `npm run dev` for development (PORT=4444 npx y-webrtc ). + - To deploy the App, you need to set up a publicly available signaling server and set the address in the `synx.ts`. E.g. with Docker: + + ```version: '3.1' +services: + y-webrtc-signaling: + container_name: y-webrtc-signaling + image: funnyzak/y-webrtc-signaling:latest + restart: always + network_mode: bridge + ports: + - "4444:4444" + dns: 8.8.8.8``` \ No newline at end of file diff --git a/src/sync.ts b/src/sync.ts index ec65fcf..01cfec5 100644 --- a/src/sync.ts +++ b/src/sync.ts @@ -29,7 +29,7 @@ export function initSync(roomId: string): AppSync { : "offline"; const provider = new WebrtcProvider(roomId, doc,{ - signaling: ["ws://localhost:4444", "ws://lynxpi.ddns.net:4444"] + signaling: ["ws://localhost:4444"] }); const persistence = new IndexeddbPersistence(roomId, doc); -- 2.49.1 From d0fe9f49aa6328f6fabc8baaf2042f8fbea669d0 Mon Sep 17 00:00:00 2001 From: 1ynx Date: Sun, 10 May 2026 20:47:30 +0200 Subject: [PATCH 10/10] * fix readme --- README.md | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index dcdcfd9..e7d49a7 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,17 @@ # Polly - P2P Poll App -A lightweight, real-time collaborative polling application that uses Yjs and WebRTC to allow multiple users to create options, vote, and see live results without a centralized database or back-end server. +A lightweight, real-time collaborative polling application that uses [Yjs](https://yjs.dev/) and [WebRTC](https://de.wikipedia.org/wiki/WebRTC) to allow multiple users to create options, vote, and see live results without a centralized database or back-end server. ### 🚀 Features -- Real-time Collaboration: Instant synchronization of poll titles, options, and votes across all connected peers using CRDTs (Conflict-free Replicated Data Types). -- P2P Connectivity: Uses WebRTC via y-webrtc for direct browser-to-browser communication. +- Real-time Collaboration: Instant synchronization of poll titles, options, and votes across all connected peers using [CRDTs (Conflict-free Replicated Data Types)](https://en.wikipedia.org/wiki/Conflict-free_replicated_data_type). +- P2P Connectivity: Uses WebRTC via [y-webrtc](https://github.com/yjs/y-webrtc) for direct browser-to-browser communication. - Dynamic Voting: * Add new options on the fly. - Live-updating progress bars and vote tallies. - Automatic sorting of options by vote count. - Voting Deadline: A shared countdown timer (2 minutes) that locks the poll for all participants once expired. - Awareness & Presence: A status bar showing connection health and the number of active peers currently in the room. -- Local Persistence: Uses y-indexeddb to save the poll state locally in your browser, ensuring data isn't lost if you refresh or lose connection. +- Local Persistence: Uses [y-indexeddb](https://github.com/yjs/y-indexeddb) to save the poll state locally in your browser, ensuring data isn't lost if you refresh or lose connection. - No Setup Required: Unique "rooms" are created via URL parameters, making it easy to share a link and start a poll instantly. ### 🛠 Tech Stack @@ -41,16 +41,16 @@ You can simulate a second user by opening an incognito Tab. ```npm run dev``` - Deployment: - - The code currently uses an Y-Webrtc-Signaling-Server at localhost that starts with `npm run dev` for development (PORT=4444 npx y-webrtc ). - - To deploy the App, you need to set up a publicly available signaling server and set the address in the `synx.ts`. E.g. with Docker: + - The code currently uses an Y-Webrtc-Signaling-Server at localhost:4444 that starts with `npm run dev` for development. + - To deploy the App, you need to set up a publicly available signaling server and set the address in the `synx.ts`. E.g. with Docker using the [funnyzak/y-webrtc-signaling](https://hub.docker.com/r/funnyzak/y-webrtc-signaling) image: - ```version: '3.1' -services: - y-webrtc-signaling: - container_name: y-webrtc-signaling - image: funnyzak/y-webrtc-signaling:latest - restart: always - network_mode: bridge - ports: - - "4444:4444" - dns: 8.8.8.8``` \ No newline at end of file + ```version: '3.1' + services: + y-webrtc-signaling: + container_name: y-webrtc-signaling + image: funnyzak/y-webrtc-signaling:latest + restart: always + network_mode: bridge + ports: + - "4444:4444" + dns: 8.8.8.8``` \ No newline at end of file -- 2.49.1