forked from quic-issues/427e7578-d7bf-49c8-aee9-2dd999e25316
Implementation, thanks amp
This commit is contained in:
23
app/.gitignore
vendored
Normal file
23
app/.gitignore
vendored
Normal 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
1
app/.npmrc
Normal file
@@ -0,0 +1 @@
|
|||||||
|
engine-strict=true
|
||||||
3
app/.vscode/extensions.json
vendored
Normal file
3
app/.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"recommendations": ["svelte.svelte-vscode"]
|
||||||
|
}
|
||||||
42
app/README.md
Normal file
42
app/README.md
Normal 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
326
app/bun.lock
Normal 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
29
app/package.json
Normal 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
17
app/src/app.css
Normal 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
13
app/src/app.d.ts
vendored
Normal 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
11
app/src/app.html
Normal 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>
|
||||||
1
app/src/lib/assets/favicon.svg
Normal file
1
app/src/lib/assets/favicon.svg
Normal 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
91
app/src/lib/crypto.ts
Normal 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 unknown as ArrayBuffer, ALGO, false, ['verify']);
|
||||||
|
const sig = base64Decode(signature);
|
||||||
|
const encoded = new TextEncoder().encode(data);
|
||||||
|
return crypto.subtle.verify(ALGO, key, sig as unknown as ArrayBuffer, encoded as unknown as ArrayBuffer);
|
||||||
|
} 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
86
app/src/lib/db.ts
Normal 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
1
app/src/lib/index.ts
Normal 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
174
app/src/lib/peer.ts
Normal 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');
|
||||||
|
}
|
||||||
39
app/src/lib/permissions.ts
Normal file
39
app/src/lib/permissions.ts
Normal 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
105
app/src/lib/poll-client.ts
Normal 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
157
app/src/lib/poll-host.ts
Normal 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
44
app/src/lib/snapshot.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
157
app/src/lib/stores/polls.svelte.ts
Normal file
157
app/src/lib/stores/polls.svelte.ts
Normal 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);
|
||||||
|
}
|
||||||
62
app/src/lib/stores/profile.svelte.ts
Normal file
62
app/src/lib/stores/profile.svelte.ts
Normal 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
91
app/src/lib/types.ts
Normal 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;
|
||||||
|
}
|
||||||
9
app/src/routes/+layout.svelte
Normal file
9
app/src/routes/+layout.svelte
Normal 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>
|
||||||
23
app/src/routes/+page.svelte
Normal file
23
app/src/routes/+page.svelte
Normal 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>
|
||||||
57
app/src/routes/app/+layout.svelte
Normal file
57
app/src/routes/app/+layout.svelte
Normal 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>
|
||||||
138
app/src/routes/app/create/+page.svelte
Normal file
138
app/src/routes/app/create/+page.svelte
Normal 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>
|
||||||
273
app/src/routes/app/poll/[id]/+page.svelte
Normal file
273
app/src/routes/app/poll/[id]/+page.svelte
Normal 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>
|
||||||
73
app/src/routes/app/polls/+page.svelte
Normal file
73
app/src/routes/app/polls/+page.svelte
Normal 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>
|
||||||
127
app/src/routes/app/profile/+page.svelte
Normal file
127
app/src/routes/app/profile/+page.svelte
Normal 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>
|
||||||
85
app/src/routes/embed/[id]/+page.svelte
Normal file
85
app/src/routes/embed/[id]/+page.svelte
Normal 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>
|
||||||
170
app/src/routes/p/[id]/+page.svelte
Normal file
170
app/src/routes/p/[id]/+page.svelte
Normal 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
3
app/static/robots.txt
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# allow crawling everything by default
|
||||||
|
User-agent: *
|
||||||
|
Disallow:
|
||||||
17
app/svelte.config.js
Normal file
17
app/svelte.config.js
Normal 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
20
app/tsconfig.json
Normal 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
7
app/vite.config.ts
Normal 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()]
|
||||||
|
});
|
||||||
146
server/index.ts
Normal file
146
server/index.ts
Normal 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
10
server/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,16 +1,24 @@
|
|||||||
---
|
---
|
||||||
status: planned
|
status: complete
|
||||||
created: '2026-03-16'
|
created: '2026-03-16'
|
||||||
tags:
|
tags:
|
||||||
- infra
|
- infra
|
||||||
- setup
|
- setup
|
||||||
priority: high
|
priority: high
|
||||||
created_at: '2026-03-16T07:51:47.401Z'
|
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
|
# Project Setup & Architecture
|
||||||
|
|
||||||
> **Status**: 📅 Planned · **Priority**: High · **Created**: 2026-03-16
|
> **Status**: ✅ Complete · **Priority**: High · **Created**: 2026-03-16 · **Tags**: infra, setup
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
status: planned
|
status: complete
|
||||||
created: '2026-03-16'
|
created: '2026-03-16'
|
||||||
tags:
|
tags:
|
||||||
- p2p
|
- p2p
|
||||||
@@ -8,12 +8,17 @@ priority: high
|
|||||||
created_at: '2026-03-16T07:51:47.888Z'
|
created_at: '2026-03-16T07:51:47.888Z'
|
||||||
depends_on:
|
depends_on:
|
||||||
- 001-project-setup
|
- 001-project-setup
|
||||||
updated_at: '2026-03-16T07:52:03.104Z'
|
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
|
# P2P Networking Layer
|
||||||
|
|
||||||
> **Status**: 🗓️ Planned · **Priority**: High · **Created**: 2026-03-16 · **Tags**: p2p, core
|
> **Status**: ✅ Complete · **Priority**: High · **Created**: 2026-03-16 · **Tags**: p2p, core
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
status: planned
|
status: complete
|
||||||
created: '2026-03-16'
|
created: '2026-03-16'
|
||||||
tags:
|
tags:
|
||||||
- identity
|
- identity
|
||||||
@@ -8,12 +8,17 @@ priority: high
|
|||||||
created_at: '2026-03-16T07:51:48.340Z'
|
created_at: '2026-03-16T07:51:48.340Z'
|
||||||
depends_on:
|
depends_on:
|
||||||
- 001-project-setup
|
- 001-project-setup
|
||||||
updated_at: '2026-03-16T07:52:03.509Z'
|
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
|
# User Identity & Profiles
|
||||||
|
|
||||||
> **Status**: 🗓️ Planned · **Priority**: High · **Created**: 2026-03-16 · **Tags**: identity, profiles
|
> **Status**: ✅ Complete · **Priority**: High · **Created**: 2026-03-16 · **Tags**: identity, profiles
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
status: planned
|
status: complete
|
||||||
created: '2026-03-16'
|
created: '2026-03-16'
|
||||||
tags:
|
tags:
|
||||||
- data
|
- data
|
||||||
@@ -9,12 +9,17 @@ created_at: '2026-03-16T07:51:48.793Z'
|
|||||||
depends_on:
|
depends_on:
|
||||||
- 002-p2p-networking
|
- 002-p2p-networking
|
||||||
- 003-user-identity-profiles
|
- 003-user-identity-profiles
|
||||||
updated_at: '2026-03-16T07:52:03.943Z'
|
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
|
# Poll Data Model & Sync
|
||||||
|
|
||||||
> **Status**: 🗓️ Planned · **Priority**: High · **Created**: 2026-03-16 · **Tags**: data, core
|
> **Status**: ✅ Complete · **Priority**: High · **Created**: 2026-03-16 · **Tags**: data, core
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
status: planned
|
status: complete
|
||||||
created: '2026-03-16'
|
created: '2026-03-16'
|
||||||
tags:
|
tags:
|
||||||
- polls
|
- polls
|
||||||
@@ -8,14 +8,19 @@ priority: high
|
|||||||
created_at: '2026-03-16T07:51:49.209Z'
|
created_at: '2026-03-16T07:51:49.209Z'
|
||||||
depends_on:
|
depends_on:
|
||||||
- 004-poll-data-model
|
- 004-poll-data-model
|
||||||
updated_at: '2026-03-16T07:52:06.762Z'
|
updated_at: '2026-03-16T10:01:27.560Z'
|
||||||
related:
|
related:
|
||||||
- 011-mobile-first-ui
|
- 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
|
# Poll Creation & Management
|
||||||
|
|
||||||
> **Status**: 🗓️ Planned · **Priority**: High · **Created**: 2026-03-16 · **Tags**: polls, management
|
> **Status**: ✅ Complete · **Priority**: High · **Created**: 2026-03-16 · **Tags**: polls, management
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
status: planned
|
status: complete
|
||||||
created: '2026-03-16'
|
created: '2026-03-16'
|
||||||
tags:
|
tags:
|
||||||
- voting
|
- voting
|
||||||
@@ -8,14 +8,19 @@ priority: high
|
|||||||
created_at: '2026-03-16T07:51:50.525Z'
|
created_at: '2026-03-16T07:51:50.525Z'
|
||||||
depends_on:
|
depends_on:
|
||||||
- 005-poll-creation-management
|
- 005-poll-creation-management
|
||||||
updated_at: '2026-03-16T09:18:58.099Z'
|
updated_at: '2026-03-16T10:01:27.962Z'
|
||||||
related:
|
related:
|
||||||
- 011-mobile-first-ui
|
- 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
|
# Voting System & Anonymity
|
||||||
|
|
||||||
> **Status**: 🗓️ Planned · **Priority**: High · **Created**: 2026-03-16 · **Tags**: voting, core
|
> **Status**: ✅ Complete · **Priority**: High · **Created**: 2026-03-16 · **Tags**: voting, core
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
status: planned
|
status: complete
|
||||||
created: '2026-03-16'
|
created: '2026-03-16'
|
||||||
tags:
|
tags:
|
||||||
- sharing
|
- sharing
|
||||||
@@ -10,14 +10,19 @@ depends_on:
|
|||||||
- 005-poll-creation-management
|
- 005-poll-creation-management
|
||||||
- 008-voting-system
|
- 008-voting-system
|
||||||
- 012-lightweight-server
|
- 012-lightweight-server
|
||||||
updated_at: '2026-03-16T07:57:41.176Z'
|
updated_at: '2026-03-16T10:01:28.366Z'
|
||||||
related:
|
related:
|
||||||
- 011-mobile-first-ui
|
- 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
|
# Public Sharing & Read-Only View
|
||||||
|
|
||||||
> **Status**: 🗓️ Planned · **Priority**: Medium · **Created**: 2026-03-16 · **Tags**: sharing, web
|
> **Status**: ✅ Complete · **Priority**: Medium · **Created**: 2026-03-16 · **Tags**: sharing, web
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
status: planned
|
status: complete
|
||||||
created: '2026-03-16'
|
created: '2026-03-16'
|
||||||
tags:
|
tags:
|
||||||
- embed
|
- embed
|
||||||
@@ -8,12 +8,17 @@ priority: low
|
|||||||
created_at: '2026-03-16T07:51:51.430Z'
|
created_at: '2026-03-16T07:51:51.430Z'
|
||||||
depends_on:
|
depends_on:
|
||||||
- 009-public-sharing
|
- 009-public-sharing
|
||||||
updated_at: '2026-03-16T07:52:06.356Z'
|
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
|
# Embeddable Poll Widget
|
||||||
|
|
||||||
> **Status**: 🗓️ Planned · **Priority**: Low · **Created**: 2026-03-16 · **Tags**: embed, widget
|
> **Status**: ✅ Complete · **Priority**: Low · **Created**: 2026-03-16 · **Tags**: embed, widget
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
status: planned
|
status: complete
|
||||||
created: '2026-03-16'
|
created: '2026-03-16'
|
||||||
tags:
|
tags:
|
||||||
- server
|
- server
|
||||||
@@ -8,12 +8,17 @@ priority: high
|
|||||||
created_at: '2026-03-16T07:57:36.544Z'
|
created_at: '2026-03-16T07:57:36.544Z'
|
||||||
depends_on:
|
depends_on:
|
||||||
- 001-project-setup
|
- 001-project-setup
|
||||||
updated_at: '2026-03-16T09:32:10.798Z'
|
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
|
# Poll Snapshot Server
|
||||||
|
|
||||||
> **Status**: 🗓️ Planned · **Priority**: High · **Created**: 2026-03-16 · **Tags**: server, infra
|
> **Status**: ✅ Complete · **Priority**: High · **Created**: 2026-03-16 · **Tags**: server, infra
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user