Compare commits

..

3 Commits

Author SHA1 Message Date
e3c192a84f Fix type error 2026-03-16 23:18:41 +13:00
ab508d827d Implementation, thanks amp 2026-03-16 23:03:27 +13:00
b7539ac86e Init with specs 2026-03-16 22:44:21 +13:00
55 changed files with 3585 additions and 2140 deletions

View File

@@ -1,57 +1,13 @@
# P2P Poll App # P2P Poll App
A peer-to-peer polling application where users can create polls, add options, and vote in real-time without a central server. A decentralized polling app built with SvelteKit + PeerJS. Mobile-first, peer-to-peer.
## Features ## UI Guidelines
- **Real-time P2P voting** using WebRTC via PeerJS All UI code must follow mobile-first principles:
- **Dynamic option management** - add/remove options during polling
- **Duplicate vote prevention** - one vote per user
- **Automatic data synchronization** across all connected peers
- **Local storage persistence** for poll recovery
- **Responsive design** works on desktop and mobile
- **No server required** - uses PeerJS free signaling service
## How to Use - **Touch targets**: ≥ 44px
- **Layout**: Single-column on mobile, expand on larger screens
1. **Open the app** in your browser (open `index.html`) - **Navigation**: Bottom tab bar (Home, Create, Profile)
2. **First user (Host)**: Leave the Peer ID field empty and click "Connect to Host" - **Dark mode**: Respect `prefers-color-scheme`
3. **Copy your Peer ID** using the "Copy Your Peer ID" button - **Tailwind**: Mobile breakpoints are the default; use `md:` / `lg:` to scale up
4. **Share your Peer ID** with other users (via chat, email, etc.)
5. **Other users**: Paste the host's Peer ID and click "Connect to Host"
6. **Create a poll** with question and options
7. **Vote** by clicking on options (one vote per person)
8. **Watch results** update in real-time across all devices
## Technical Details
- **P2P Library**: PeerJS (WebRTC-based)
- **Frontend**: Vanilla JavaScript with modern CSS
- **Data Sync**: Custom conflict resolution for concurrent operations
- **Storage**: localStorage for basic persistence
- **Network**: Full mesh topology where each peer connects to all others
## File Structure
```
├── index.html # Main application
├── css/
│ └── styles.css # Application styling
└── js/
├── app.js # Main application logic
├── peer-manager.js # P2P connection handling
├── poll-manager.js # Poll data and sync logic
└── ui-controller.js # UI interactions
```
## Browser Support
Requires modern browsers with WebRTC support:
- Chrome 23+
- Firefox 22+
- Safari 11+
- Edge 79+
## Development
Simply open `index.html` in a browser - no build process required. For testing with multiple peers, open the app in multiple browser tabs or windows.

23
app/.gitignore vendored Normal file
View File

@@ -0,0 +1,23 @@
node_modules
# Output
.output
.vercel
.netlify
.wrangler
/.svelte-kit
/build
# OS
.DS_Store
Thumbs.db
# Env
.env
.env.*
!.env.example
!.env.test
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*

1
app/.npmrc Normal file
View File

@@ -0,0 +1 @@
engine-strict=true

3
app/.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"recommendations": ["svelte.svelte-vscode"]
}

42
app/README.md Normal file
View File

@@ -0,0 +1,42 @@
# sv
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
## Creating a project
If you're seeing this, you've probably already done this step. Congrats!
```sh
# create a new project
npx sv create my-app
```
To recreate this project with the same configuration:
```sh
# recreate this project
npx sv@0.12.7 create --template minimal --types ts --no-install app
```
## Developing
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
```sh
npm run dev
# or start the server and open the app in a new browser tab
npm run dev -- --open
```
## Building
To create a production version of your app:
```sh
npm run build
```
You can preview the production build with `npm run preview`.
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.

326
app/bun.lock Normal file
View File

@@ -0,0 +1,326 @@
{
"lockfileVersion": 1,
"workspaces": {
"": {
"name": "app",
"dependencies": {
"idb-keyval": "^6.2.2",
"peerjs": "^1.5.5",
},
"devDependencies": {
"@sveltejs/adapter-auto": "^7.0.0",
"@sveltejs/kit": "^2.50.2",
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"@tailwindcss/vite": "^4.2.1",
"svelte": "^5.51.0",
"svelte-check": "^4.4.2",
"tailwindcss": "^4.2.1",
"typescript": "^5.9.3",
"vite": "^7.3.1",
},
},
},
"packages": {
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.4", "", { "os": "aix", "cpu": "ppc64" }, "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.27.4", "", { "os": "android", "cpu": "arm" }, "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ=="],
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.4", "", { "os": "android", "cpu": "arm64" }, "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw=="],
"@esbuild/android-x64": ["@esbuild/android-x64@0.27.4", "", { "os": "android", "cpu": "x64" }, "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw=="],
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ=="],
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw=="],
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.4", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw=="],
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ=="],
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.4", "", { "os": "linux", "cpu": "arm" }, "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg=="],
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA=="],
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.4", "", { "os": "linux", "cpu": "ia32" }, "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA=="],
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.4", "", { "os": "linux", "cpu": "none" }, "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA=="],
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.4", "", { "os": "linux", "cpu": "none" }, "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw=="],
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA=="],
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.4", "", { "os": "linux", "cpu": "none" }, "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw=="],
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA=="],
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.4", "", { "os": "linux", "cpu": "x64" }, "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA=="],
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.4", "", { "os": "none", "cpu": "arm64" }, "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q=="],
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.4", "", { "os": "none", "cpu": "x64" }, "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg=="],
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.4", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow=="],
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.4", "", { "os": "openbsd", "cpu": "x64" }, "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ=="],
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.4", "", { "os": "none", "cpu": "arm64" }, "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg=="],
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.4", "", { "os": "sunos", "cpu": "x64" }, "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g=="],
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg=="],
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw=="],
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.4", "", { "os": "win32", "cpu": "x64" }, "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@msgpack/msgpack": ["@msgpack/msgpack@2.8.0", "", {}, "sha512-h9u4u/jiIRKbq25PM+zymTyW6bhTzELvOoUd+AvYriWOAKpLGnIamaET3pnHYoI5iYphAHBI4ayx0MehR+VVPQ=="],
"@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.59.0", "", { "os": "android", "cpu": "arm" }, "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg=="],
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.59.0", "", { "os": "android", "cpu": "arm64" }, "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q=="],
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.59.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg=="],
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.59.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w=="],
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.59.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA=="],
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.59.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg=="],
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.59.0", "", { "os": "linux", "cpu": "arm" }, "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw=="],
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.59.0", "", { "os": "linux", "cpu": "arm" }, "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA=="],
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.59.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA=="],
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.59.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA=="],
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg=="],
"@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q=="],
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.59.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA=="],
"@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.59.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA=="],
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg=="],
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg=="],
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.59.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w=="],
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.59.0", "", { "os": "linux", "cpu": "x64" }, "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg=="],
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.59.0", "", { "os": "linux", "cpu": "x64" }, "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg=="],
"@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.59.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ=="],
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.59.0", "", { "os": "none", "cpu": "arm64" }, "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA=="],
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.59.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A=="],
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.59.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA=="],
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.59.0", "", { "os": "win32", "cpu": "x64" }, "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA=="],
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.59.0", "", { "os": "win32", "cpu": "x64" }, "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA=="],
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.9", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA=="],
"@sveltejs/adapter-auto": ["@sveltejs/adapter-auto@7.0.1", "", { "peerDependencies": { "@sveltejs/kit": "^2.0.0" } }, "sha512-dvuPm1E7M9NI/+canIQ6KKQDU2AkEefEZ2Dp7cY6uKoPq9Z/PhOXABe526UdW2mN986gjVkuSLkOYIBnS/M2LQ=="],
"@sveltejs/kit": ["@sveltejs/kit@2.55.0", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/cookie": "^0.6.0", "acorn": "^8.14.1", "cookie": "^0.6.0", "devalue": "^5.6.4", "esm-env": "^1.2.2", "kleur": "^4.1.5", "magic-string": "^0.30.5", "mrmime": "^2.0.0", "set-cookie-parser": "^3.0.0", "sirv": "^3.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.0.0", "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0", "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": "^5.3.3", "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0" }, "optionalPeers": ["@opentelemetry/api", "typescript"], "bin": { "svelte-kit": "svelte-kit.js" } }, "sha512-MdFRjevVxmAknf2NbaUkDF16jSIzXMWd4Nfah0Qp8TtQVoSp3bV4jKt8mX7z7qTUTWvgSaxtR0EG5WJf53gcuA=="],
"@sveltejs/vite-plugin-svelte": ["@sveltejs/vite-plugin-svelte@6.2.4", "", { "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^5.0.0", "deepmerge": "^4.3.1", "magic-string": "^0.30.21", "obug": "^2.1.0", "vitefu": "^1.1.1" }, "peerDependencies": { "svelte": "^5.0.0", "vite": "^6.3.0 || ^7.0.0" } }, "sha512-ou/d51QSdTyN26D7h6dSpusAKaZkAiGM55/AKYi+9AGZw7q85hElbjK3kEyzXHhLSnRISHOYzVge6x0jRZ7DXA=="],
"@sveltejs/vite-plugin-svelte-inspector": ["@sveltejs/vite-plugin-svelte-inspector@5.0.2", "", { "dependencies": { "obug": "^2.1.0" }, "peerDependencies": { "@sveltejs/vite-plugin-svelte": "^6.0.0-next.0", "svelte": "^5.0.0", "vite": "^6.3.0 || ^7.0.0" } }, "sha512-TZzRTcEtZffICSAoZGkPSl6Etsj2torOVrx6Uw0KpXxrec9Gg6jFWQ60Q3+LmNGfZSxHRCZL7vXVZIWmuV50Ig=="],
"@tailwindcss/node": ["@tailwindcss/node@4.2.1", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.31.1", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.1" } }, "sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg=="],
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.2.1", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.2.1", "@tailwindcss/oxide-darwin-arm64": "4.2.1", "@tailwindcss/oxide-darwin-x64": "4.2.1", "@tailwindcss/oxide-freebsd-x64": "4.2.1", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.1", "@tailwindcss/oxide-linux-arm64-gnu": "4.2.1", "@tailwindcss/oxide-linux-arm64-musl": "4.2.1", "@tailwindcss/oxide-linux-x64-gnu": "4.2.1", "@tailwindcss/oxide-linux-x64-musl": "4.2.1", "@tailwindcss/oxide-wasm32-wasi": "4.2.1", "@tailwindcss/oxide-win32-arm64-msvc": "4.2.1", "@tailwindcss/oxide-win32-x64-msvc": "4.2.1" } }, "sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw=="],
"@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.2.1", "", { "os": "android", "cpu": "arm64" }, "sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg=="],
"@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.2.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw=="],
"@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.2.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw=="],
"@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.2.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA=="],
"@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.2.1", "", { "os": "linux", "cpu": "arm" }, "sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw=="],
"@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.2.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ=="],
"@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.2.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ=="],
"@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.2.1", "", { "os": "linux", "cpu": "x64" }, "sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g=="],
"@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.2.1", "", { "os": "linux", "cpu": "x64" }, "sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g=="],
"@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.2.1", "", { "dependencies": { "@emnapi/core": "^1.8.1", "@emnapi/runtime": "^1.8.1", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.1.1", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q=="],
"@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.2.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA=="],
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.2.1", "", { "os": "win32", "cpu": "x64" }, "sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ=="],
"@tailwindcss/vite": ["@tailwindcss/vite@4.2.1", "", { "dependencies": { "@tailwindcss/node": "4.2.1", "@tailwindcss/oxide": "4.2.1", "tailwindcss": "4.2.1" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-TBf2sJjYeb28jD2U/OhwdW0bbOsxkWPwQ7SrqGf9sVcoYwZj7rkXljroBO9wKBut9XnmQLXanuDUeqQK0lGg/w=="],
"@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="],
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
"@typescript-eslint/types": ["@typescript-eslint/types@8.57.0", "", {}, "sha512-dTLI8PEXhjUC7B9Kre+u0XznO696BhXcTlOn0/6kf1fHaQW8+VjJAVHJ3eTI14ZapTxdkOmc80HblPQLaEeJdg=="],
"acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
"aria-query": ["aria-query@5.3.1", "", {}, "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g=="],
"axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="],
"chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
"cookie": ["cookie@0.6.0", "", {}, "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw=="],
"deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"devalue": ["devalue@5.6.4", "", {}, "sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA=="],
"enhanced-resolve": ["enhanced-resolve@5.20.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ=="],
"esbuild": ["esbuild@0.27.4", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.4", "@esbuild/android-arm": "0.27.4", "@esbuild/android-arm64": "0.27.4", "@esbuild/android-x64": "0.27.4", "@esbuild/darwin-arm64": "0.27.4", "@esbuild/darwin-x64": "0.27.4", "@esbuild/freebsd-arm64": "0.27.4", "@esbuild/freebsd-x64": "0.27.4", "@esbuild/linux-arm": "0.27.4", "@esbuild/linux-arm64": "0.27.4", "@esbuild/linux-ia32": "0.27.4", "@esbuild/linux-loong64": "0.27.4", "@esbuild/linux-mips64el": "0.27.4", "@esbuild/linux-ppc64": "0.27.4", "@esbuild/linux-riscv64": "0.27.4", "@esbuild/linux-s390x": "0.27.4", "@esbuild/linux-x64": "0.27.4", "@esbuild/netbsd-arm64": "0.27.4", "@esbuild/netbsd-x64": "0.27.4", "@esbuild/openbsd-arm64": "0.27.4", "@esbuild/openbsd-x64": "0.27.4", "@esbuild/openharmony-arm64": "0.27.4", "@esbuild/sunos-x64": "0.27.4", "@esbuild/win32-arm64": "0.27.4", "@esbuild/win32-ia32": "0.27.4", "@esbuild/win32-x64": "0.27.4" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ=="],
"esm-env": ["esm-env@1.2.2", "", {}, "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="],
"esrap": ["esrap@2.2.4", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15", "@typescript-eslint/types": "^8.2.0" } }, "sha512-suICpxAmZ9A8bzJjEl/+rLJiDKC0X4gYWUxT6URAWBLvlXmtbZd5ySMu/N2ZGEtMCAmflUDPSehrP9BQcsGcSg=="],
"eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
"idb-keyval": ["idb-keyval@6.2.2", "", {}, "sha512-yjD9nARJ/jb1g+CvD0tlhUHOrJ9Sy0P8T9MF3YaLlHnSRpwPfpTX0XIvpmw3gAJUmEu3FiICLBDPXVwyEvrleg=="],
"is-reference": ["is-reference@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.6" } }, "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw=="],
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
"kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
"lightningcss": ["lightningcss@1.31.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.31.1", "lightningcss-darwin-arm64": "1.31.1", "lightningcss-darwin-x64": "1.31.1", "lightningcss-freebsd-x64": "1.31.1", "lightningcss-linux-arm-gnueabihf": "1.31.1", "lightningcss-linux-arm64-gnu": "1.31.1", "lightningcss-linux-arm64-musl": "1.31.1", "lightningcss-linux-x64-gnu": "1.31.1", "lightningcss-linux-x64-musl": "1.31.1", "lightningcss-win32-arm64-msvc": "1.31.1", "lightningcss-win32-x64-msvc": "1.31.1" } }, "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ=="],
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.31.1", "", { "os": "android", "cpu": "arm64" }, "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg=="],
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.31.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg=="],
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.31.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA=="],
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.31.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A=="],
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.31.1", "", { "os": "linux", "cpu": "arm" }, "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g=="],
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.31.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg=="],
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.31.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg=="],
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.31.1", "", { "os": "linux", "cpu": "x64" }, "sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA=="],
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.31.1", "", { "os": "linux", "cpu": "x64" }, "sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA=="],
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.31.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w=="],
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.31.1", "", { "os": "win32", "cpu": "x64" }, "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw=="],
"locate-character": ["locate-character@3.0.0", "", {}, "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="],
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="],
"mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="],
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="],
"peerjs": ["peerjs@1.5.5", "", { "dependencies": { "@msgpack/msgpack": "^2.8.0", "eventemitter3": "^4.0.7", "peerjs-js-binarypack": "^2.1.0", "webrtc-adapter": "^9.0.0" } }, "sha512-viMUCPDL6CSfOu0ZqVcFqbWRXNHIbv2lPqNbrBIjbFYrflebOjItJ4hPfhjnuUCstqciHVu9vVJ7jFqqKi/EuQ=="],
"peerjs-js-binarypack": ["peerjs-js-binarypack@2.1.0", "", {}, "sha512-YIwCC+pTzp3Bi8jPI9UFKO0t0SLo6xALnHkiNt/iUFmUUZG0fEEmEyFKvjsDKweiFitzHRyhuh6NvyJZ4nNxMg=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
"postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="],
"readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
"rollup": ["rollup@4.59.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.59.0", "@rollup/rollup-android-arm64": "4.59.0", "@rollup/rollup-darwin-arm64": "4.59.0", "@rollup/rollup-darwin-x64": "4.59.0", "@rollup/rollup-freebsd-arm64": "4.59.0", "@rollup/rollup-freebsd-x64": "4.59.0", "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", "@rollup/rollup-linux-arm-musleabihf": "4.59.0", "@rollup/rollup-linux-arm64-gnu": "4.59.0", "@rollup/rollup-linux-arm64-musl": "4.59.0", "@rollup/rollup-linux-loong64-gnu": "4.59.0", "@rollup/rollup-linux-loong64-musl": "4.59.0", "@rollup/rollup-linux-ppc64-gnu": "4.59.0", "@rollup/rollup-linux-ppc64-musl": "4.59.0", "@rollup/rollup-linux-riscv64-gnu": "4.59.0", "@rollup/rollup-linux-riscv64-musl": "4.59.0", "@rollup/rollup-linux-s390x-gnu": "4.59.0", "@rollup/rollup-linux-x64-gnu": "4.59.0", "@rollup/rollup-linux-x64-musl": "4.59.0", "@rollup/rollup-openbsd-x64": "4.59.0", "@rollup/rollup-openharmony-arm64": "4.59.0", "@rollup/rollup-win32-arm64-msvc": "4.59.0", "@rollup/rollup-win32-ia32-msvc": "4.59.0", "@rollup/rollup-win32-x64-gnu": "4.59.0", "@rollup/rollup-win32-x64-msvc": "4.59.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg=="],
"sade": ["sade@1.8.1", "", { "dependencies": { "mri": "^1.1.0" } }, "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A=="],
"sdp": ["sdp@3.2.1", "", {}, "sha512-lwsAIzOPlH8/7IIjjz3K0zYBk7aBVVcvjMwt3M4fLxpjMYyy7i3I97SLHebgn4YBjirkzfp3RvRDWSKsh/+WFw=="],
"set-cookie-parser": ["set-cookie-parser@3.0.1", "", {}, "sha512-n7Z7dXZhJbwuAHhNzkTti6Aw9QDDjZtm3JTpTGATIdNzdQz5GuFs22w90BcvF4INfnrL5xrX3oGsuqO5Dx3A1Q=="],
"sirv": ["sirv@3.0.2", "", { "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"svelte": ["svelte@5.53.12", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.6.4", "esm-env": "^1.2.1", "esrap": "^2.2.2", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-4x/uk4rQe/d7RhfvS8wemTfNjQ0bJbKvamIzRBfTe2eHHjzBZ7PZicUQrC2ryj83xxEacfA1zHKd1ephD1tAxA=="],
"svelte-check": ["svelte-check@4.4.5", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": ">=5.0.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-1bSwIRCvvmSHrlK52fOlZmVtUZgil43jNL/2H18pRpa+eQjzGt6e3zayxhp1S7GajPFKNM/2PMCG+DZFHlG9fw=="],
"tailwindcss": ["tailwindcss@4.2.1", "", {}, "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw=="],
"tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
"totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"vite": ["vite@7.3.1", "", { "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" }, "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" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="],
"vitefu": ["vitefu@1.1.2", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-beta.0" }, "optionalPeers": ["vite"] }, "sha512-zpKATdUbzbsycPFBN71nS2uzBUQiVnFoOrr2rvqv34S1lcAgMKKkjWleLGeiJlZ8lwCXvtWaRn7R3ZC16SYRuw=="],
"webrtc-adapter": ["webrtc-adapter@9.0.4", "", { "dependencies": { "sdp": "^3.2.0" } }, "sha512-5ZZY1+lGq8LEKuDlg9M2RPJHlH3R7OVwyHqMcUsLKCgd9Wvf+QrFTCItkXXYPmrJn8H6gRLXbSgxLLdexiqHxw=="],
"zimmerframe": ["zimmerframe@1.1.4", "", {}, "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.9.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-0DQ98G9ZQZOxfUcQn1waV2yS8aWdZ6kJMbYCJB3oUBecjWYO1fqJ+a1DRfPF3O5JEkwqwP1A9QEN/9mYm2Yd0w=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.9.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg=="],
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" }, "bundled": true }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="],
"@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
}
}

29
app/package.json Normal file
View File

@@ -0,0 +1,29 @@
{
"name": "app",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
},
"devDependencies": {
"@sveltejs/adapter-auto": "^7.0.0",
"@sveltejs/kit": "^2.50.2",
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"@tailwindcss/vite": "^4.2.1",
"svelte": "^5.51.0",
"svelte-check": "^4.4.2",
"tailwindcss": "^4.2.1",
"typescript": "^5.9.3",
"vite": "^7.3.1"
},
"dependencies": {
"idb-keyval": "^6.2.2",
"peerjs": "^1.5.5"
}
}

17
app/src/app.css Normal file
View File

@@ -0,0 +1,17 @@
@import 'tailwindcss';
@theme {
--font-sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
'Helvetica Neue', Arial, sans-serif;
--color-primary: #6366f1;
--color-primary-hover: #4f46e5;
--color-surface: #ffffff;
--color-surface-dark: #0f172a;
--color-muted: #64748b;
}
@media (prefers-color-scheme: dark) {
:root {
color-scheme: dark;
}
}

13
app/src/app.d.ts vendored Normal file
View File

@@ -0,0 +1,13 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};

11
app/src/app.html Normal file
View File

@@ -0,0 +1,11 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

91
app/src/lib/crypto.ts Normal file
View File

@@ -0,0 +1,91 @@
import { loadKeypair, saveKeypair, loadPublicKeyRaw, savePublicKeyRaw } from './db.js';
const ALGO = { name: 'Ed25519' } as const;
let cachedKeypair: CryptoKeyPair | undefined;
let cachedPublicKeyRaw: Uint8Array | undefined;
export async function getOrCreateKeypair(): Promise<CryptoKeyPair> {
if (cachedKeypair) return cachedKeypair;
const existing = await loadKeypair();
if (existing) {
cachedKeypair = existing;
cachedPublicKeyRaw = await loadPublicKeyRaw() ?? undefined;
return existing;
}
const keypair = await crypto.subtle.generateKey(ALGO, false, ['sign', 'verify']);
const raw = new Uint8Array(await crypto.subtle.exportKey('raw', keypair.publicKey));
await saveKeypair(keypair);
await savePublicKeyRaw(raw);
cachedKeypair = keypair;
cachedPublicKeyRaw = raw;
return keypair;
}
export async function getPublicKeyRaw(): Promise<Uint8Array> {
if (cachedPublicKeyRaw) return cachedPublicKeyRaw;
await getOrCreateKeypair();
return cachedPublicKeyRaw!;
}
export async function getUserId(): Promise<string> {
const raw = await getPublicKeyRaw();
return base58Encode(raw);
}
export async function sign(data: string): Promise<string> {
const keypair = await getOrCreateKeypair();
const encoded = new TextEncoder().encode(data);
const sig = await crypto.subtle.sign(ALGO, keypair.privateKey, encoded);
return base64Encode(new Uint8Array(sig));
}
export async function verify(data: string, signature: string, publicKeyRaw: Uint8Array): Promise<boolean> {
try {
const key = await crypto.subtle.importKey('raw', publicKeyRaw as BufferSource, ALGO, false, ['verify']);
const sig = base64Decode(signature);
const encoded = new TextEncoder().encode(data);
return crypto.subtle.verify(ALGO, key, sig as BufferSource, encoded as BufferSource);
} catch {
return false;
}
}
// --- Base58 (Bitcoin-style) ---
const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
export function base58Encode(bytes: Uint8Array): string {
let num = 0n;
for (const b of bytes) num = num * 256n + BigInt(b);
let result = '';
while (num > 0n) {
result = BASE58_ALPHABET[Number(num % 58n)] + result;
num = num / 58n;
}
for (const b of bytes) {
if (b !== 0) break;
result = '1' + result;
}
return result || '1';
}
function base64Encode(bytes: Uint8Array): string {
let binary = '';
for (const b of bytes) binary += String.fromCharCode(b);
return btoa(binary);
}
function base64Decode(str: string): Uint8Array {
const binary = atob(str);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
return bytes;
}

86
app/src/lib/db.ts Normal file
View File

@@ -0,0 +1,86 @@
import { get, set, del, keys, createStore } from 'idb-keyval';
import type { Poll, UserProfile } from './types.js';
const profileStore = createStore('evocracy-profiles', 'profiles');
const pollStore = createStore('evocracy-polls', 'polls');
const outboxStore = createStore('evocracy-outbox', 'outbox');
const metaStore = createStore('evocracy-meta', 'meta');
// --- Profile ---
export async function loadProfile(): Promise<UserProfile | undefined> {
return get<UserProfile>('self', profileStore);
}
export async function saveProfile(profile: UserProfile): Promise<void> {
await set('self', profile, profileStore);
}
// --- Keypair ---
export async function loadKeypair(): Promise<CryptoKeyPair | undefined> {
return get<CryptoKeyPair>('keypair', metaStore);
}
export async function saveKeypair(keypair: CryptoKeyPair): Promise<void> {
await set('keypair', keypair, metaStore);
}
export async function loadPublicKeyRaw(): Promise<Uint8Array | undefined> {
return get<Uint8Array>('publicKeyRaw', metaStore);
}
export async function savePublicKeyRaw(raw: Uint8Array): Promise<void> {
await set('publicKeyRaw', raw, metaStore);
}
// --- Polls ---
export async function loadPoll(id: string): Promise<Poll | undefined> {
return get<Poll>(id, pollStore);
}
export async function savePoll(poll: Poll): Promise<void> {
await set(poll.id, poll, pollStore);
}
export async function deletePoll(id: string): Promise<void> {
await del(id, pollStore);
}
export async function loadAllPolls(): Promise<Poll[]> {
const allKeys = await keys(pollStore);
const polls: Poll[] = [];
for (const key of allKeys) {
const poll = await get<Poll>(key, pollStore);
if (poll) polls.push(poll);
}
return polls;
}
// --- Outbox ---
export interface OutboxEntry {
commandId: string;
pollId: string;
message: unknown;
createdAt: number;
}
export async function addToOutbox(entry: OutboxEntry): Promise<void> {
await set(entry.commandId, entry, outboxStore);
}
export async function removeFromOutbox(commandId: string): Promise<void> {
await del(commandId, outboxStore);
}
export async function getOutboxEntries(): Promise<OutboxEntry[]> {
const allKeys = await keys(outboxStore);
const entries: OutboxEntry[] = [];
for (const key of allKeys) {
const entry = await get<OutboxEntry>(key, outboxStore);
if (entry) entries.push(entry);
}
return entries;
}

1
app/src/lib/index.ts Normal file
View File

@@ -0,0 +1 @@
// place files you want to import through the `$lib` alias in this folder.

174
app/src/lib/peer.ts Normal file
View File

@@ -0,0 +1,174 @@
import Peer, { type DataConnection } from 'peerjs';
import type { Message, Poll } from './types.js';
import { getUserId } from './crypto.js';
import { addToOutbox, removeFromOutbox, getOutboxEntries } from './db.js';
export type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'error';
type MessageHandler = (msg: Message, peerId: string) => void;
type StateHandler = (state: ConnectionState) => void;
let peer: Peer | null = null;
const connections = new Map<string, DataConnection>();
const messageHandlers = new Set<MessageHandler>();
const stateHandlers = new Set<StateHandler>();
let currentState: ConnectionState = 'disconnected';
function setState(state: ConnectionState) {
currentState = state;
stateHandlers.forEach((h) => h(state));
}
export function getConnectionState(): ConnectionState {
return currentState;
}
export function onMessage(handler: MessageHandler): () => void {
messageHandlers.add(handler);
return () => messageHandlers.delete(handler);
}
export function onStateChange(handler: StateHandler): () => void {
stateHandlers.add(handler);
return () => stateHandlers.delete(handler);
}
export function getConnectedPeers(): string[] {
return Array.from(connections.keys());
}
export async function initPeer(): Promise<Peer> {
if (peer && !peer.destroyed) return peer;
const userId = await getUserId();
setState('connecting');
return new Promise((resolve, reject) => {
const p = new Peer(userId, { debug: 1 });
p.on('open', () => {
peer = p;
setState('connected');
resolve(p);
});
p.on('connection', (conn) => {
setupConnection(conn);
});
p.on('error', (err) => {
console.error('[peer] error:', err);
setState('error');
reject(err);
});
p.on('disconnected', () => {
setState('disconnected');
// Auto-reconnect
setTimeout(() => {
if (p && !p.destroyed) p.reconnect();
}, 2000);
});
});
}
function setupConnection(conn: DataConnection) {
conn.on('open', () => {
connections.set(conn.peer, conn);
});
conn.on('data', (data) => {
const msg = data as Message;
messageHandlers.forEach((h) => h(msg, conn.peer));
});
conn.on('close', () => {
connections.delete(conn.peer);
});
conn.on('error', (err) => {
console.error(`[peer] connection error with ${conn.peer}:`, err);
connections.delete(conn.peer);
});
}
export async function connectToPeer(peerId: string): Promise<DataConnection> {
const p = await initPeer();
const existing = connections.get(peerId);
if (existing && existing.open) return existing;
return new Promise((resolve, reject) => {
const conn = p.connect(peerId, { reliable: true });
conn.on('open', () => {
connections.set(peerId, conn);
// Flush outbox for this peer
flushOutbox(peerId);
resolve(conn);
});
conn.on('error', (err) => {
reject(err);
});
setupConnection(conn);
});
}
export function sendToPeer(peerId: string, msg: Message): boolean {
const conn = connections.get(peerId);
if (!conn || !conn.open) return false;
conn.send(msg);
return true;
}
export function broadcast(msg: Message, exclude?: string): void {
for (const [peerId, conn] of connections) {
if (peerId === exclude) continue;
if (conn.open) conn.send(msg);
}
}
export async function sendWithOutbox(
peerId: string,
msg: Message & { commandId: string },
pollId: string
): Promise<void> {
await addToOutbox({
commandId: msg.commandId,
pollId,
message: msg,
createdAt: Date.now()
});
sendToPeer(peerId, msg);
}
export function acknowledgeCommand(commandId: string): void {
removeFromOutbox(commandId);
}
async function flushOutbox(peerId: string): Promise<void> {
const entries = await getOutboxEntries();
for (const entry of entries) {
sendToPeer(peerId, entry.message as Message);
}
}
export function disconnectPeer(peerId: string): void {
const conn = connections.get(peerId);
if (conn) {
conn.close();
connections.delete(peerId);
}
}
export function destroyPeer(): void {
if (peer) {
peer.destroy();
peer = null;
}
connections.clear();
setState('disconnected');
}

View File

@@ -0,0 +1,39 @@
import type { Poll, PollAction, Role } from './types.js';
const ROLE_PERMISSIONS: Record<Role | 'owner', Set<PollAction>> = {
viewer: new Set(['view']),
participant: new Set(['view', 'vote', 'addOption']),
moderator: new Set(['view', 'vote', 'addOption', 'manageUsers', 'managePoll']),
owner: new Set(['view', 'vote', 'addOption', 'manageUsers', 'managePoll', 'deletePoll'])
};
export function getRole(poll: Poll, userId: string): Role | 'owner' {
if (poll.ownerId === userId) return 'owner';
const assignment = poll.roles.find((r) => r.userId === userId);
return assignment?.role ?? 'viewer';
}
export function can(poll: Poll, userId: string, action: PollAction): boolean {
const role = getRole(poll, userId);
return ROLE_PERMISSIONS[role].has(action);
}
export function canVote(poll: Poll, userId: string): boolean {
return poll.status === 'open' && can(poll, userId, 'vote');
}
export function canAddOption(poll: Poll, userId: string): boolean {
return poll.status === 'open' && can(poll, userId, 'addOption');
}
export function canManageUsers(poll: Poll, userId: string): boolean {
return can(poll, userId, 'manageUsers');
}
export function canManagePoll(poll: Poll, userId: string): boolean {
return can(poll, userId, 'managePoll');
}
export function canDeletePoll(poll: Poll, userId: string): boolean {
return can(poll, userId, 'deletePoll');
}

105
app/src/lib/poll-client.ts Normal file
View File

@@ -0,0 +1,105 @@
import { connectToPeer, sendToPeer, sendWithOutbox, onMessage, acknowledgeCommand } from './peer.js';
import { updatePollInStore } from './stores/polls.svelte.js';
import type { Message, Poll, PollOption, Vote } from './types.js';
const pendingCallbacks = new Map<string, { resolve: () => void; reject: (err: Error) => void }>();
let clientUnsub: (() => void) | null = null;
export function startClient(): () => void {
if (clientUnsub) return clientUnsub;
const unsub = onMessage(async (msg: Message) => {
switch (msg.type) {
case 'poll:state': {
await updatePollInStore(msg.payload);
break;
}
case 'ack': {
acknowledgeCommand(msg.payload.commandId);
const cb = pendingCallbacks.get(msg.payload.commandId);
if (cb) {
cb.resolve();
pendingCallbacks.delete(msg.payload.commandId);
}
break;
}
case 'error': {
const cb = pendingCallbacks.get(msg.payload.commandId);
if (cb) {
cb.reject(new Error(msg.payload.message));
pendingCallbacks.delete(msg.payload.commandId);
}
break;
}
}
});
clientUnsub = () => {
unsub();
clientUnsub = null;
};
return clientUnsub;
}
export async function joinPoll(ownerPeerId: string, pollId: string): Promise<void> {
await connectToPeer(ownerPeerId);
sendToPeer(ownerPeerId, { type: 'sync:request', payload: { pollId } });
}
export async function submitVote(ownerPeerId: string, pollId: string, optionId: string, anonymous: boolean): Promise<void> {
const commandId = crypto.randomUUID();
const vote: Vote = {
optionId,
voterId: anonymous ? null : undefined as unknown as string, // owner will set
timestamp: Date.now()
};
const msg: Message = {
type: 'poll:vote',
commandId,
payload: { ...vote, pollId }
};
await sendWithOutbox(ownerPeerId, msg as Message & { commandId: string }, pollId);
return new Promise((resolve, reject) => {
pendingCallbacks.set(commandId, { resolve, reject });
setTimeout(() => {
if (pendingCallbacks.has(commandId)) {
pendingCallbacks.delete(commandId);
reject(new Error('Vote timed out'));
}
}, 10000);
});
}
export async function submitOption(ownerPeerId: string, pollId: string, text: string): Promise<void> {
const commandId = crypto.randomUUID();
const option: PollOption = {
id: crypto.randomUUID(),
text,
addedBy: '',
addedAt: Date.now()
};
const msg: Message = {
type: 'poll:option:add',
commandId,
payload: { ...option, pollId }
};
await sendWithOutbox(ownerPeerId, msg as Message & { commandId: string }, pollId);
return new Promise((resolve, reject) => {
pendingCallbacks.set(commandId, { resolve, reject });
setTimeout(() => {
if (pendingCallbacks.has(commandId)) {
pendingCallbacks.delete(commandId);
reject(new Error('Add option timed out'));
}
}, 10000);
});
}

157
app/src/lib/poll-host.ts Normal file
View File

@@ -0,0 +1,157 @@
import { onMessage, sendToPeer, broadcast } from './peer.js';
import { getUserId } from './crypto.js';
import { getPollById, updatePollInStore, setRole, removeRole, setPollStatus } from './stores/polls.svelte.js';
import { canVote, canAddOption, canManageUsers, canManagePoll } from './permissions.js';
import type { Message, Poll, PollOption, Vote } from './types.js';
const processedCommands = new Set<string>();
let revision = 0;
export function startHosting(): () => void {
const unsub = onMessage(async (msg, peerId) => {
const userId = await getUserId();
switch (msg.type) {
case 'sync:request': {
const poll = getPollById(msg.payload.pollId);
if (poll && poll.ownerId === userId) {
sendToPeer(peerId, { type: 'poll:state', payload: poll });
}
break;
}
case 'poll:vote': {
if (processedCommands.has(msg.commandId)) {
sendToPeer(peerId, { type: 'ack', payload: { commandId: msg.commandId, revision } });
break;
}
const poll = getPollById(msg.payload.pollId);
if (!poll || poll.ownerId !== userId) break;
if (!canVote(poll, peerId)) {
sendToPeer(peerId, {
type: 'error',
payload: { commandId: msg.commandId, message: 'Not authorized to vote' }
});
break;
}
const vote: Vote = {
optionId: msg.payload.optionId,
voterId: poll.anonymous ? null : peerId,
timestamp: msg.payload.timestamp
};
const filteredVotes = poll.anonymous
? poll.votes
: poll.votes.filter((v) => v.voterId !== peerId);
const updated: Poll = { ...poll, votes: [...filteredVotes, vote] };
await updatePollInStore(updated);
revision++;
processedCommands.add(msg.commandId);
sendToPeer(peerId, { type: 'ack', payload: { commandId: msg.commandId, revision } });
broadcast({ type: 'poll:state', payload: updated }, peerId);
break;
}
case 'poll:option:add': {
if (processedCommands.has(msg.commandId)) {
sendToPeer(peerId, { type: 'ack', payload: { commandId: msg.commandId, revision } });
break;
}
const poll2 = getPollById(msg.payload.pollId);
if (!poll2 || poll2.ownerId !== userId) break;
if (!canAddOption(poll2, peerId)) {
sendToPeer(peerId, {
type: 'error',
payload: { commandId: msg.commandId, message: 'Not authorized to add options' }
});
break;
}
const option: PollOption = {
id: msg.payload.id,
text: msg.payload.text,
addedBy: peerId,
addedAt: msg.payload.addedAt
};
const updated2: Poll = { ...poll2, options: [...poll2.options, option] };
await updatePollInStore(updated2);
revision++;
processedCommands.add(msg.commandId);
sendToPeer(peerId, { type: 'ack', payload: { commandId: msg.commandId, revision } });
broadcast({ type: 'poll:state', payload: updated2 }, peerId);
break;
}
case 'poll:role:update': {
if (processedCommands.has(msg.commandId)) {
sendToPeer(peerId, { type: 'ack', payload: { commandId: msg.commandId, revision } });
break;
}
const poll3 = getPollById(msg.payload.pollId);
if (!poll3 || poll3.ownerId !== userId) break;
if (!canManageUsers(poll3, peerId)) {
sendToPeer(peerId, {
type: 'error',
payload: { commandId: msg.commandId, message: 'Not authorized to manage users' }
});
break;
}
await setRole(msg.payload.pollId, msg.payload.userId, msg.payload.role);
revision++;
processedCommands.add(msg.commandId);
const refreshed = getPollById(msg.payload.pollId);
sendToPeer(peerId, { type: 'ack', payload: { commandId: msg.commandId, revision } });
if (refreshed) broadcast({ type: 'poll:state', payload: refreshed }, peerId);
break;
}
case 'poll:status:update': {
if (processedCommands.has(msg.commandId)) {
sendToPeer(peerId, { type: 'ack', payload: { commandId: msg.commandId, revision } });
break;
}
const poll4 = getPollById(msg.payload.pollId);
if (!poll4 || poll4.ownerId !== userId) break;
if (!canManagePoll(poll4, peerId)) {
sendToPeer(peerId, {
type: 'error',
payload: { commandId: msg.commandId, message: 'Not authorized to manage poll' }
});
break;
}
await setPollStatus(msg.payload.pollId, msg.payload.status);
revision++;
processedCommands.add(msg.commandId);
const refreshed2 = getPollById(msg.payload.pollId);
sendToPeer(peerId, { type: 'ack', payload: { commandId: msg.commandId, revision } });
if (refreshed2) broadcast({ type: 'poll:state', payload: refreshed2 }, peerId);
break;
}
case 'user:profile': {
// Store peer's profile for display purposes
// Could be extended to a peer profile cache
break;
}
}
});
return unsub;
}

44
app/src/lib/snapshot.ts Normal file
View File

@@ -0,0 +1,44 @@
import { getUserId } from './crypto.js';
import type { Poll, PollSnapshot } from './types.js';
const SNAPSHOT_API = typeof import.meta !== 'undefined'
? (import.meta.env?.VITE_SNAPSHOT_API || '')
: '';
export async function pushSnapshot(poll: Poll): Promise<boolean> {
if (!SNAPSHOT_API) return false;
if (poll.visibility !== 'public') return false;
const userId = await getUserId();
if (poll.ownerId !== userId) return false;
const voteCounts: Record<string, number> = {};
for (const opt of poll.options) voteCounts[opt.id] = 0;
for (const vote of poll.votes) voteCounts[vote.optionId] = (voteCounts[vote.optionId] || 0) + 1;
const snapshot: PollSnapshot & { signature: string } = {
pollId: poll.id,
ownerId: poll.ownerId,
ownerPeerId: userId, // peerId === userId in our design
title: poll.title,
description: poll.description,
options: poll.options.map((o) => ({ id: o.id, text: o.text })),
voteCounts,
totalVotes: poll.votes.length,
status: poll.status,
anonymous: poll.anonymous,
updatedAt: Date.now(),
signature: '' // TODO: sign with Ed25519
};
try {
const res = await fetch(`${SNAPSHOT_API}/api/polls/${poll.id}/snapshot`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(snapshot)
});
return res.ok;
} catch {
return false;
}
}

View File

@@ -0,0 +1,157 @@
import { loadAllPolls, savePoll, deletePoll as dbDeletePoll, loadPoll } from '$lib/db.js';
import type { Poll, PollOption, Vote, RoleAssignment } from '$lib/types.js';
import { getUserId } from '$lib/crypto.js';
let polls = $state<Poll[]>([]);
let loading = $state(true);
export function getPolls() {
return {
get all() { return polls; },
get loading() { return loading; },
get owned() {
const id = currentUserId;
return id ? polls.filter((p) => p.ownerId === id) : [];
},
get participating() {
const id = currentUserId;
return id ? polls.filter((p) => p.ownerId !== id) : [];
}
};
}
let currentUserId: string | null = null;
export async function initPolls(): Promise<void> {
loading = true;
currentUserId = await getUserId();
polls = await loadAllPolls();
loading = false;
}
export async function createPoll(data: {
title: string;
description: string;
anonymous: boolean;
visibility: Poll['visibility'];
options?: string[];
}): Promise<Poll> {
const userId = await getUserId();
const now = Date.now();
const poll: Poll = {
id: crypto.randomUUID(),
ownerId: userId,
title: data.title,
description: data.description,
anonymous: data.anonymous,
status: 'draft',
visibility: data.visibility,
createdAt: now,
options: (data.options ?? []).map((text) => ({
id: crypto.randomUUID(),
text,
addedBy: userId,
addedAt: now
})),
votes: [],
roles: []
};
await savePoll(poll);
polls = [...polls, poll];
return poll;
}
export async function updatePollInStore(poll: Poll): Promise<void> {
await savePoll(poll);
polls = polls.map((p) => (p.id === poll.id ? poll : p));
}
export function getPollById(id: string): Poll | undefined {
return polls.find((p) => p.id === id);
}
export async function refreshPoll(id: string): Promise<Poll | undefined> {
const poll = await loadPoll(id);
if (poll) {
polls = polls.map((p) => (p.id === id ? poll : p));
if (!polls.find((p) => p.id === id)) {
polls = [...polls, poll];
}
}
return poll;
}
export async function deletePollFromStore(id: string): Promise<void> {
await dbDeletePoll(id);
polls = polls.filter((p) => p.id !== id);
}
export async function addOptionToPoll(pollId: string, text: string): Promise<PollOption | null> {
const poll = getPollById(pollId);
if (!poll || poll.status !== 'open') return null;
const userId = await getUserId();
const option: PollOption = {
id: crypto.randomUUID(),
text,
addedBy: userId,
addedAt: Date.now()
};
const updated = { ...poll, options: [...poll.options, option] };
await updatePollInStore(updated);
return option;
}
export async function castVote(pollId: string, optionId: string): Promise<Vote | null> {
const poll = getPollById(pollId);
if (!poll || poll.status !== 'open') return null;
const userId = await getUserId();
const vote: Vote = {
optionId,
voterId: poll.anonymous ? null : userId,
timestamp: Date.now()
};
// Remove previous vote by this user (for vote changes)
const filteredVotes = poll.anonymous
? poll.votes // Can't deduplicate anonymous votes by voterId
: poll.votes.filter((v) => v.voterId !== userId);
const updated = { ...poll, votes: [...filteredVotes, vote] };
await updatePollInStore(updated);
return vote;
}
export async function setPollStatus(pollId: string, status: Poll['status']): Promise<void> {
const poll = getPollById(pollId);
if (!poll) return;
const updated = {
...poll,
status,
...(status === 'closed' ? { closedAt: Date.now() } : {})
};
await updatePollInStore(updated);
}
export async function setRole(pollId: string, userId: string, role: RoleAssignment['role']): Promise<void> {
const poll = getPollById(pollId);
if (!poll) return;
const roles = poll.roles.filter((r) => r.userId !== userId);
roles.push({ userId, role });
const updated = { ...poll, roles };
await updatePollInStore(updated);
}
export async function removeRole(pollId: string, userId: string): Promise<void> {
const poll = getPollById(pollId);
if (!poll) return;
const updated = { ...poll, roles: poll.roles.filter((r) => r.userId !== userId) };
await updatePollInStore(updated);
}

View File

@@ -0,0 +1,62 @@
import { loadProfile, saveProfile } from '$lib/db.js';
import { getUserId } from '$lib/crypto.js';
import type { Tag, UserProfile } from '$lib/types.js';
let profile = $state<UserProfile | null>(null);
let loading = $state(true);
export function getProfile() {
return {
get current() { return profile; },
get loading() { return loading; }
};
}
export async function initProfile(): Promise<UserProfile> {
loading = true;
const existing = await loadProfile();
if (existing) {
profile = existing;
loading = false;
return existing;
}
const userId = await getUserId();
const newProfile: UserProfile = {
id: userId,
name: '',
bio: '',
tags: [],
updatedAt: Date.now(),
signature: ''
};
await saveProfile(newProfile);
profile = newProfile;
loading = false;
return newProfile;
}
export async function updateProfile(updates: Partial<Pick<UserProfile, 'name' | 'bio' | 'tags'>>): Promise<void> {
if (!profile) return;
profile = {
...profile,
...updates,
updatedAt: Date.now()
};
await saveProfile(profile);
}
export async function addTag(tag: Tag): Promise<void> {
if (!profile) return;
const tags = [...profile.tags, tag];
await updateProfile({ tags });
}
export async function removeTag(index: number): Promise<void> {
if (!profile) return;
const tags = profile.tags.filter((_, i) => i !== index);
await updateProfile({ tags });
}

91
app/src/lib/types.ts Normal file
View File

@@ -0,0 +1,91 @@
// === User Identity ===
export interface Tag {
category: 'location' | 'interest' | 'expertise' | (string & {});
value: string;
}
export interface UserProfile {
id: string;
name: string;
bio: string;
tags: Tag[];
updatedAt: number;
signature: string;
}
// === Poll ===
export interface Poll {
id: string;
ownerId: string;
title: string;
description: string;
anonymous: boolean;
status: 'draft' | 'open' | 'closed';
visibility: 'private' | 'link' | 'public';
createdAt: number;
closedAt?: number;
options: PollOption[];
votes: Vote[];
roles: RoleAssignment[];
}
export interface PollOption {
id: string;
text: string;
addedBy: string;
addedAt: number;
}
export interface Vote {
optionId: string;
voterId: string | null;
timestamp: number;
}
export type Role = 'viewer' | 'participant' | 'moderator';
export interface RoleAssignment {
userId: string;
role: Role;
}
// === Permissions ===
export type PollAction =
| 'view'
| 'vote'
| 'addOption'
| 'manageUsers'
| 'managePoll'
| 'deletePoll';
// === P2P Messages ===
export type Message =
| { type: 'poll:state'; commandId?: string; payload: Poll }
| { type: 'poll:vote'; commandId: string; payload: Vote & { pollId: string } }
| { type: 'poll:option:add'; commandId: string; payload: PollOption & { pollId: string } }
| { type: 'poll:role:update'; commandId: string; payload: RoleAssignment & { pollId: string } }
| { type: 'poll:status:update'; commandId: string; payload: { pollId: string; status: Poll['status'] } }
| { type: 'user:profile'; payload: UserProfile }
| { type: 'ack'; payload: { commandId: string; revision: number } }
| { type: 'error'; payload: { commandId: string; message: string } }
| { type: 'sync:request'; payload: { pollId: string } };
// === Snapshot (server) ===
export interface PollSnapshot {
pollId: string;
ownerId: string;
ownerPeerId: string;
title: string;
description: string;
options: { id: string; text: string }[];
voteCounts: Record<string, number>;
totalVotes: number;
status: Poll['status'];
anonymous: boolean;
updatedAt: number;
}

View File

@@ -0,0 +1,9 @@
<script lang="ts">
import '../app.css';
let { children } = $props();
</script>
<div class="min-h-screen bg-white text-gray-900 dark:bg-gray-950 dark:text-gray-100">
{@render children()}
</div>

View File

@@ -0,0 +1,23 @@
<script lang="ts">
import { goto } from '$app/navigation';
</script>
<svelte:head>
<title>evocracy</title>
</svelte:head>
<div class="flex min-h-screen items-center justify-center p-6">
<div class="w-full max-w-sm text-center">
<h1 class="mb-2 text-3xl font-bold">evocracy</h1>
<p class="mb-8 text-gray-500 dark:text-gray-400">Decentralized polling, peer-to-peer.</p>
<div class="flex flex-col gap-3">
<a
href="/app/polls"
class="block rounded-xl bg-indigo-500 px-6 py-3 text-center font-medium text-white active:bg-indigo-600"
>
Get Started
</a>
</div>
</div>
</div>

View File

@@ -0,0 +1,57 @@
<script lang="ts">
import { page } from '$app/state';
import { onMount } from 'svelte';
import { initProfile } from '$lib/stores/profile.svelte.js';
import { initPolls } from '$lib/stores/polls.svelte.js';
import { initPeer } from '$lib/peer.js';
import { startHosting } from '$lib/poll-host.js';
import { startClient } from '$lib/poll-client.js';
let { children } = $props();
let initialized = $state(false);
onMount(async () => {
await initProfile();
await initPolls();
await initPeer();
startHosting();
startClient();
initialized = true;
});
const tabs = [
{ href: '/app/polls', label: 'Polls', icon: '📊' },
{ href: '/app/create', label: 'Create', icon: '' },
{ href: '/app/profile', label: 'Profile', icon: '👤' }
];
</script>
<div class="flex min-h-screen flex-col pb-16">
{#if !initialized}
<div class="flex flex-1 items-center justify-center">
<p class="text-gray-400">Loading...</p>
</div>
{:else}
<main class="flex-1">
{@render children()}
</main>
{/if}
<!-- Bottom Tab Bar -->
<nav class="fixed bottom-0 left-0 right-0 border-t border-gray-200 bg-white dark:border-gray-800 dark:bg-gray-950">
<div class="mx-auto flex max-w-lg">
{#each tabs as tab}
<a
href={tab.href}
class="flex flex-1 flex-col items-center gap-0.5 py-2 text-xs transition-colors
{page.url.pathname.startsWith(tab.href)
? 'text-indigo-500'
: 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'}"
>
<span class="text-xl">{tab.icon}</span>
<span>{tab.label}</span>
</a>
{/each}
</div>
</nav>
</div>

View File

@@ -0,0 +1,138 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { createPoll } from '$lib/stores/polls.svelte.js';
let title = $state('');
let description = $state('');
let anonymous = $state(false);
let visibility = $state<'private' | 'link' | 'public'>('link');
let optionTexts = $state(['', '']);
let submitting = $state(false);
function addOption() {
optionTexts = [...optionTexts, ''];
}
function removeOption(index: number) {
optionTexts = optionTexts.filter((_, i) => i !== index);
}
async function handleSubmit(e: Event) {
e.preventDefault();
if (!title.trim()) return;
submitting = true;
const options = optionTexts.map((t) => t.trim()).filter(Boolean);
const poll = await createPoll({ title: title.trim(), description: description.trim(), anonymous, visibility, options });
await goto(`/app/poll/${poll.id}`);
}
</script>
<svelte:head>
<title>Create Poll evocracy</title>
</svelte:head>
<div class="p-4">
<h1 class="mb-4 text-xl font-bold">Create Poll</h1>
<form onsubmit={handleSubmit} class="flex flex-col gap-4">
<div>
<label for="title" class="mb-1 block text-sm font-medium">Title</label>
<input
id="title"
bind:value={title}
type="text"
required
class="w-full rounded-lg border border-gray-300 bg-transparent px-3 py-3 text-base dark:border-gray-700"
placeholder="What's the question?"
/>
</div>
<div>
<label for="desc" class="mb-1 block text-sm font-medium">Description</label>
<textarea
id="desc"
bind:value={description}
rows={3}
class="w-full rounded-lg border border-gray-300 bg-transparent px-3 py-3 text-base dark:border-gray-700"
placeholder="Add context (optional)"
></textarea>
</div>
<div>
<span class="mb-2 block text-sm font-medium">Options</span>
<div class="flex flex-col gap-2">
{#each optionTexts as _, i}
<div class="flex gap-2">
<input
bind:value={optionTexts[i]}
type="text"
class="flex-1 rounded-lg border border-gray-300 bg-transparent px-3 py-3 text-base dark:border-gray-700"
placeholder="Option {i + 1}"
/>
{#if optionTexts.length > 2}
<button
type="button"
onclick={() => removeOption(i)}
class="rounded-lg px-3 py-3 text-gray-400 active:bg-gray-100 dark:active:bg-gray-800"
>
</button>
{/if}
</div>
{/each}
</div>
<button
type="button"
onclick={addOption}
class="mt-2 text-sm text-indigo-500 active:text-indigo-600"
>
+ Add option
</button>
</div>
<div class="flex items-center justify-between rounded-lg border border-gray-200 px-4 py-3 dark:border-gray-800">
<div>
<div class="text-sm font-medium">Anonymous voting</div>
<div class="text-xs text-gray-500">Voter identities won't be recorded</div>
</div>
<button
type="button"
role="switch"
aria-checked={anonymous}
aria-label="Toggle anonymous voting"
onclick={() => (anonymous = !anonymous)}
class="relative h-7 w-12 rounded-full transition-colors {anonymous
? 'bg-indigo-500'
: 'bg-gray-300 dark:bg-gray-700'}"
>
<span
class="absolute top-0.5 left-0.5 h-6 w-6 rounded-full bg-white shadow transition-transform {anonymous
? 'translate-x-5'
: ''}"
></span>
</button>
</div>
<div>
<label for="visibility" class="mb-1 block text-sm font-medium">Visibility</label>
<select
id="visibility"
bind:value={visibility}
class="w-full rounded-lg border border-gray-300 bg-transparent px-3 py-3 text-base dark:border-gray-700"
>
<option value="private">Private invite only</option>
<option value="link">Link anyone with link can view</option>
<option value="public">Public snapshot shared on server</option>
</select>
</div>
<button
type="submit"
disabled={!title.trim() || submitting}
class="rounded-xl bg-indigo-500 px-6 py-3 font-medium text-white active:bg-indigo-600 disabled:opacity-50"
>
{submitting ? 'Creating...' : 'Create Poll'}
</button>
</form>
</div>

View File

@@ -0,0 +1,273 @@
<script lang="ts">
import { page } from '$app/state';
import { goto } from '$app/navigation';
import { getPollById, deletePollFromStore, setPollStatus, addOptionToPoll, castVote, setRole, removeRole } from '$lib/stores/polls.svelte.js';
import { getUserId } from '$lib/crypto.js';
import { canVote, canAddOption, canManagePoll, canDeletePoll, canManageUsers, getRole } from '$lib/permissions.js';
import { broadcast } from '$lib/peer.js';
import { onMount } from 'svelte';
import type { Poll } from '$lib/types.js';
let userId = $state('');
let newOption = $state('');
let shareUrl = $state('');
let pollId = $derived(page.params.id ?? '');
let showManage = $state(false);
let newUserId = $state('');
let newUserRole = $state<'viewer' | 'participant' | 'moderator'>('participant');
let copied = $state(false);
onMount(async () => {
userId = await getUserId();
shareUrl = `${window.location.origin}/p/${pollId}`;
});
let poll = $derived(getPollById(pollId));
let myRole = $derived(poll ? getRole(poll, userId) : 'viewer');
let userVote = $derived(
poll && !poll.anonymous ? poll.votes.find((v) => v.voterId === userId) : null
);
let totalVotes = $derived(poll ? poll.votes.length : 0);
function voteCounts(p: Poll): Record<string, number> {
const counts: Record<string, number> = {};
for (const opt of p.options) counts[opt.id] = 0;
for (const vote of p.votes) counts[vote.optionId] = (counts[vote.optionId] || 0) + 1;
return counts;
}
async function handleVote(optionId: string) {
if (!poll || !canVote(poll, userId)) return;
await castVote(poll.id, optionId);
const updated = getPollById(poll.id);
if (updated) broadcast({ type: 'poll:state', payload: updated });
}
async function handleAddOption() {
if (!poll || !newOption.trim() || !canAddOption(poll, userId)) return;
await addOptionToPoll(poll.id, newOption.trim());
newOption = '';
const updated = getPollById(poll.id);
if (updated) broadcast({ type: 'poll:state', payload: updated });
}
async function handleStatusChange(status: Poll['status']) {
if (!poll || !canManagePoll(poll, userId)) return;
await setPollStatus(poll.id, status);
const updated = getPollById(poll.id);
if (updated) broadcast({ type: 'poll:state', payload: updated });
}
async function handleDelete() {
if (!poll || !canDeletePoll(poll, userId)) return;
if (!confirm('Delete this poll permanently?')) return;
await deletePollFromStore(poll.id);
await goto('/app/polls');
}
async function handleAddUser() {
if (!poll || !newUserId.trim() || !canManageUsers(poll, userId)) return;
await setRole(poll.id, newUserId.trim(), newUserRole);
newUserId = '';
const updated = getPollById(poll.id);
if (updated) broadcast({ type: 'poll:state', payload: updated });
}
async function handleRemoveUser(uid: string) {
if (!poll || !canManageUsers(poll, userId)) return;
await removeRole(poll.id, uid);
const updated = getPollById(poll.id);
if (updated) broadcast({ type: 'poll:state', payload: updated });
}
function copyShareLink() {
navigator.clipboard.writeText(shareUrl);
copied = true;
setTimeout(() => (copied = false), 2000);
}
</script>
<svelte:head>
<title>{poll?.title ?? 'Poll'} evocracy</title>
</svelte:head>
<div class="p-4">
{#if !poll}
<p class="text-gray-400">Poll not found</p>
<a href="/app/polls" class="mt-2 inline-block text-sm text-indigo-500">← Back to polls</a>
{:else}
<!-- Header -->
<div class="mb-4">
<a href="/app/polls" class="text-sm text-gray-400">← Back</a>
<h1 class="mt-1 text-xl font-bold">{poll.title}</h1>
{#if poll.description}
<p class="mt-1 text-sm text-gray-500">{poll.description}</p>
{/if}
<div class="mt-2 flex flex-wrap gap-2 text-xs">
<span class="rounded-full bg-gray-100 px-2 py-0.5 dark:bg-gray-800">
{poll.status === 'draft' ? '📝 Draft' : poll.status === 'open' ? '🟢 Open' : '🔴 Closed'}
</span>
<span class="rounded-full bg-gray-100 px-2 py-0.5 dark:bg-gray-800">
{poll.anonymous ? '🔒 Anonymous' : '👤 Named'}
</span>
<span class="rounded-full bg-gray-100 px-2 py-0.5 dark:bg-gray-800">
{myRole}
</span>
</div>
</div>
<!-- Share Link -->
<div class="mb-4 flex gap-2">
<input
value={shareUrl}
readonly
class="flex-1 rounded-lg border border-gray-200 bg-gray-50 px-3 py-2 text-sm dark:border-gray-800 dark:bg-gray-900"
/>
<button
onclick={copyShareLink}
class="rounded-lg bg-gray-100 px-3 py-2 text-sm active:bg-gray-200 dark:bg-gray-800 dark:active:bg-gray-700"
>
{copied ? '✓' : 'Copy'}
</button>
</div>
<!-- Results -->
<div class="mb-4">
<h2 class="mb-2 text-sm font-medium uppercase tracking-wide text-gray-400">
Results ({totalVotes} vote{totalVotes !== 1 ? 's' : ''})
</h2>
{#each poll.options as option}
{@const counts = voteCounts(poll)}
{@const count = counts[option.id] || 0}
{@const pct = totalVotes > 0 ? (count / totalVotes) * 100 : 0}
{@const isMyVote = userVote?.optionId === option.id}
<button
onclick={() => handleVote(option.id)}
disabled={!canVote(poll, userId)}
class="mb-2 w-full rounded-lg border p-3 text-left transition
{isMyVote
? 'border-indigo-500 bg-indigo-50 dark:bg-indigo-950'
: 'border-gray-200 dark:border-gray-800'}
{canVote(poll, userId) ? 'active:bg-gray-50 dark:active:bg-gray-900' : ''}"
>
<div class="flex justify-between text-sm">
<span class="font-medium">
{isMyVote ? '✓ ' : ''}{option.text}
</span>
<span class="text-gray-500">{count} ({pct.toFixed(0)}%)</span>
</div>
<div class="mt-1 h-1.5 w-full overflow-hidden rounded-full bg-gray-100 dark:bg-gray-800">
<div
class="h-full rounded-full bg-indigo-500 transition-all"
style="width: {pct}%"
></div>
</div>
</button>
{/each}
</div>
<!-- Add Option -->
{#if canAddOption(poll, userId)}
<form onsubmit={(e) => { e.preventDefault(); handleAddOption(); }} class="mb-4 flex gap-2">
<input
bind:value={newOption}
type="text"
placeholder="Add an option..."
class="flex-1 rounded-lg border border-gray-300 bg-transparent px-3 py-3 text-base dark:border-gray-700"
/>
<button
type="submit"
disabled={!newOption.trim()}
class="rounded-lg bg-indigo-500 px-4 py-3 text-sm font-medium text-white active:bg-indigo-600 disabled:opacity-50"
>
Add
</button>
</form>
{/if}
<!-- Poll Management -->
{#if canManagePoll(poll, userId)}
<div class="border-t border-gray-200 pt-4 dark:border-gray-800">
<button
onclick={() => (showManage = !showManage)}
class="mb-3 text-sm font-medium text-gray-500"
>
{showManage ? '▾' : '▸'} Management
</button>
{#if showManage}
<div class="flex flex-col gap-3">
<!-- Status Controls -->
<div class="flex flex-wrap gap-2">
{#if poll.status === 'draft'}
<button onclick={() => handleStatusChange('open')}
class="rounded-lg bg-green-500 px-4 py-2 text-sm font-medium text-white active:bg-green-600">
Start Poll
</button>
{/if}
{#if poll.status === 'open'}
<button onclick={() => handleStatusChange('closed')}
class="rounded-lg bg-red-500 px-4 py-2 text-sm font-medium text-white active:bg-red-600">
Close Poll
</button>
{/if}
{#if poll.status === 'closed'}
<button onclick={() => handleStatusChange('open')}
class="rounded-lg bg-green-500 px-4 py-2 text-sm font-medium text-white active:bg-green-600">
Re-open Poll
</button>
{/if}
</div>
<!-- User Management -->
{#if canManageUsers(poll, userId)}
<div>
<h3 class="mb-2 text-sm font-medium">Participants ({poll.roles.length})</h3>
{#each poll.roles as role}
<div class="mb-1 flex items-center justify-between rounded-lg bg-gray-50 px-3 py-2 text-sm dark:bg-gray-900">
<span class="font-mono text-xs">{role.userId.slice(0, 12)}...</span>
<div class="flex items-center gap-2">
<span class="text-gray-500">{role.role}</span>
<button onclick={() => handleRemoveUser(role.userId)}
class="text-red-400 active:text-red-600"></button>
</div>
</div>
{/each}
<form onsubmit={(e) => { e.preventDefault(); handleAddUser(); }} class="mt-2 flex gap-2">
<input
bind:value={newUserId}
type="text"
placeholder="User ID"
class="flex-1 rounded-lg border border-gray-300 bg-transparent px-3 py-2 text-sm dark:border-gray-700"
/>
<select bind:value={newUserRole}
class="rounded-lg border border-gray-300 bg-transparent px-2 py-2 text-sm dark:border-gray-700">
<option value="viewer">Viewer</option>
<option value="participant">Participant</option>
<option value="moderator">Moderator</option>
</select>
<button type="submit" disabled={!newUserId.trim()}
class="rounded-lg bg-indigo-500 px-3 py-2 text-sm text-white disabled:opacity-50">
Add
</button>
</form>
</div>
{/if}
<!-- Delete -->
{#if canDeletePoll(poll, userId)}
<button
onclick={handleDelete}
class="mt-2 rounded-lg border border-red-300 px-4 py-2 text-sm text-red-500 active:bg-red-50 dark:border-red-800 dark:active:bg-red-950"
>
Delete Poll
</button>
{/if}
</div>
{/if}
</div>
{/if}
{/if}
</div>

View File

@@ -0,0 +1,73 @@
<script lang="ts">
import { getPolls } from '$lib/stores/polls.svelte.js';
const polls = getPolls();
const statusIcon: Record<string, string> = {
draft: '📝',
open: '🟢',
closed: '🔴'
};
</script>
<svelte:head>
<title>My Polls evocracy</title>
</svelte:head>
<div class="p-4">
<h1 class="mb-4 text-xl font-bold">My Polls</h1>
{#if polls.loading}
<p class="text-gray-400">Loading...</p>
{:else if polls.all.length === 0}
<div class="rounded-xl border border-dashed border-gray-300 p-8 text-center dark:border-gray-700">
<p class="mb-3 text-gray-500">No polls yet</p>
<a
href="/app/create"
class="inline-block rounded-lg bg-indigo-500 px-4 py-2 text-sm font-medium text-white active:bg-indigo-600"
>
Create your first poll
</a>
</div>
{:else}
{#if polls.owned.length > 0}
<h2 class="mb-2 text-sm font-medium uppercase tracking-wide text-gray-400">Owned</h2>
<div class="mb-6 flex flex-col gap-2">
{#each polls.owned as poll}
<a
href="/app/poll/{poll.id}"
class="rounded-xl border border-gray-200 p-4 active:bg-gray-50 dark:border-gray-800 dark:active:bg-gray-900"
>
<div class="flex items-center gap-2">
<span>{statusIcon[poll.status] ?? '📊'}</span>
<span class="font-medium">{poll.title || 'Untitled'}</span>
</div>
<div class="mt-1 text-sm text-gray-500">
{poll.options.length} options · {poll.votes.length} votes
</div>
</a>
{/each}
</div>
{/if}
{#if polls.participating.length > 0}
<h2 class="mb-2 text-sm font-medium uppercase tracking-wide text-gray-400">Participating</h2>
<div class="flex flex-col gap-2">
{#each polls.participating as poll}
<a
href="/app/poll/{poll.id}"
class="rounded-xl border border-gray-200 p-4 active:bg-gray-50 dark:border-gray-800 dark:active:bg-gray-900"
>
<div class="flex items-center gap-2">
<span>{statusIcon[poll.status] ?? '📊'}</span>
<span class="font-medium">{poll.title || 'Untitled'}</span>
</div>
<div class="mt-1 text-sm text-gray-500">
{poll.options.length} options · {poll.votes.length} votes
</div>
</a>
{/each}
</div>
{/if}
{/if}
</div>

View File

@@ -0,0 +1,127 @@
<script lang="ts">
import { getProfile, updateProfile, addTag, removeTag } from '$lib/stores/profile.svelte.js';
import { getUserId } from '$lib/crypto.js';
import { onMount } from 'svelte';
const profile = getProfile();
let userId = $state('');
let name = $state('');
let bio = $state('');
let newTagCategory = $state('location');
let newTagValue = $state('');
let saved = $state(false);
onMount(async () => {
userId = await getUserId();
if (profile.current) {
name = profile.current.name;
bio = profile.current.bio;
}
});
async function handleSave() {
await updateProfile({ name: name.trim(), bio: bio.trim() });
saved = true;
setTimeout(() => (saved = false), 2000);
}
async function handleAddTag() {
if (!newTagValue.trim()) return;
await addTag({ category: newTagCategory, value: newTagValue.trim() });
newTagValue = '';
}
async function handleRemoveTag(index: number) {
await removeTag(index);
}
</script>
<svelte:head>
<title>Profile evocracy</title>
</svelte:head>
<div class="p-4">
<h1 class="mb-4 text-xl font-bold">Profile</h1>
{#if profile.loading}
<p class="text-gray-400">Loading...</p>
{:else}
<div class="mb-4 rounded-lg bg-gray-50 p-3 dark:bg-gray-900">
<div class="text-xs text-gray-400">Your Peer ID</div>
<div class="font-mono text-sm break-all">{userId}</div>
</div>
<form onsubmit={(e) => { e.preventDefault(); handleSave(); }} class="flex flex-col gap-4">
<div>
<label for="name" class="mb-1 block text-sm font-medium">Name</label>
<input
id="name"
bind:value={name}
type="text"
class="w-full rounded-lg border border-gray-300 bg-transparent px-3 py-3 text-base dark:border-gray-700"
placeholder="Your display name"
/>
</div>
<div>
<label for="bio" class="mb-1 block text-sm font-medium">Bio</label>
<textarea
id="bio"
bind:value={bio}
rows={3}
class="w-full rounded-lg border border-gray-300 bg-transparent px-3 py-3 text-base dark:border-gray-700"
placeholder="A short bio"
></textarea>
</div>
<button
type="submit"
class="rounded-xl bg-indigo-500 px-6 py-3 font-medium text-white active:bg-indigo-600"
>
{saved ? '✓ Saved' : 'Save Profile'}
</button>
</form>
<!-- Tags -->
<div class="mt-6">
<h2 class="mb-2 text-sm font-medium">Tags</h2>
{#if profile.current?.tags.length}
<div class="mb-3 flex flex-wrap gap-2">
{#each profile.current.tags as tag, i}
<span class="inline-flex items-center gap-1 rounded-full bg-gray-100 px-3 py-1 text-sm dark:bg-gray-800">
<span class="text-xs text-gray-400">{tag.category}:</span>
{tag.value}
<button onclick={() => handleRemoveTag(i)} class="ml-1 text-gray-400 hover:text-gray-600"></button>
</span>
{/each}
</div>
{/if}
<form onsubmit={(e) => { e.preventDefault(); handleAddTag(); }} class="flex gap-2">
<select
bind:value={newTagCategory}
class="rounded-lg border border-gray-300 bg-transparent px-2 py-2 text-sm dark:border-gray-700"
>
<option value="location">Location</option>
<option value="interest">Interest</option>
<option value="expertise">Expertise</option>
</select>
<input
bind:value={newTagValue}
type="text"
placeholder="Value"
class="flex-1 rounded-lg border border-gray-300 bg-transparent px-3 py-2 text-sm dark:border-gray-700"
/>
<button
type="submit"
disabled={!newTagValue.trim()}
class="rounded-lg bg-gray-100 px-3 py-2 text-sm active:bg-gray-200 disabled:opacity-50 dark:bg-gray-800"
>
Add
</button>
</form>
</div>
{/if}
</div>

View File

@@ -0,0 +1,85 @@
<script lang="ts">
import { page } from '$app/state';
import { onMount } from 'svelte';
import type { PollSnapshot } from '$lib/types.js';
const SNAPSHOT_API = import.meta.env.VITE_SNAPSHOT_API || '';
let snapshot = $state<PollSnapshot | null>(null);
let loading = $state(true);
let totalVotes = $derived(snapshot?.totalVotes ?? 0);
let options = $derived(snapshot?.options ?? []);
let counts = $derived(snapshot?.voteCounts ?? {});
onMount(async () => {
if (SNAPSHOT_API) {
try {
const res = await fetch(`${SNAPSHOT_API}/api/polls/${page.params.id}/snapshot`);
if (res.ok) snapshot = await res.json();
} catch { /* ignore */ }
}
loading = false;
// Notify parent of height for auto-resize
notifyHeight();
});
function notifyHeight() {
if (typeof window !== 'undefined' && window.parent !== window) {
const height = document.body.scrollHeight;
window.parent.postMessage({ type: 'evocracy:resize', height }, '*');
}
}
$effect(() => {
// Re-notify on data change
if (snapshot) {
requestAnimationFrame(notifyHeight);
}
});
</script>
<svelte:head>
<style>
body { margin: 0; background: transparent; }
</style>
</svelte:head>
<div class="p-3 font-sans text-sm text-gray-900 dark:text-gray-100">
{#if loading}
<p class="text-gray-400">Loading...</p>
{:else if !snapshot}
<p class="text-gray-400">Poll unavailable</p>
{:else}
<div class="mb-2 font-medium">{snapshot.title}</div>
<div class="mb-3 text-xs text-gray-500">
{totalVotes} vote{totalVotes !== 1 ? 's' : ''}
· {snapshot.status === 'open' ? '🟢 Open' : '🔴 Closed'}
</div>
<div class="flex flex-col gap-1.5">
{#each options as option}
{@const count = counts[option.id] || 0}
{@const pct = totalVotes > 0 ? (count / totalVotes) * 100 : 0}
<div>
<div class="flex justify-between text-xs">
<span>{option.text}</span>
<span class="text-gray-500">{pct.toFixed(0)}%</span>
</div>
<div class="mt-0.5 h-1 w-full overflow-hidden rounded-full bg-gray-100 dark:bg-gray-800">
<div class="h-full rounded-full bg-indigo-500" style="width: {pct}%"></div>
</div>
</div>
{/each}
</div>
<div class="mt-3 text-center">
<a
href="/p/{page.params.id}"
target="_blank"
rel="noopener"
class="text-xs text-indigo-500 underline"
>
View full poll on evocracy
</a>
</div>
{/if}
</div>

View File

@@ -0,0 +1,170 @@
<script lang="ts">
import { page } from '$app/state';
import { onMount, onDestroy } from 'svelte';
import type { PollSnapshot, Poll } from '$lib/types.js';
const SNAPSHOT_API = import.meta.env.VITE_SNAPSHOT_API || '';
let snapshot = $state<PollSnapshot | null>(null);
let livePoll = $state<Poll | null>(null);
let loading = $state(true);
let liveConnected = $state(false);
let error = $state('');
// Use live data if available, otherwise snapshot
let title = $derived(livePoll?.title ?? snapshot?.title ?? '');
let description = $derived(livePoll?.description ?? snapshot?.description ?? '');
let status = $derived(livePoll?.status ?? snapshot?.status ?? 'draft');
let anonymous = $derived(livePoll?.anonymous ?? snapshot?.anonymous ?? false);
let options = $derived(
livePoll
? livePoll.options.map((o) => ({ id: o.id, text: o.text }))
: snapshot?.options ?? []
);
let counts = $derived.by(() => {
if (livePoll) {
const c: Record<string, number> = {};
for (const o of livePoll.options) c[o.id] = 0;
for (const v of livePoll.votes) c[v.optionId] = (c[v.optionId] || 0) + 1;
return c;
}
return snapshot?.voteCounts ?? {};
});
let totalVotes = $derived(
livePoll ? livePoll.votes.length : snapshot?.totalVotes ?? 0
);
onMount(async () => {
await fetchSnapshot();
loading = false;
});
async function fetchSnapshot() {
if (!SNAPSHOT_API) return;
try {
const res = await fetch(`${SNAPSHOT_API}/api/polls/${page.params.id}/snapshot`);
if (res.ok) {
snapshot = await res.json();
// Try live connection if we have ownerPeerId
if (snapshot?.ownerPeerId) {
tryLiveConnection(snapshot.ownerPeerId);
}
} else {
error = 'Poll not found';
}
} catch {
error = 'Could not load poll';
}
}
async function tryLiveConnection(ownerPeerId: string) {
try {
const { default: Peer } = await import('peerjs');
const peer = new Peer();
peer.on('open', () => {
const conn = peer.connect(ownerPeerId, { reliable: true });
conn.on('open', () => {
liveConnected = true;
conn.send({ type: 'sync:request', payload: { pollId: page.params.id } });
});
conn.on('data', (data: unknown) => {
const msg = data as { type: string; payload: Poll };
if (msg.type === 'poll:state') {
livePoll = msg.payload;
}
});
conn.on('close', () => {
liveConnected = false;
});
});
} catch {
// Live connection failed, use snapshot
}
}
</script>
<svelte:head>
<title>{title || 'Poll'} evocracy</title>
<meta property="og:title" content={title || 'Poll on evocracy'} />
<meta property="og:description" content={description || 'View poll results'} />
<meta property="og:type" content="website" />
</svelte:head>
<div class="mx-auto max-w-lg p-4">
{#if loading}
<div class="flex min-h-[50vh] items-center justify-center">
<p class="text-gray-400">Loading poll...</p>
</div>
{:else if error && !snapshot && !livePoll}
<div class="flex min-h-[50vh] flex-col items-center justify-center text-center">
<p class="mb-2 text-gray-400">{error}</p>
<p class="text-sm text-gray-500">The poll owner may need to be online, or the poll may not exist.</p>
</div>
{:else}
<!-- Header -->
<div class="mb-6">
<div class="mb-1 flex items-center gap-2">
<h1 class="text-2xl font-bold">{title}</h1>
{#if liveConnected}
<span class="rounded-full bg-green-100 px-2 py-0.5 text-xs text-green-700 dark:bg-green-900 dark:text-green-300">
Live
</span>
{/if}
</div>
{#if description}
<p class="text-gray-500">{description}</p>
{/if}
<div class="mt-2 flex gap-2 text-xs">
<span class="rounded-full bg-gray-100 px-2 py-0.5 dark:bg-gray-800">
{status === 'open' ? '🟢 Open' : status === 'closed' ? '🔴 Closed' : '📝 Draft'}
</span>
<span class="rounded-full bg-gray-100 px-2 py-0.5 dark:bg-gray-800">
{anonymous ? '🔒 Anonymous' : '👤 Named'}
</span>
<span class="rounded-full bg-gray-100 px-2 py-0.5 dark:bg-gray-800">
{totalVotes} vote{totalVotes !== 1 ? 's' : ''}
</span>
</div>
</div>
<!-- Results -->
<div class="flex flex-col gap-2">
{#each options as option}
{@const count = counts[option.id] || 0}
{@const pct = totalVotes > 0 ? (count / totalVotes) * 100 : 0}
<div class="rounded-lg border border-gray-200 p-3 dark:border-gray-800">
<div class="flex justify-between text-sm">
<span class="font-medium">{option.text}</span>
<span class="text-gray-500">{count} ({pct.toFixed(0)}%)</span>
</div>
<div class="mt-1 h-1.5 w-full overflow-hidden rounded-full bg-gray-100 dark:bg-gray-800">
<div
class="h-full rounded-full bg-indigo-500 transition-all"
style="width: {pct}%"
></div>
</div>
</div>
{/each}
</div>
<!-- CTA -->
<div class="mt-6 text-center">
<a
href="/app/polls"
class="inline-block rounded-xl bg-indigo-500 px-6 py-3 text-sm font-medium text-white active:bg-indigo-600"
>
Join to vote
</a>
</div>
<p class="mt-4 text-center text-xs text-gray-400">
Powered by <a href="/" class="underline">evocracy</a>
</p>
{/if}
</div>

3
app/static/robots.txt Normal file
View File

@@ -0,0 +1,3 @@
# allow crawling everything by default
User-agent: *
Disallow:

17
app/svelte.config.js Normal file
View File

@@ -0,0 +1,17 @@
import adapter from '@sveltejs/adapter-auto';
/** @type {import('@sveltejs/kit').Config} */
const config = {
kit: {
// adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.
// If your environment is not supported, or you settled on a specific environment, switch out the adapter.
// See https://svelte.dev/docs/kit/adapters for more information about adapters.
adapter: adapter()
},
vitePlugin: {
dynamicCompileOptions: ({ filename }) =>
filename.includes('node_modules') ? undefined : { runes: true }
}
};
export default config;

20
app/tsconfig.json Normal file
View File

@@ -0,0 +1,20 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"rewriteRelativeImportExtensions": true,
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
//
// To make changes to top-level options such as include and exclude, we recommend extending
// the generated config; see https://svelte.dev/docs/kit/configuration#typescript
}

7
app/vite.config.ts Normal file
View File

@@ -0,0 +1,7 @@
import tailwindcss from '@tailwindcss/vite';
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [tailwindcss(), sveltekit()]
});

View File

@@ -1,665 +0,0 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
color: #333;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
.main-content {
display: grid;
grid-template-columns: 280px 1fr;
gap: 20px;
margin-top: 20px;
}
.sidebar {
display: flex;
flex-direction: column;
gap: 20px;
}
.sidebar-section {
background: white;
border-radius: 12px;
padding: 20px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.sidebar-section h3 {
margin: 0 0 15px 0;
color: #333;
font-size: 1.1rem;
}
.sidebar-hint {
margin-bottom: 10px;
}
.hint-text {
font-size: 0.8rem;
color: #6b7280;
font-style: italic;
text-align: center;
padding: 8px;
background: #f9fafb;
border-radius: 6px;
border: 1px solid #e5e7eb;
}
.polls-list {
max-height: 300px;
overflow-y: auto;
}
.poll-item {
padding: 12px;
border: 1px solid #e5e7eb;
border-radius: 8px;
margin-bottom: 8px;
cursor: pointer;
transition: all 0.3s;
position: relative;
}
.poll-item:hover {
border-color: #667eea;
background: #f9fafb;
transform: translateY(-1px);
}
.poll-item.active {
border-color: #667eea;
background: #ede9fe;
}
.poll-item-title {
font-weight: 500;
color: #333;
margin-bottom: 4px;
}
.poll-item-meta {
font-size: 0.8rem;
color: #6b7280;
}
.poll-item::after {
content: ">";
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
color: #9ca3af;
font-size: 0.8rem;
opacity: 0;
transition: opacity 0.3s;
}
.poll-item:hover::after {
opacity: 1;
}
.no-polls {
color: #9ca3af;
font-style: italic;
text-align: center;
padding: 20px 0;
}
.peers-info {
display: flex;
flex-direction: column;
gap: 10px;
}
.peers-status {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.9rem;
}
.status-text {
padding: 2px 8px;
border-radius: 12px;
font-size: 0.8rem;
font-weight: 500;
}
.status-text.connected {
background: #dcfce7;
color: #16a34a;
}
.status-text.disconnected {
background: #fef2f2;
color: #dc2626;
}
.status-text.connecting {
background: #fef3c7;
color: #d97706;
}
.peers-sidebar {
list-style: none;
margin: 0;
padding: 0;
max-height: 150px;
overflow-y: auto;
}
.peers-sidebar li {
padding: 6px 0;
border-bottom: 1px solid #f3f4f6;
font-size: 0.9rem;
color: #6b7280;
}
.peers-sidebar li:last-child {
border-bottom: none;
}
header {
background: white;
border-radius: 12px;
padding: 20px;
margin-bottom: 20px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
display: flex;
justify-content: space-between;
align-items: center;
}
h1 {
color: #667eea;
font-size: 2rem;
}
.connection-status {
display: flex;
align-items: center;
gap: 10px;
font-size: 0.9rem;
}
#peer-id {
background: #f0f0f0;
padding: 5px 10px;
border-radius: 6px;
font-family: monospace;
max-width: 150px;
overflow: hidden;
text-overflow: ellipsis;
}
.status-connected {
color: #10b981;
}
.status-disconnected {
color: #ef4444;
}
.status-connecting {
color: #f59e0b;
}
.section {
background: white;
border-radius: 12px;
padding: 20px;
margin-bottom: 20px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.section.hidden {
display: none;
}
h2 {
color: #333;
margin-bottom: 20px;
font-size: 1.5rem;
}
h3 {
color: #666;
margin-bottom: 15px;
font-size: 1.1rem;
}
.connection-info {
background: #f0f9ff;
padding: 15px;
border-radius: 8px;
margin-bottom: 20px;
border-left: 4px solid #3b82f6;
}
.connection-info p {
margin: 5px 0;
font-size: 0.9rem;
color: #1e40af;
}
.connection-controls {
display: flex;
gap: 10px;
margin-bottom: 20px;
flex-wrap: wrap;
}
input[type="text"] {
flex: 1;
min-width: 200px;
padding: 12px;
border: 2px solid #e5e7eb;
border-radius: 8px;
font-size: 1rem;
transition: border-color 0.3s;
}
input[type="text"]:focus {
outline: none;
border-color: #667eea;
}
button {
background: #667eea;
color: white;
border: none;
padding: 12px 20px;
border-radius: 8px;
font-size: 1rem;
cursor: pointer;
transition: all 0.3s;
}
button:hover {
background: #5a67d8;
transform: translateY(-1px);
}
button:disabled {
background: #9ca3af;
cursor: not-allowed;
transform: none;
}
button.small-btn {
padding: 8px 12px;
font-size: 0.9rem;
}
button.danger {
background: #ef4444;
}
button.danger:hover {
background: #dc2626;
}
.poll-form {
display: flex;
flex-direction: column;
gap: 15px;
}
#options-container {
display: flex;
flex-direction: column;
gap: 10px;
}
.option-input {
display: flex;
gap: 10px;
align-items: center;
}
.option-text {
flex: 1;
}
.remove-option-btn {
background: #ef4444;
color: white;
border: none;
padding: 4px 8px;
border-radius: 4px;
cursor: pointer;
font-size: 0.8rem;
display: flex;
align-items: center;
justify-content: center;
min-width: 60px;
}
.remove-option-btn.hidden {
display: none;
}
.poll-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.vote-hint {
margin-bottom: 20px;
}
.vote-hint .hint-text {
font-size: 0.9rem;
color: #6b7280;
text-align: center;
padding: 10px;
background: #f0f9ff;
border-radius: 8px;
border: 1px solid #bfdbfe;
}
.options-list {
display: flex;
flex-direction: column;
gap: 15px;
}
.poll-option {
background: #f9fafb;
padding: 15px;
border-radius: 8px;
border: 2px solid transparent;
transition: all 0.3s;
cursor: pointer;
position: relative;
}
.poll-option:hover {
border-color: #667eea;
transform: translateY(-1px);
}
.poll-option.voted {
border-color: #10b981;
background: #ecfdf5;
}
.poll-option.voted::before {
content: "✓";
position: absolute;
top: 10px;
right: 10px;
background: #10b981;
color: white;
width: 20px;
height: 20px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: bold;
}
.poll-option.change-vote {
border-color: #f59e0b;
background: #fffbeb;
}
.poll-option.change-vote:hover {
border-color: #d97706;
}
.option-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.option-text {
font-weight: 500;
color: #333;
}
.vote-count {
background: #667eea;
color: white;
padding: 4px 8px;
border-radius: 12px;
font-size: 0.8rem;
}
.vote-bar {
background: #e5e7eb;
height: 8px;
border-radius: 4px;
overflow: hidden;
}
.vote-fill {
background: #667eea;
height: 100%;
transition: width 0.3s ease;
}
.poll-actions {
display: flex;
gap: 10px;
margin-top: 20px;
}
.overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
z-index: 1000;
}
.overlay.hidden {
display: none;
}
.loading-spinner {
width: 40px;
height: 40px;
border: 4px solid #f3f3f3;
border-top: 4px solid #667eea;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 20px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.notification {
position: fixed;
top: 20px;
right: 20px;
background: #333;
color: white;
padding: 15px 20px;
border-radius: 8px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
z-index: 1001;
max-width: 300px;
}
.notification.success {
background: #10b981;
}
.notification.error {
background: #ef4444;
}
.notification.hidden {
display: none;
}
/* Modal Styles */
.modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1002;
}
.modal.hidden {
display: none;
}
.modal-content {
background: white;
border-radius: 12px;
width: 90%;
max-width: 400px;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
overflow: hidden;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px;
border-bottom: 1px solid #e5e7eb;
}
.modal-header h3 {
margin: 0;
color: #333;
font-size: 1.2rem;
}
.modal-close {
background: none;
border: none;
font-size: 1.5rem;
color: #6b7280;
cursor: pointer;
padding: 0;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
transition: all 0.3s;
}
.modal-close:hover {
background: #f3f4f6;
color: #374151;
}
.modal-body {
padding: 20px;
}
.modal-body input {
width: 100%;
padding: 12px;
border: 2px solid #e5e7eb;
border-radius: 8px;
font-size: 1rem;
transition: border-color 0.3s;
}
.modal-body input:focus {
outline: none;
border-color: #667eea;
}
.modal-footer {
padding: 20px;
border-top: 1px solid #e5e7eb;
display: flex;
gap: 10px;
justify-content: flex-end;
}
.btn-secondary {
background: #6b7280;
color: white;
}
.btn-secondary:hover {
background: #4b5563;
}
.btn-primary {
background: #667eea;
color: white;
}
.btn-primary:hover {
background: #5a67d8;
}
@media (max-width: 768px) {
.container {
padding: 10px;
}
.main-content {
grid-template-columns: 1fr;
gap: 15px;
}
.sidebar {
order: 2;
}
main {
order: 1;
}
header {
flex-direction: column;
gap: 15px;
text-align: center;
}
.connection-controls {
flex-direction: column;
}
.poll-header {
flex-direction: column;
gap: 10px;
align-items: flex-start;
}
}

View File

@@ -1,133 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>P2P Polling App</title>
<link rel="stylesheet" href="css/styles.css">
<script src="https://unpkg.com/peerjs@1.5.2/dist/peerjs.min.js"></script>
</head>
<body>
<div class="container">
<header>
<h1>P2P Polling App</h1>
<div class="connection-status">
<span id="peer-id">Loading...</span>
<span id="connection-indicator" class="status-disconnected"></span>
</div>
</header>
<div class="main-content">
<!-- Sidebar -->
<aside class="sidebar">
<div class="sidebar-section">
<h3>Available Polls</h3>
<div class="sidebar-hint">
<p class="hint-text">Click a poll to view and vote</p>
</div>
<div id="polls-list" class="polls-list">
<p class="no-polls">No polls created yet</p>
</div>
</div>
<div class="sidebar-section">
<h3>Connected Peers</h3>
<div class="peers-info">
<div class="peers-status">
<span>Count: <span id="peer-count">0</span></span>
<span id="connection-status-text" class="status-text">Disconnected</span>
</div>
<ul id="peers" class="peers-sidebar"></ul>
</div>
</div>
</aside>
<main>
<!-- Connection Setup -->
<section id="connection-section" class="section">
<h2>Connect to Peer</h2>
<div class="connection-info">
<p><strong>Host:</strong> Share your Peer ID below with others</p>
<p><strong>Joiner:</strong> Enter the host's Peer ID to connect</p>
</div>
<div class="connection-controls">
<input type="text" id="room-id" placeholder="Enter host's Peer ID to connect">
<button id="join-btn">Connect to Host / Create New Poll</button>
<button id="copy-id-btn">Copy Your Peer ID</button>
</div>
</section>
<!-- Poll Creation -->
<section id="poll-creation" class="section hidden">
<h2>Create Poll</h2>
<div class="poll-form">
<input type="text" id="poll-question" placeholder="Enter your poll question">
<div id="options-container">
<div class="option-input">
<input type="text" class="option-text" placeholder="Option 1">
<button class="remove-option-btn hidden">Remove</button>
</div>
<div class="option-input">
<input type="text" class="option-text" placeholder="Option 2">
<button class="remove-option-btn hidden">Remove</button>
</div>
</div>
<button id="add-option-btn">+ Add Option</button>
<button id="create-poll-btn">Create Poll</button>
</div>
</section>
<!-- Active Poll -->
<section id="active-poll" class="section hidden">
<div class="poll-header">
<h2 id="poll-question-display"></h2>
<button id="add-poll-option-btn" class="small-btn">+ Add Option</button>
</div>
<div class="vote-hint">
<p class="hint-text">Click any option to vote. Click your voted option again to remove your vote.</p>
</div>
<div id="poll-options" class="options-list"></div>
<div class="poll-actions">
<button id="reset-poll-btn">Reset Poll</button>
<button id="new-poll-btn" class="small-btn">New Poll</button>
</div>
</section>
</main>
</div>
<!-- Loading Overlay -->
<div id="loading-overlay" class="overlay hidden">
<div class="loading-spinner"></div>
<p>Connecting...</p>
</div>
<!-- Notification Toast -->
<div id="notification" class="notification hidden"></div>
<!-- Add Option Modal -->
<div id="add-option-modal" class="modal hidden">
<div class="modal-content">
<div class="modal-header">
<h3>Add New Option</h3>
<button class="modal-close" id="close-modal-btn">&times;</button>
</div>
<div class="modal-body">
<input type="text" id="new-option-input" placeholder="Enter new option text">
</div>
<div class="modal-footer">
<button id="cancel-modal-btn" class="btn-secondary">Cancel</button>
<button id="save-option-btn" class="btn-primary">Add Option</button>
</div>
</div>
</div>
</div>
<script src="js/peer-manager.js"></script>
<script src="js/poll-manager.js"></script>
<script src="js/ui-controller.js"></script>
<script src="js/app.js"></script>
</body>
</html>

210
js/app.js
View File

@@ -1,210 +0,0 @@
// Main application entry point
class P2PPollApp {
constructor() {
this.peerManager = null;
this.pollManager = null;
this.uiController = null;
this.isInitialized = false;
}
async initialize() {
try {
// Initialize peer manager
this.peerManager = new PeerManager();
this.pollManager = new PollManager(this.peerManager);
this.uiController = new UIController(this.peerManager, this.pollManager);
// Set up event handlers
this.setupEventHandlers();
// Initialize peer connection
await this.peerManager.initialize();
// Load saved data
this.pollManager.loadFromLocalStorage();
// If there are saved polls, update the sidebar
const savedPolls = this.pollManager.getAvailablePolls();
if (savedPolls.length > 0) {
this.uiController.updatePollsList(savedPolls);
}
this.isInitialized = true;
console.log('P2P Poll App initialized successfully');
} catch (error) {
console.error('Failed to initialize app:', error);
this.uiController?.showNotification('Failed to initialize app: ' + error.message, 'error');
}
}
setupEventHandlers() {
// Peer manager events
this.peerManager.onConnectionStatusChange = (status, peerId) => {
this.uiController.updatePeerId(peerId);
if (status === 'connected') {
this.uiController.showNotification('Connected to P2P network', 'success');
} else if (status === 'disconnected') {
this.uiController.showNotification('Disconnected from P2P network', 'error');
}
};
this.peerManager.onPeerConnected = (peerId) => {
this.uiController.showNotification(`Peer ${peerId} connected`, 'success');
// If we're the host and have polls, send them to the new peer
if (this.peerManager.isHost) {
const availablePolls = this.pollManager.getAvailablePolls();
if (availablePolls.length > 0) {
availablePolls.forEach(poll => {
this.peerManager.sendMessage(peerId, {
type: 'poll_update',
poll: poll,
senderId: this.peerManager.getPeerId()
});
});
// Also send current poll if there is one
const currentPoll = this.pollManager.getCurrentPoll();
if (currentPoll) {
this.peerManager.sendMessage(peerId, {
type: 'current_poll',
poll: currentPoll,
senderId: this.peerManager.getPeerId()
});
}
}
} else {
this.peerManager.sendMessage(peerId, {
type: 'sync_request',
senderId: this.peerManager.getPeerId()
});
}
};
this.peerManager.onPeerDisconnected = (peerId) => {
this.uiController.showNotification(`Peer ${peerId} disconnected`, 'info');
};
this.peerManager.onMessageReceived = (message, senderId) => {
this.handleMessage(message, senderId);
};
// Poll manager events
this.pollManager.onPollCreated = (poll) => {
this.uiController.renderPoll(poll);
};
this.pollManager.onPollUpdated = (poll) => {
this.uiController.renderPoll(poll);
};
this.pollManager.onPollSelected = (poll) => {
if (poll) {
this.uiController.renderPoll(poll);
this.uiController.showActivePoll();
} else {
this.uiController.showPollCreation();
}
};
this.pollManager.onPollsListUpdated = (polls) => {
this.uiController.updatePollsList(polls);
};
}
handleMessage(message, senderId) {
console.log('Processing message:', message, 'from:', senderId);
switch (message.type) {
case 'poll_update':
this.pollManager.syncPoll(message.poll);
break;
case 'current_poll':
this.pollManager.syncPoll(message.poll);
if (this.pollManager.getCurrentPoll() && this.pollManager.getCurrentPoll().id === message.poll.id) {
this.uiController.renderPoll(message.poll);
this.uiController.showActivePoll();
}
break;
case 'vote':
this.pollManager.handleVoteMessage(message.optionId, message.voterId);
break;
case 'unvote':
this.pollManager.handleUnvoteMessage(message.voterId);
break;
case 'poll_reset':
this.pollManager.handlePollReset();
break;
case 'sync_request':
const currentPoll = this.pollManager.getCurrentPoll();
if (currentPoll) {
this.peerManager.sendMessage(senderId, {
type: 'current_poll',
poll: currentPoll,
senderId: this.peerManager.getPeerId()
});
}
const availablePolls = this.pollManager.getAvailablePolls();
availablePolls.forEach(poll => {
this.peerManager.sendMessage(senderId, {
type: 'poll_update',
poll: poll,
senderId: this.peerManager.getPeerId()
});
});
break;
default:
console.warn('Unknown message type:', message.type);
}
}
destroy() {
if (this.peerManager) {
this.peerManager.destroy();
}
}
}
document.addEventListener('DOMContentLoaded', async () => {
const app = new P2PPollApp();
try {
await app.initialize();
} catch (error) {
console.error('App initialization failed:', error);
// Show error message to user
const errorDiv = document.createElement('div');
errorDiv.className = 'notification error';
errorDiv.textContent = 'Failed to initialize app. Please refresh the page.';
errorDiv.style.position = 'fixed';
errorDiv.style.top = '20px';
errorDiv.style.right = '20px';
errorDiv.style.zIndex = '1001';
document.body.appendChild(errorDiv);
}
// Cleanup on page unload
window.addEventListener('beforeunload', () => {
app.destroy();
});
});
// Handle page visibility changes
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
console.log('Page hidden - connection may become unstable');
} else {
console.log('Page visible - checking connection status');
}
});

View File

@@ -1,233 +0,0 @@
class PeerManager {
constructor() {
this.peer = null;
this.connections = new Map();
this.roomId = null;
this.isHost = false;
this.onPeerConnected = null;
this.onPeerDisconnected = null;
this.onMessageReceived = null;
this.onConnectionStatusChange = null;
}
async initialize() {
try {
this.peer = new Peer({
debug: 2,
config: {
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'stun:stun1.l.google.com:19302' }
]
}
});
this.peer.on('open', (id) => {
console.log('My peer ID is:', id);
this.updateConnectionStatus('connected');
this.updatePeersList();
if (this.onConnectionStatusChange) {
this.onConnectionStatusChange('connected', id);
}
});
this.peer.on('connection', (conn) => {
this.handleIncomingConnection(conn);
});
this.peer.on('disconnected', () => {
this.updateConnectionStatus('disconnected');
this.updatePeersList();
});
this.peer.on('error', (err) => {
console.error('Peer error:', err);
this.updateConnectionStatus('error');
});
} catch (error) {
console.error('Failed to initialize peer:', error);
throw error;
}
}
async createRoom() {
if (!this.peer) {
throw new Error('Peer not initialized');
}
this.roomId = this.generateRoomId();
this.isHost = true;
console.log('Created room:', this.roomId);
return this.roomId;
}
async joinRoom(roomId) {
if (!this.peer) {
throw new Error('Peer not initialized');
}
this.roomId = roomId;
this.isHost = false;
console.log('Attempting to join room:', roomId);
try {
const conn = this.peer.connect(roomId);
await this.setupConnection(conn);
console.log('Successfully joined room');
return true;
} catch (error) {
console.error('Failed to join room:', error);
throw new Error('Could not connect to host. Make sure the host is online and you have the correct peer ID.');
}
}
handleIncomingConnection(conn) {
this.setupConnection(conn);
}
async setupConnection(conn) {
return new Promise((resolve, reject) => {
conn.on('open', () => {
console.log('Connected to:', conn.peer);
this.connections.set(conn.peer, conn);
this.updatePeersList();
if (this.isHost) {
if (this.onPeerConnected) {
this.onPeerConnected(conn.peer);
}
} else {
if (this.onPeerConnected) {
this.onPeerConnected(conn.peer);
}
}
resolve(conn);
});
conn.on('data', (data) => {
this.handleMessage(data, conn.peer);
});
conn.on('close', () => {
console.log('Connection closed:', conn.peer);
this.connections.delete(conn.peer);
this.updatePeersList();
if (this.onPeerDisconnected) {
this.onPeerDisconnected(conn.peer);
}
});
conn.on('error', (err) => {
console.error('Connection error:', err);
reject(err);
});
});
}
handleMessage(data, senderId) {
console.log('Received message:', data, 'from:', senderId);
if (this.onMessageReceived) {
this.onMessageReceived(data, senderId);
}
}
sendMessage(peerId, message) {
const conn = this.connections.get(peerId);
if (conn && conn.open) {
conn.send(message);
return true;
}
return false;
}
broadcastMessage(message) {
let sentCount = 0;
this.connections.forEach((conn, peerId) => {
if (conn.open) {
conn.send(message);
sentCount++;
}
});
return sentCount;
}
getPeerId() {
return this.peer ? this.peer.id : null;
}
getRoomId() {
return this.roomId;
}
getConnectedPeers() {
return Array.from(this.connections.keys());
}
getPeerCount() {
return this.connections.size;
}
isConnected() {
return this.peer && this.peer.disconnected !== true;
}
generateRoomId() {
return Math.random().toString(36).substr(2, 9).toUpperCase();
}
updateConnectionStatus(status) {
const indicator = document.getElementById('connection-indicator');
const statusText = document.getElementById('connection-status-text');
if (indicator) {
indicator.className = `status-${status}`;
}
if (statusText) {
statusText.textContent = status.charAt(0).toUpperCase() + status.slice(1);
statusText.className = `status-text ${status}`;
}
}
updatePeersList() {
const peersList = document.getElementById('peers');
const peerCount = document.getElementById('peer-count');
if (peersList && peerCount) {
peerCount.textContent = this.getPeerCount();
peersList.innerHTML = '';
if (this.getPeerCount() === 0) {
const li = document.createElement('li');
li.textContent = 'No connected peers';
li.style.fontStyle = 'italic';
li.style.color = '#9ca3af';
peersList.appendChild(li);
} else {
this.getConnectedPeers().forEach(peerId => {
const li = document.createElement('li');
li.textContent = `${peerId} ${peerId === this.peer.id ? '(You)' : ''}`;
li.style.fontWeight = peerId === this.peer.id ? 'bold' : 'normal';
li.style.color = peerId === this.peer.id ? '#667eea' : '#6b7280';
peersList.appendChild(li);
});
}
}
}
destroy() {
if (this.peer) {
this.connections.forEach(conn => conn.close());
this.peer.destroy();
this.peer = null;
this.connections.clear();
}
}
}

View File

@@ -1,428 +0,0 @@
class PollManager {
constructor(peerManager) {
this.peerManager = peerManager;
this.currentPoll = null;
this.availablePolls = new Map(); // pollId -> poll
this.myVotes = new Set();
this.onPollUpdated = null;
this.onPollCreated = null;
this.onPollSelected = null;
this.onPollsListUpdated = null;
}
createPoll(question, options) {
const poll = {
id: this.generatePollId(),
question: question.trim(),
options: options.map((text, index) => ({
id: `opt-${index}`,
text: text.trim(),
votes: 0,
voters: []
})),
createdBy: this.peerManager.getPeerId(),
createdAt: Date.now()
};
// Clear any existing votes when creating a new poll
this.myVotes.clear();
this.currentPoll = poll;
this.availablePolls.set(poll.id, poll);
this.saveToLocalStorage();
if (this.onPollCreated) {
this.onPollCreated(poll);
}
if (this.onPollsListUpdated) {
this.onPollsListUpdated(Array.from(this.availablePolls.values()));
}
// Broadcast new poll to all peers
this.broadcastPollUpdate();
return poll;
}
addOption(text) {
if (!this.currentPoll) {
throw new Error('No active poll');
}
const newOption = {
id: `opt-${Date.now()}`,
text: text.trim(),
votes: 0,
voters: []
};
this.currentPoll.options.push(newOption);
this.saveToLocalStorage();
this.notifyPollUpdated();
this.broadcastPollUpdate();
return newOption;
}
vote(optionId) {
if (!this.currentPoll) {
throw new Error('No active poll');
}
const myPeerId = this.peerManager.getPeerId();
// Check if already voted for this option
if (this.myVotes.has(optionId)) {
return false;
}
this.removePreviousVote(myPeerId);
// Add new vote
const option = this.currentPoll.options.find(opt => opt.id === optionId);
if (!option) {
throw new Error('Option not found');
}
option.votes++;
option.voters.push(myPeerId);
this.myVotes.add(optionId);
this.saveToLocalStorage();
this.notifyPollUpdated();
this.broadcastVote(optionId, myPeerId);
return true;
}
removePreviousVote(peerId) {
if (!this.currentPoll) return;
this.currentPoll.options.forEach(option => {
const voterIndex = option.voters.indexOf(peerId);
if (voterIndex !== -1) {
option.votes--;
option.voters.splice(voterIndex, 1);
// Remove from my votes if it's my vote
if (peerId === this.peerManager.getPeerId()) {
this.myVotes.delete(option.id);
}
}
});
}
resetPoll() {
if (!this.currentPoll) {
throw new Error('No active poll');
}
this.currentPoll.options.forEach(option => {
option.votes = 0;
option.voters = [];
});
this.myVotes.clear();
this.saveToLocalStorage();
this.notifyPollUpdated();
this.broadcastPollReset();
}
syncPoll(pollData) {
if (!this.availablePolls.has(pollData.id)) {
this.availablePolls.set(pollData.id, pollData);
this.saveToLocalStorage();
if (this.onPollsListUpdated) {
this.onPollsListUpdated(Array.from(this.availablePolls.values()));
}
}
if (!this.currentPoll || this.currentPoll.id === pollData.id) {
this.currentPoll = pollData;
this.availablePolls.set(pollData.id, pollData);
this.validateMyVotes(pollData);
this.saveToLocalStorage();
if (this.onPollCreated) {
this.onPollCreated(pollData);
}
} else if (this.currentPoll.id === pollData.id) {
this.mergePollData(pollData);
this.availablePolls.set(pollData.id, this.currentPoll);
this.validateMyVotes(this.currentPoll);
this.saveToLocalStorage();
this.notifyPollUpdated();
}
}
mergePollData(remotePoll) {
const localOptions = new Map(this.currentPoll.options.map(opt => [opt.id, opt]));
const remoteOptions = new Map(remotePoll.options.map(opt => [opt.id, opt]));
remoteOptions.forEach((remoteOpt, id) => {
if (!localOptions.has(id)) {
this.currentPoll.options.push(remoteOpt);
localOptions.set(id, remoteOpt);
}
});
this.currentPoll.options.forEach(localOpt => {
const remoteOpt = remoteOptions.get(localOpt.id);
if (remoteOpt) {
const allVoters = new Set([...localOpt.voters, ...remoteOpt.voters]);
localOpt.voters = Array.from(allVoters);
localOpt.votes = localOpt.voters.length;
}
});
}
handleVoteMessage(optionId, voterId) {
if (!this.currentPoll) return;
this.removePreviousVote(voterId);
const option = this.currentPoll.options.find(opt => opt.id === optionId);
if (option && !option.voters.includes(voterId)) {
option.votes++;
option.voters.push(voterId);
// Update my votes if this is my vote
if (voterId === this.peerManager.getPeerId()) {
this.myVotes.add(optionId);
}
this.saveToLocalStorage();
this.notifyPollUpdated();
}
}
handleUnvoteMessage(voterId) {
if (!this.currentPoll) return;
// Remove vote from this voter
this.removePreviousVote(voterId);
// Update my votes if this is my vote
if (voterId === this.peerManager.getPeerId()) {
this.myVotes.clear();
}
this.saveToLocalStorage();
this.notifyPollUpdated();
}
handlePollReset() {
if (!this.currentPoll) return;
this.currentPoll.options.forEach(option => {
option.votes = 0;
option.voters = [];
});
this.myVotes.clear();
this.saveToLocalStorage();
this.notifyPollUpdated();
}
broadcastPollUpdate() {
if (!this.currentPoll) return;
this.peerManager.broadcastMessage({
type: 'poll_update',
poll: this.currentPoll,
senderId: this.peerManager.getPeerId()
});
}
broadcastVote(optionId, voterId) {
this.peerManager.broadcastMessage({
type: 'vote',
optionId: optionId,
voterId: voterId,
senderId: this.peerManager.getPeerId()
});
}
broadcastPollReset() {
this.peerManager.broadcastMessage({
type: 'poll_reset',
senderId: this.peerManager.getPeerId()
});
}
selectPoll(pollId) {
const poll = this.availablePolls.get(pollId);
if (poll) {
this.currentPoll = poll;
// Clear votes when switching to a different poll
// Only clear if this is a different poll than what we had voted in
const currentVotedOption = this.getMyVotedOption();
if (currentVotedOption && !poll.options.some(opt => opt.id === currentVotedOption)) {
this.myVotes.clear();
}
this.saveToLocalStorage();
if (this.onPollSelected) {
this.onPollSelected(poll);
}
if (this.onPollUpdated) {
this.onPollUpdated(poll);
}
return true;
}
return false;
}
createNewPoll() {
this.currentPoll = null;
// Clear current poll inputs but keep available polls
if (this.onPollSelected) {
this.onPollSelected(null);
}
}
getAvailablePolls() {
return Array.from(this.availablePolls.values());
}
unvote() {
if (!this.currentPoll) {
throw new Error('No active poll');
}
const myPeerId = this.peerManager.getPeerId();
const myVotedOption = this.getMyVotedOption();
if (!myVotedOption) {
return false; // No vote to remove
}
// Remove vote from current option
this.removePreviousVote(myPeerId);
this.saveToLocalStorage();
this.notifyPollUpdated();
this.broadcastUnvote(myPeerId);
return true;
}
validateMyVotes(poll) {
const myPeerId = this.peerManager.getPeerId();
const validVotes = new Set();
console.log('Validating votes for peer:', myPeerId);
console.log('Current myVotes:', Array.from(this.myVotes));
console.log('Poll voters:', poll.options.map(opt => ({ id: opt.id, voters: opt.voters })));
// Check each vote in myVotes to see if it's actually mine
this.myVotes.forEach(optionId => {
const option = poll.options.find(opt => opt.id === optionId);
if (option && option.voters.includes(myPeerId)) {
// This vote is actually mine
validVotes.add(optionId);
console.log(`Vote ${optionId} is valid for peer ${myPeerId}`);
} else {
console.log(`Vote ${optionId} is NOT valid for peer ${myPeerId}`);
}
});
// Replace myVotes with only the valid votes
this.myVotes = validVotes;
console.log('Final valid votes:', Array.from(this.myVotes));
}
getCurrentPoll() {
return this.currentPoll;
}
broadcastUnvote(peerId) {
this.peerManager.broadcastMessage({
type: 'unvote',
voterId: peerId,
senderId: this.peerManager.getPeerId()
});
}
hasVoted(optionId) {
return this.myVotes.has(optionId);
}
getMyVotedOption() {
return Array.from(this.myVotes)[0] || null;
}
generatePollId() {
return `poll-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
}
saveToLocalStorage() {
// Save all available polls
const pollsData = Array.from(this.availablePolls.values());
localStorage.setItem('p2p-polls-list', JSON.stringify(pollsData));
// Save current poll separately
if (this.currentPoll) {
localStorage.setItem('p2p-poll-current', JSON.stringify(this.currentPoll));
} else {
localStorage.removeItem('p2p-poll-current');
}
localStorage.setItem('p2p-poll-my-votes', JSON.stringify(Array.from(this.myVotes)));
}
loadFromLocalStorage() {
try {
const savedPollsList = localStorage.getItem('p2p-polls-list');
const savedVotes = localStorage.getItem('p2p-poll-my-votes');
if (savedPollsList) {
const polls = JSON.parse(savedPollsList);
this.availablePolls.clear();
polls.forEach(poll => {
this.availablePolls.set(poll.id, poll);
});
if (this.onPollsListUpdated) {
this.onPollsListUpdated(polls);
}
}
if (savedVotes) {
this.myVotes = new Set(JSON.parse(savedVotes));
// Validate votes against current poll
const currentPoll = this.currentPoll || this.availablePolls.values().next().value;
if (currentPoll) {
this.validateMyVotes(currentPoll);
}
}
} catch (error) {
console.error('Failed to load from localStorage:', error);
}
}
clearData() {
this.currentPoll = null;
this.availablePolls.clear();
this.myVotes.clear();
localStorage.removeItem('p2p-poll-current');
localStorage.removeItem('p2p-polls-list');
localStorage.removeItem('p2p-poll-my-votes');
}
notifyPollUpdated() {
if (this.onPollUpdated) {
this.onPollUpdated(this.currentPoll);
}
}
}

View File

@@ -1,419 +0,0 @@
class UIController {
constructor(peerManager, pollManager) {
this.peerManager = peerManager;
this.pollManager = pollManager;
this.initializeEventListeners();
}
initializeEventListeners() {
// Connection controls
document.getElementById('join-btn').addEventListener('click', () => this.handleJoinRoom());
document.getElementById('copy-id-btn').addEventListener('click', () => this.copyPeerId());
// Poll creation
document.getElementById('create-poll-btn').addEventListener('click', () => this.handleCreatePoll());
document.getElementById('add-option-btn').addEventListener('click', () => this.addOptionInput());
// Active poll
document.getElementById('add-poll-option-btn').addEventListener('click', () => this.showAddOptionModal());
document.getElementById('reset-poll-btn').addEventListener('click', () => this.handleResetPoll());
document.getElementById('new-poll-btn').addEventListener('click', () => this.handleNewPoll());
// Modal controls
document.getElementById('close-modal-btn').addEventListener('click', () => this.hideAddOptionModal());
document.getElementById('cancel-modal-btn').addEventListener('click', () => this.hideAddOptionModal());
document.getElementById('save-option-btn').addEventListener('click', () => this.handleAddOptionFromModal());
// Enter key handlers
document.getElementById('room-id').addEventListener('keypress', (e) => {
if (e.key === 'Enter') this.handleJoinRoom();
});
document.getElementById('poll-question').addEventListener('keypress', (e) => {
if (e.key === 'Enter') this.handleCreatePoll();
});
document.getElementById('new-option-input').addEventListener('keypress', (e) => {
if (e.key === 'Enter') this.handleAddOptionFromModal();
});
// Close modal on background click
document.getElementById('add-option-modal').addEventListener('click', (e) => {
if (e.target.id === 'add-option-modal') {
this.hideAddOptionModal();
}
});
}
handleJoinRoom() {
const roomIdInput = document.getElementById('room-id');
const peerId = roomIdInput.value.trim();
this.showLoading(true);
if (peerId) {
// Connect to existing peer (host)
this.peerManager.joinRoom(peerId)
.then(() => {
this.showNotification('Connected to host successfully!', 'success');
this.showPollCreation();
})
.catch(error => {
this.showNotification('Failed to connect: ' + error.message, 'error');
console.error('Connect error:', error);
})
.finally(() => {
this.showLoading(false);
});
} else {
// Act as host - just show poll creation
this.peerManager.createRoom();
this.showNotification('You are the host. Share your Peer ID with others to connect.', 'success');
this.showPollCreation();
this.showLoading(false);
}
}
async copyPeerId() {
const peerId = this.peerManager.getPeerId();
if (peerId) {
try {
await navigator.clipboard.writeText(peerId);
this.showNotification('Peer ID copied to clipboard!', 'success');
} catch (error) {
// Fallback for older browsers
const textArea = document.createElement('textarea');
textArea.value = peerId;
document.body.appendChild(textArea);
textArea.select();
document.execCommand('copy');
document.body.removeChild(textArea);
this.showNotification('Peer ID copied to clipboard!', 'success');
}
}
}
handleCreatePoll() {
const questionInput = document.getElementById('poll-question');
const optionInputs = document.querySelectorAll('.option-text');
const options = [];
console.log('Question input:', questionInput);
console.log('Found option inputs:', optionInputs.length);
// Check if question input exists
if (!questionInput) {
console.error('Question input not found!');
this.showNotification('Error: Question input not found', 'error');
return;
}
const question = questionInput.value ? questionInput.value.trim() : '';
console.log('Question:', question);
optionInputs.forEach((input, index) => {
console.log(`Input ${index}:`, input, 'value:', input?.value);
if (input && typeof input.value !== 'undefined') {
const text = input.value.trim();
if (text) {
options.push(text);
}
} else {
console.warn(`Input ${index} is invalid or has no value property`);
}
});
console.log('Final options:', options);
if (!question) {
this.showNotification('Please enter a poll question', 'error');
return;
}
if (options.length < 2) {
this.showNotification('Please enter at least 2 options', 'error');
return;
}
try {
this.pollManager.createPoll(question, options);
this.showNotification('Poll created successfully!', 'success');
} catch (error) {
console.error('Create poll error:', error);
this.showNotification('Failed to create poll: ' + error.message, 'error');
}
}
showAddOptionModal() {
const modal = document.getElementById('add-option-modal');
const input = document.getElementById('new-option-input');
modal.classList.remove('hidden');
input.value = '';
input.focus();
}
hideAddOptionModal() {
const modal = document.getElementById('add-option-modal');
const input = document.getElementById('new-option-input');
modal.classList.add('hidden');
input.value = '';
}
handleAddOptionFromModal() {
const input = document.getElementById('new-option-input');
const optionText = input.value.trim();
if (!optionText) {
this.showNotification('Please enter an option text', 'error');
return;
}
try {
this.pollManager.addOption(optionText);
this.hideAddOptionModal();
this.showNotification('Option added successfully!', 'success');
} catch (error) {
this.showNotification('Failed to add option: ' + error.message, 'error');
}
}
handleAddPollOption() {
this.showAddOptionModal();
}
handleResetPoll() {
if (confirm('Are you sure you want to reset all votes?')) {
try {
this.pollManager.resetPoll();
this.showNotification('Poll reset successfully!', 'success');
} catch (error) {
this.showNotification('Failed to reset poll: ' + error.message, 'error');
}
}
}
handleNewPoll() {
this.pollManager.createNewPoll();
this.showPollCreation();
this.clearPollForm();
}
addOptionInput() {
const container = document.getElementById('options-container');
const optionCount = container.children.length;
const optionDiv = document.createElement('div');
optionDiv.className = 'option-input';
optionDiv.innerHTML = `
<input type="text" class="option-text" placeholder="Option ${optionCount + 1}">
<button class="remove-option-btn">Remove</button>
`;
container.appendChild(optionDiv);
this.updateOptionRemoveButtons();
// Add event listener to new input
const newInput = optionDiv.querySelector('.option-text');
newInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') this.handleCreatePoll();
});
// Add event listener to remove button
const removeBtn = optionDiv.querySelector('.remove-option-btn');
removeBtn.addEventListener('click', () => {
optionDiv.remove();
this.updateOptionRemoveButtons();
this.updateOptionPlaceholders();
});
// Focus on new input
newInput.focus();
}
updateOptionRemoveButtons() {
const optionInputs = document.querySelectorAll('.option-input');
const removeButtons = document.querySelectorAll('.remove-option-btn');
removeButtons.forEach((btn, index) => {
if (optionInputs.length <= 2) {
btn.classList.add('hidden');
} else {
btn.classList.remove('hidden');
}
});
}
updateOptionPlaceholders() {
const optionInputs = document.querySelectorAll('.option-text');
optionInputs.forEach((input, index) => {
input.placeholder = `Option ${index + 1}`;
});
}
clearPollForm() {
document.getElementById('poll-question').value = '';
const container = document.getElementById('options-container');
container.innerHTML = `
<div class="option-input">
<input type="text" class="option-text" placeholder="Option 1">
<button class="remove-option-btn hidden">Remove</button>
</div>
<div class="option-input">
<input type="text" class="option-text" placeholder="Option 2">
<button class="remove-option-btn hidden">Remove</button>
</div>
`;
this.updateOptionRemoveButtons();
}
showPollCreation() {
document.getElementById('connection-section').classList.add('hidden');
document.getElementById('poll-creation').classList.remove('hidden');
document.getElementById('active-poll').classList.add('hidden');
}
showActivePoll() {
document.getElementById('connection-section').classList.add('hidden');
document.getElementById('poll-creation').classList.add('hidden');
document.getElementById('active-poll').classList.remove('hidden');
}
updatePeerId(peerId) {
const element = document.getElementById('peer-id');
if (element) {
element.textContent = peerId || 'Loading...';
}
}
renderPoll(poll) {
if (!poll) return;
this.showActivePoll();
// Update poll question
document.getElementById('poll-question-display').textContent = poll.question;
// Render options
const optionsContainer = document.getElementById('poll-options');
optionsContainer.innerHTML = '';
const totalVotes = poll.options.reduce((sum, opt) => sum + opt.votes, 0);
const myVotedOption = this.pollManager.getMyVotedOption();
poll.options.forEach(option => {
const optionDiv = document.createElement('div');
optionDiv.className = 'poll-option';
const hasVoted = this.pollManager.hasVoted(option.id);
const myVotedOption = this.pollManager.getMyVotedOption();
console.log(`Option ${option.id}: hasVoted=${hasVoted}, myVotedOption=${myVotedOption}`);
if (hasVoted) {
optionDiv.classList.add('voted');
} else if (myVotedOption) {
// User has voted but not for this option - this is a change vote option
optionDiv.classList.add('change-vote');
}
const percentage = totalVotes > 0 ? (option.votes / totalVotes * 100).toFixed(1) : 0;
optionDiv.innerHTML = `
<div class="option-header">
<span class="option-text">${option.text}</span>
<span class="vote-count">${option.votes} votes</span>
</div>
<div class="vote-bar">
<div class="vote-fill" style="width: ${percentage}%"></div>
</div>
`;
optionDiv.addEventListener('click', () => {
const myVotedOption = this.pollManager.getMyVotedOption();
if (this.pollManager.hasVoted(option.id)) {
// Clicking your current vote - unvote
if (confirm('Remove your vote?')) {
const success = this.pollManager.unvote();
if (success) {
this.showNotification('Vote removed!', 'success');
}
}
} else {
// Either first vote or changing vote
const success = this.pollManager.vote(option.id);
if (success) {
if (myVotedOption) {
this.showNotification('Vote changed successfully!', 'success');
} else {
this.showNotification('Vote recorded!', 'success');
}
}
}
});
optionsContainer.appendChild(optionDiv);
});
}
showNotification(message, type = 'info') {
const notification = document.getElementById('notification');
notification.textContent = message;
notification.className = `notification ${type}`;
notification.classList.remove('hidden');
setTimeout(() => {
notification.classList.add('hidden');
}, 3000);
}
showLoading(show) {
const overlay = document.getElementById('loading-overlay');
if (show) {
overlay.classList.remove('hidden');
} else {
overlay.classList.add('hidden');
}
}
updatePeersList() {
// Use the peer manager's updatePeersList method
this.peerManager.updatePeersList();
}
updatePollsList(polls) {
const pollsList = document.getElementById('polls-list');
if (!pollsList) return;
if (polls.length === 0) {
pollsList.innerHTML = '<p class="no-polls">No polls created yet</p>';
return;
}
pollsList.innerHTML = '';
polls.forEach(poll => {
const pollDiv = document.createElement('div');
pollDiv.className = 'poll-item';
if (this.pollManager.getCurrentPoll() && this.pollManager.getCurrentPoll().id === poll.id) {
pollDiv.classList.add('active');
}
const totalVotes = poll.options.reduce((sum, opt) => sum + opt.votes, 0);
const createdDate = new Date(poll.createdAt).toLocaleString();
pollDiv.innerHTML = `
<div class="poll-item-title">${poll.question}</div>
<div class="poll-item-meta">${totalVotes} votes • ${createdDate}</div>
`;
pollDiv.addEventListener('click', () => {
this.pollManager.selectPoll(poll.id);
});
pollsList.appendChild(pollDiv);
});
}
}

146
server/index.ts Normal file
View File

@@ -0,0 +1,146 @@
import { readFile, writeFile, mkdir } from 'fs/promises';
import { existsSync } from 'fs';
import { join } from 'path';
const DATA_DIR = join(import.meta.dir, 'data');
const PORT = parseInt(process.env.PORT || '3001');
// Ensure data dir exists
if (!existsSync(DATA_DIR)) await mkdir(DATA_DIR, { recursive: true });
// In-memory binding: pollId → ownerId (first writer wins)
const ownerBindings = new Map<string, string>();
// Load existing bindings from disk
async function loadBindings() {
const bindingsFile = join(DATA_DIR, '_bindings.json');
if (existsSync(bindingsFile)) {
const data = JSON.parse(await readFile(bindingsFile, 'utf-8'));
for (const [k, v] of Object.entries(data)) ownerBindings.set(k, v as string);
}
}
async function saveBindings() {
const bindingsFile = join(DATA_DIR, '_bindings.json');
await writeFile(bindingsFile, JSON.stringify(Object.fromEntries(ownerBindings)));
}
await loadBindings();
// Rate limiting: simple per-IP counter
const rateLimits = new Map<string, { count: number; reset: number }>();
const RATE_LIMIT = 60; // requests per minute
function checkRateLimit(ip: string): boolean {
const now = Date.now();
const entry = rateLimits.get(ip);
if (!entry || now > entry.reset) {
rateLimits.set(ip, { count: 1, reset: now + 60_000 });
return true;
}
entry.count++;
return entry.count <= RATE_LIMIT;
}
const server = Bun.serve({
port: PORT,
async fetch(req) {
const url = new URL(req.url);
const path = url.pathname;
// CORS
if (req.method === 'OPTIONS') {
return new Response(null, { headers: corsHeaders() });
}
// GET /api/polls/:id/snapshot
const getMatch = path.match(/^\/api\/polls\/([^/]+)\/snapshot$/);
if (getMatch && req.method === 'GET') {
const pollId = getMatch[1];
const file = join(DATA_DIR, `${pollId}.json`);
if (!existsSync(file)) {
return json({ error: 'Not found' }, 404);
}
const data = await readFile(file, 'utf-8');
return json(JSON.parse(data), 200);
}
// PUT /api/polls/:id/snapshot
const putMatch = path.match(/^\/api\/polls\/([^/]+)\/snapshot$/);
if (putMatch && req.method === 'PUT') {
const ip = req.headers.get('x-forwarded-for') || 'unknown';
if (!checkRateLimit(ip)) {
return json({ error: 'Rate limit exceeded' }, 429);
}
const pollId = putMatch[1];
const body = await req.json();
// Validate required fields
if (!body.ownerId || !body.title || !body.signature) {
return json({ error: 'Missing required fields' }, 400);
}
// Check owner binding
const existingOwner = ownerBindings.get(pollId);
if (existingOwner && existingOwner !== body.ownerId) {
return json({ error: 'Unauthorized: owner mismatch' }, 403);
}
// TODO: verify Ed25519 signature against body.ownerId
// For now, trust the ownerId binding as basic auth
// Bind on first write
if (!existingOwner) {
ownerBindings.set(pollId, body.ownerId);
await saveBindings();
}
const snapshot = {
pollId,
ownerId: body.ownerId,
ownerPeerId: body.ownerPeerId,
title: body.title,
description: body.description || '',
options: body.options || [],
voteCounts: body.voteCounts || {},
totalVotes: body.totalVotes || 0,
status: body.status || 'draft',
anonymous: body.anonymous ?? false,
updatedAt: Date.now()
};
const file = join(DATA_DIR, `${pollId}.json`);
await writeFile(file, JSON.stringify(snapshot, null, 2));
return json({ ok: true }, 200);
}
return json({ error: 'Not found' }, 404);
}
});
function corsHeaders(): Record<string, string> {
return {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, PUT, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type'
};
}
function json(data: unknown, status: number): Response {
return new Response(JSON.stringify(data), {
status,
headers: {
'Content-Type': 'application/json',
...corsHeaders()
}
});
}
console.log(`Snapshot server running on http://localhost:${PORT}`);

10
server/package.json Normal file
View File

@@ -0,0 +1,10 @@
{
"name": "evocracy-server",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "bun run --watch index.ts",
"start": "bun run index.ts"
}
}

View File

@@ -0,0 +1,80 @@
---
status: complete
created: '2026-03-16'
tags:
- infra
- setup
priority: high
created_at: '2026-03-16T07:51:47.401Z'
updated_at: '2026-03-16T10:01:25.930Z'
transitions:
- status: in-progress
at: '2026-03-16T09:49:02.744Z'
- status: complete
at: '2026-03-16T10:01:25.930Z'
completed_at: '2026-03-16T10:01:25.930Z'
completed: '2026-03-16'
---
# Project Setup & Architecture
> **Status**: ✅ Complete · **Priority**: High · **Created**: 2026-03-16 · **Tags**: infra, setup
## Overview
Scaffold the SvelteKit project with PeerJS for WebRTC-based P2P communication. This is the foundation all other specs build on. The app is a decentralized polling tool—no central backend for poll data.
## Design
- **Framework**: SvelteKit (SSR for public sharing pages, SPA for the app itself)
- **P2P**: PeerJS (WebRTC data channels for peer communication)
- **Signaling**: PeerJS Cloud (free tier to start; can self-host later)
- **State**: Svelte stores + IndexedDB (via `idb-keyval` or similar) for local persistence
- **Styling**: Tailwind CSS (mobile-first utility classes)
- **Build/Deploy**: Vercel or Cloudflare Pages (static adapter for SPA routes, SSR for public pages)
### Mobile-First UI Principles (applies to ALL UI work)
- Touch targets ≥ 44px
- Single-column layout on mobile; expand on larger screens
- Bottom navigation bar (thumb-friendly): Home, Create (+), Profile
- Minimal chrome—content first
- Dark mode support (`prefers-color-scheme`)
- System font stack for performance
- Tailwind mobile breakpoints as default (design small → scale up)
### Architecture
```
┌─────────────────────────────────────┐
│ SvelteKit App (runs in browser) │
│ ├── Svelte Stores (reactive state) │
│ ├── IndexedDB (persistence) │
│ ├── PeerJS (WebRTC data channels) │
│ └── Crypto (identity keypairs) │
└──────────┬──────────────────────────┘
│ WebRTC
┌─────┴─────┐
│ Other Peers│
└───────────┘
```
## Plan
- [ ] Init SvelteKit project with TypeScript
- [ ] Install dependencies: peerjs, tailwindcss, idb-keyval
- [ ] Set up Tailwind with mobile-first config
- [ ] Create basic app layout (shell, navigation)
- [ ] Set up IndexedDB persistence layer
- [ ] Configure SvelteKit adapter (adapter-auto for Vercel/CF, SSR mode)
## Test
- [ ] `npm run dev` starts without errors
- [ ] Tailwind classes render correctly
- [ ] IndexedDB read/write works in browser
## Notes
- PeerJS Cloud has rate limits—fine for development, may need self-hosted signaling for production
- No server-side database; all poll data lives on peers' devices

View File

@@ -0,0 +1,86 @@
---
status: complete
created: '2026-03-16'
tags:
- p2p
- core
priority: high
created_at: '2026-03-16T07:51:47.888Z'
depends_on:
- 001-project-setup
updated_at: '2026-03-16T10:01:26.362Z'
completed_at: '2026-03-16T10:01:26.362Z'
completed: '2026-03-16'
transitions:
- status: complete
at: '2026-03-16T10:01:26.362Z'
---
# P2P Networking Layer
> **Status**: ✅ Complete · **Priority**: High · **Created**: 2026-03-16 · **Tags**: p2p, core
## Overview
Build the P2P networking layer using PeerJS. This handles connection lifecycle, message passing, and data synchronization between peers. Every poll operates as a "room" where the owner's peer ID is the room identifier.
## Design
- **Connection model**: Star topology per poll—owner acts as relay hub; participants connect to owner
- **Peer ID**: Derived from user's public key (deterministic, resumable)
- **Messages**: JSON-based protocol over PeerJS data channels
- **Reconnection**: Auto-reconnect with exponential backoff
- **Sync**: On connect, owner sends full poll state snapshot; subsequent changes are incremental messages
### Message Protocol
```typescript
type Message =
| { type: 'poll:state'; payload: Poll }
| { type: 'poll:vote'; payload: Vote }
| { type: 'poll:option:add'; payload: Option }
| { type: 'poll:role:update'; payload: RoleUpdate }
| { type: 'user:profile'; payload: UserProfile }
| { type: 'peer:discovery'; payload: PeerInfo[] }
| { type: 'ack'; payload: { commandId: string; revision: number } }
| { type: 'error'; payload: { commandId: string; message: string } }
| { type: 'sync:request'; payload: { pollId: string } }
```
### Offline Behavior
When the poll owner is offline, the poll is unreachable. Participants can view their local cached copy but cannot submit new votes until the owner reconnects.
### Outbox & Acknowledgment
- Every mutation message includes a `commandId` (UUID) for deduplication
- Sender persists the command in a local IndexedDB outbox before sending
- Owner responds with `ack { commandId, revision }` on success or `error { commandId, message }` on failure
- On reconnect, client resends any unacked commands from the outbox
- Owner deduplicates by `commandId`
- UI shows "Pending sync" indicator for unacked mutations
## Plan
- [ ] Create PeerJS service (singleton, manages connection lifecycle)
- [ ] Implement message send/receive with typed protocol
- [ ] Add connection state management (connecting, connected, disconnected)
- [ ] Implement auto-reconnect with backoff
- [ ] Add "room" concept—join a poll by connecting to owner's peer ID
- [ ] Handle peer disconnect/cleanup
- [ ] Implement outbox (IndexedDB-backed pending command queue)
- [ ] Implement ack/error response handling
- [ ] Implement resend-on-reconnect for unacked commands
## Test
- [ ] Two browser tabs can establish a PeerJS connection
- [ ] Messages round-trip correctly
- [ ] Reconnection works after simulated disconnect
- [ ] Unacked commands are resent after reconnect
- [ ] Owner deduplicates commands by commandId
## Notes
- Star topology keeps it simple—owner must be online for live interaction
- Could explore gossip/mesh topology later for resilience, but adds complexity

View File

@@ -0,0 +1,70 @@
---
status: complete
created: '2026-03-16'
tags:
- identity
- profiles
priority: high
created_at: '2026-03-16T07:51:48.340Z'
depends_on:
- 001-project-setup
updated_at: '2026-03-16T10:01:26.785Z'
completed_at: '2026-03-16T10:01:26.785Z'
completed: '2026-03-16'
transitions:
- status: complete
at: '2026-03-16T10:01:26.785Z'
---
# User Identity & Profiles
> **Status**: ✅ Complete · **Priority**: High · **Created**: 2026-03-16 · **Tags**: identity, profiles
## Overview
Users need a persistent identity without a centralized account system. Each user generates a cryptographic keypair locally. Their public key serves as their unique ID. Users can edit their profile (name, bio, tags).
## Design
- **Identity**: Ed25519 keypair generated via Web Crypto API, stored in IndexedDB
- **User ID**: Base58-encoded public key (short, URL-safe)
- **Profile fields**: `name` (display name), `bio` (short text), `tags` (array of categorized tags)
- **Tags structure**: `{ category: string, value: string }` — e.g. `{ category: "location", value: "Berlin" }`, `{ category: "expertise", value: "UX Design" }`
- **Tag categories**: location, interest, expertise (extensible)
- **Signing**: Profile updates are signed with private key (used for server writes; P2P messages use connection identity)
- **Storage**: Profile stored locally in IndexedDB; shared with peers on connect
- **Discovery**: Deferred. Tags are stored locally and exchanged with peers, but there is no directory server for searching users by tag yet. When added, profiles will already have the right structure (see archived spec 007)
### Profile Schema
```typescript
interface UserProfile {
id: string; // base58(publicKey)
name: string;
bio: string;
tags: Tag[];
updatedAt: number; // timestamp
signature: string; // signed(hash(profile), privateKey)
}
interface Tag {
category: 'location' | 'interest' | 'expertise' | string;
value: string;
}
```
## Plan
- [ ] Implement keypair generation + storage in IndexedDB
- [ ] Create profile store (Svelte store backed by IndexedDB)
- [ ] Build profile edit page (name, bio, tag management)
- [ ] Implement tag CRUD with category selector
- [ ] Add profile signing/verification utilities
- [ ] Profile exchange on peer connect
## Test
- [ ] Keypair persists across page reloads
- [ ] Profile updates are saved and signed
- [ ] Tags can be added/removed by category
- [ ] Profile signature verification works

View File

@@ -0,0 +1,125 @@
---
status: complete
created: '2026-03-16'
tags:
- data
- core
priority: high
created_at: '2026-03-16T07:51:48.793Z'
depends_on:
- 002-p2p-networking
- 003-user-identity-profiles
updated_at: '2026-03-16T10:01:27.181Z'
completed_at: '2026-03-16T10:01:27.181Z'
completed: '2026-03-16'
transitions:
- status: complete
at: '2026-03-16T10:01:27.181Z'
---
# Poll Data Model & Sync
> **Status**: ✅ Complete · **Priority**: High · **Created**: 2026-03-16 · **Tags**: data, core
## Overview
Define the core data structures for polls, options, votes, and their sync behavior across peers. Polls are owned by the creating user and stored on their device (and cached by participants).
## Design
### Poll Schema
```typescript
interface Poll {
id: string; // uuid
ownerId: string; // creator's public key
title: string;
description: string;
anonymous: boolean; // set at creation, immutable
status: 'draft' | 'open' | 'closed';
visibility: 'private' | 'link' | 'public'; // who can view via URL
createdAt: number;
closedAt?: number;
options: Option[];
votes: Vote[];
roles: RoleAssignment[];
}
interface Option {
id: string;
text: string;
addedBy: string; // user ID
addedAt: number;
}
interface Vote {
optionId: string;
voterId: string | null; // null if poll.anonymous === true
timestamp: number;
signature: string; // proves vote authenticity
}
interface RoleAssignment {
userId: string;
role: 'viewer' | 'participant' | 'moderator';
}
```
### Anonymity
- When `anonymous: true`, votes store `voterId: null`. The owner's device may transiently see the sender's peer ID during live submission, but the identity is **not persisted**. Anonymity means: hidden from other participants and public snapshots. It is not cryptographic anonymity from the poll owner.
- When `anonymous: false`, `voterId` is the voter's public key
- This flag is set at poll creation and **cannot be changed** after any votes are cast
### Visibility
- `private`: Only users with assigned roles can access. Poll link requires role assignment.
- `link`: Anyone with the poll link can view as a viewer. No directory listing.
- `public`: Poll snapshot is published to the server. Discoverable via direct link (no directory listing yet — can be added with future discovery feature).
### Roles & Permissions
| Action | Viewer | Participant | Moderator | Owner |
|---|---|---|---|---|
| View poll & results | ✅ | ✅ | ✅ | ✅ |
| Add options | ❌ | ✅ | ✅ | ✅ |
| Vote | ❌ | ✅ | ✅ | ✅ |
| Add/remove users | ❌ | ❌ | ✅ | ✅ |
| Start/stop poll | ❌ | ❌ | ✅ | ✅ |
| Delete poll | ❌ | ❌ | ❌ | ✅ |
- Owner is implicit (`poll.ownerId === userId`); not stored in `roles[]`
- `RoleAssignment` entries in `poll.roles[]` grant viewer, participant, or moderator access
- Users without a role assignment who connect via link get `viewer` by default
- Permission checks happen both client-side (UI gating) and owner-side on message receipt (owner validates before applying any mutation)
- Role changes are broadcast to all connected peers
### Sync Strategy
- Owner is the source of truth
- On connect: owner sends full poll snapshot
- Changes (new vote, new option, role change) are sent as incremental messages
- Participants cache poll locally for offline viewing
## Plan
- [ ] Define TypeScript interfaces for Poll, Option, Vote, RoleAssignment
- [ ] Create poll store (Svelte store + IndexedDB)
- [ ] Implement poll CRUD operations locally
- [ ] Implement sync: snapshot on connect, incremental updates
- [ ] Enforce anonymity invariant (voterId null when anonymous)
- [ ] Create permission check utility (`canVote()`, `canAddOption()`, `canModerate()`, etc.)
- [ ] Implement owner-side validation of incoming messages against roles
- [ ] Implement role change broadcast over PeerJS
## Test
- [ ] Poll can be created, read, updated locally
- [ ] Anonymous polls never store voter identity
- [ ] Poll state syncs correctly between two peers
- [ ] Incremental updates apply correctly to cached state
- [ ] Viewer cannot vote or add options
- [ ] Participant can vote and add options
- [ ] Moderator can add/remove users and start/stop poll
- [ ] Only owner can delete
- [ ] Unknown users connecting via link get viewer role

View File

@@ -0,0 +1,68 @@
---
status: complete
created: '2026-03-16'
tags:
- polls
- management
priority: high
created_at: '2026-03-16T07:51:49.209Z'
depends_on:
- 004-poll-data-model
updated_at: '2026-03-16T10:01:27.560Z'
related:
- 011-mobile-first-ui
completed_at: '2026-03-16T10:01:27.560Z'
completed: '2026-03-16'
transitions:
- status: complete
at: '2026-03-16T10:01:27.560Z'
---
# Poll Creation & Management
> **Status**: ✅ Complete · **Priority**: High · **Created**: 2026-03-16 · **Tags**: polls, management
## Overview
UI and logic for creating, configuring, and managing polls. The poll creator (owner) can set title, description, anonymity mode, add initial options, manage the poll lifecycle (draft → open → closed), and delete the poll.
## Design
### Poll Creation Flow
1. User taps "Create Poll"
2. Fills in: title, description, anonymous (toggle), initial options (optional)
3. Poll is created in `draft` status (only owner can see it)
4. Owner shares poll link → users who join get `viewer` role; owner promotes to participant/moderator
5. Owner (or moderator) starts the poll → status becomes `open`
6. Owner (or moderator) stops the poll → status becomes `closed`
### Owner Capabilities
- **Delete poll**: Only the owner can permanently delete
- **Start/stop**: Owner and moderators can transition `draft→open→closed`
- **Re-open**: Owner can move `closed→open` (but not change anonymity)
### Pages
- `/app/create` — poll creation form
- `/app/poll/[id]` — poll view/management (adapts based on role)
- `/app/polls` — list of user's polls (owned + participating)
## Plan
- [ ] Build poll creation form (title, description, anonymous toggle, initial options)
- [ ] Implement poll lifecycle state machine (draft → open → closed)
- [ ] Build poll list page (my polls, polls I participate in)
- [ ] Build poll detail/management page
- [ ] Add delete poll functionality (owner only)
- [ ] Wire up to PeerJS—poll becomes "live" when owner opens it
- [ ] Build user/role management UI in poll detail page
- [ ] Implement invite flow (shareable link — users join as viewer, owner promotes)
## Test
- [ ] Poll can be created with all fields
- [ ] Anonymity toggle locks after first vote
- [ ] Poll lifecycle transitions work correctly
- [ ] Owner can delete poll; others cannot

View File

@@ -0,0 +1,79 @@
---
status: complete
created: '2026-03-16'
tags:
- voting
- core
priority: high
created_at: '2026-03-16T07:51:50.525Z'
depends_on:
- 005-poll-creation-management
updated_at: '2026-03-16T10:01:27.962Z'
related:
- 011-mobile-first-ui
completed_at: '2026-03-16T10:01:27.962Z'
completed: '2026-03-16'
transitions:
- status: complete
at: '2026-03-16T10:01:27.962Z'
---
# Voting System & Anonymity
> **Status**: ✅ Complete · **Priority**: High · **Created**: 2026-03-16 · **Tags**: voting, core
## Overview
Core voting functionality: participants can add options to a poll and cast votes. Supports both anonymous and non-anonymous modes as configured at poll creation. Votes are sent to the poll owner via PeerJS.
## Design
### Adding Options
- Participants and above can add options while the poll is `open`
- Options have text only (keep it simple)
- Owner validates the sender has permission before accepting
### Casting Votes
- V1: single-choice (one vote per participant). Data model uses `optionId: string` which can later be extended to `optionIds: string[]` for multi-choice or a ranked array for ranked-choice.
- Vote is signed with voter's private key for authenticity
- In anonymous mode: owner records the vote but strips `voterId` before storing
- In non-anonymous mode: `voterId` is stored alongside the vote
- Vote changes: a participant can change their vote while the poll is open (replaces previous)
### Results
- Results are visible to all roles (viewers included)
- Show: option text, vote count, percentage
- Non-anonymous: also show who voted for what
- Results update in real-time via PeerJS messages
### Vote Flow
```
Participant Owner (relay)
│ │
│─── poll:vote ───────────────▶│ validate permission
│ │ if anonymous: strip voterId
│ │ store vote
│◀── poll:state:update ───────│ broadcast updated results
```
## Plan
- [ ] Build "add option" UI and message handler
- [ ] Build voting UI (option list with vote button)
- [ ] Implement vote submission (sign + send to owner)
- [ ] Owner-side vote processing (validate, anonymize if needed, store)
- [ ] Build results display (bar chart or simple percentage view)
- [ ] Implement vote change (replace previous vote)
## Test
- [ ] Participant can add an option
- [ ] Participant can cast a vote
- [ ] Anonymous vote does not leak voter identity
- [ ] Results update in real-time across connected peers
- [ ] Vote change replaces (not duplicates) previous vote
- [ ] Viewers can see results but not vote

View File

@@ -0,0 +1,70 @@
---
status: complete
created: '2026-03-16'
tags:
- sharing
- web
priority: medium
created_at: '2026-03-16T07:51:51.015Z'
depends_on:
- 005-poll-creation-management
- 008-voting-system
- 012-lightweight-server
updated_at: '2026-03-16T10:01:28.366Z'
related:
- 011-mobile-first-ui
completed_at: '2026-03-16T10:01:28.366Z'
completed: '2026-03-16'
transitions:
- status: complete
at: '2026-03-16T10:01:28.366Z'
---
# Public Sharing & Read-Only View
> **Status**: ✅ Complete · **Priority**: Medium · **Created**: 2026-03-16 · **Tags**: sharing, web
## Overview
Make polls shareable via URL to anyone on the web—even non-users. A public link shows poll results in a read-only view. If the poll owner is online, results update live via WebRTC. If offline, the page shows a cached snapshot.
## Design
### Shareable URL
- Format: `https://evocracy.app/p/[pollId]`
- Anyone with the link can view (no identity required)
- The page fetches the snapshot from the server (includes `ownerPeerId`), then attempts a PeerJS connection for live data
- Falls back to a static snapshot if owner is offline
### Snapshot Strategy
- When a poll is shared publicly, the owner's client pushes a JSON snapshot to the lightweight server (same server as spec 007 directory)
- Endpoint: `PUT /api/polls/:id/snapshot` (authenticated by owner's signature)
- Public fetch: `GET /api/polls/:id/snapshot` (no auth required)
- Snapshot is updated whenever poll state changes while owner is online
- Snapshot includes: title, description, options, vote counts, status (no voter identities even for non-anonymous)
### Public View Page
- Read-only: title, description, options with vote counts/percentages
- Visual bar chart of results
- "Owner offline" indicator if can't connect
- Open Graph meta tags for social media previews
- Optional: "Join to vote" CTA linking to app
## Plan
- [ ] Create `/p/[id]` public route (SvelteKit SSR or client-side)
- [ ] Implement PeerJS connection attempt for live data
- [ ] Build read-only results view
- [ ] Add Open Graph meta tags for link previews
- [ ] Implement snapshot fallback (based on decision above)
- [ ] Add "Share" button to poll management page (copy link)
## Test
- [ ] Public URL shows poll results without authentication
- [ ] Live updates work when owner is online
- [ ] Graceful fallback when owner is offline
- [ ] Social media link preview shows poll title/description

View File

@@ -0,0 +1,76 @@
---
status: complete
created: '2026-03-16'
tags:
- embed
- widget
priority: low
created_at: '2026-03-16T07:51:51.430Z'
depends_on:
- 009-public-sharing
updated_at: '2026-03-16T10:01:28.760Z'
completed_at: '2026-03-16T10:01:28.760Z'
completed: '2026-03-16'
transitions:
- status: complete
at: '2026-03-16T10:01:28.760Z'
---
# Embeddable Poll Widget
> **Status**: ✅ Complete · **Priority**: Low · **Created**: 2026-03-16 · **Tags**: embed, widget
## Overview
Allow polls to be embedded on external websites and potentially in messaging apps. This extends the public sharing with an inline experience.
## Design
### Embed Options
1. **iframe embed** (simplest, broadest support)
- `<iframe src="https://evocracy.app/embed/[pollId]" width="400" height="300"></iframe>`
- Renders a compact read-only results view
- Self-contained, works anywhere iframes are supported
- Uses `postMessage` to communicate height for responsive sizing
2. **oEmbed protocol** (for platforms that support it—Notion, WordPress, Medium, etc.)
- Endpoint: `https://evocracy.app/oembed?url=https://evocracy.app/poll/[pollId]`
- Returns iframe-based rich embed
3. **Web Component** (deferred to v2)
- `<script src="https://evocracy.app/widget.js"></script>`
- `<evocracy-poll poll-id="[pollId]"></evocracy-poll>`
- Shadow DOM for style isolation
- Deferred: iframe covers 90% of use cases
### Messenger Compatibility
- iMessage / WhatsApp / Telegram: Open Graph link previews (handled by spec 009)
- Slack / Discord: oEmbed + Open Graph unfurling
- Actual inline voting in messengers is not feasible without platform-specific bots
### Embed Route
- `/embed/[id]` — minimal chrome, compact layout, no navigation
- Auto-resizes via `postMessage` to parent frame
## Plan
- [ ] Create `/embed/[id]` route with compact poll results view
- [ ] Implement iframe auto-resize via postMessage
- [ ] Add "Get embed code" UI to poll management page
- [ ] Implement oEmbed endpoint (`/oembed`)
- [ ] ~Build web component wrapper (`widget.js`)~ (deferred to v2)
## Test
- [ ] iframe embed renders correctly on a test HTML page
- [ ] Auto-resize adjusts to content height
- [ ] oEmbed endpoint returns valid JSON
- [ ] Web component renders in isolation (Shadow DOM)
## Notes
- **DECIDED**: Web component deferred to v2—iframe alone covers 90% of use cases
- Messenger "embeds" are really just link previews, not interactive widgets

View File

@@ -0,0 +1,86 @@
---
status: complete
created: '2026-03-16'
tags:
- server
- infra
priority: high
created_at: '2026-03-16T07:57:36.544Z'
depends_on:
- 001-project-setup
updated_at: '2026-03-16T10:01:29.167Z'
completed_at: '2026-03-16T10:01:29.167Z'
completed: '2026-03-16'
transitions:
- status: complete
at: '2026-03-16T10:01:29.167Z'
---
# Poll Snapshot Server
> **Status**: ✅ Complete · **Priority**: High · **Created**: 2026-03-16 · **Tags**: server, infra
## Overview
A minimal server that stores poll snapshots for public/offline viewing. This is the only server-side component — intentionally thin. No user accounts, no directory, no auth beyond signature verification.
## Design
### Tech Stack
- **Runtime**: Cloudflare Workers (or a simple Bun server if self-hosting)
- **Storage**: Cloudflare KV (or SQLite for self-hosted)
- **Auth**: Snapshot writes are signed with the poll owner's Ed25519 private key
### API Endpoints
- `PUT /api/polls/:id/snapshot` — Store/update snapshot (signed by poll owner)
- Body: `{ pollId, ownerId, ownerPeerId, title, description, options[], voteCounts, status, signature }`
- `GET /api/polls/:id/snapshot` — Fetch snapshot (public, no auth)
### Security
- Write operations require a valid Ed25519 signature
- Verify signature against `ownerId` in the request body; bind `pollId → ownerId` on first write; reject future writes if `ownerId` changes
- No sessions, no cookies, no passwords
- Rate limiting on writes to prevent abuse
### Data Model
```typescript
// KV key: poll:{pollId}:snapshot → PollSnapshot
interface PollSnapshot {
pollId: string;
ownerId: string;
ownerPeerId: string;
title: string;
description: string;
options: { id: string; text: string }[];
voteCounts: Record<string, number>;
status: 'draft' | 'open' | 'closed';
updatedAt: number;
}
```
## Plan
- [ ] Set up Cloudflare Workers project (or simple Bun server)
- [ ] Implement Ed25519 signature verification middleware
- [ ] Implement poll snapshot store/fetch endpoints
- [ ] Add rate limiting
- [ ] Deploy
## Test
- [ ] Snapshot store with valid signature succeeds
- [ ] Snapshot store with invalid signature is rejected
- [ ] Snapshot fetch returns stored data (public, no auth)
- [ ] Second write from different ownerId is rejected
- [ ] Rate limiting works
## Notes
- The P2P app works without this server — it just loses public sharing and offline snapshot viewing
- Future: user directory endpoints can be added here when peer discovery is implemented (see archived spec 007)
- Consider a TTL on snapshots (e.g., auto-expire 90 days after last update)

View File

@@ -0,0 +1,67 @@
---
status: archived
created: '2026-03-16'
tags:
- auth
- roles
priority: high
created_at: '2026-03-16T07:51:49.636Z'
depends_on:
- 004-poll-data-model
updated_at: '2026-03-16T09:18:35.900Z'
transitions:
- status: archived
at: '2026-03-16T09:18:35.900Z'
---
# Role & Permission System
> **Status**: 📦 Archived · **Priority**: High · **Created**: 2026-03-16 · **Tags**: auth, roles
## Overview
Each poll has a role-based permission system. The owner assigns roles to users they discover or invite. Roles control what actions a user can perform on a poll.
## Design
### Roles & Permissions
| Action | Viewer | Participant | Moderator | Owner |
|---|---|---|---|---|
| View poll & results | ✅ | ✅ | ✅ | ✅ |
| Add options | ❌ | ✅ | ✅ | ✅ |
| Vote | ❌ | ✅ | ✅ | ✅ |
| Add/remove users | ❌ | ❌ | ✅ | ✅ |
| Start/stop poll | ❌ | ❌ | ✅ | ✅ |
| Delete poll | ❌ | ❌ | ❌ | ✅ |
### Implementation
- Owner is implicit (poll.ownerId === userId)
- Roles stored in `poll.roles[]` array
- Role changes are broadcast to all connected peers
- Moderators can invite users by peer ID (discovered via spec 007) or by sharing a poll link
- Permission checks happen both client-side (UI) and on message receipt (owner validates)
### Invite Flow
1. Owner/moderator discovers a user (see spec 007) or has their peer ID
2. Assigns them a role → updates `poll.roles[]`
3. When that user connects, they receive the poll state including their role
4. Users without a role who connect via link get `viewer` by default
## Plan
- [ ] Implement role assignment data model
- [ ] Create permission check utility (`canVote()`, `canModerate()`, etc.)
- [ ] Build user management UI in poll detail page
- [ ] Implement role change broadcast over PeerJS
- [ ] Owner-side validation of incoming messages against roles
## Test
- [ ] Viewer cannot vote or add options
- [ ] Participant can vote and add options
- [ ] Moderator can add/remove users and start/stop poll
- [ ] Only owner can delete
- [ ] Unknown users connecting via link get viewer role

View File

@@ -0,0 +1,65 @@
---
status: archived
created: '2026-03-16'
tags:
- p2p
- discovery
priority: medium
created_at: '2026-03-16T07:51:50.076Z'
depends_on:
- 002-p2p-networking
- 003-user-identity-profiles
- 012-lightweight-server
updated_at: '2026-03-16T09:31:19.812Z'
transitions:
- status: archived
at: '2026-03-16T09:31:19.812Z'
---
# Peer Discovery by Tags
> **Status**: 📦 Archived · **Priority**: Medium · **Created**: 2026-03-16 · **Tags**: p2p, discovery
## Overview
Enable poll owners to discover other users to invite, based on user tags (location, interests, expertise). This is P2P—there's no central user directory. Discovery works by asking connected peers for introductions.
## Design
### Discovery Mechanism
1. **Direct invite**: Share a poll link or paste a peer ID manually
2. **Directory server**: Lightweight server where users can register their profile for discoverability
- Users opt-in to being listed (toggle in profile)
- Server stores: peer ID, name, tags, `discoverable` flag
- Query endpoint: `GET /api/users?tag=location:Berlin&tag=expertise:UX`
- Minimal server—just a thin REST API over a key-value store (e.g., Cloudflare Workers + KV, or a simple SQLite API)
3. **Peer-chain discovery** (secondary): Ask connected peers "who do you know matching these tags?"
- Supplements the directory for users who are connected but not listed
### Privacy Controls
- Users opt-in to being discoverable (setting in profile)
- Directory only stores public profile data (name, tags)—no private keys, no bio
- Users can remove themselves from directory at any time
## Plan
- [ ] Build lightweight directory server (REST API + KV store)
- [ ] Add `discoverable` toggle to user profile
- [ ] Implement directory registration (opt-in publish profile)
- [ ] Implement directory search (query by tags)
- [ ] Build discovery UI (search by tags, browse results)
- [ ] Implement "invite from discovery" flow
- [ ] Implement peer-chain discovery as fallback
## Test
- [ ] Discovery request returns matching peers from connected contacts
- [ ] Non-discoverable users are not shared
- [ ] Discovered peer can be invited to a poll
## Notes
- **DECIDED**: Adding a lightweight directory server for discoverability
- Directory server can be reused for poll snapshot storage (see spec 009)

View File

@@ -0,0 +1,74 @@
---
status: archived
created: '2026-03-16'
tags:
- ui
- mobile
priority: high
created_at: '2026-03-16T07:51:51.869Z'
related:
- 005-poll-creation-management
- 008-voting-system
- 009-public-sharing
updated_at: '2026-03-16T09:07:18.408Z'
transitions:
- status: archived
at: '2026-03-16T09:07:18.408Z'
---
# Mobile-First UI Design
> **Status**: 📦 Archived · **Priority**: High · **Created**: 2026-03-16 · **Tags**: ui, mobile
## Overview
Design all UI mobile-first. The primary use case is people voting on their phones. Desktop is a secondary concern—layouts should scale up, not be shoehorned down.
## Design
### Principles
- Touch targets ≥ 44px
- Single-column layout on mobile; expand on larger screens
- Bottom navigation bar (thumb-friendly)
- Minimal chrome—content first
- System font stack for performance
### Key Screens
1. **Home / Poll list**: Cards showing poll title, status, vote count
2. **Create poll**: Simple form, large inputs, toggle for anonymity
3. **Poll detail**: Results visualization, vote buttons, participant list
4. **Profile**: Edit name, bio, tags
5. **Poll management** (owner/mod): User list, role controls, start/stop
### Navigation
- Bottom tab bar: Home, Create (+), Profile
- Poll detail accessed by tapping a poll card
- Management accessed via gear icon within poll detail
### Styling
- Tailwind CSS with mobile breakpoints as default
- Dark mode support (respects `prefers-color-scheme`)
- CSS transitions for state changes (vote submitted, poll status change)
## Plan
- [ ] Design bottom tab navigation component
- [ ] Build poll list (home) with card layout
- [ ] Build poll creation form (mobile-optimized)
- [ ] Build poll detail view with results visualization
- [ ] Build profile edit page with tag management
- [ ] Build poll management panel (users, roles, lifecycle)
- [ ] Add dark mode toggle / system preference detection
- [ ] Test on various mobile viewport sizes
## Test
- [ ] All touch targets meet 44px minimum
- [ ] Layout works on 320px428px width (small to large phones)
- [ ] No horizontal scroll on any page
- [ ] Dark mode renders correctly
- [ ] Navigation is accessible via keyboard/screen reader