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"] +}