Compare commits
3 Commits
proposal-0
...
proposal-7
| Author | SHA1 | Date | |
|---|---|---|---|
| e3c192a84f | |||
| ab508d827d | |||
| b7539ac86e |
@@ -1,15 +0,0 @@
|
||||
# This file is used by the build system to adjust CSS and JS output to support the specified browsers below.
|
||||
# For additional information regarding the format and rule options, please see:
|
||||
# https://github.com/browserslist/browserslist#queries
|
||||
|
||||
# For the full list of supported browsers by the Angular framework, please see:
|
||||
# https://angular.dev/reference/versions#browser-support
|
||||
|
||||
# You can see what browsers were selected by your queries by running:
|
||||
# npx browserslist
|
||||
|
||||
Chrome >=107
|
||||
Firefox >=106
|
||||
Edge >=107
|
||||
Safari >=16.1
|
||||
iOS >=16.1
|
||||
@@ -1,16 +0,0 @@
|
||||
# Editor configuration, see https://editorconfig.org
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.ts]
|
||||
quote_type = single
|
||||
|
||||
[*.md]
|
||||
max_line_length = off
|
||||
trim_trailing_whitespace = false
|
||||
@@ -1,47 +0,0 @@
|
||||
{
|
||||
"root": true,
|
||||
"ignorePatterns": ["projects/**/*"],
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["*.ts"],
|
||||
"parserOptions": {
|
||||
"project": ["tsconfig.json"],
|
||||
"createDefaultProgram": true
|
||||
},
|
||||
"extends": [
|
||||
"plugin:@angular-eslint/recommended",
|
||||
"plugin:@angular-eslint/template/process-inline-templates"
|
||||
],
|
||||
"rules": {
|
||||
"@angular-eslint/prefer-standalone": "off",
|
||||
"@angular-eslint/component-class-suffix": [
|
||||
"error",
|
||||
{
|
||||
"suffixes": ["Page", "Component"]
|
||||
}
|
||||
],
|
||||
"@angular-eslint/component-selector": [
|
||||
"error",
|
||||
{
|
||||
"type": "element",
|
||||
"prefix": "app",
|
||||
"style": "kebab-case"
|
||||
}
|
||||
],
|
||||
"@angular-eslint/directive-selector": [
|
||||
"error",
|
||||
{
|
||||
"type": "attribute",
|
||||
"prefix": "app",
|
||||
"style": "camelCase"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["*.html"],
|
||||
"extends": ["plugin:@angular-eslint/template/recommended"],
|
||||
"rules": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
71
.gitignore
vendored
71
.gitignore
vendored
@@ -1,71 +0,0 @@
|
||||
# Specifies intentionally untracked files to ignore when using Git
|
||||
# http://git-scm.com/docs/gitignore
|
||||
|
||||
*~
|
||||
*.sw[mnpcod]
|
||||
.tmp
|
||||
*.tmp
|
||||
*.tmp.*
|
||||
UserInterfaceState.xcuserstate
|
||||
$RECYCLE.BIN/
|
||||
|
||||
*.log
|
||||
log.txt
|
||||
|
||||
|
||||
/.sourcemaps
|
||||
/.versions
|
||||
/coverage
|
||||
|
||||
# Ionic
|
||||
/.ionic
|
||||
/www
|
||||
/platforms
|
||||
/plugins
|
||||
|
||||
# Compiled output
|
||||
/dist
|
||||
/tmp
|
||||
/out-tsc
|
||||
/bazel-out
|
||||
|
||||
# Node
|
||||
/node_modules
|
||||
npm-debug.log
|
||||
yarn-error.log
|
||||
|
||||
# IDEs and editors
|
||||
.idea/
|
||||
.project
|
||||
.classpath
|
||||
.c9/
|
||||
*.launch
|
||||
.settings/
|
||||
*.sublime-project
|
||||
*.sublime-workspace
|
||||
|
||||
# Visual Studio Code
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
.history/*
|
||||
|
||||
|
||||
# Miscellaneous
|
||||
/.angular
|
||||
/.angular/cache
|
||||
.sass-cache/
|
||||
/.nx
|
||||
/.nx/cache
|
||||
/connect.lock
|
||||
/coverage
|
||||
/libpeerconnection.log
|
||||
testem.log
|
||||
/typings
|
||||
|
||||
# System files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
CLAUDE.md
|
||||
5
.vscode/extensions.json
vendored
5
.vscode/extensions.json
vendored
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"Webnative.webnative"
|
||||
]
|
||||
}
|
||||
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"typescript.preferences.autoImportFileExcludePatterns": ["@ionic/angular/common", "@ionic/angular/standalone"]
|
||||
}
|
||||
352
README.md
352
README.md
@@ -1,347 +1,13 @@
|
||||
# P2P Survey App
|
||||
# P2P Poll App
|
||||
|
||||
A serverless peer-to-peer survey application built with Ionic and Angular.
|
||||
Survey creators store all data locally in their browser and participants connect
|
||||
directly via WebRTC — no backend server required.
|
||||
A decentralized polling app built with SvelteKit + PeerJS. Mobile-first, peer-to-peer.
|
||||
|
||||
---
|
||||
## UI Guidelines
|
||||
|
||||
## Table of Contents
|
||||
All UI code must follow mobile-first principles:
|
||||
|
||||
1. [Overview](#overview)
|
||||
2. [Architecture](#architecture)
|
||||
3. [Features](#features)
|
||||
4. [Setup & Installation](#setup--installation)
|
||||
5. [How It Works](#how-it-works)
|
||||
- [Creator Flow](#creator-flow)
|
||||
- [Participant Flow](#participant-flow)
|
||||
6. [Question Types](#question-types)
|
||||
7. [Technology Stack](#technology-stack)
|
||||
8. [Limitations](#limitations)
|
||||
9. [Self-Hosting the PeerJS Signaling Server](#self-hosting-the-peerjs-signaling-server)
|
||||
10. [Development Commands](#development-commands)
|
||||
11. [Technology Choices](#technology-choices)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
P2P Survey App lets you create and distribute surveys without any server-side
|
||||
infrastructure. All survey data — questions, participant tokens, and responses —
|
||||
lives exclusively in the survey creator's browser (IndexedDB). Participants
|
||||
connect directly to the creator's browser using WebRTC peer-to-peer data
|
||||
channels, facilitated by the PeerJS library.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Creator Browser (Host)
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ Ionic/Angular App │
|
||||
│ ┌─────────────────┐ ┌───────────────────┐│
|
||||
│ │ PeerService │ │ Dexie (IndexedDB)││
|
||||
│ │ (PeerJS/WebRTC)│<─>│ surveys ││
|
||||
│ └────────┬────────┘ │ participants ││
|
||||
│ │ │ responses ││
|
||||
│ Peer ID: survey-{id}└───────────────────┘│
|
||||
└───────────┼─────────────────────────────────┘
|
||||
│
|
||||
[PeerJS Cloud – signaling only, not data relay]
|
||||
│
|
||||
Participant Browser
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ Opens: /participate?host=survey-{id} │
|
||||
│ &token={uuid} │
|
||||
│ │
|
||||
│ 1. Connect to host peer via WebRTC │
|
||||
│ 2. Send { type: 'join', token } │
|
||||
│ 3. Receive survey questions │
|
||||
│ 4. Fill in answers │
|
||||
│ 5. Send { type: 'submit', answers } │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Star topology
|
||||
|
||||
The survey creator acts as the central hub. Each participant opens a direct
|
||||
WebRTC data channel to the creator's browser. The creator's browser validates
|
||||
tokens, stores responses, and optionally pushes aggregated results back.
|
||||
|
||||
The PeerJS signaling server is only used for the initial WebRTC handshake
|
||||
(exchanging ICE candidates). Once connected, all data flows directly between
|
||||
the two browsers — the signaling server never sees response data.
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
- **Create surveys** with four question types: free text, multiple choice,
|
||||
yes/no, and 1–5 rating
|
||||
- **Generate unique participant links** — each link contains a UUID token that
|
||||
identifies one participant
|
||||
- **Token-based access control** — each token can only be submitted once;
|
||||
reuse is rejected by the host
|
||||
- **Draft saving** — participants can save answers before final submission
|
||||
without locking their token
|
||||
- **Live results** — the creator sees responses update in real time using
|
||||
Dexie's `liveQuery`
|
||||
- **Optional results sharing** — the creator can toggle whether participants
|
||||
see aggregated results after submitting
|
||||
- **CSV export** — download all responses as a comma-separated file
|
||||
- **Local-only storage** — all data lives in the creator's IndexedDB; delete
|
||||
the survey and the data is gone
|
||||
|
||||
---
|
||||
|
||||
## Setup & Installation
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js 18+ and npm 9+
|
||||
- A modern browser (Chrome, Firefox, Edge, Safari)
|
||||
|
||||
### Install
|
||||
|
||||
```bash
|
||||
# 1. Install Ionic CLI globally (skip if already installed)
|
||||
npm install -g @ionic/cli
|
||||
|
||||
# 2. Install project dependencies
|
||||
npm install
|
||||
|
||||
# 3. Start the development server
|
||||
ionic serve
|
||||
```
|
||||
|
||||
The app opens at `http://localhost:8100`.
|
||||
|
||||
### Build for production
|
||||
|
||||
```bash
|
||||
ionic build --prod
|
||||
```
|
||||
|
||||
Output is written to `www/`. Deploy the contents of `www/` to any static
|
||||
web host (GitHub Pages, Netlify, Vercel, Nginx, etc.).
|
||||
|
||||
---
|
||||
|
||||
## How It Works
|
||||
|
||||
### Creator Flow
|
||||
|
||||
1. **Open the app** and click **Create Survey**.
|
||||
2. **Enter a title** (required) and optional description.
|
||||
3. **Add questions** — choose a type for each and mark which are required.
|
||||
4. Click **Create** to save the survey to IndexedDB and navigate to the
|
||||
**Survey Detail** page.
|
||||
5. On the detail page:
|
||||
- Toggle **Show results to participants** if desired.
|
||||
- Enter a number and click **Generate Links** to create unique participant
|
||||
tokens. Copy links to share.
|
||||
- Click **Start Hosting** to initialise the PeerJS peer. The app listens
|
||||
for incoming connections using the peer ID `survey-{surveyId}`.
|
||||
6. **Keep this page or the Results page open** while collecting responses.
|
||||
If the creator navigates away, the peer is destroyed and participants
|
||||
cannot connect.
|
||||
7. Click **View Results** to see live aggregated and individual responses.
|
||||
8. Click **Export CSV** to download all responses.
|
||||
|
||||
### Participant Flow
|
||||
|
||||
1. **Open the unique link** shared by the survey creator.
|
||||
2. The app parses `host` and `token` from the URL query parameters.
|
||||
3. A WebRTC connection is established to the host's peer ID.
|
||||
4. The participant sends `{ type: 'join', token }`.
|
||||
5. The host validates the token:
|
||||
- **Invalid token** → error card is shown.
|
||||
- **Already submitted** → error card is shown (token is locked).
|
||||
- **Valid** → survey questions are sent back.
|
||||
6. Participant fills in answers. Answers are auto-saved as drafts after each
|
||||
blur event (free text) or selection change (other types).
|
||||
7. Click **Submit** to finalise. The token is locked on the host side.
|
||||
8. If the creator enabled result sharing, aggregated results are shown.
|
||||
|
||||
---
|
||||
|
||||
## Question Types
|
||||
|
||||
| Type | Input widget | Aggregation |
|
||||
|---|---|---|
|
||||
| **Free Text** | Multi-line textarea | List of all answers |
|
||||
| **Multiple Choice** | Radio buttons (creator-defined options) | Count per option + bar chart |
|
||||
| **Yes / No** | Radio buttons (Yes / No) | Count per option + bar chart |
|
||||
| **Rating** | 5 buttons (1–5) | Average score + distribution bar chart |
|
||||
|
||||
---
|
||||
|
||||
## Technology Stack
|
||||
|
||||
| Library | Version | Role |
|
||||
|---|---|---|
|
||||
| `@ionic/angular` | 8.x | Mobile-first UI components |
|
||||
| `@angular/core` | 20.x | Application framework |
|
||||
| `peerjs` | 1.5.x | WebRTC data channel wrapper |
|
||||
| `dexie` | 4.x | IndexedDB wrapper with liveQuery |
|
||||
| `qrcode` | 1.5.x | QR code generation (optional usage) |
|
||||
|
||||
---
|
||||
|
||||
## Limitations
|
||||
|
||||
### Host must be online
|
||||
|
||||
The survey creator's browser **must be open** and on the Survey Detail or
|
||||
Results page for participants to submit responses. There is no server that
|
||||
relays or buffers messages. If the creator goes offline, participants see a
|
||||
"Host is Offline" card.
|
||||
|
||||
### Data loss
|
||||
|
||||
All data is stored in the creator's browser IndexedDB. Data is lost if:
|
||||
- The creator explicitly deletes the survey
|
||||
- The browser's storage is cleared (private/incognito mode, clearing site data)
|
||||
- The browser's IndexedDB quota is exceeded
|
||||
|
||||
Export responses to CSV regularly to prevent data loss.
|
||||
|
||||
### PeerJS Cloud rate limits
|
||||
|
||||
The free PeerJS Cloud signaling server (`0.peerjs.com`) has rate limits and
|
||||
may occasionally be unavailable. For production use, self-host the signaling
|
||||
server (see below).
|
||||
|
||||
### NAT traversal
|
||||
|
||||
WebRTC uses STUN to traverse most NAT configurations. Strict corporate
|
||||
firewalls or symmetric NAT may block direct connections. For full reliability
|
||||
in such environments, add a TURN server to the PeerJS configuration in
|
||||
`src/app/services/peer.service.ts`.
|
||||
|
||||
### Browser compatibility
|
||||
|
||||
Requires a browser that supports:
|
||||
- WebRTC (DataChannels)
|
||||
- IndexedDB
|
||||
- `crypto.randomUUID()`
|
||||
|
||||
All modern browsers (Chrome 86+, Firefox 78+, Safari 15.4+, Edge 86+) satisfy
|
||||
these requirements.
|
||||
|
||||
---
|
||||
|
||||
## Self-Hosting the PeerJS Signaling Server
|
||||
|
||||
For production deployments, run your own PeerJS server to avoid rate limits.
|
||||
|
||||
### Option 1: npm
|
||||
|
||||
```bash
|
||||
npm install -g peer
|
||||
peerjs --port 9000 --key peerjs
|
||||
```
|
||||
|
||||
### Option 2: Docker
|
||||
|
||||
```bash
|
||||
docker run -p 9000:9000 peerjs/peerjs-server
|
||||
```
|
||||
|
||||
### Option 3: Node.js
|
||||
|
||||
```javascript
|
||||
// server.js
|
||||
const { PeerServer } = require('peer');
|
||||
const server = PeerServer({ port: 9000, path: '/peerjs' });
|
||||
```
|
||||
|
||||
### Configure the app to use your server
|
||||
|
||||
Edit `src/app/services/peer.service.ts` and update the `Peer` constructor:
|
||||
|
||||
```typescript
|
||||
this.peer = new Peer(peerId, {
|
||||
host: 'your-peer-server.example.com',
|
||||
port: 9000,
|
||||
path: '/peerjs',
|
||||
secure: true, // use wss:// if your server has TLS
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Development Commands
|
||||
|
||||
```bash
|
||||
# Start dev server with live reload
|
||||
ionic serve
|
||||
|
||||
# Build for production
|
||||
ionic build --prod
|
||||
|
||||
# Run TypeScript type check
|
||||
npx tsc --noEmit
|
||||
|
||||
# Lint
|
||||
npx ng lint
|
||||
|
||||
# Build Angular app only (no Ionic wrapper)
|
||||
npx ng build
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Technology Choices
|
||||
|
||||
### PeerJS over Gun.js
|
||||
|
||||
Gun.js is a decentralized graph database that syncs between peers. However,
|
||||
a survey has a **star topology** (one host, many participants) — not a mesh
|
||||
where every peer is equal. PeerJS maps directly to this model: the creator
|
||||
opens one peer ID, participants connect to it, and all data flows through
|
||||
that central point. Token validation and response deduplication are trivial
|
||||
to enforce at the host. Gun.js would require additional complexity to achieve
|
||||
the same guarantees.
|
||||
|
||||
### Dexie.js over raw IndexedDB
|
||||
|
||||
Raw IndexedDB uses a callback-based API that is verbose and error-prone.
|
||||
Dexie wraps it in clean Promises, adds a TypeScript-friendly schema
|
||||
definition, and provides `liveQuery` — a reactive subscription mechanism
|
||||
that automatically re-runs queries when the underlying data changes. This
|
||||
powers the live results view without any manual polling or event wiring.
|
||||
|
||||
### Module-based Angular over Standalone Components
|
||||
|
||||
The Ionic CLI scaffolds a module-based project by default with Angular 20.
|
||||
Module-based components provide a clear separation of concern via `NgModule`
|
||||
declarations and are well-supported by the Ionic ecosystem.
|
||||
|
||||
---
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
src/
|
||||
└── app/
|
||||
├── shared/
|
||||
│ └── models/
|
||||
│ └── survey.models.ts # All TypeScript interfaces + P2PMessage type
|
||||
├── database/
|
||||
│ └── database.ts # Dexie singleton (AppDatabase)
|
||||
├── services/
|
||||
│ ├── peer.service.ts # PeerJS wrapper (NgZone-aware)
|
||||
│ ├── survey.service.ts # Survey/participant CRUD
|
||||
│ └── response.service.ts # Response storage + aggregation
|
||||
├── pages/
|
||||
│ ├── home/ # Survey list
|
||||
│ ├── create-survey/ # Survey creation/editing
|
||||
│ ├── survey-detail/ # Settings, link generation, hosting
|
||||
│ ├── survey-results/ # Live results view
|
||||
│ └── participate/ # Participant survey form
|
||||
├── app-routing.module.ts
|
||||
├── app.component.ts
|
||||
└── app.module.ts
|
||||
```
|
||||
- **Touch targets**: ≥ 44px
|
||||
- **Layout**: Single-column on mobile, expand on larger screens
|
||||
- **Navigation**: Bottom tab bar (Home, Create, Profile)
|
||||
- **Dark mode**: Respect `prefers-color-scheme`
|
||||
- **Tailwind**: Mobile breakpoints are the default; use `md:` / `lg:` to scale up
|
||||
170
angular.json
170
angular.json
@@ -1,170 +0,0 @@
|
||||
{
|
||||
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
||||
"version": 1,
|
||||
"newProjectRoot": "projects",
|
||||
"projects": {
|
||||
"app": {
|
||||
"projectType": "application",
|
||||
"schematics": {},
|
||||
"root": "",
|
||||
"sourceRoot": "src",
|
||||
"prefix": "app",
|
||||
"architect": {
|
||||
"build": {
|
||||
"builder": "@angular-devkit/build-angular:browser",
|
||||
"options": {
|
||||
"outputPath": "www",
|
||||
"index": "src/index.html",
|
||||
"main": "src/main.ts",
|
||||
"polyfills": "src/polyfills.ts",
|
||||
"tsConfig": "tsconfig.app.json",
|
||||
"inlineStyleLanguage": "scss",
|
||||
"assets": [
|
||||
{
|
||||
"glob": "**/*",
|
||||
"input": "src/assets",
|
||||
"output": "assets"
|
||||
},
|
||||
{
|
||||
"glob": "**/*.svg",
|
||||
"input": "node_modules/ionicons/dist/ionicons/svg",
|
||||
"output": "./svg"
|
||||
},
|
||||
{
|
||||
"glob": "serve.json",
|
||||
"input": "src",
|
||||
"output": "/"
|
||||
},
|
||||
{
|
||||
"glob": "serve.json",
|
||||
"input": "src",
|
||||
"output": "/"
|
||||
}
|
||||
],
|
||||
"styles": [
|
||||
"src/global.scss",
|
||||
"src/theme/variables.scss"
|
||||
],
|
||||
"scripts": [],
|
||||
"allowedCommonJsDependencies": [
|
||||
"sdp",
|
||||
"webrtc-adapter"
|
||||
]
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"budgets": [
|
||||
{
|
||||
"type": "initial",
|
||||
"maximumWarning": "2mb",
|
||||
"maximumError": "5mb"
|
||||
},
|
||||
{
|
||||
"type": "anyComponentStyle",
|
||||
"maximumWarning": "2kb",
|
||||
"maximumError": "4kb"
|
||||
}
|
||||
],
|
||||
"fileReplacements": [
|
||||
{
|
||||
"replace": "src/environments/environment.ts",
|
||||
"with": "src/environments/environment.prod.ts"
|
||||
}
|
||||
],
|
||||
"outputHashing": "all"
|
||||
},
|
||||
"development": {
|
||||
"buildOptimizer": false,
|
||||
"optimization": false,
|
||||
"vendorChunk": true,
|
||||
"extractLicenses": false,
|
||||
"sourceMap": true,
|
||||
"namedChunks": true
|
||||
},
|
||||
"ci": {
|
||||
"progress": false
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "production"
|
||||
},
|
||||
"serve": {
|
||||
"builder": "@angular-devkit/build-angular:dev-server",
|
||||
"configurations": {
|
||||
"production": {
|
||||
"buildTarget": "app:build:production"
|
||||
},
|
||||
"development": {
|
||||
"buildTarget": "app:build:development"
|
||||
},
|
||||
"ci": {
|
||||
"progress": false
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "development"
|
||||
},
|
||||
"extract-i18n": {
|
||||
"builder": "@angular-devkit/build-angular:extract-i18n",
|
||||
"options": {
|
||||
"buildTarget": "app:build"
|
||||
}
|
||||
},
|
||||
"test": {
|
||||
"builder": "@angular-devkit/build-angular:karma",
|
||||
"options": {
|
||||
"main": "src/test.ts",
|
||||
"polyfills": "src/polyfills.ts",
|
||||
"tsConfig": "tsconfig.spec.json",
|
||||
"karmaConfig": "karma.conf.js",
|
||||
"inlineStyleLanguage": "scss",
|
||||
"assets": [
|
||||
{
|
||||
"glob": "**/*",
|
||||
"input": "src/assets",
|
||||
"output": "assets"
|
||||
},
|
||||
{
|
||||
"glob": "**/*.svg",
|
||||
"input": "node_modules/ionicons/dist/ionicons/svg",
|
||||
"output": "./svg"
|
||||
}
|
||||
],
|
||||
"styles": [
|
||||
"src/global.scss",
|
||||
"src/theme/variables.scss"
|
||||
],
|
||||
"scripts": []
|
||||
},
|
||||
"configurations": {
|
||||
"ci": {
|
||||
"progress": false,
|
||||
"watch": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"builder": "@angular-eslint/builder:lint",
|
||||
"options": {
|
||||
"lintFilePatterns": [
|
||||
"src/**/*.ts",
|
||||
"src/**/*.html"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"cli": {
|
||||
"schematicCollections": [
|
||||
"@ionic/angular-toolkit"
|
||||
],
|
||||
"analytics": false
|
||||
},
|
||||
"schematics": {
|
||||
"@ionic/angular-toolkit:component": {
|
||||
"styleext": "scss"
|
||||
},
|
||||
"@ionic/angular-toolkit:page": {
|
||||
"styleext": "scss"
|
||||
}
|
||||
}
|
||||
}
|
||||
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 BufferSource, ALGO, false, ['verify']);
|
||||
const sig = base64Decode(signature);
|
||||
const encoded = new TextEncoder().encode(data);
|
||||
return crypto.subtle.verify(ALGO, key, sig as BufferSource, encoded as BufferSource);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Base58 (Bitcoin-style) ---
|
||||
|
||||
const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
|
||||
|
||||
export function base58Encode(bytes: Uint8Array): string {
|
||||
let num = 0n;
|
||||
for (const b of bytes) num = num * 256n + BigInt(b);
|
||||
|
||||
let result = '';
|
||||
while (num > 0n) {
|
||||
result = BASE58_ALPHABET[Number(num % 58n)] + result;
|
||||
num = num / 58n;
|
||||
}
|
||||
|
||||
for (const b of bytes) {
|
||||
if (b !== 0) break;
|
||||
result = '1' + result;
|
||||
}
|
||||
|
||||
return result || '1';
|
||||
}
|
||||
|
||||
function base64Encode(bytes: Uint8Array): string {
|
||||
let binary = '';
|
||||
for (const b of bytes) binary += String.fromCharCode(b);
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
function base64Decode(str: string): Uint8Array {
|
||||
const binary = atob(str);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
||||
return bytes;
|
||||
}
|
||||
86
app/src/lib/db.ts
Normal file
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()]
|
||||
});
|
||||
@@ -1,9 +0,0 @@
|
||||
import type { CapacitorConfig } from '@capacitor/cli';
|
||||
|
||||
const config: CapacitorConfig = {
|
||||
appId: 'io.ionic.starter',
|
||||
appName: 'IonicAngularVersion',
|
||||
webDir: 'www'
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"name": "IonicAngularVersion",
|
||||
"integrations": {
|
||||
"capacitor": {}
|
||||
},
|
||||
"type": "angular"
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
// Karma configuration file, see link for more information
|
||||
// https://karma-runner.github.io/1.0/config/configuration-file.html
|
||||
|
||||
module.exports = function (config) {
|
||||
config.set({
|
||||
basePath: '',
|
||||
frameworks: ['jasmine', '@angular-devkit/build-angular'],
|
||||
plugins: [
|
||||
require('karma-jasmine'),
|
||||
require('karma-chrome-launcher'),
|
||||
require('karma-jasmine-html-reporter'),
|
||||
require('karma-coverage'),
|
||||
require('@angular-devkit/build-angular/plugins/karma')
|
||||
],
|
||||
client: {
|
||||
jasmine: {
|
||||
// you can add configuration options for Jasmine here
|
||||
// the possible options are listed at https://jasmine.github.io/api/edge/Configuration.html
|
||||
// for example, you can disable the random execution with `random: false`
|
||||
// or set a specific seed with `seed: 4321`
|
||||
},
|
||||
clearContext: false // leave Jasmine Spec Runner output visible in browser
|
||||
},
|
||||
jasmineHtmlReporter: {
|
||||
suppressAll: true // removes the duplicated traces
|
||||
},
|
||||
coverageReporter: {
|
||||
dir: require('path').join(__dirname, './coverage/app'),
|
||||
subdir: '.',
|
||||
reporters: [
|
||||
{ type: 'html' },
|
||||
{ type: 'text-summary' }
|
||||
]
|
||||
},
|
||||
reporters: ['progress', 'kjhtml'],
|
||||
port: 9876,
|
||||
colors: true,
|
||||
logLevel: config.LOG_INFO,
|
||||
autoWatch: true,
|
||||
browsers: ['Chrome'],
|
||||
singleRun: false,
|
||||
restartOnFileChange: true
|
||||
});
|
||||
};
|
||||
19093
package-lock.json
generated
19093
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
68
package.json
68
package.json
@@ -1,68 +0,0 @@
|
||||
{
|
||||
"name": "IonicAngularVersion",
|
||||
"version": "0.0.1",
|
||||
"author": "Ionic Framework",
|
||||
"homepage": "https://ionicframework.com/",
|
||||
"scripts": {
|
||||
"ng": "ng",
|
||||
"start": "ng serve",
|
||||
"build": "ng build",
|
||||
"watch": "ng build --watch --configuration development",
|
||||
"test": "ng test",
|
||||
"lint": "ng lint"
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@angular/animations": "^20.0.0",
|
||||
"@angular/common": "^20.0.0",
|
||||
"@angular/compiler": "^20.0.0",
|
||||
"@angular/core": "^20.0.0",
|
||||
"@angular/forms": "^20.0.0",
|
||||
"@angular/platform-browser": "^20.0.0",
|
||||
"@angular/platform-browser-dynamic": "^20.0.0",
|
||||
"@angular/router": "^20.0.0",
|
||||
"@capacitor/app": "8.0.1",
|
||||
"@capacitor/core": "8.2.0",
|
||||
"@capacitor/haptics": "8.0.1",
|
||||
"@capacitor/keyboard": "8.0.1",
|
||||
"@capacitor/status-bar": "8.0.1",
|
||||
"@ionic/angular": "^8.0.0",
|
||||
"dexie": "^4.3.0",
|
||||
"ionicons": "^7.0.0",
|
||||
"peerjs": "^1.5.5",
|
||||
"qrcode": "^1.5.4",
|
||||
"rxjs": "~7.8.0",
|
||||
"tslib": "^2.3.0",
|
||||
"zone.js": "~0.15.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular-devkit/build-angular": "^20.0.0",
|
||||
"@angular-eslint/builder": "^20.0.0",
|
||||
"@angular-eslint/eslint-plugin": "^20.0.0",
|
||||
"@angular-eslint/eslint-plugin-template": "^20.0.0",
|
||||
"@angular-eslint/schematics": "^20.0.0",
|
||||
"@angular-eslint/template-parser": "^20.0.0",
|
||||
"@angular/cli": "^20.0.0",
|
||||
"@angular/compiler-cli": "^20.0.0",
|
||||
"@angular/language-service": "^20.0.0",
|
||||
"@capacitor/cli": "8.2.0",
|
||||
"@ionic/angular-toolkit": "^12.0.0",
|
||||
"@types/jasmine": "~5.1.0",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@typescript-eslint/eslint-plugin": "^8.18.0",
|
||||
"@typescript-eslint/parser": "^8.18.0",
|
||||
"eslint": "^9.16.0",
|
||||
"eslint-plugin-import": "^2.29.1",
|
||||
"eslint-plugin-jsdoc": "^48.2.1",
|
||||
"eslint-plugin-prefer-arrow": "1.2.2",
|
||||
"jasmine-core": "~5.1.0",
|
||||
"jasmine-spec-reporter": "~5.0.0",
|
||||
"karma": "~6.4.0",
|
||||
"karma-chrome-launcher": "~3.2.0",
|
||||
"karma-coverage": "~2.2.0",
|
||||
"karma-jasmine": "~5.1.0",
|
||||
"karma-jasmine-html-reporter": "~2.1.0",
|
||||
"typescript": "~5.9.0"
|
||||
},
|
||||
"description": "An Ionic project"
|
||||
}
|
||||
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"
|
||||
}
|
||||
}
|
||||
80
specs/001-project-setup/README.md
Normal file
80
specs/001-project-setup/README.md
Normal file
@@ -0,0 +1,80 @@
|
||||
---
|
||||
status: complete
|
||||
created: '2026-03-16'
|
||||
tags:
|
||||
- infra
|
||||
- setup
|
||||
priority: high
|
||||
created_at: '2026-03-16T07:51:47.401Z'
|
||||
updated_at: '2026-03-16T10:01:25.930Z'
|
||||
transitions:
|
||||
- status: in-progress
|
||||
at: '2026-03-16T09:49:02.744Z'
|
||||
- status: complete
|
||||
at: '2026-03-16T10:01:25.930Z'
|
||||
completed_at: '2026-03-16T10:01:25.930Z'
|
||||
completed: '2026-03-16'
|
||||
---
|
||||
|
||||
# Project Setup & Architecture
|
||||
|
||||
> **Status**: ✅ Complete · **Priority**: High · **Created**: 2026-03-16 · **Tags**: infra, setup
|
||||
|
||||
## Overview
|
||||
|
||||
Scaffold the SvelteKit project with PeerJS for WebRTC-based P2P communication. This is the foundation all other specs build on. The app is a decentralized polling tool—no central backend for poll data.
|
||||
|
||||
## Design
|
||||
|
||||
- **Framework**: SvelteKit (SSR for public sharing pages, SPA for the app itself)
|
||||
- **P2P**: PeerJS (WebRTC data channels for peer communication)
|
||||
- **Signaling**: PeerJS Cloud (free tier to start; can self-host later)
|
||||
- **State**: Svelte stores + IndexedDB (via `idb-keyval` or similar) for local persistence
|
||||
- **Styling**: Tailwind CSS (mobile-first utility classes)
|
||||
- **Build/Deploy**: Vercel or Cloudflare Pages (static adapter for SPA routes, SSR for public pages)
|
||||
|
||||
### Mobile-First UI Principles (applies to ALL UI work)
|
||||
|
||||
- Touch targets ≥ 44px
|
||||
- Single-column layout on mobile; expand on larger screens
|
||||
- Bottom navigation bar (thumb-friendly): Home, Create (+), Profile
|
||||
- Minimal chrome—content first
|
||||
- Dark mode support (`prefers-color-scheme`)
|
||||
- System font stack for performance
|
||||
- Tailwind mobile breakpoints as default (design small → scale up)
|
||||
|
||||
### Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ SvelteKit App (runs in browser) │
|
||||
│ ├── Svelte Stores (reactive state) │
|
||||
│ ├── IndexedDB (persistence) │
|
||||
│ ├── PeerJS (WebRTC data channels) │
|
||||
│ └── Crypto (identity keypairs) │
|
||||
└──────────┬──────────────────────────┘
|
||||
│ WebRTC
|
||||
┌─────┴─────┐
|
||||
│ Other Peers│
|
||||
└───────────┘
|
||||
```
|
||||
|
||||
## Plan
|
||||
|
||||
- [ ] Init SvelteKit project with TypeScript
|
||||
- [ ] Install dependencies: peerjs, tailwindcss, idb-keyval
|
||||
- [ ] Set up Tailwind with mobile-first config
|
||||
- [ ] Create basic app layout (shell, navigation)
|
||||
- [ ] Set up IndexedDB persistence layer
|
||||
- [ ] Configure SvelteKit adapter (adapter-auto for Vercel/CF, SSR mode)
|
||||
|
||||
## Test
|
||||
|
||||
- [ ] `npm run dev` starts without errors
|
||||
- [ ] Tailwind classes render correctly
|
||||
- [ ] IndexedDB read/write works in browser
|
||||
|
||||
## Notes
|
||||
|
||||
- PeerJS Cloud has rate limits—fine for development, may need self-hosted signaling for production
|
||||
- No server-side database; all poll data lives on peers' devices
|
||||
86
specs/002-p2p-networking/README.md
Normal file
86
specs/002-p2p-networking/README.md
Normal file
@@ -0,0 +1,86 @@
|
||||
---
|
||||
status: complete
|
||||
created: '2026-03-16'
|
||||
tags:
|
||||
- p2p
|
||||
- core
|
||||
priority: high
|
||||
created_at: '2026-03-16T07:51:47.888Z'
|
||||
depends_on:
|
||||
- 001-project-setup
|
||||
updated_at: '2026-03-16T10:01:26.362Z'
|
||||
completed_at: '2026-03-16T10:01:26.362Z'
|
||||
completed: '2026-03-16'
|
||||
transitions:
|
||||
- status: complete
|
||||
at: '2026-03-16T10:01:26.362Z'
|
||||
---
|
||||
|
||||
# P2P Networking Layer
|
||||
|
||||
> **Status**: ✅ Complete · **Priority**: High · **Created**: 2026-03-16 · **Tags**: p2p, core
|
||||
|
||||
## Overview
|
||||
|
||||
Build the P2P networking layer using PeerJS. This handles connection lifecycle, message passing, and data synchronization between peers. Every poll operates as a "room" where the owner's peer ID is the room identifier.
|
||||
|
||||
## Design
|
||||
|
||||
- **Connection model**: Star topology per poll—owner acts as relay hub; participants connect to owner
|
||||
- **Peer ID**: Derived from user's public key (deterministic, resumable)
|
||||
- **Messages**: JSON-based protocol over PeerJS data channels
|
||||
- **Reconnection**: Auto-reconnect with exponential backoff
|
||||
- **Sync**: On connect, owner sends full poll state snapshot; subsequent changes are incremental messages
|
||||
|
||||
### Message Protocol
|
||||
|
||||
```typescript
|
||||
type Message =
|
||||
| { type: 'poll:state'; payload: Poll }
|
||||
| { type: 'poll:vote'; payload: Vote }
|
||||
| { type: 'poll:option:add'; payload: Option }
|
||||
| { type: 'poll:role:update'; payload: RoleUpdate }
|
||||
| { type: 'user:profile'; payload: UserProfile }
|
||||
| { type: 'peer:discovery'; payload: PeerInfo[] }
|
||||
| { type: 'ack'; payload: { commandId: string; revision: number } }
|
||||
| { type: 'error'; payload: { commandId: string; message: string } }
|
||||
| { type: 'sync:request'; payload: { pollId: string } }
|
||||
```
|
||||
|
||||
### Offline Behavior
|
||||
|
||||
When the poll owner is offline, the poll is unreachable. Participants can view their local cached copy but cannot submit new votes until the owner reconnects.
|
||||
|
||||
### Outbox & Acknowledgment
|
||||
|
||||
- Every mutation message includes a `commandId` (UUID) for deduplication
|
||||
- Sender persists the command in a local IndexedDB outbox before sending
|
||||
- Owner responds with `ack { commandId, revision }` on success or `error { commandId, message }` on failure
|
||||
- On reconnect, client resends any unacked commands from the outbox
|
||||
- Owner deduplicates by `commandId`
|
||||
- UI shows "Pending sync" indicator for unacked mutations
|
||||
|
||||
## Plan
|
||||
|
||||
- [ ] Create PeerJS service (singleton, manages connection lifecycle)
|
||||
- [ ] Implement message send/receive with typed protocol
|
||||
- [ ] Add connection state management (connecting, connected, disconnected)
|
||||
- [ ] Implement auto-reconnect with backoff
|
||||
- [ ] Add "room" concept—join a poll by connecting to owner's peer ID
|
||||
- [ ] Handle peer disconnect/cleanup
|
||||
- [ ] Implement outbox (IndexedDB-backed pending command queue)
|
||||
- [ ] Implement ack/error response handling
|
||||
- [ ] Implement resend-on-reconnect for unacked commands
|
||||
|
||||
## Test
|
||||
|
||||
- [ ] Two browser tabs can establish a PeerJS connection
|
||||
- [ ] Messages round-trip correctly
|
||||
- [ ] Reconnection works after simulated disconnect
|
||||
- [ ] Unacked commands are resent after reconnect
|
||||
- [ ] Owner deduplicates commands by commandId
|
||||
|
||||
## Notes
|
||||
|
||||
- Star topology keeps it simple—owner must be online for live interaction
|
||||
- Could explore gossip/mesh topology later for resilience, but adds complexity
|
||||
70
specs/003-user-identity-profiles/README.md
Normal file
70
specs/003-user-identity-profiles/README.md
Normal file
@@ -0,0 +1,70 @@
|
||||
---
|
||||
status: complete
|
||||
created: '2026-03-16'
|
||||
tags:
|
||||
- identity
|
||||
- profiles
|
||||
priority: high
|
||||
created_at: '2026-03-16T07:51:48.340Z'
|
||||
depends_on:
|
||||
- 001-project-setup
|
||||
updated_at: '2026-03-16T10:01:26.785Z'
|
||||
completed_at: '2026-03-16T10:01:26.785Z'
|
||||
completed: '2026-03-16'
|
||||
transitions:
|
||||
- status: complete
|
||||
at: '2026-03-16T10:01:26.785Z'
|
||||
---
|
||||
|
||||
# User Identity & Profiles
|
||||
|
||||
> **Status**: ✅ Complete · **Priority**: High · **Created**: 2026-03-16 · **Tags**: identity, profiles
|
||||
|
||||
## Overview
|
||||
|
||||
Users need a persistent identity without a centralized account system. Each user generates a cryptographic keypair locally. Their public key serves as their unique ID. Users can edit their profile (name, bio, tags).
|
||||
|
||||
## Design
|
||||
|
||||
- **Identity**: Ed25519 keypair generated via Web Crypto API, stored in IndexedDB
|
||||
- **User ID**: Base58-encoded public key (short, URL-safe)
|
||||
- **Profile fields**: `name` (display name), `bio` (short text), `tags` (array of categorized tags)
|
||||
- **Tags structure**: `{ category: string, value: string }` — e.g. `{ category: "location", value: "Berlin" }`, `{ category: "expertise", value: "UX Design" }`
|
||||
- **Tag categories**: location, interest, expertise (extensible)
|
||||
- **Signing**: Profile updates are signed with private key (used for server writes; P2P messages use connection identity)
|
||||
- **Storage**: Profile stored locally in IndexedDB; shared with peers on connect
|
||||
- **Discovery**: Deferred. Tags are stored locally and exchanged with peers, but there is no directory server for searching users by tag yet. When added, profiles will already have the right structure (see archived spec 007)
|
||||
|
||||
### Profile Schema
|
||||
|
||||
```typescript
|
||||
interface UserProfile {
|
||||
id: string; // base58(publicKey)
|
||||
name: string;
|
||||
bio: string;
|
||||
tags: Tag[];
|
||||
updatedAt: number; // timestamp
|
||||
signature: string; // signed(hash(profile), privateKey)
|
||||
}
|
||||
|
||||
interface Tag {
|
||||
category: 'location' | 'interest' | 'expertise' | string;
|
||||
value: string;
|
||||
}
|
||||
```
|
||||
|
||||
## Plan
|
||||
|
||||
- [ ] Implement keypair generation + storage in IndexedDB
|
||||
- [ ] Create profile store (Svelte store backed by IndexedDB)
|
||||
- [ ] Build profile edit page (name, bio, tag management)
|
||||
- [ ] Implement tag CRUD with category selector
|
||||
- [ ] Add profile signing/verification utilities
|
||||
- [ ] Profile exchange on peer connect
|
||||
|
||||
## Test
|
||||
|
||||
- [ ] Keypair persists across page reloads
|
||||
- [ ] Profile updates are saved and signed
|
||||
- [ ] Tags can be added/removed by category
|
||||
- [ ] Profile signature verification works
|
||||
125
specs/004-poll-data-model/README.md
Normal file
125
specs/004-poll-data-model/README.md
Normal file
@@ -0,0 +1,125 @@
|
||||
---
|
||||
status: complete
|
||||
created: '2026-03-16'
|
||||
tags:
|
||||
- data
|
||||
- core
|
||||
priority: high
|
||||
created_at: '2026-03-16T07:51:48.793Z'
|
||||
depends_on:
|
||||
- 002-p2p-networking
|
||||
- 003-user-identity-profiles
|
||||
updated_at: '2026-03-16T10:01:27.181Z'
|
||||
completed_at: '2026-03-16T10:01:27.181Z'
|
||||
completed: '2026-03-16'
|
||||
transitions:
|
||||
- status: complete
|
||||
at: '2026-03-16T10:01:27.181Z'
|
||||
---
|
||||
|
||||
# Poll Data Model & Sync
|
||||
|
||||
> **Status**: ✅ Complete · **Priority**: High · **Created**: 2026-03-16 · **Tags**: data, core
|
||||
|
||||
## Overview
|
||||
|
||||
Define the core data structures for polls, options, votes, and their sync behavior across peers. Polls are owned by the creating user and stored on their device (and cached by participants).
|
||||
|
||||
## Design
|
||||
|
||||
### Poll Schema
|
||||
|
||||
```typescript
|
||||
interface Poll {
|
||||
id: string; // uuid
|
||||
ownerId: string; // creator's public key
|
||||
title: string;
|
||||
description: string;
|
||||
anonymous: boolean; // set at creation, immutable
|
||||
status: 'draft' | 'open' | 'closed';
|
||||
visibility: 'private' | 'link' | 'public'; // who can view via URL
|
||||
createdAt: number;
|
||||
closedAt?: number;
|
||||
options: Option[];
|
||||
votes: Vote[];
|
||||
roles: RoleAssignment[];
|
||||
}
|
||||
|
||||
interface Option {
|
||||
id: string;
|
||||
text: string;
|
||||
addedBy: string; // user ID
|
||||
addedAt: number;
|
||||
}
|
||||
|
||||
interface Vote {
|
||||
optionId: string;
|
||||
voterId: string | null; // null if poll.anonymous === true
|
||||
timestamp: number;
|
||||
signature: string; // proves vote authenticity
|
||||
}
|
||||
|
||||
interface RoleAssignment {
|
||||
userId: string;
|
||||
role: 'viewer' | 'participant' | 'moderator';
|
||||
}
|
||||
```
|
||||
|
||||
### Anonymity
|
||||
|
||||
- When `anonymous: true`, votes store `voterId: null`. The owner's device may transiently see the sender's peer ID during live submission, but the identity is **not persisted**. Anonymity means: hidden from other participants and public snapshots. It is not cryptographic anonymity from the poll owner.
|
||||
- When `anonymous: false`, `voterId` is the voter's public key
|
||||
- This flag is set at poll creation and **cannot be changed** after any votes are cast
|
||||
|
||||
### Visibility
|
||||
|
||||
- `private`: Only users with assigned roles can access. Poll link requires role assignment.
|
||||
- `link`: Anyone with the poll link can view as a viewer. No directory listing.
|
||||
- `public`: Poll snapshot is published to the server. Discoverable via direct link (no directory listing yet — can be added with future discovery feature).
|
||||
|
||||
### Roles & Permissions
|
||||
|
||||
| Action | Viewer | Participant | Moderator | Owner |
|
||||
|---|---|---|---|---|
|
||||
| View poll & results | ✅ | ✅ | ✅ | ✅ |
|
||||
| Add options | ❌ | ✅ | ✅ | ✅ |
|
||||
| Vote | ❌ | ✅ | ✅ | ✅ |
|
||||
| Add/remove users | ❌ | ❌ | ✅ | ✅ |
|
||||
| Start/stop poll | ❌ | ❌ | ✅ | ✅ |
|
||||
| Delete poll | ❌ | ❌ | ❌ | ✅ |
|
||||
|
||||
- Owner is implicit (`poll.ownerId === userId`); not stored in `roles[]`
|
||||
- `RoleAssignment` entries in `poll.roles[]` grant viewer, participant, or moderator access
|
||||
- Users without a role assignment who connect via link get `viewer` by default
|
||||
- Permission checks happen both client-side (UI gating) and owner-side on message receipt (owner validates before applying any mutation)
|
||||
- Role changes are broadcast to all connected peers
|
||||
|
||||
### Sync Strategy
|
||||
|
||||
- Owner is the source of truth
|
||||
- On connect: owner sends full poll snapshot
|
||||
- Changes (new vote, new option, role change) are sent as incremental messages
|
||||
- Participants cache poll locally for offline viewing
|
||||
|
||||
## Plan
|
||||
|
||||
- [ ] Define TypeScript interfaces for Poll, Option, Vote, RoleAssignment
|
||||
- [ ] Create poll store (Svelte store + IndexedDB)
|
||||
- [ ] Implement poll CRUD operations locally
|
||||
- [ ] Implement sync: snapshot on connect, incremental updates
|
||||
- [ ] Enforce anonymity invariant (voterId null when anonymous)
|
||||
- [ ] Create permission check utility (`canVote()`, `canAddOption()`, `canModerate()`, etc.)
|
||||
- [ ] Implement owner-side validation of incoming messages against roles
|
||||
- [ ] Implement role change broadcast over PeerJS
|
||||
|
||||
## Test
|
||||
|
||||
- [ ] Poll can be created, read, updated locally
|
||||
- [ ] Anonymous polls never store voter identity
|
||||
- [ ] Poll state syncs correctly between two peers
|
||||
- [ ] Incremental updates apply correctly to cached state
|
||||
- [ ] Viewer cannot vote or add options
|
||||
- [ ] Participant can vote and add options
|
||||
- [ ] Moderator can add/remove users and start/stop poll
|
||||
- [ ] Only owner can delete
|
||||
- [ ] Unknown users connecting via link get viewer role
|
||||
68
specs/005-poll-creation-management/README.md
Normal file
68
specs/005-poll-creation-management/README.md
Normal file
@@ -0,0 +1,68 @@
|
||||
---
|
||||
status: complete
|
||||
created: '2026-03-16'
|
||||
tags:
|
||||
- polls
|
||||
- management
|
||||
priority: high
|
||||
created_at: '2026-03-16T07:51:49.209Z'
|
||||
depends_on:
|
||||
- 004-poll-data-model
|
||||
updated_at: '2026-03-16T10:01:27.560Z'
|
||||
related:
|
||||
- 011-mobile-first-ui
|
||||
completed_at: '2026-03-16T10:01:27.560Z'
|
||||
completed: '2026-03-16'
|
||||
transitions:
|
||||
- status: complete
|
||||
at: '2026-03-16T10:01:27.560Z'
|
||||
---
|
||||
|
||||
# Poll Creation & Management
|
||||
|
||||
> **Status**: ✅ Complete · **Priority**: High · **Created**: 2026-03-16 · **Tags**: polls, management
|
||||
|
||||
## Overview
|
||||
|
||||
UI and logic for creating, configuring, and managing polls. The poll creator (owner) can set title, description, anonymity mode, add initial options, manage the poll lifecycle (draft → open → closed), and delete the poll.
|
||||
|
||||
## Design
|
||||
|
||||
### Poll Creation Flow
|
||||
|
||||
1. User taps "Create Poll"
|
||||
2. Fills in: title, description, anonymous (toggle), initial options (optional)
|
||||
3. Poll is created in `draft` status (only owner can see it)
|
||||
4. Owner shares poll link → users who join get `viewer` role; owner promotes to participant/moderator
|
||||
5. Owner (or moderator) starts the poll → status becomes `open`
|
||||
6. Owner (or moderator) stops the poll → status becomes `closed`
|
||||
|
||||
### Owner Capabilities
|
||||
|
||||
- **Delete poll**: Only the owner can permanently delete
|
||||
- **Start/stop**: Owner and moderators can transition `draft→open→closed`
|
||||
- **Re-open**: Owner can move `closed→open` (but not change anonymity)
|
||||
|
||||
### Pages
|
||||
|
||||
- `/app/create` — poll creation form
|
||||
- `/app/poll/[id]` — poll view/management (adapts based on role)
|
||||
- `/app/polls` — list of user's polls (owned + participating)
|
||||
|
||||
## Plan
|
||||
|
||||
- [ ] Build poll creation form (title, description, anonymous toggle, initial options)
|
||||
- [ ] Implement poll lifecycle state machine (draft → open → closed)
|
||||
- [ ] Build poll list page (my polls, polls I participate in)
|
||||
- [ ] Build poll detail/management page
|
||||
- [ ] Add delete poll functionality (owner only)
|
||||
- [ ] Wire up to PeerJS—poll becomes "live" when owner opens it
|
||||
- [ ] Build user/role management UI in poll detail page
|
||||
- [ ] Implement invite flow (shareable link — users join as viewer, owner promotes)
|
||||
|
||||
## Test
|
||||
|
||||
- [ ] Poll can be created with all fields
|
||||
- [ ] Anonymity toggle locks after first vote
|
||||
- [ ] Poll lifecycle transitions work correctly
|
||||
- [ ] Owner can delete poll; others cannot
|
||||
79
specs/008-voting-system/README.md
Normal file
79
specs/008-voting-system/README.md
Normal file
@@ -0,0 +1,79 @@
|
||||
---
|
||||
status: complete
|
||||
created: '2026-03-16'
|
||||
tags:
|
||||
- voting
|
||||
- core
|
||||
priority: high
|
||||
created_at: '2026-03-16T07:51:50.525Z'
|
||||
depends_on:
|
||||
- 005-poll-creation-management
|
||||
updated_at: '2026-03-16T10:01:27.962Z'
|
||||
related:
|
||||
- 011-mobile-first-ui
|
||||
completed_at: '2026-03-16T10:01:27.962Z'
|
||||
completed: '2026-03-16'
|
||||
transitions:
|
||||
- status: complete
|
||||
at: '2026-03-16T10:01:27.962Z'
|
||||
---
|
||||
|
||||
# Voting System & Anonymity
|
||||
|
||||
> **Status**: ✅ Complete · **Priority**: High · **Created**: 2026-03-16 · **Tags**: voting, core
|
||||
|
||||
## Overview
|
||||
|
||||
Core voting functionality: participants can add options to a poll and cast votes. Supports both anonymous and non-anonymous modes as configured at poll creation. Votes are sent to the poll owner via PeerJS.
|
||||
|
||||
## Design
|
||||
|
||||
### Adding Options
|
||||
|
||||
- Participants and above can add options while the poll is `open`
|
||||
- Options have text only (keep it simple)
|
||||
- Owner validates the sender has permission before accepting
|
||||
|
||||
### Casting Votes
|
||||
|
||||
- V1: single-choice (one vote per participant). Data model uses `optionId: string` which can later be extended to `optionIds: string[]` for multi-choice or a ranked array for ranked-choice.
|
||||
- Vote is signed with voter's private key for authenticity
|
||||
- In anonymous mode: owner records the vote but strips `voterId` before storing
|
||||
- In non-anonymous mode: `voterId` is stored alongside the vote
|
||||
- Vote changes: a participant can change their vote while the poll is open (replaces previous)
|
||||
|
||||
### Results
|
||||
|
||||
- Results are visible to all roles (viewers included)
|
||||
- Show: option text, vote count, percentage
|
||||
- Non-anonymous: also show who voted for what
|
||||
- Results update in real-time via PeerJS messages
|
||||
|
||||
### Vote Flow
|
||||
|
||||
```
|
||||
Participant Owner (relay)
|
||||
│ │
|
||||
│─── poll:vote ───────────────▶│ validate permission
|
||||
│ │ if anonymous: strip voterId
|
||||
│ │ store vote
|
||||
│◀── poll:state:update ───────│ broadcast updated results
|
||||
```
|
||||
|
||||
## Plan
|
||||
|
||||
- [ ] Build "add option" UI and message handler
|
||||
- [ ] Build voting UI (option list with vote button)
|
||||
- [ ] Implement vote submission (sign + send to owner)
|
||||
- [ ] Owner-side vote processing (validate, anonymize if needed, store)
|
||||
- [ ] Build results display (bar chart or simple percentage view)
|
||||
- [ ] Implement vote change (replace previous vote)
|
||||
|
||||
## Test
|
||||
|
||||
- [ ] Participant can add an option
|
||||
- [ ] Participant can cast a vote
|
||||
- [ ] Anonymous vote does not leak voter identity
|
||||
- [ ] Results update in real-time across connected peers
|
||||
- [ ] Vote change replaces (not duplicates) previous vote
|
||||
- [ ] Viewers can see results but not vote
|
||||
70
specs/009-public-sharing/README.md
Normal file
70
specs/009-public-sharing/README.md
Normal file
@@ -0,0 +1,70 @@
|
||||
---
|
||||
status: complete
|
||||
created: '2026-03-16'
|
||||
tags:
|
||||
- sharing
|
||||
- web
|
||||
priority: medium
|
||||
created_at: '2026-03-16T07:51:51.015Z'
|
||||
depends_on:
|
||||
- 005-poll-creation-management
|
||||
- 008-voting-system
|
||||
- 012-lightweight-server
|
||||
updated_at: '2026-03-16T10:01:28.366Z'
|
||||
related:
|
||||
- 011-mobile-first-ui
|
||||
completed_at: '2026-03-16T10:01:28.366Z'
|
||||
completed: '2026-03-16'
|
||||
transitions:
|
||||
- status: complete
|
||||
at: '2026-03-16T10:01:28.366Z'
|
||||
---
|
||||
|
||||
# Public Sharing & Read-Only View
|
||||
|
||||
> **Status**: ✅ Complete · **Priority**: Medium · **Created**: 2026-03-16 · **Tags**: sharing, web
|
||||
|
||||
## Overview
|
||||
|
||||
Make polls shareable via URL to anyone on the web—even non-users. A public link shows poll results in a read-only view. If the poll owner is online, results update live via WebRTC. If offline, the page shows a cached snapshot.
|
||||
|
||||
## Design
|
||||
|
||||
### Shareable URL
|
||||
|
||||
- Format: `https://evocracy.app/p/[pollId]`
|
||||
- Anyone with the link can view (no identity required)
|
||||
- The page fetches the snapshot from the server (includes `ownerPeerId`), then attempts a PeerJS connection for live data
|
||||
- Falls back to a static snapshot if owner is offline
|
||||
|
||||
### Snapshot Strategy
|
||||
|
||||
- When a poll is shared publicly, the owner's client pushes a JSON snapshot to the lightweight server (same server as spec 007 directory)
|
||||
- Endpoint: `PUT /api/polls/:id/snapshot` (authenticated by owner's signature)
|
||||
- Public fetch: `GET /api/polls/:id/snapshot` (no auth required)
|
||||
- Snapshot is updated whenever poll state changes while owner is online
|
||||
- Snapshot includes: title, description, options, vote counts, status (no voter identities even for non-anonymous)
|
||||
|
||||
### Public View Page
|
||||
|
||||
- Read-only: title, description, options with vote counts/percentages
|
||||
- Visual bar chart of results
|
||||
- "Owner offline" indicator if can't connect
|
||||
- Open Graph meta tags for social media previews
|
||||
- Optional: "Join to vote" CTA linking to app
|
||||
|
||||
## Plan
|
||||
|
||||
- [ ] Create `/p/[id]` public route (SvelteKit SSR or client-side)
|
||||
- [ ] Implement PeerJS connection attempt for live data
|
||||
- [ ] Build read-only results view
|
||||
- [ ] Add Open Graph meta tags for link previews
|
||||
- [ ] Implement snapshot fallback (based on decision above)
|
||||
- [ ] Add "Share" button to poll management page (copy link)
|
||||
|
||||
## Test
|
||||
|
||||
- [ ] Public URL shows poll results without authentication
|
||||
- [ ] Live updates work when owner is online
|
||||
- [ ] Graceful fallback when owner is offline
|
||||
- [ ] Social media link preview shows poll title/description
|
||||
76
specs/010-embeddable-widget/README.md
Normal file
76
specs/010-embeddable-widget/README.md
Normal file
@@ -0,0 +1,76 @@
|
||||
---
|
||||
status: complete
|
||||
created: '2026-03-16'
|
||||
tags:
|
||||
- embed
|
||||
- widget
|
||||
priority: low
|
||||
created_at: '2026-03-16T07:51:51.430Z'
|
||||
depends_on:
|
||||
- 009-public-sharing
|
||||
updated_at: '2026-03-16T10:01:28.760Z'
|
||||
completed_at: '2026-03-16T10:01:28.760Z'
|
||||
completed: '2026-03-16'
|
||||
transitions:
|
||||
- status: complete
|
||||
at: '2026-03-16T10:01:28.760Z'
|
||||
---
|
||||
|
||||
# Embeddable Poll Widget
|
||||
|
||||
> **Status**: ✅ Complete · **Priority**: Low · **Created**: 2026-03-16 · **Tags**: embed, widget
|
||||
|
||||
## Overview
|
||||
|
||||
Allow polls to be embedded on external websites and potentially in messaging apps. This extends the public sharing with an inline experience.
|
||||
|
||||
## Design
|
||||
|
||||
### Embed Options
|
||||
|
||||
1. **iframe embed** (simplest, broadest support)
|
||||
- `<iframe src="https://evocracy.app/embed/[pollId]" width="400" height="300"></iframe>`
|
||||
- Renders a compact read-only results view
|
||||
- Self-contained, works anywhere iframes are supported
|
||||
- Uses `postMessage` to communicate height for responsive sizing
|
||||
|
||||
2. **oEmbed protocol** (for platforms that support it—Notion, WordPress, Medium, etc.)
|
||||
- Endpoint: `https://evocracy.app/oembed?url=https://evocracy.app/poll/[pollId]`
|
||||
- Returns iframe-based rich embed
|
||||
|
||||
3. **Web Component** (deferred to v2)
|
||||
- `<script src="https://evocracy.app/widget.js"></script>`
|
||||
- `<evocracy-poll poll-id="[pollId]"></evocracy-poll>`
|
||||
- Shadow DOM for style isolation
|
||||
- Deferred: iframe covers 90% of use cases
|
||||
|
||||
### Messenger Compatibility
|
||||
|
||||
- iMessage / WhatsApp / Telegram: Open Graph link previews (handled by spec 009)
|
||||
- Slack / Discord: oEmbed + Open Graph unfurling
|
||||
- Actual inline voting in messengers is not feasible without platform-specific bots
|
||||
|
||||
### Embed Route
|
||||
|
||||
- `/embed/[id]` — minimal chrome, compact layout, no navigation
|
||||
- Auto-resizes via `postMessage` to parent frame
|
||||
|
||||
## Plan
|
||||
|
||||
- [ ] Create `/embed/[id]` route with compact poll results view
|
||||
- [ ] Implement iframe auto-resize via postMessage
|
||||
- [ ] Add "Get embed code" UI to poll management page
|
||||
- [ ] Implement oEmbed endpoint (`/oembed`)
|
||||
- [ ] ~Build web component wrapper (`widget.js`)~ (deferred to v2)
|
||||
|
||||
## Test
|
||||
|
||||
- [ ] iframe embed renders correctly on a test HTML page
|
||||
- [ ] Auto-resize adjusts to content height
|
||||
- [ ] oEmbed endpoint returns valid JSON
|
||||
- [ ] Web component renders in isolation (Shadow DOM)
|
||||
|
||||
## Notes
|
||||
|
||||
- **DECIDED**: Web component deferred to v2—iframe alone covers 90% of use cases
|
||||
- Messenger "embeds" are really just link previews, not interactive widgets
|
||||
86
specs/012-lightweight-server/README.md
Normal file
86
specs/012-lightweight-server/README.md
Normal file
@@ -0,0 +1,86 @@
|
||||
---
|
||||
status: complete
|
||||
created: '2026-03-16'
|
||||
tags:
|
||||
- server
|
||||
- infra
|
||||
priority: high
|
||||
created_at: '2026-03-16T07:57:36.544Z'
|
||||
depends_on:
|
||||
- 001-project-setup
|
||||
updated_at: '2026-03-16T10:01:29.167Z'
|
||||
completed_at: '2026-03-16T10:01:29.167Z'
|
||||
completed: '2026-03-16'
|
||||
transitions:
|
||||
- status: complete
|
||||
at: '2026-03-16T10:01:29.167Z'
|
||||
---
|
||||
|
||||
# Poll Snapshot Server
|
||||
|
||||
> **Status**: ✅ Complete · **Priority**: High · **Created**: 2026-03-16 · **Tags**: server, infra
|
||||
|
||||
## Overview
|
||||
|
||||
A minimal server that stores poll snapshots for public/offline viewing. This is the only server-side component — intentionally thin. No user accounts, no directory, no auth beyond signature verification.
|
||||
|
||||
## Design
|
||||
|
||||
### Tech Stack
|
||||
|
||||
- **Runtime**: Cloudflare Workers (or a simple Bun server if self-hosting)
|
||||
- **Storage**: Cloudflare KV (or SQLite for self-hosted)
|
||||
- **Auth**: Snapshot writes are signed with the poll owner's Ed25519 private key
|
||||
|
||||
### API Endpoints
|
||||
|
||||
- `PUT /api/polls/:id/snapshot` — Store/update snapshot (signed by poll owner)
|
||||
- Body: `{ pollId, ownerId, ownerPeerId, title, description, options[], voteCounts, status, signature }`
|
||||
- `GET /api/polls/:id/snapshot` — Fetch snapshot (public, no auth)
|
||||
|
||||
### Security
|
||||
|
||||
- Write operations require a valid Ed25519 signature
|
||||
- Verify signature against `ownerId` in the request body; bind `pollId → ownerId` on first write; reject future writes if `ownerId` changes
|
||||
- No sessions, no cookies, no passwords
|
||||
- Rate limiting on writes to prevent abuse
|
||||
|
||||
### Data Model
|
||||
|
||||
```typescript
|
||||
// KV key: poll:{pollId}:snapshot → PollSnapshot
|
||||
|
||||
interface PollSnapshot {
|
||||
pollId: string;
|
||||
ownerId: string;
|
||||
ownerPeerId: string;
|
||||
title: string;
|
||||
description: string;
|
||||
options: { id: string; text: string }[];
|
||||
voteCounts: Record<string, number>;
|
||||
status: 'draft' | 'open' | 'closed';
|
||||
updatedAt: number;
|
||||
}
|
||||
```
|
||||
|
||||
## Plan
|
||||
|
||||
- [ ] Set up Cloudflare Workers project (or simple Bun server)
|
||||
- [ ] Implement Ed25519 signature verification middleware
|
||||
- [ ] Implement poll snapshot store/fetch endpoints
|
||||
- [ ] Add rate limiting
|
||||
- [ ] Deploy
|
||||
|
||||
## Test
|
||||
|
||||
- [ ] Snapshot store with valid signature succeeds
|
||||
- [ ] Snapshot store with invalid signature is rejected
|
||||
- [ ] Snapshot fetch returns stored data (public, no auth)
|
||||
- [ ] Second write from different ownerId is rejected
|
||||
- [ ] Rate limiting works
|
||||
|
||||
## Notes
|
||||
|
||||
- The P2P app works without this server — it just loses public sharing and offline snapshot viewing
|
||||
- Future: user directory endpoints can be added here when peer discovery is implemented (see archived spec 007)
|
||||
- Consider a TTL on snapshots (e.g., auto-expire 90 days after last update)
|
||||
67
specs/archived/006-role-permission-system/README.md
Normal file
67
specs/archived/006-role-permission-system/README.md
Normal file
@@ -0,0 +1,67 @@
|
||||
---
|
||||
status: archived
|
||||
created: '2026-03-16'
|
||||
tags:
|
||||
- auth
|
||||
- roles
|
||||
priority: high
|
||||
created_at: '2026-03-16T07:51:49.636Z'
|
||||
depends_on:
|
||||
- 004-poll-data-model
|
||||
updated_at: '2026-03-16T09:18:35.900Z'
|
||||
transitions:
|
||||
- status: archived
|
||||
at: '2026-03-16T09:18:35.900Z'
|
||||
---
|
||||
|
||||
# Role & Permission System
|
||||
|
||||
> **Status**: 📦 Archived · **Priority**: High · **Created**: 2026-03-16 · **Tags**: auth, roles
|
||||
|
||||
## Overview
|
||||
|
||||
Each poll has a role-based permission system. The owner assigns roles to users they discover or invite. Roles control what actions a user can perform on a poll.
|
||||
|
||||
## Design
|
||||
|
||||
### Roles & Permissions
|
||||
|
||||
| Action | Viewer | Participant | Moderator | Owner |
|
||||
|---|---|---|---|---|
|
||||
| View poll & results | ✅ | ✅ | ✅ | ✅ |
|
||||
| Add options | ❌ | ✅ | ✅ | ✅ |
|
||||
| Vote | ❌ | ✅ | ✅ | ✅ |
|
||||
| Add/remove users | ❌ | ❌ | ✅ | ✅ |
|
||||
| Start/stop poll | ❌ | ❌ | ✅ | ✅ |
|
||||
| Delete poll | ❌ | ❌ | ❌ | ✅ |
|
||||
|
||||
### Implementation
|
||||
|
||||
- Owner is implicit (poll.ownerId === userId)
|
||||
- Roles stored in `poll.roles[]` array
|
||||
- Role changes are broadcast to all connected peers
|
||||
- Moderators can invite users by peer ID (discovered via spec 007) or by sharing a poll link
|
||||
- Permission checks happen both client-side (UI) and on message receipt (owner validates)
|
||||
|
||||
### Invite Flow
|
||||
|
||||
1. Owner/moderator discovers a user (see spec 007) or has their peer ID
|
||||
2. Assigns them a role → updates `poll.roles[]`
|
||||
3. When that user connects, they receive the poll state including their role
|
||||
4. Users without a role who connect via link get `viewer` by default
|
||||
|
||||
## Plan
|
||||
|
||||
- [ ] Implement role assignment data model
|
||||
- [ ] Create permission check utility (`canVote()`, `canModerate()`, etc.)
|
||||
- [ ] Build user management UI in poll detail page
|
||||
- [ ] Implement role change broadcast over PeerJS
|
||||
- [ ] Owner-side validation of incoming messages against roles
|
||||
|
||||
## Test
|
||||
|
||||
- [ ] Viewer cannot vote or add options
|
||||
- [ ] Participant can vote and add options
|
||||
- [ ] Moderator can add/remove users and start/stop poll
|
||||
- [ ] Only owner can delete
|
||||
- [ ] Unknown users connecting via link get viewer role
|
||||
65
specs/archived/007-peer-discovery/README.md
Normal file
65
specs/archived/007-peer-discovery/README.md
Normal file
@@ -0,0 +1,65 @@
|
||||
---
|
||||
status: archived
|
||||
created: '2026-03-16'
|
||||
tags:
|
||||
- p2p
|
||||
- discovery
|
||||
priority: medium
|
||||
created_at: '2026-03-16T07:51:50.076Z'
|
||||
depends_on:
|
||||
- 002-p2p-networking
|
||||
- 003-user-identity-profiles
|
||||
- 012-lightweight-server
|
||||
updated_at: '2026-03-16T09:31:19.812Z'
|
||||
transitions:
|
||||
- status: archived
|
||||
at: '2026-03-16T09:31:19.812Z'
|
||||
---
|
||||
|
||||
# Peer Discovery by Tags
|
||||
|
||||
> **Status**: 📦 Archived · **Priority**: Medium · **Created**: 2026-03-16 · **Tags**: p2p, discovery
|
||||
|
||||
## Overview
|
||||
|
||||
Enable poll owners to discover other users to invite, based on user tags (location, interests, expertise). This is P2P—there's no central user directory. Discovery works by asking connected peers for introductions.
|
||||
|
||||
## Design
|
||||
|
||||
### Discovery Mechanism
|
||||
|
||||
1. **Direct invite**: Share a poll link or paste a peer ID manually
|
||||
2. **Directory server**: Lightweight server where users can register their profile for discoverability
|
||||
- Users opt-in to being listed (toggle in profile)
|
||||
- Server stores: peer ID, name, tags, `discoverable` flag
|
||||
- Query endpoint: `GET /api/users?tag=location:Berlin&tag=expertise:UX`
|
||||
- Minimal server—just a thin REST API over a key-value store (e.g., Cloudflare Workers + KV, or a simple SQLite API)
|
||||
3. **Peer-chain discovery** (secondary): Ask connected peers "who do you know matching these tags?"
|
||||
- Supplements the directory for users who are connected but not listed
|
||||
|
||||
### Privacy Controls
|
||||
|
||||
- Users opt-in to being discoverable (setting in profile)
|
||||
- Directory only stores public profile data (name, tags)—no private keys, no bio
|
||||
- Users can remove themselves from directory at any time
|
||||
|
||||
## Plan
|
||||
|
||||
- [ ] Build lightweight directory server (REST API + KV store)
|
||||
- [ ] Add `discoverable` toggle to user profile
|
||||
- [ ] Implement directory registration (opt-in publish profile)
|
||||
- [ ] Implement directory search (query by tags)
|
||||
- [ ] Build discovery UI (search by tags, browse results)
|
||||
- [ ] Implement "invite from discovery" flow
|
||||
- [ ] Implement peer-chain discovery as fallback
|
||||
|
||||
## Test
|
||||
|
||||
- [ ] Discovery request returns matching peers from connected contacts
|
||||
- [ ] Non-discoverable users are not shared
|
||||
- [ ] Discovered peer can be invited to a poll
|
||||
|
||||
## Notes
|
||||
|
||||
- **DECIDED**: Adding a lightweight directory server for discoverability
|
||||
- Directory server can be reused for poll snapshot storage (see spec 009)
|
||||
74
specs/archived/011-mobile-first-ui/README.md
Normal file
74
specs/archived/011-mobile-first-ui/README.md
Normal file
@@ -0,0 +1,74 @@
|
||||
---
|
||||
status: archived
|
||||
created: '2026-03-16'
|
||||
tags:
|
||||
- ui
|
||||
- mobile
|
||||
priority: high
|
||||
created_at: '2026-03-16T07:51:51.869Z'
|
||||
related:
|
||||
- 005-poll-creation-management
|
||||
- 008-voting-system
|
||||
- 009-public-sharing
|
||||
updated_at: '2026-03-16T09:07:18.408Z'
|
||||
transitions:
|
||||
- status: archived
|
||||
at: '2026-03-16T09:07:18.408Z'
|
||||
---
|
||||
|
||||
# Mobile-First UI Design
|
||||
|
||||
> **Status**: 📦 Archived · **Priority**: High · **Created**: 2026-03-16 · **Tags**: ui, mobile
|
||||
|
||||
## Overview
|
||||
|
||||
Design all UI mobile-first. The primary use case is people voting on their phones. Desktop is a secondary concern—layouts should scale up, not be shoehorned down.
|
||||
|
||||
## Design
|
||||
|
||||
### Principles
|
||||
|
||||
- Touch targets ≥ 44px
|
||||
- Single-column layout on mobile; expand on larger screens
|
||||
- Bottom navigation bar (thumb-friendly)
|
||||
- Minimal chrome—content first
|
||||
- System font stack for performance
|
||||
|
||||
### Key Screens
|
||||
|
||||
1. **Home / Poll list**: Cards showing poll title, status, vote count
|
||||
2. **Create poll**: Simple form, large inputs, toggle for anonymity
|
||||
3. **Poll detail**: Results visualization, vote buttons, participant list
|
||||
4. **Profile**: Edit name, bio, tags
|
||||
5. **Poll management** (owner/mod): User list, role controls, start/stop
|
||||
|
||||
### Navigation
|
||||
|
||||
- Bottom tab bar: Home, Create (+), Profile
|
||||
- Poll detail accessed by tapping a poll card
|
||||
- Management accessed via gear icon within poll detail
|
||||
|
||||
### Styling
|
||||
|
||||
- Tailwind CSS with mobile breakpoints as default
|
||||
- Dark mode support (respects `prefers-color-scheme`)
|
||||
- CSS transitions for state changes (vote submitted, poll status change)
|
||||
|
||||
## Plan
|
||||
|
||||
- [ ] Design bottom tab navigation component
|
||||
- [ ] Build poll list (home) with card layout
|
||||
- [ ] Build poll creation form (mobile-optimized)
|
||||
- [ ] Build poll detail view with results visualization
|
||||
- [ ] Build profile edit page with tag management
|
||||
- [ ] Build poll management panel (users, roles, lifecycle)
|
||||
- [ ] Add dark mode toggle / system preference detection
|
||||
- [ ] Test on various mobile viewport sizes
|
||||
|
||||
## Test
|
||||
|
||||
- [ ] All touch targets meet 44px minimum
|
||||
- [ ] Layout works on 320px–428px width (small to large phones)
|
||||
- [ ] No horizontal scroll on any page
|
||||
- [ ] Dark mode renders correctly
|
||||
- [ ] Navigation is accessible via keyboard/screen reader
|
||||
@@ -1,63 +0,0 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { PreloadAllModules, RouterModule, Routes } from '@angular/router';
|
||||
|
||||
const routes: Routes = [
|
||||
// Default redirect
|
||||
{
|
||||
path: '',
|
||||
redirectTo: 'home',
|
||||
pathMatch: 'full',
|
||||
},
|
||||
// Home: list of all surveys created by this user
|
||||
{
|
||||
path: 'home',
|
||||
loadChildren: () => import('./home/home.module').then((m) => m.HomePageModule),
|
||||
},
|
||||
// Create a new survey
|
||||
{
|
||||
path: 'create-survey',
|
||||
loadChildren: () =>
|
||||
import('./pages/create-survey/create-survey.module').then(
|
||||
(m) => m.CreateSurveyPageModule
|
||||
),
|
||||
},
|
||||
// Edit an existing survey (id param)
|
||||
{
|
||||
path: 'create-survey/:id',
|
||||
loadChildren: () =>
|
||||
import('./pages/create-survey/create-survey.module').then(
|
||||
(m) => m.CreateSurveyPageModule
|
||||
),
|
||||
},
|
||||
// Survey detail: manage settings, generate links, start hosting
|
||||
{
|
||||
path: 'survey/:id',
|
||||
loadChildren: () =>
|
||||
import('./pages/survey-detail/survey-detail.module').then(
|
||||
(m) => m.SurveyDetailPageModule
|
||||
),
|
||||
},
|
||||
// Survey results: live aggregated view
|
||||
{
|
||||
path: 'survey/:id/results',
|
||||
loadChildren: () =>
|
||||
import('./pages/survey-results/survey-results.module').then(
|
||||
(m) => m.SurveyResultsPageModule
|
||||
),
|
||||
},
|
||||
// Participate: opened by participants via their unique link
|
||||
// Uses query params: ?host=survey-{id}&token={uuid}
|
||||
{
|
||||
path: 'participate',
|
||||
loadChildren: () =>
|
||||
import('./pages/participate/participate.module').then(
|
||||
(m) => m.ParticipatePageModule
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
imports: [RouterModule.forRoot(routes, { preloadingStrategy: PreloadAllModules })],
|
||||
exports: [RouterModule],
|
||||
})
|
||||
export class AppRoutingModule {}
|
||||
@@ -1,3 +0,0 @@
|
||||
<ion-app>
|
||||
<ion-router-outlet></ion-router-outlet>
|
||||
</ion-app>
|
||||
@@ -1,21 +0,0 @@
|
||||
import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
|
||||
import { AppComponent } from './app.component';
|
||||
|
||||
describe('AppComponent', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
declarations: [AppComponent],
|
||||
schemas: [CUSTOM_ELEMENTS_SCHEMA],
|
||||
}).compileComponents();
|
||||
});
|
||||
|
||||
it('should create the app', () => {
|
||||
const fixture = TestBed.createComponent(AppComponent);
|
||||
const app = fixture.componentInstance;
|
||||
expect(app).toBeTruthy();
|
||||
});
|
||||
|
||||
});
|
||||
@@ -1,11 +0,0 @@
|
||||
import { Component } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
templateUrl: 'app.component.html',
|
||||
styleUrls: ['app.component.scss'],
|
||||
standalone: false,
|
||||
})
|
||||
export class AppComponent {
|
||||
constructor() {}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { BrowserModule } from '@angular/platform-browser';
|
||||
import { RouteReuseStrategy } from '@angular/router';
|
||||
|
||||
import { IonicModule, IonicRouteStrategy } from '@ionic/angular';
|
||||
|
||||
import { AppComponent } from './app.component';
|
||||
import { AppRoutingModule } from './app-routing.module';
|
||||
|
||||
@NgModule({
|
||||
declarations: [AppComponent],
|
||||
imports: [BrowserModule, IonicModule.forRoot(), AppRoutingModule],
|
||||
providers: [{ provide: RouteReuseStrategy, useClass: IonicRouteStrategy }],
|
||||
bootstrap: [AppComponent],
|
||||
})
|
||||
export class AppModule {}
|
||||
@@ -1,46 +0,0 @@
|
||||
/**
|
||||
* database.ts
|
||||
* Dexie (IndexedDB) singleton for the P2P Survey App.
|
||||
*
|
||||
* All survey data — surveys, participant tokens, and responses — lives
|
||||
* exclusively in the creator's browser. Dexie wraps the raw IndexedDB API
|
||||
* with a clean, Promise-based interface and live-query support.
|
||||
*
|
||||
* Usage (inject via InjectionToken, see main.ts):
|
||||
* constructor(@Inject(DATABASE_TOKEN) private db: AppDatabase) {}
|
||||
*/
|
||||
|
||||
import Dexie, { type Table } from 'dexie';
|
||||
import type { Survey, Participant, Response } from '../shared/models/survey.models';
|
||||
|
||||
/** Typed Dexie database class */
|
||||
export class AppDatabase extends Dexie {
|
||||
/** All surveys created by this user */
|
||||
surveys!: Table<Survey, string>;
|
||||
|
||||
/**
|
||||
* Pre-generated participant tokens.
|
||||
* Primary key: token (UUID string).
|
||||
*/
|
||||
participants!: Table<Participant, string>;
|
||||
|
||||
/**
|
||||
* Submitted responses.
|
||||
* Primary key: id (UUID string).
|
||||
*/
|
||||
responses!: Table<Response, string>;
|
||||
|
||||
constructor() {
|
||||
super('P2PSurveyDB');
|
||||
|
||||
this.version(1).stores({
|
||||
// Indexed fields: primary key first, then fields used in queries
|
||||
surveys: 'id, status, createdAt',
|
||||
participants: 'token, surveyId, locked',
|
||||
responses: 'id, surveyId, participantToken, submittedAt',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Module-level singleton — import this wherever you need direct DB access */
|
||||
export const db = new AppDatabase();
|
||||
@@ -1,16 +0,0 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { RouterModule, Routes } from '@angular/router';
|
||||
import { HomePage } from './home.page';
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: HomePage,
|
||||
}
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
imports: [RouterModule.forChild(routes)],
|
||||
exports: [RouterModule]
|
||||
})
|
||||
export class HomePageRoutingModule {}
|
||||
@@ -1,13 +0,0 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { IonicModule } from '@ionic/angular';
|
||||
|
||||
import { HomePageRoutingModule } from './home-routing.module';
|
||||
import { HomePage } from './home.page';
|
||||
|
||||
@NgModule({
|
||||
imports: [CommonModule, FormsModule, IonicModule, HomePageRoutingModule],
|
||||
declarations: [HomePage],
|
||||
})
|
||||
export class HomePageModule {}
|
||||
@@ -1,54 +0,0 @@
|
||||
<ion-header>
|
||||
<ion-toolbar color="primary">
|
||||
<ion-title>My Surveys</ion-title>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content>
|
||||
<!-- Empty state -->
|
||||
<div *ngIf="surveys.length === 0" class="empty-state">
|
||||
<ion-icon name="clipboard-outline" size="large"></ion-icon>
|
||||
<h2>No Surveys Yet</h2>
|
||||
<p>Create your first survey to get started.</p>
|
||||
<ion-button (click)="createNewSurvey()" expand="block" class="ion-margin-top">
|
||||
<ion-icon slot="start" name="add-outline"></ion-icon>
|
||||
Create Survey
|
||||
</ion-button>
|
||||
</div>
|
||||
|
||||
<!-- Survey list -->
|
||||
<ion-list *ngIf="surveys.length > 0" lines="none" class="survey-list">
|
||||
<ion-item-sliding *ngFor="let survey of surveys">
|
||||
<ion-item button (click)="openSurvey(survey)" detail="true">
|
||||
<ion-label>
|
||||
<h2>{{ survey.title }}</h2>
|
||||
<p>
|
||||
{{ survey.questions.length }} question{{ survey.questions.length !== 1 ? 's' : '' }}
|
||||
•
|
||||
{{ responseCounts[survey.id] ?? 0 }} response{{ (responseCounts[survey.id] ?? 0) !== 1 ? 's' : '' }}
|
||||
</p>
|
||||
<p>
|
||||
<ion-badge [color]="statusColor(survey.status)">{{ survey.status }}</ion-badge>
|
||||
</p>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
|
||||
<!-- Swipe-to-reveal action buttons -->
|
||||
<ion-item-options side="end">
|
||||
<ion-item-option color="primary" (click)="editSurvey(survey, $event)">
|
||||
<ion-icon slot="icon-only" name="create-outline"></ion-icon>
|
||||
</ion-item-option>
|
||||
<ion-item-option color="danger" (click)="confirmDelete(survey, $event)">
|
||||
<ion-icon slot="icon-only" name="trash-outline"></ion-icon>
|
||||
</ion-item-option>
|
||||
</ion-item-options>
|
||||
</ion-item-sliding>
|
||||
</ion-list>
|
||||
|
||||
<!-- Floating action button -->
|
||||
<ion-fab vertical="bottom" horizontal="end" slot="fixed">
|
||||
<ion-fab-button (click)="createNewSurvey()" color="primary">
|
||||
<ion-icon name="add-outline"></ion-icon>
|
||||
</ion-fab-button>
|
||||
</ion-fab>
|
||||
</ion-content>
|
||||
@@ -1,34 +0,0 @@
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px 24px;
|
||||
text-align: center;
|
||||
|
||||
ion-icon {
|
||||
font-size: 64px;
|
||||
color: var(--ion-color-medium);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.4rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
p {
|
||||
color: var(--ion-color-medium);
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.survey-list {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
ion-item-sliding ion-item {
|
||||
--border-radius: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { IonicModule } from '@ionic/angular';
|
||||
|
||||
import { HomePage } from './home.page';
|
||||
|
||||
describe('HomePage', () => {
|
||||
let component: HomePage;
|
||||
let fixture: ComponentFixture<HomePage>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
declarations: [HomePage],
|
||||
imports: [IonicModule.forRoot()]
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(HomePage);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -1,124 +0,0 @@
|
||||
/**
|
||||
* home.page.ts
|
||||
* Home page — displays all surveys created by this user.
|
||||
* Surveys are loaded from IndexedDB via a live query so the list
|
||||
* updates automatically when surveys are added or deleted.
|
||||
*/
|
||||
|
||||
import { Component, OnInit, OnDestroy } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { AlertController, ToastController } from '@ionic/angular';
|
||||
import { Subscription } from 'rxjs';
|
||||
import { SurveyService } from '../services/survey.service';
|
||||
import { ResponseService } from '../services/response.service';
|
||||
import type { Survey } from '../shared/models/survey.models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-home',
|
||||
templateUrl: 'home.page.html',
|
||||
styleUrls: ['home.page.scss'],
|
||||
standalone: false,
|
||||
})
|
||||
export class HomePage implements OnInit, OnDestroy {
|
||||
surveys: Survey[] = [];
|
||||
/** Map from surveyId → response count (refreshed on each emission) */
|
||||
responseCounts: Record<string, number | undefined> = {};
|
||||
|
||||
private surveySubscription?: Subscription;
|
||||
|
||||
constructor(
|
||||
private router: Router,
|
||||
private surveyService: SurveyService,
|
||||
private responseService: ResponseService,
|
||||
private alertCtrl: AlertController,
|
||||
private toastCtrl: ToastController
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
// Subscribe to the live query — list updates automatically
|
||||
this.surveySubscription = this.surveyService
|
||||
.getAllSurveys$()
|
||||
.subscribe(async (surveys) => {
|
||||
this.surveys = surveys;
|
||||
// Refresh response counts whenever the survey list changes
|
||||
await this.loadResponseCounts(surveys);
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.surveySubscription?.unsubscribe();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Navigation
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/** Navigate to the create-survey page */
|
||||
createNewSurvey(): void {
|
||||
this.router.navigate(['/create-survey']);
|
||||
}
|
||||
|
||||
/** Navigate to the detail page for a survey */
|
||||
openSurvey(survey: Survey): void {
|
||||
this.router.navigate(['/survey', survey.id]);
|
||||
}
|
||||
|
||||
/** Navigate to the edit page for a survey */
|
||||
editSurvey(survey: Survey, event: Event): void {
|
||||
event.stopPropagation(); // Prevent the card click from also firing
|
||||
this.router.navigate(['/create-survey', survey.id]);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Deletion
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/** Ask the user to confirm before deleting a survey */
|
||||
async confirmDelete(survey: Survey, event: Event): Promise<void> {
|
||||
event.stopPropagation();
|
||||
|
||||
const alert = await this.alertCtrl.create({
|
||||
header: 'Delete Survey',
|
||||
message: `Delete "${survey.title}"? This will permanently remove all participant links and responses.`,
|
||||
buttons: [
|
||||
{ text: 'Cancel', role: 'cancel' },
|
||||
{
|
||||
text: 'Delete',
|
||||
role: 'destructive',
|
||||
handler: () => this.deleteSurvey(survey),
|
||||
},
|
||||
],
|
||||
});
|
||||
await alert.present();
|
||||
}
|
||||
|
||||
private async deleteSurvey(survey: Survey): Promise<void> {
|
||||
await this.surveyService.deleteSurvey(survey.id);
|
||||
const toast = await this.toastCtrl.create({
|
||||
message: `"${survey.title}" was deleted.`,
|
||||
duration: 2000,
|
||||
color: 'medium',
|
||||
});
|
||||
await toast.present();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private async loadResponseCounts(surveys: Survey[]): Promise<void> {
|
||||
const counts: Record<string, number> = {};
|
||||
await Promise.all(
|
||||
surveys.map(async (s) => {
|
||||
const responses = await this.responseService.getResponses(s.id);
|
||||
counts[s.id] = responses.length;
|
||||
})
|
||||
);
|
||||
this.responseCounts = counts;
|
||||
}
|
||||
|
||||
/** Returns a human-readable status label with an Ionic color */
|
||||
statusColor(status: Survey['status']): string {
|
||||
return status === 'active' ? 'success' : status === 'closed' ? 'medium' : 'warning';
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { Routes, RouterModule } from '@angular/router';
|
||||
|
||||
import { CreateSurveyPage } from './create-survey.page';
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: CreateSurveyPage
|
||||
}
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
imports: [RouterModule.forChild(routes)],
|
||||
exports: [RouterModule],
|
||||
})
|
||||
export class CreateSurveyPageRoutingModule {}
|
||||
@@ -1,20 +0,0 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
|
||||
import { IonicModule } from '@ionic/angular';
|
||||
|
||||
import { CreateSurveyPageRoutingModule } from './create-survey-routing.module';
|
||||
|
||||
import { CreateSurveyPage } from './create-survey.page';
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
IonicModule,
|
||||
CreateSurveyPageRoutingModule
|
||||
],
|
||||
declarations: [CreateSurveyPage]
|
||||
})
|
||||
export class CreateSurveyPageModule {}
|
||||
@@ -1,131 +0,0 @@
|
||||
<ion-header>
|
||||
<ion-toolbar color="primary">
|
||||
<ion-buttons slot="start">
|
||||
<ion-button (click)="cancel()">
|
||||
<ion-icon slot="icon-only" name="close-outline"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-buttons>
|
||||
<ion-title>{{ isEditMode ? 'Edit Survey' : 'New Survey' }}</ion-title>
|
||||
<ion-buttons slot="end">
|
||||
<ion-button [disabled]="!isFormValid" (click)="save()" strong>
|
||||
{{ isEditMode ? 'Update' : 'Create' }}
|
||||
</ion-button>
|
||||
</ion-buttons>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content class="ion-padding">
|
||||
|
||||
<!-- Survey metadata -->
|
||||
<ion-card>
|
||||
<ion-card-header>
|
||||
<ion-card-title>Survey Details</ion-card-title>
|
||||
</ion-card-header>
|
||||
<ion-card-content>
|
||||
<ion-item lines="none">
|
||||
<ion-label position="stacked">Title *</ion-label>
|
||||
<ion-input
|
||||
[(ngModel)]="title"
|
||||
placeholder="e.g. Employee Feedback Q2"
|
||||
maxlength="120"
|
||||
clearInput="true">
|
||||
</ion-input>
|
||||
</ion-item>
|
||||
<ion-item lines="none">
|
||||
<ion-label position="stacked">Description (optional)</ion-label>
|
||||
<ion-textarea
|
||||
[(ngModel)]="description"
|
||||
placeholder="Briefly explain the purpose of this survey…"
|
||||
rows="3"
|
||||
maxlength="500">
|
||||
</ion-textarea>
|
||||
</ion-item>
|
||||
</ion-card-content>
|
||||
</ion-card>
|
||||
|
||||
<!-- Questions -->
|
||||
<ion-card *ngFor="let question of questions; let i = index; trackBy: trackQuestion">
|
||||
<ion-card-header>
|
||||
<ion-card-subtitle>Question {{ i + 1 }}</ion-card-subtitle>
|
||||
<div class="question-actions">
|
||||
<ion-button fill="clear" size="small" (click)="moveQuestionUp(i)" [disabled]="i === 0">
|
||||
<ion-icon slot="icon-only" name="chevron-up-outline"></ion-icon>
|
||||
</ion-button>
|
||||
<ion-button fill="clear" size="small" (click)="moveQuestionDown(i)" [disabled]="i === questions.length - 1">
|
||||
<ion-icon slot="icon-only" name="chevron-down-outline"></ion-icon>
|
||||
</ion-button>
|
||||
<ion-button fill="clear" size="small" color="danger" (click)="removeQuestion(i)">
|
||||
<ion-icon slot="icon-only" name="trash-outline"></ion-icon>
|
||||
</ion-button>
|
||||
</div>
|
||||
</ion-card-header>
|
||||
|
||||
<ion-card-content>
|
||||
<!-- Question text -->
|
||||
<ion-item lines="none">
|
||||
<ion-label position="stacked">Question Text *</ion-label>
|
||||
<ion-input
|
||||
[(ngModel)]="question.text"
|
||||
placeholder="Enter your question…"
|
||||
maxlength="300">
|
||||
</ion-input>
|
||||
</ion-item>
|
||||
|
||||
<!-- Question type -->
|
||||
<ion-item lines="none">
|
||||
<ion-label position="stacked">Type</ion-label>
|
||||
<ion-select [(ngModel)]="question.type" (ionChange)="onTypeChange(question)">
|
||||
<ion-select-option value="text">Free Text</ion-select-option>
|
||||
<ion-select-option value="multiple_choice">Multiple Choice</ion-select-option>
|
||||
<ion-select-option value="yes_no">Yes / No</ion-select-option>
|
||||
<ion-select-option value="rating">Rating (1–5)</ion-select-option>
|
||||
</ion-select>
|
||||
</ion-item>
|
||||
|
||||
<!-- Multiple choice options -->
|
||||
<div *ngIf="question.type === 'multiple_choice'" class="options-section">
|
||||
<ion-list-header>
|
||||
<ion-label>Answer Options</ion-label>
|
||||
</ion-list-header>
|
||||
<ion-item
|
||||
*ngFor="let option of question.options; let oi = index; trackBy: trackOption"
|
||||
lines="none">
|
||||
<ion-input
|
||||
[(ngModel)]="question.options![oi]"
|
||||
[placeholder]="'Option ' + (oi + 1)"
|
||||
maxlength="200">
|
||||
</ion-input>
|
||||
<ion-button
|
||||
slot="end"
|
||||
fill="clear"
|
||||
color="danger"
|
||||
size="small"
|
||||
(click)="removeOption(question, oi)"
|
||||
[disabled]="(question.options?.length ?? 0) <= 2">
|
||||
<ion-icon slot="icon-only" name="close-circle-outline"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-item>
|
||||
<ion-button fill="clear" size="small" (click)="addOption(question)">
|
||||
<ion-icon slot="start" name="add-outline"></ion-icon>
|
||||
Add Option
|
||||
</ion-button>
|
||||
</div>
|
||||
|
||||
<!-- Required toggle -->
|
||||
<ion-item lines="none">
|
||||
<ion-label>Required</ion-label>
|
||||
<ion-toggle [(ngModel)]="question.required" slot="end"></ion-toggle>
|
||||
</ion-item>
|
||||
</ion-card-content>
|
||||
</ion-card>
|
||||
|
||||
<!-- Add question button -->
|
||||
<ion-button expand="block" fill="outline" (click)="addQuestion()" class="ion-margin-top">
|
||||
<ion-icon slot="start" name="add-circle-outline"></ion-icon>
|
||||
Add Question
|
||||
</ion-button>
|
||||
|
||||
<!-- Validation hint -->
|
||||
<p *ngIf="questions.length === 0" class="hint-text">Add at least one question to continue.</p>
|
||||
|
||||
</ion-content>
|
||||
@@ -1,18 +0,0 @@
|
||||
.options-section {
|
||||
margin-top: 8px;
|
||||
padding: 8px 0;
|
||||
border-top: 1px solid var(--ion-color-light);
|
||||
}
|
||||
|
||||
.question-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: -8px;
|
||||
}
|
||||
|
||||
.hint-text {
|
||||
color: var(--ion-color-medium);
|
||||
font-size: 0.875rem;
|
||||
text-align: center;
|
||||
padding: 8px 16px;
|
||||
}
|
||||
@@ -1,203 +0,0 @@
|
||||
/**
|
||||
* create-survey.page.ts
|
||||
* Page for creating a new survey or editing an existing one.
|
||||
*
|
||||
* When accessed via /create-survey → creates a new survey
|
||||
* When accessed via /create-survey/:id → loads and edits the existing survey
|
||||
*/
|
||||
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { ToastController } from '@ionic/angular';
|
||||
import { SurveyService } from '../../services/survey.service';
|
||||
import type { Survey, Question } from '../../shared/models/survey.models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-create-survey',
|
||||
templateUrl: './create-survey.page.html',
|
||||
styleUrls: ['./create-survey.page.scss'],
|
||||
standalone: false,
|
||||
})
|
||||
export class CreateSurveyPage implements OnInit {
|
||||
/** True when editing an existing survey */
|
||||
isEditMode = false;
|
||||
existingSurveyId?: string;
|
||||
|
||||
// Form fields
|
||||
title = '';
|
||||
description = '';
|
||||
questions: Question[] = [];
|
||||
|
||||
/** Track which question's options are being edited */
|
||||
expandedQuestionIndex: number | null = null;
|
||||
|
||||
constructor(
|
||||
private route: ActivatedRoute,
|
||||
private router: Router,
|
||||
private surveyService: SurveyService,
|
||||
private toastCtrl: ToastController
|
||||
) {}
|
||||
|
||||
async ngOnInit(): Promise<void> {
|
||||
const id = this.route.snapshot.paramMap.get('id');
|
||||
if (id) {
|
||||
this.isEditMode = true;
|
||||
this.existingSurveyId = id;
|
||||
await this.loadSurvey(id);
|
||||
} else {
|
||||
// Start with one empty question for a better UX
|
||||
this.addQuestion();
|
||||
}
|
||||
}
|
||||
|
||||
private async loadSurvey(id: string): Promise<void> {
|
||||
const survey = await this.surveyService.getSurvey(id);
|
||||
if (!survey) {
|
||||
const toast = await this.toastCtrl.create({
|
||||
message: 'Survey not found.',
|
||||
duration: 2000,
|
||||
color: 'danger',
|
||||
});
|
||||
await toast.present();
|
||||
this.router.navigate(['/home']);
|
||||
return;
|
||||
}
|
||||
this.title = survey.title;
|
||||
this.description = survey.description ?? '';
|
||||
// Deep copy so edits don't mutate the original until saved
|
||||
this.questions = JSON.parse(JSON.stringify(survey.questions));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Question management
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
addQuestion(): void {
|
||||
const question: Question = {
|
||||
id: crypto.randomUUID(),
|
||||
text: '',
|
||||
type: 'text',
|
||||
required: false,
|
||||
};
|
||||
this.questions.push(question);
|
||||
this.expandedQuestionIndex = this.questions.length - 1;
|
||||
}
|
||||
|
||||
removeQuestion(index: number): void {
|
||||
this.questions.splice(index, 1);
|
||||
if (this.expandedQuestionIndex === index) {
|
||||
this.expandedQuestionIndex = null;
|
||||
}
|
||||
}
|
||||
|
||||
moveQuestionUp(index: number): void {
|
||||
if (index === 0) return;
|
||||
const temp = this.questions[index - 1];
|
||||
this.questions[index - 1] = this.questions[index];
|
||||
this.questions[index] = temp;
|
||||
}
|
||||
|
||||
moveQuestionDown(index: number): void {
|
||||
if (index === this.questions.length - 1) return;
|
||||
const temp = this.questions[index + 1];
|
||||
this.questions[index + 1] = this.questions[index];
|
||||
this.questions[index] = temp;
|
||||
}
|
||||
|
||||
toggleExpand(index: number): void {
|
||||
this.expandedQuestionIndex =
|
||||
this.expandedQuestionIndex === index ? null : index;
|
||||
}
|
||||
|
||||
onTypeChange(question: Question): void {
|
||||
// Initialise options array when switching to multiple_choice
|
||||
if (question.type === 'multiple_choice' && !question.options?.length) {
|
||||
question.options = ['Option 1', 'Option 2'];
|
||||
}
|
||||
}
|
||||
|
||||
addOption(question: Question): void {
|
||||
if (!question.options) question.options = [];
|
||||
question.options.push(`Option ${question.options.length + 1}`);
|
||||
}
|
||||
|
||||
removeOption(question: Question, optIndex: number): void {
|
||||
question.options?.splice(optIndex, 1);
|
||||
}
|
||||
|
||||
trackOption(index: number): number {
|
||||
return index;
|
||||
}
|
||||
|
||||
trackQuestion(index: number): number {
|
||||
return index;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Validation
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
get isFormValid(): boolean {
|
||||
if (!this.title.trim()) return false;
|
||||
if (this.questions.length === 0) return false;
|
||||
return this.questions.every(
|
||||
(q) =>
|
||||
q.text.trim() &&
|
||||
(q.type !== 'multiple_choice' || (q.options && q.options.length >= 2))
|
||||
);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Save / Update
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
async save(): Promise<void> {
|
||||
if (!this.isFormValid) return;
|
||||
|
||||
const questionsToSave = this.questions.map((q) => ({
|
||||
...q,
|
||||
text: q.text.trim(),
|
||||
// Strip empty options for multiple_choice
|
||||
options:
|
||||
q.type === 'multiple_choice'
|
||||
? q.options?.filter((o) => o.trim()) ?? []
|
||||
: undefined,
|
||||
}));
|
||||
|
||||
if (this.isEditMode && this.existingSurveyId) {
|
||||
await this.surveyService.updateSurvey(this.existingSurveyId, {
|
||||
title: this.title.trim(),
|
||||
description: this.description.trim() || undefined,
|
||||
questions: questionsToSave,
|
||||
});
|
||||
const toast = await this.toastCtrl.create({
|
||||
message: 'Survey updated.',
|
||||
duration: 2000,
|
||||
color: 'success',
|
||||
});
|
||||
await toast.present();
|
||||
this.router.navigate(['/survey', this.existingSurveyId]);
|
||||
} else {
|
||||
const survey = await this.surveyService.createSurvey({
|
||||
title: this.title.trim(),
|
||||
description: this.description.trim() || undefined,
|
||||
questions: questionsToSave,
|
||||
});
|
||||
const toast = await this.toastCtrl.create({
|
||||
message: 'Survey created.',
|
||||
duration: 2000,
|
||||
color: 'success',
|
||||
});
|
||||
await toast.present();
|
||||
this.router.navigate(['/survey', survey.id]);
|
||||
}
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
if (this.isEditMode && this.existingSurveyId) {
|
||||
this.router.navigate(['/survey', this.existingSurveyId]);
|
||||
} else {
|
||||
this.router.navigate(['/home']);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { Routes, RouterModule } from '@angular/router';
|
||||
|
||||
import { ParticipatePage } from './participate.page';
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: ParticipatePage
|
||||
}
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
imports: [RouterModule.forChild(routes)],
|
||||
exports: [RouterModule],
|
||||
})
|
||||
export class ParticipatePageRoutingModule {}
|
||||
@@ -1,20 +0,0 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
|
||||
import { IonicModule } from '@ionic/angular';
|
||||
|
||||
import { ParticipatePageRoutingModule } from './participate-routing.module';
|
||||
|
||||
import { ParticipatePage } from './participate.page';
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
IonicModule,
|
||||
ParticipatePageRoutingModule
|
||||
],
|
||||
declarations: [ParticipatePage]
|
||||
})
|
||||
export class ParticipatePageModule {}
|
||||
@@ -1,185 +0,0 @@
|
||||
<ion-header>
|
||||
<ion-toolbar color="primary">
|
||||
<ion-title>Survey</ion-title>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content class="ion-padding">
|
||||
|
||||
<!-- ── Connecting ── -->
|
||||
<div *ngIf="state === 'connecting'" class="state-card">
|
||||
<ion-spinner name="crescent"></ion-spinner>
|
||||
<h3>Connecting to survey host…</h3>
|
||||
<p>Please wait while we establish a peer-to-peer connection.</p>
|
||||
</div>
|
||||
|
||||
<!-- ── Connected (waiting for survey data) ── -->
|
||||
<div *ngIf="state === 'connected'" class="state-card">
|
||||
<ion-spinner name="dots"></ion-spinner>
|
||||
<h3>Loading survey…</h3>
|
||||
</div>
|
||||
|
||||
<!-- ── Host offline ── -->
|
||||
<ion-card *ngIf="state === 'host-offline'" color="warning">
|
||||
<ion-card-header>
|
||||
<ion-card-title>
|
||||
<ion-icon name="wifi-outline"></ion-icon>
|
||||
Host is Offline
|
||||
</ion-card-title>
|
||||
</ion-card-header>
|
||||
<ion-card-content>
|
||||
<p>
|
||||
The survey host's browser is not currently accepting connections.
|
||||
The survey creator needs to open the survey and click <strong>Start Hosting</strong>.
|
||||
</p>
|
||||
<p>Please try again later or ask the survey creator to come online.</p>
|
||||
</ion-card-content>
|
||||
</ion-card>
|
||||
|
||||
<!-- ── Error ── -->
|
||||
<ion-card *ngIf="state === 'error'" color="danger">
|
||||
<ion-card-header>
|
||||
<ion-card-title>
|
||||
<ion-icon name="alert-circle-outline"></ion-icon>
|
||||
Error
|
||||
</ion-card-title>
|
||||
</ion-card-header>
|
||||
<ion-card-content>
|
||||
<p>{{ errorMessage }}</p>
|
||||
</ion-card-content>
|
||||
</ion-card>
|
||||
|
||||
<!-- ── Survey form ── -->
|
||||
<ng-container *ngIf="state === 'survey-loaded' && survey">
|
||||
<div class="survey-header">
|
||||
<h2>{{ survey.title }}</h2>
|
||||
<p *ngIf="survey.description" class="survey-description">{{ survey.description }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Question cards -->
|
||||
<ion-card *ngFor="let question of survey.questions; let i = index">
|
||||
<ion-card-header>
|
||||
<ion-card-subtitle>
|
||||
Question {{ i + 1 }}
|
||||
<span *ngIf="question.required" class="required-mark">*</span>
|
||||
</ion-card-subtitle>
|
||||
<ion-card-title class="question-title">{{ question.text }}</ion-card-title>
|
||||
</ion-card-header>
|
||||
<ion-card-content>
|
||||
|
||||
<!-- Free text -->
|
||||
<ion-item *ngIf="question.type === 'text'" lines="none">
|
||||
<ion-textarea
|
||||
[(ngModel)]="answers[question.id]"
|
||||
placeholder="Your answer…"
|
||||
rows="4"
|
||||
maxlength="2000"
|
||||
(ionBlur)="saveDraft()">
|
||||
</ion-textarea>
|
||||
</ion-item>
|
||||
|
||||
<!-- Multiple choice -->
|
||||
<ion-radio-group
|
||||
*ngIf="question.type === 'multiple_choice'"
|
||||
[(ngModel)]="answers[question.id]"
|
||||
(ngModelChange)="saveDraft()">
|
||||
<ion-item *ngFor="let option of question.options" lines="none">
|
||||
<ion-radio [value]="option" slot="start"></ion-radio>
|
||||
<ion-label>{{ option }}</ion-label>
|
||||
</ion-item>
|
||||
</ion-radio-group>
|
||||
|
||||
<!-- Yes / No -->
|
||||
<ion-radio-group
|
||||
*ngIf="question.type === 'yes_no'"
|
||||
[(ngModel)]="answers[question.id]"
|
||||
(ngModelChange)="saveDraft()">
|
||||
<ion-item lines="none">
|
||||
<ion-radio value="Yes" slot="start"></ion-radio>
|
||||
<ion-label>Yes</ion-label>
|
||||
</ion-item>
|
||||
<ion-item lines="none">
|
||||
<ion-radio value="No" slot="start"></ion-radio>
|
||||
<ion-label>No</ion-label>
|
||||
</ion-item>
|
||||
</ion-radio-group>
|
||||
|
||||
<!-- Rating 1–5 -->
|
||||
<div *ngIf="question.type === 'rating'" class="rating-row">
|
||||
<ion-button
|
||||
*ngFor="let val of ratingValues()"
|
||||
[fill]="answers[question.id] === val ? 'solid' : 'outline'"
|
||||
[color]="answers[question.id] === val ? 'primary' : 'medium'"
|
||||
(click)="answers[question.id] = val; saveDraft()"
|
||||
size="small">
|
||||
{{ val }}
|
||||
</ion-button>
|
||||
</div>
|
||||
|
||||
</ion-card-content>
|
||||
</ion-card>
|
||||
|
||||
<p class="required-note">* Required question</p>
|
||||
|
||||
<div class="submit-area">
|
||||
<ion-button expand="block" (click)="submit()" [disabled]="!isFormValid">
|
||||
<ion-icon slot="start" name="checkmark-outline"></ion-icon>
|
||||
Submit
|
||||
</ion-button>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<!-- ── Submitted ── -->
|
||||
<div *ngIf="state === 'submitted'" class="state-card success-state">
|
||||
<ion-icon name="checkmark-circle-outline" color="success" size="large"></ion-icon>
|
||||
<h2>Thank you!</h2>
|
||||
<p>Your response has been submitted successfully.</p>
|
||||
|
||||
<!-- Show results if host sent them -->
|
||||
<ng-container *ngIf="results && survey">
|
||||
<div class="results-divider">
|
||||
<ion-label>Survey Results</ion-label>
|
||||
</div>
|
||||
|
||||
<ion-card *ngFor="let q of survey.questions">
|
||||
<ion-card-header>
|
||||
<ion-card-title class="question-title">{{ q.text }}</ion-card-title>
|
||||
</ion-card-header>
|
||||
<ion-card-content>
|
||||
<ng-container *ngIf="getQuestionResult(q.id) as qr">
|
||||
|
||||
<!-- Tally (multiple_choice / yes_no) -->
|
||||
<div *ngIf="qr.tally">
|
||||
<div *ngFor="let entry of tallyEntries(qr.tally)" class="bar-row">
|
||||
<span class="bar-label">{{ entry.key }}</span>
|
||||
<div class="bar-track">
|
||||
<div class="bar-fill" [style.width.%]="tallyPercent(entry.count)"></div>
|
||||
</div>
|
||||
<span class="bar-count">{{ entry.count }} ({{ tallyPercent(entry.count) }}%)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Text answers -->
|
||||
<div *ngIf="qr.texts">
|
||||
<p *ngFor="let t of qr.texts; let i = index">{{ i + 1 }}. {{ t }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Rating -->
|
||||
<div *ngIf="qr.ratingDistribution">
|
||||
<p>Average: <strong>{{ formatAvg(qr.ratingAvg) }}</strong> / 5</p>
|
||||
<div *ngFor="let count of qr.ratingDistribution; let i = index" class="bar-row">
|
||||
<span class="bar-label">{{ ratingLabel(i) }} ★</span>
|
||||
<div class="bar-track">
|
||||
<div class="bar-fill" [style.width.%]="tallyPercent(count)"></div>
|
||||
</div>
|
||||
<span class="bar-count">{{ count }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</ng-container>
|
||||
</ion-card-content>
|
||||
</ion-card>
|
||||
</ng-container>
|
||||
</div>
|
||||
|
||||
</ion-content>
|
||||
@@ -1,119 +0,0 @@
|
||||
.state-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 60px 24px;
|
||||
text-align: center;
|
||||
|
||||
ion-spinner {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
ion-icon {
|
||||
font-size: 72px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
h3, h2 {
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
p {
|
||||
color: var(--ion-color-medium);
|
||||
max-width: 320px;
|
||||
}
|
||||
}
|
||||
|
||||
.success-state {
|
||||
ion-icon {
|
||||
color: var(--ion-color-success);
|
||||
}
|
||||
}
|
||||
|
||||
.survey-header {
|
||||
margin-bottom: 16px;
|
||||
|
||||
h2 {
|
||||
font-size: 1.4rem;
|
||||
font-weight: 700;
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.survey-description {
|
||||
color: var(--ion-color-medium);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.question-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
white-space: normal;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.required-mark {
|
||||
color: var(--ion-color-danger);
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.required-note {
|
||||
color: var(--ion-color-medium);
|
||||
font-size: 0.8rem;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.rating-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.submit-area {
|
||||
padding: 16px 0 32px;
|
||||
}
|
||||
|
||||
.results-divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 24px 0 8px;
|
||||
width: 100%;
|
||||
font-weight: 600;
|
||||
color: var(--ion-color-primary);
|
||||
}
|
||||
|
||||
.bar-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.bar-label {
|
||||
min-width: 60px;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.bar-track {
|
||||
flex: 1;
|
||||
height: 14px;
|
||||
background: var(--ion-color-light);
|
||||
border-radius: 7px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bar-fill {
|
||||
height: 100%;
|
||||
background: var(--ion-color-primary);
|
||||
border-radius: 7px;
|
||||
min-width: 2px;
|
||||
}
|
||||
|
||||
.bar-count {
|
||||
font-size: 0.8rem;
|
||||
color: var(--ion-color-medium);
|
||||
}
|
||||
@@ -1,267 +0,0 @@
|
||||
/**
|
||||
* participate.page.ts
|
||||
* Survey participation page — opened by participants via their unique share link.
|
||||
*
|
||||
* URL format: /participate?host=survey-{surveyId}&token={participantToken}
|
||||
*
|
||||
* Connection flow:
|
||||
* 1. Parse host peer ID and token from URL query params
|
||||
* 2. Initialise a PeerJS peer with a random ID
|
||||
* 3. Connect to the host peer
|
||||
* 4. Send { type: 'join', token } to identify the participant
|
||||
* 5. Receive survey data from host
|
||||
* 6. Participant fills in answers (drafts saved via 'update' messages)
|
||||
* 7. On submit, send { type: 'submit', ... } — host locks the token
|
||||
* 8. Optionally receive aggregated results if the host has that setting enabled
|
||||
*/
|
||||
|
||||
import { Component, OnInit, OnDestroy } from '@angular/core';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { ToastController } from '@ionic/angular';
|
||||
import { DataConnection } from 'peerjs';
|
||||
import { PeerService } from '../../services/peer.service';
|
||||
import type {
|
||||
Survey,
|
||||
Question,
|
||||
P2PMessage,
|
||||
SurveyResults,
|
||||
QuestionResult,
|
||||
} from '../../shared/models/survey.models';
|
||||
|
||||
/** Possible states of the participant UI */
|
||||
type ParticipantState =
|
||||
| 'connecting' // Trying to reach the host peer
|
||||
| 'connected' // DataConnection open, waiting for survey data
|
||||
| 'survey-loaded' // Survey received, participant filling in answers
|
||||
| 'submitted' // Final answers submitted and acknowledged
|
||||
| 'host-offline' // Could not connect within the timeout
|
||||
| 'error'; // Protocol error (invalid token, already submitted, etc.)
|
||||
|
||||
@Component({
|
||||
selector: 'app-participate',
|
||||
templateUrl: './participate.page.html',
|
||||
styleUrls: ['./participate.page.scss'],
|
||||
standalone: false,
|
||||
})
|
||||
export class ParticipatePage implements OnInit, OnDestroy {
|
||||
state: ParticipantState = 'connecting';
|
||||
errorMessage = '';
|
||||
|
||||
survey?: Survey;
|
||||
/** Map from questionId → current answer value */
|
||||
answers: Record<string, unknown> = {};
|
||||
results?: SurveyResults;
|
||||
|
||||
/** The participant's unique token (from URL) */
|
||||
private token = '';
|
||||
/** The host peer ID (from URL) */
|
||||
private hostPeerId = '';
|
||||
private conn?: DataConnection;
|
||||
private offlineTimeout?: ReturnType<typeof setTimeout>;
|
||||
|
||||
constructor(
|
||||
private route: ActivatedRoute,
|
||||
private peerService: PeerService,
|
||||
private toastCtrl: ToastController
|
||||
) {}
|
||||
|
||||
async ngOnInit(): Promise<void> {
|
||||
this.hostPeerId = this.route.snapshot.queryParamMap.get('host') ?? '';
|
||||
this.token = this.route.snapshot.queryParamMap.get('token') ?? '';
|
||||
|
||||
if (!this.hostPeerId || !this.token) {
|
||||
this.state = 'error';
|
||||
this.errorMessage = 'Invalid link. Please check the URL and try again.';
|
||||
return;
|
||||
}
|
||||
|
||||
await this.connectToHost();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
clearTimeout(this.offlineTimeout);
|
||||
this.peerService.destroy();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// P2P connection
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private async connectToHost(): Promise<void> {
|
||||
try {
|
||||
// Initialise with a random peer ID (participants don't need a fixed ID)
|
||||
await this.peerService.init();
|
||||
|
||||
this.conn = this.peerService.connectTo(this.hostPeerId);
|
||||
|
||||
// If the host does not respond within 8 seconds, show the offline card
|
||||
this.offlineTimeout = setTimeout(() => {
|
||||
if (this.state === 'connecting') {
|
||||
this.state = 'host-offline';
|
||||
}
|
||||
}, 8000);
|
||||
|
||||
this.conn.on('open', () => {
|
||||
clearTimeout(this.offlineTimeout);
|
||||
this.state = 'connected';
|
||||
// Identify ourselves to the host
|
||||
this.conn!.send({ type: 'join', token: this.token } as P2PMessage);
|
||||
});
|
||||
|
||||
this.conn.on('data', (rawMsg) => {
|
||||
const msg = rawMsg as P2PMessage;
|
||||
this.handleHostMessage(msg);
|
||||
});
|
||||
|
||||
this.conn.on('error', () => {
|
||||
clearTimeout(this.offlineTimeout);
|
||||
this.state = 'host-offline';
|
||||
});
|
||||
|
||||
this.conn.on('close', () => {
|
||||
// Do not override 'submitted' state on normal close
|
||||
if (this.state !== 'submitted') {
|
||||
this.state = 'host-offline';
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
this.state = 'host-offline';
|
||||
}
|
||||
}
|
||||
|
||||
/** Process a message received from the host */
|
||||
private handleHostMessage(msg: P2PMessage): void {
|
||||
switch (msg.type) {
|
||||
case 'survey':
|
||||
this.survey = msg.data;
|
||||
// Pre-fill answers map with empty values
|
||||
this.answers = {};
|
||||
for (const q of this.survey.questions) {
|
||||
this.answers[q.id] = q.type === 'rating' ? null : '';
|
||||
}
|
||||
this.state = 'survey-loaded';
|
||||
break;
|
||||
|
||||
case 'ack':
|
||||
// 'submitted' is handled as a state change
|
||||
if (msg.status === 'submitted') {
|
||||
this.state = 'submitted';
|
||||
}
|
||||
break;
|
||||
|
||||
case 'results':
|
||||
this.results = msg.data;
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
this.state = 'error';
|
||||
this.errorMessage = this.friendlyError(msg.reason);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Form interactions
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/** Save a draft without locking the token */
|
||||
saveDraft(): void {
|
||||
if (!this.conn?.open) return;
|
||||
this.conn.send({
|
||||
type: 'update',
|
||||
token: this.token,
|
||||
answers: this.answers,
|
||||
} as P2PMessage);
|
||||
}
|
||||
|
||||
/** Submit final answers — the token will be locked on the host side */
|
||||
async submit(): Promise<void> {
|
||||
if (!this.isFormValid) {
|
||||
const toast = await this.toastCtrl.create({
|
||||
message: 'Please answer all required questions before submitting.',
|
||||
duration: 3000,
|
||||
color: 'warning',
|
||||
});
|
||||
await toast.present();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.conn?.open) {
|
||||
this.state = 'host-offline';
|
||||
return;
|
||||
}
|
||||
|
||||
this.conn.send({
|
||||
type: 'submit',
|
||||
token: this.token,
|
||||
answers: this.answers,
|
||||
} as P2PMessage);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Validation
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
get isFormValid(): boolean {
|
||||
if (!this.survey) return false;
|
||||
return this.survey.questions
|
||||
.filter((q) => q.required)
|
||||
.every((q) => {
|
||||
const ans = this.answers[q.id];
|
||||
return ans !== null && ans !== undefined && ans !== '';
|
||||
});
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Results display helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
getQuestionResult(questionId: string): QuestionResult | undefined {
|
||||
return this.results?.answers[questionId];
|
||||
}
|
||||
|
||||
tallyEntries(tally: Record<string, number>): { key: string; count: number }[] {
|
||||
return Object.entries(tally)
|
||||
.map(([key, count]) => ({ key, count }))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
}
|
||||
|
||||
tallyPercent(count: number): number {
|
||||
if (!this.results) return 0;
|
||||
return Math.round((count / this.results.totalResponses) * 100);
|
||||
}
|
||||
|
||||
formatAvg(avg: number | undefined): string {
|
||||
return avg != null ? avg.toFixed(1) : '–';
|
||||
}
|
||||
|
||||
ratingLabel(index: number): string {
|
||||
return String(index + 1);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/** Returns an array [1, 2, 3, 4, 5] for the rating question template */
|
||||
ratingValues(): number[] {
|
||||
return [1, 2, 3, 4, 5];
|
||||
}
|
||||
|
||||
private friendlyError(
|
||||
reason: 'invalid_token' | 'already_submitted' | 'survey_not_found' | 'survey_closed' | 'survey_draft'
|
||||
): string {
|
||||
switch (reason) {
|
||||
case 'invalid_token':
|
||||
return 'This link is not valid for this survey. Please check the URL.';
|
||||
case 'already_submitted':
|
||||
return 'You have already submitted a response for this survey. Each link can only be used once.';
|
||||
case 'survey_not_found':
|
||||
return 'The survey could not be found on the host. It may have been deleted.';
|
||||
case 'survey_draft':
|
||||
return 'This survey is not yet open for responses. Please try again later.';
|
||||
case 'survey_closed':
|
||||
return 'This survey is closed and no longer accepting responses.';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { Routes, RouterModule } from '@angular/router';
|
||||
|
||||
import { SurveyDetailPage } from './survey-detail.page';
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: SurveyDetailPage
|
||||
}
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
imports: [RouterModule.forChild(routes)],
|
||||
exports: [RouterModule],
|
||||
})
|
||||
export class SurveyDetailPageRoutingModule {}
|
||||
@@ -1,20 +0,0 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
|
||||
import { IonicModule } from '@ionic/angular';
|
||||
|
||||
import { SurveyDetailPageRoutingModule } from './survey-detail-routing.module';
|
||||
|
||||
import { SurveyDetailPage } from './survey-detail.page';
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
IonicModule,
|
||||
SurveyDetailPageRoutingModule
|
||||
],
|
||||
declarations: [SurveyDetailPage]
|
||||
})
|
||||
export class SurveyDetailPageModule {}
|
||||
@@ -1,167 +0,0 @@
|
||||
<ion-header>
|
||||
<ion-toolbar color="primary">
|
||||
<ion-buttons slot="start">
|
||||
<ion-back-button defaultHref="/home"></ion-back-button>
|
||||
</ion-buttons>
|
||||
<ion-title>{{ survey?.title ?? 'Survey' }}</ion-title>
|
||||
<ion-buttons slot="end">
|
||||
<ion-button (click)="editSurvey()">
|
||||
<ion-icon slot="icon-only" name="create-outline"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-buttons>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content class="ion-padding" *ngIf="survey">
|
||||
|
||||
<!-- Overview card -->
|
||||
<ion-card>
|
||||
<ion-card-header>
|
||||
<ion-card-title>{{ survey.title }}</ion-card-title>
|
||||
<ion-card-subtitle *ngIf="survey.description">{{ survey.description }}</ion-card-subtitle>
|
||||
</ion-card-header>
|
||||
<ion-card-content>
|
||||
<p>
|
||||
<strong>{{ survey.questions.length }}</strong> question{{ survey.questions.length !== 1 ? 's' : '' }}
|
||||
•
|
||||
<strong>{{ responseCount }}</strong> response{{ responseCount !== 1 ? 's' : '' }}
|
||||
•
|
||||
<ion-badge [color]="survey.status === 'active' ? 'success' : 'medium'">{{ survey.status }}</ion-badge>
|
||||
</p>
|
||||
<p class="hint-text">Created: {{ survey.createdAt | date:'medium' }}</p>
|
||||
</ion-card-content>
|
||||
</ion-card>
|
||||
|
||||
<!-- Settings -->
|
||||
<ion-card>
|
||||
<ion-card-header>
|
||||
<ion-card-title>Settings</ion-card-title>
|
||||
</ion-card-header>
|
||||
<ion-card-content>
|
||||
<ion-item lines="none">
|
||||
<ion-label>
|
||||
<h3>Show results to participants</h3>
|
||||
<p>After submitting, participants see aggregated results.</p>
|
||||
</ion-label>
|
||||
<ion-toggle
|
||||
[(ngModel)]="survey.showResultsToParticipants"
|
||||
(ionChange)="toggleShowResults()"
|
||||
slot="end">
|
||||
</ion-toggle>
|
||||
</ion-item>
|
||||
|
||||
<ion-item lines="none">
|
||||
<ion-label>Survey Status</ion-label>
|
||||
<ion-select
|
||||
[(ngModel)]="survey.status"
|
||||
(ionChange)="changeSurveyStatus(survey.status)"
|
||||
slot="end">
|
||||
<ion-select-option value="draft">Draft</ion-select-option>
|
||||
<ion-select-option value="active">Active</ion-select-option>
|
||||
<ion-select-option value="closed">Closed</ion-select-option>
|
||||
</ion-select>
|
||||
</ion-item>
|
||||
</ion-card-content>
|
||||
</ion-card>
|
||||
|
||||
<!-- Hosting control -->
|
||||
<ion-card>
|
||||
<ion-card-header>
|
||||
<ion-card-title>Hosting</ion-card-title>
|
||||
</ion-card-header>
|
||||
<ion-card-content>
|
||||
<ion-item lines="none" *ngIf="isHosting">
|
||||
<ion-icon name="wifi" slot="start" color="success"></ion-icon>
|
||||
<ion-label color="success">
|
||||
<strong>Hosting active</strong>
|
||||
<p>Participants can connect now. Keep this page open.</p>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
<ion-item lines="none" *ngIf="!isHosting">
|
||||
<ion-icon name="wifi-outline" slot="start" color="medium"></ion-icon>
|
||||
<ion-label color="medium">
|
||||
Not hosting. Start hosting to receive responses.
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
|
||||
<div class="ion-margin-top button-row">
|
||||
<ion-button *ngIf="!isHosting" expand="block" (click)="startHosting()"
|
||||
[disabled]="survey.status !== 'active'">
|
||||
<ion-icon slot="start" name="play-outline"></ion-icon>
|
||||
Start Hosting
|
||||
</ion-button>
|
||||
<ion-button *ngIf="isHosting" expand="block" color="medium" fill="outline" (click)="stopHosting()">
|
||||
<ion-icon slot="start" name="stop-outline"></ion-icon>
|
||||
Stop Hosting
|
||||
</ion-button>
|
||||
<ion-button expand="block" fill="outline" (click)="viewResults()">
|
||||
<ion-icon slot="start" name="bar-chart-outline"></ion-icon>
|
||||
View Results
|
||||
</ion-button>
|
||||
</div>
|
||||
</ion-card-content>
|
||||
</ion-card>
|
||||
|
||||
<!-- Generate participant links -->
|
||||
<ion-card>
|
||||
<ion-card-header>
|
||||
<ion-card-title>Share Links</ion-card-title>
|
||||
</ion-card-header>
|
||||
<ion-card-content>
|
||||
<p>Generate unique links to share with participants. Each link can only be used once.</p>
|
||||
<ion-item lines="none">
|
||||
<ion-label>Number of links</ion-label>
|
||||
<ion-input
|
||||
type="number"
|
||||
[(ngModel)]="linkCount"
|
||||
min="1"
|
||||
max="200"
|
||||
slot="end"
|
||||
style="max-width: 80px; text-align: right;">
|
||||
</ion-input>
|
||||
</ion-item>
|
||||
<ion-button expand="block" (click)="generateLinks()" class="ion-margin-top">
|
||||
<ion-icon slot="start" name="link-outline"></ion-icon>
|
||||
Generate Links
|
||||
</ion-button>
|
||||
</ion-card-content>
|
||||
</ion-card>
|
||||
|
||||
<!-- Participant list -->
|
||||
<ion-card *ngIf="participants.length > 0">
|
||||
<ion-card-header>
|
||||
<ion-card-title>Participants ({{ participants.length }})</ion-card-title>
|
||||
</ion-card-header>
|
||||
<ion-card-content>
|
||||
<ion-list lines="none">
|
||||
<ion-item *ngFor="let p of participants">
|
||||
<ion-label>
|
||||
<h3>{{ p.label ?? ('Token ' + truncateToken(p.token)) }}</h3>
|
||||
<p>
|
||||
<ion-badge [color]="participantStatusColor(p)">{{ participantStatus(p) }}</ion-badge>
|
||||
<span *ngIf="p.usedAt" class="hint-text"> • Accessed {{ p.usedAt | date:'short' }}</span>
|
||||
</p>
|
||||
</ion-label>
|
||||
<ion-button
|
||||
slot="end"
|
||||
fill="clear"
|
||||
size="small"
|
||||
(click)="copyLink(p.token)"
|
||||
title="Copy link">
|
||||
<ion-icon slot="icon-only" name="copy-outline"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-item>
|
||||
</ion-list>
|
||||
</ion-card-content>
|
||||
</ion-card>
|
||||
|
||||
<!-- No participants yet -->
|
||||
<ion-card *ngIf="participants.length === 0">
|
||||
<ion-card-content>
|
||||
<p class="hint-text ion-text-center">
|
||||
No links generated yet. Use the form above to create share links.
|
||||
</p>
|
||||
</ion-card-content>
|
||||
</ion-card>
|
||||
|
||||
</ion-content>
|
||||
@@ -1,14 +0,0 @@
|
||||
.hint-text {
|
||||
color: var(--ion-color-medium);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.button-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
ion-badge {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
@@ -1,323 +0,0 @@
|
||||
/**
|
||||
* survey-detail.page.ts
|
||||
* Survey management page for the survey creator (host).
|
||||
*
|
||||
* Features:
|
||||
* - View survey info and settings
|
||||
* - Toggle whether participants can see aggregated results
|
||||
* - Generate unique participant share links
|
||||
* - Display the list of participants with their submission status
|
||||
* - Start/stop hosting (activates the PeerJS peer so participants can connect)
|
||||
*/
|
||||
|
||||
import { Component, OnInit, OnDestroy } from '@angular/core';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { ToastController } from '@ionic/angular';
|
||||
import { DataConnection } from 'peerjs';
|
||||
import { Subscription } from 'rxjs';
|
||||
import { SurveyService } from '../../services/survey.service';
|
||||
import { ResponseService } from '../../services/response.service';
|
||||
import { PeerService } from '../../services/peer.service';
|
||||
import type { Survey, Participant, P2PMessage } from '../../shared/models/survey.models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-survey-detail',
|
||||
templateUrl: './survey-detail.page.html',
|
||||
styleUrls: ['./survey-detail.page.scss'],
|
||||
standalone: false,
|
||||
})
|
||||
export class SurveyDetailPage implements OnInit, OnDestroy {
|
||||
survey?: Survey;
|
||||
participants: Participant[] = [];
|
||||
responseCount = 0;
|
||||
|
||||
/** Number of tokens to generate (bound to the input field) */
|
||||
linkCount = 5;
|
||||
|
||||
/** True while the PeerJS peer is open and accepting connections */
|
||||
isHosting = false;
|
||||
|
||||
/** Base URL used when constructing share links */
|
||||
get appBaseUrl(): string {
|
||||
return window.location.origin;
|
||||
}
|
||||
|
||||
private surveyId = '';
|
||||
private connectionSubscription?: Subscription;
|
||||
private participantSubscription?: Subscription;
|
||||
|
||||
constructor(
|
||||
private route: ActivatedRoute,
|
||||
private router: Router,
|
||||
private surveyService: SurveyService,
|
||||
private responseService: ResponseService,
|
||||
private peerService: PeerService,
|
||||
private toastCtrl: ToastController
|
||||
) {}
|
||||
|
||||
async ngOnInit(): Promise<void> {
|
||||
this.surveyId = this.route.snapshot.paramMap.get('id') ?? '';
|
||||
if (!this.surveyId) {
|
||||
this.router.navigate(['/home']);
|
||||
return;
|
||||
}
|
||||
await this.loadSurvey();
|
||||
this.subscribeToParticipants();
|
||||
await this.loadResponseCount();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.connectionSubscription?.unsubscribe();
|
||||
this.participantSubscription?.unsubscribe();
|
||||
// Stop hosting when navigating away
|
||||
this.stopHosting();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Data loading
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private async loadSurvey(): Promise<void> {
|
||||
this.survey = await this.surveyService.getSurvey(this.surveyId);
|
||||
if (!this.survey) {
|
||||
const toast = await this.toastCtrl.create({
|
||||
message: 'Survey not found.',
|
||||
duration: 2000,
|
||||
color: 'danger',
|
||||
});
|
||||
await toast.present();
|
||||
this.router.navigate(['/home']);
|
||||
}
|
||||
}
|
||||
|
||||
private subscribeToParticipants(): void {
|
||||
this.participantSubscription = this.surveyService
|
||||
.getParticipants$(this.surveyId)
|
||||
.subscribe((participants) => {
|
||||
this.participants = participants;
|
||||
});
|
||||
}
|
||||
|
||||
private async loadResponseCount(): Promise<void> {
|
||||
const responses = await this.responseService.getResponses(this.surveyId);
|
||||
this.responseCount = responses.length;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Settings
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/** Persist the showResultsToParticipants flag after ngModel has updated it */
|
||||
async toggleShowResults(): Promise<void> {
|
||||
if (!this.survey) return;
|
||||
await this.surveyService.updateSurvey(this.surveyId, {
|
||||
showResultsToParticipants: this.survey.showResultsToParticipants,
|
||||
});
|
||||
}
|
||||
|
||||
async changeSurveyStatus(status: Survey['status']): Promise<void> {
|
||||
if (!this.survey) return;
|
||||
await this.surveyService.updateSurvey(this.surveyId, { status });
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Link generation
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
async generateLinks(): Promise<void> {
|
||||
if (this.linkCount < 1 || this.linkCount > 200) return;
|
||||
await this.surveyService.generateParticipantTokens(this.surveyId, this.linkCount);
|
||||
const toast = await this.toastCtrl.create({
|
||||
message: `${this.linkCount} link${this.linkCount !== 1 ? 's' : ''} generated.`,
|
||||
duration: 2000,
|
||||
color: 'success',
|
||||
});
|
||||
await toast.present();
|
||||
}
|
||||
|
||||
/** Build the full share URL for a participant token */
|
||||
buildLink(token: string): string {
|
||||
return `${this.appBaseUrl}/participate?host=survey-${this.surveyId}&token=${token}`;
|
||||
}
|
||||
|
||||
/** Copy a link to the clipboard and show a toast */
|
||||
async copyLink(token: string): Promise<void> {
|
||||
const link = this.buildLink(token);
|
||||
await navigator.clipboard.writeText(link);
|
||||
const toast = await this.toastCtrl.create({
|
||||
message: 'Link copied to clipboard.',
|
||||
duration: 1500,
|
||||
color: 'medium',
|
||||
});
|
||||
await toast.present();
|
||||
}
|
||||
|
||||
/** Truncate a UUID token for display (first 8 chars) */
|
||||
truncateToken(token: string): string {
|
||||
return token.substring(0, 8) + '…';
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Hosting (PeerJS)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
async startHosting(): Promise<void> {
|
||||
if (!this.survey) return;
|
||||
|
||||
try {
|
||||
const peerId = `survey-${this.surveyId}`;
|
||||
await this.peerService.init(peerId);
|
||||
this.isHosting = true;
|
||||
|
||||
// Update survey status to active
|
||||
await this.changeSurveyStatus('active');
|
||||
|
||||
// Listen for incoming participant connections
|
||||
this.connectionSubscription = this.peerService.onConnection$.subscribe(
|
||||
(conn) => this.handleParticipantConnection(conn)
|
||||
);
|
||||
|
||||
const toast = await this.toastCtrl.create({
|
||||
message: 'Hosting started. Participants can now connect.',
|
||||
duration: 3000,
|
||||
color: 'success',
|
||||
});
|
||||
await toast.present();
|
||||
} catch (err) {
|
||||
console.error('Failed to start hosting:', err);
|
||||
const toast = await this.toastCtrl.create({
|
||||
message: 'Could not start hosting. Check your network connection.',
|
||||
duration: 3000,
|
||||
color: 'danger',
|
||||
});
|
||||
await toast.present();
|
||||
}
|
||||
}
|
||||
|
||||
stopHosting(): void {
|
||||
if (this.isHosting) {
|
||||
this.connectionSubscription?.unsubscribe();
|
||||
this.peerService.destroy();
|
||||
this.isHosting = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a new DataConnection from a participant.
|
||||
* The participant will send a 'join' message with their token.
|
||||
*/
|
||||
private handleParticipantConnection(conn: DataConnection): void {
|
||||
conn.on('open', () => {
|
||||
// Connection is open; wait for the participant to identify themselves
|
||||
});
|
||||
|
||||
conn.on('data', async (rawMsg) => {
|
||||
const msg = rawMsg as P2PMessage;
|
||||
await this.processMessage(conn, msg);
|
||||
});
|
||||
|
||||
conn.on('error', (err) => {
|
||||
console.error('Participant connection error:', err);
|
||||
});
|
||||
}
|
||||
|
||||
/** Process a single P2P message from a participant */
|
||||
private async processMessage(conn: DataConnection, msg: P2PMessage): Promise<void> {
|
||||
if (!this.survey) return;
|
||||
|
||||
if (msg.type === 'join') {
|
||||
if (this.survey.status === 'draft') {
|
||||
conn.send({ type: 'error', reason: 'survey_draft' } as P2PMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.survey.status === 'closed') {
|
||||
conn.send({ type: 'error', reason: 'survey_closed' } as P2PMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
const participant = await this.surveyService.getParticipant(msg.token);
|
||||
|
||||
if (!participant || participant.surveyId !== this.surveyId) {
|
||||
conn.send({ type: 'error', reason: 'invalid_token' } as P2PMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
if (participant.locked) {
|
||||
conn.send({ type: 'error', reason: 'already_submitted' } as P2PMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
// Record first connection time
|
||||
if (!participant.usedAt) {
|
||||
await this.surveyService.markParticipantUsed(msg.token);
|
||||
}
|
||||
|
||||
// Send survey questions to the participant
|
||||
conn.send({
|
||||
type: 'survey',
|
||||
data: this.survey,
|
||||
showResults: this.survey.showResultsToParticipants,
|
||||
} as P2PMessage);
|
||||
}
|
||||
|
||||
if (msg.type === 'update') {
|
||||
const participant = await this.surveyService.getParticipant(msg.token);
|
||||
if (!participant || participant.locked) return;
|
||||
|
||||
// Save draft (does not lock the token)
|
||||
await this.responseService.saveResponse(this.surveyId, msg.token, msg.answers);
|
||||
conn.send({ type: 'ack', status: 'updated' } as P2PMessage);
|
||||
await this.loadResponseCount();
|
||||
}
|
||||
|
||||
if (msg.type === 'submit') {
|
||||
const participant = await this.surveyService.getParticipant(msg.token);
|
||||
if (!participant || participant.locked) return;
|
||||
|
||||
// Save final response and lock the token
|
||||
await this.responseService.saveResponse(this.surveyId, msg.token, msg.answers);
|
||||
await this.surveyService.lockParticipantToken(msg.token);
|
||||
conn.send({ type: 'ack', status: 'submitted' } as P2PMessage);
|
||||
await this.loadResponseCount();
|
||||
|
||||
// If configured, push aggregated results back to the participant
|
||||
if (this.survey.showResultsToParticipants) {
|
||||
const results = await this.responseService.computeResults(this.survey);
|
||||
conn.send({ type: 'results', data: results } as P2PMessage);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Navigation
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
editSurvey(): void {
|
||||
this.router.navigate(['/create-survey', this.surveyId]);
|
||||
}
|
||||
|
||||
viewResults(): void {
|
||||
this.router.navigate(['/survey', this.surveyId, 'results']);
|
||||
}
|
||||
|
||||
goBack(): void {
|
||||
this.router.navigate(['/home']);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
participantStatus(p: Participant): string {
|
||||
if (p.locked) return 'submitted';
|
||||
if (p.usedAt) return 'in progress';
|
||||
return 'pending';
|
||||
}
|
||||
|
||||
participantStatusColor(p: Participant): string {
|
||||
if (p.locked) return 'success';
|
||||
if (p.usedAt) return 'warning';
|
||||
return 'medium';
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { Routes, RouterModule } from '@angular/router';
|
||||
|
||||
import { SurveyResultsPage } from './survey-results.page';
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: SurveyResultsPage
|
||||
}
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
imports: [RouterModule.forChild(routes)],
|
||||
exports: [RouterModule],
|
||||
})
|
||||
export class SurveyResultsPageRoutingModule {}
|
||||
@@ -1,18 +0,0 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { IonicModule } from '@ionic/angular';
|
||||
|
||||
import { SurveyResultsPageRoutingModule } from './survey-results-routing.module';
|
||||
import { SurveyResultsPage } from './survey-results.page';
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
IonicModule,
|
||||
SurveyResultsPageRoutingModule,
|
||||
],
|
||||
declarations: [SurveyResultsPage],
|
||||
})
|
||||
export class SurveyResultsPageModule {}
|
||||
@@ -1,125 +0,0 @@
|
||||
<ion-header>
|
||||
<ion-toolbar color="primary">
|
||||
<ion-buttons slot="start">
|
||||
<ion-back-button [defaultHref]="'/survey/' + surveyId"></ion-back-button>
|
||||
</ion-buttons>
|
||||
<ion-title>Results</ion-title>
|
||||
<ion-buttons slot="end">
|
||||
<ion-button (click)="exportCsv()" [disabled]="responses.length === 0" title="Export CSV">
|
||||
<ion-icon slot="icon-only" name="download-outline"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-buttons>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content>
|
||||
|
||||
<!-- Survey title and response count -->
|
||||
<div class="results-header ion-padding">
|
||||
<h2>{{ survey?.title }}</h2>
|
||||
<p>
|
||||
<strong>{{ responses.length }}</strong> response{{ responses.length !== 1 ? 's' : '' }} collected
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Tab selector -->
|
||||
<ion-segment [(ngModel)]="activeTab" class="ion-padding-horizontal">
|
||||
<ion-segment-button value="summary">
|
||||
<ion-label>Summary</ion-label>
|
||||
</ion-segment-button>
|
||||
<ion-segment-button value="individual">
|
||||
<ion-label>Individual</ion-label>
|
||||
</ion-segment-button>
|
||||
</ion-segment>
|
||||
|
||||
<!-- No responses yet -->
|
||||
<div *ngIf="responses.length === 0" class="empty-state ion-padding">
|
||||
<ion-icon name="hourglass-outline" size="large"></ion-icon>
|
||||
<h3>No responses yet</h3>
|
||||
<p>Start hosting on the survey detail page so participants can connect.</p>
|
||||
</div>
|
||||
|
||||
<!-- ── Summary tab ── -->
|
||||
<div *ngIf="activeTab === 'summary' && responses.length > 0 && results">
|
||||
<ng-container *ngFor="let qId of questionIds()">
|
||||
<ion-card *ngIf="results.answers[qId] as qr">
|
||||
<ion-card-header>
|
||||
<ion-card-subtitle>{{ formatQuestionType(qr.type) }}</ion-card-subtitle>
|
||||
<ion-card-title class="question-title">{{ qr.questionText }}</ion-card-title>
|
||||
</ion-card-header>
|
||||
<ion-card-content>
|
||||
|
||||
<!-- Multiple choice / yes_no: bar chart -->
|
||||
<ng-container *ngIf="qr.tally">
|
||||
<div
|
||||
*ngFor="let entry of tallyEntries(qr.tally)"
|
||||
class="bar-row">
|
||||
<span class="bar-label">{{ entry.key }}</span>
|
||||
<div class="bar-track">
|
||||
<div
|
||||
class="bar-fill"
|
||||
[style.width.%]="tallyPercent(entry.count)">
|
||||
</div>
|
||||
</div>
|
||||
<span class="bar-count">{{ entry.count }} ({{ tallyPercent(entry.count) }}%)</span>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<!-- Text: list of all answers -->
|
||||
<ng-container *ngIf="qr.texts">
|
||||
<ion-list lines="inset">
|
||||
<ion-item *ngFor="let text of qr.texts; let i = index">
|
||||
<ion-label class="ion-text-wrap">
|
||||
<p>{{ i + 1 }}. {{ text }}</p>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
</ion-list>
|
||||
<p *ngIf="qr.texts.length === 0" class="hint-text">No answers yet.</p>
|
||||
</ng-container>
|
||||
|
||||
<!-- Rating: average + distribution -->
|
||||
<ng-container *ngIf="qr.ratingDistribution">
|
||||
<p class="rating-avg">
|
||||
Average: <strong>{{ formatAvg(qr.ratingAvg) }}</strong> / 5
|
||||
</p>
|
||||
<div
|
||||
*ngFor="let count of qr.ratingDistribution; let i = index"
|
||||
class="bar-row">
|
||||
<span class="bar-label">{{ ratingLabel(i) }} ★</span>
|
||||
<div class="bar-track">
|
||||
<div
|
||||
class="bar-fill"
|
||||
[style.width.%]="tallyPercent(count)">
|
||||
</div>
|
||||
</div>
|
||||
<span class="bar-count">{{ count }}</span>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
</ion-card-content>
|
||||
</ion-card>
|
||||
</ng-container>
|
||||
</div>
|
||||
|
||||
<!-- ── Individual tab ── -->
|
||||
<div *ngIf="activeTab === 'individual' && responses.length > 0">
|
||||
<ion-card *ngFor="let response of responses; let i = index">
|
||||
<ion-card-header>
|
||||
<ion-card-subtitle>Response #{{ i + 1 }} • {{ response.submittedAt | date:'medium' }}</ion-card-subtitle>
|
||||
</ion-card-header>
|
||||
<ion-card-content>
|
||||
<ion-list lines="none">
|
||||
<ion-item *ngFor="let q of survey!.questions">
|
||||
<ion-label class="ion-text-wrap">
|
||||
<p class="question-text">{{ q.text }}</p>
|
||||
<p class="answer-text">
|
||||
<strong>{{ response.answers[q.id] ?? '(no answer)' }}</strong>
|
||||
</p>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
</ion-list>
|
||||
</ion-card-content>
|
||||
</ion-card>
|
||||
</div>
|
||||
|
||||
</ion-content>
|
||||
@@ -1,93 +0,0 @@
|
||||
.results-header {
|
||||
padding-bottom: 0;
|
||||
|
||||
h2 {
|
||||
font-size: 1.3rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
p {
|
||||
color: var(--ion-color-medium);
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px 24px;
|
||||
text-align: center;
|
||||
|
||||
ion-icon {
|
||||
font-size: 56px;
|
||||
color: var(--ion-color-medium);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.bar-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.bar-label {
|
||||
min-width: 80px;
|
||||
font-size: 0.875rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.bar-track {
|
||||
flex: 1;
|
||||
height: 16px;
|
||||
background: var(--ion-color-light);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bar-fill {
|
||||
height: 100%;
|
||||
background: var(--ion-color-primary);
|
||||
border-radius: 8px;
|
||||
transition: width 0.4s ease;
|
||||
min-width: 2px;
|
||||
}
|
||||
|
||||
.bar-count {
|
||||
min-width: 70px;
|
||||
font-size: 0.8rem;
|
||||
color: var(--ion-color-medium);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.question-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.rating-avg {
|
||||
font-size: 1rem;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.hint-text {
|
||||
color: var(--ion-color-medium);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.question-text {
|
||||
color: var(--ion-color-medium);
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.answer-text {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
/**
|
||||
* survey-results.page.ts
|
||||
* Live results view for the survey creator.
|
||||
*
|
||||
* Uses Dexie liveQuery (via ResponseService) to automatically refresh
|
||||
* whenever a new response is saved. Can also export responses as CSV.
|
||||
*/
|
||||
|
||||
import { Component, OnInit, OnDestroy } from '@angular/core';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { ToastController } from '@ionic/angular';
|
||||
import { Subscription } from 'rxjs';
|
||||
import { SurveyService } from '../../services/survey.service';
|
||||
import { ResponseService } from '../../services/response.service';
|
||||
import type { Survey, Response, SurveyResults } from '../../shared/models/survey.models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-survey-results',
|
||||
templateUrl: './survey-results.page.html',
|
||||
styleUrls: ['./survey-results.page.scss'],
|
||||
standalone: false,
|
||||
})
|
||||
export class SurveyResultsPage implements OnInit, OnDestroy {
|
||||
survey?: Survey;
|
||||
responses: Response[] = [];
|
||||
results?: SurveyResults;
|
||||
|
||||
/** Controls which tab is active: 'summary' | 'individual' */
|
||||
activeTab: 'summary' | 'individual' = 'summary';
|
||||
|
||||
surveyId = '';
|
||||
private responseSubscription?: Subscription;
|
||||
|
||||
constructor(
|
||||
private route: ActivatedRoute,
|
||||
private router: Router,
|
||||
private surveyService: SurveyService,
|
||||
private responseService: ResponseService,
|
||||
private toastCtrl: ToastController
|
||||
) {}
|
||||
|
||||
async ngOnInit(): Promise<void> {
|
||||
this.surveyId = this.route.snapshot.paramMap.get('id') ?? '';
|
||||
if (!this.surveyId) {
|
||||
this.router.navigate(['/home']);
|
||||
return;
|
||||
}
|
||||
|
||||
this.survey = await this.surveyService.getSurvey(this.surveyId);
|
||||
if (!this.survey) {
|
||||
this.router.navigate(['/home']);
|
||||
return;
|
||||
}
|
||||
|
||||
// Subscribe to live updates — results refresh whenever a new response arrives
|
||||
this.responseSubscription = this.responseService
|
||||
.getResponses$(this.surveyId)
|
||||
.subscribe(async (responses) => {
|
||||
this.responses = responses;
|
||||
if (this.survey) {
|
||||
this.results = await this.responseService.computeResults(this.survey);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.responseSubscription?.unsubscribe();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Helpers for the template
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/** Returns the keys of the results.answers object in question order */
|
||||
questionIds(): string[] {
|
||||
return this.survey?.questions.map((q) => q.id) ?? [];
|
||||
}
|
||||
|
||||
/** Formats a rating average to one decimal place */
|
||||
formatAvg(avg: number | undefined): string {
|
||||
return avg != null ? avg.toFixed(1) : '–';
|
||||
}
|
||||
|
||||
/** Returns tally entries sorted by count (highest first) */
|
||||
tallyEntries(tally: Record<string, number>): { key: string; count: number }[] {
|
||||
return Object.entries(tally)
|
||||
.map(([key, count]) => ({ key, count }))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
}
|
||||
|
||||
/** Percentage of total responses for a tally value */
|
||||
tallyPercent(count: number): number {
|
||||
if (this.responses.length === 0) return 0;
|
||||
return Math.round((count / this.responses.length) * 100);
|
||||
}
|
||||
|
||||
/** Returns the label for a rating index (1-based) */
|
||||
ratingLabel(index: number): string {
|
||||
return String(index + 1);
|
||||
}
|
||||
|
||||
/** Converts snake_case question type to a readable label */
|
||||
formatQuestionType(type: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
text: 'Free Text',
|
||||
multiple_choice: 'Multiple Choice',
|
||||
yes_no: 'Yes / No',
|
||||
rating: 'Rating',
|
||||
};
|
||||
return labels[type] ?? type;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// CSV export
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
exportCsv(): void {
|
||||
if (!this.survey || this.responses.length === 0) return;
|
||||
|
||||
const questions = this.survey.questions;
|
||||
const header = ['Submitted At', ...questions.map((q) => q.text)].join(',');
|
||||
const rows = this.responses.map((r) => {
|
||||
const values = questions.map((q) => {
|
||||
const ans = r.answers[q.id] ?? '';
|
||||
// Escape commas and newlines in text answers
|
||||
const escaped = String(ans).replace(/"/g, '""');
|
||||
return `"${escaped}"`;
|
||||
});
|
||||
return [`"${r.submittedAt}"`, ...values].join(',');
|
||||
});
|
||||
|
||||
const csv = [header, ...rows].join('\n');
|
||||
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${this.survey.title.replace(/\s+/g, '_')}_results.csv`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
goBack(): void {
|
||||
this.router.navigate(['/survey', this.surveyId]);
|
||||
}
|
||||
}
|
||||
@@ -1,198 +0,0 @@
|
||||
/**
|
||||
* peer.service.ts
|
||||
* Angular service wrapping PeerJS for P2P WebRTC data channel communication.
|
||||
*
|
||||
* This service works for both the host (survey creator) and participants.
|
||||
*
|
||||
* IMPORTANT: All PeerJS event callbacks fire outside Angular's change-detection
|
||||
* zone. Every callback body is wrapped in NgZone.run() to ensure the UI updates
|
||||
* correctly after receiving P2P messages.
|
||||
*/
|
||||
|
||||
import { Injectable, NgZone, OnDestroy } from '@angular/core';
|
||||
import Peer, { DataConnection } from 'peerjs';
|
||||
import { Subject, Observable } from 'rxjs';
|
||||
|
||||
/** Connection states used to drive UI feedback */
|
||||
export type PeerConnectionState =
|
||||
| 'idle'
|
||||
| 'connecting'
|
||||
| 'connected'
|
||||
| 'disconnected'
|
||||
| 'error';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class PeerService implements OnDestroy {
|
||||
private peer: Peer | null = null;
|
||||
|
||||
/** All active data connections, keyed by the remote peer ID */
|
||||
private connections = new Map<string, DataConnection>();
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Subjects exposed as Observables — components subscribe to these
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/** Emits every new incoming DataConnection (host side) */
|
||||
private connectionSubject = new Subject<DataConnection>();
|
||||
readonly onConnection$: Observable<DataConnection> =
|
||||
this.connectionSubject.asObservable();
|
||||
|
||||
/** Emits when THIS peer disconnects from the PeerJS broker */
|
||||
private disconnectSubject = new Subject<void>();
|
||||
readonly onDisconnect$: Observable<void> =
|
||||
this.disconnectSubject.asObservable();
|
||||
|
||||
/** Emits PeerJS errors */
|
||||
private errorSubject = new Subject<Error>();
|
||||
readonly onError$: Observable<Error> = this.errorSubject.asObservable();
|
||||
|
||||
/** Current peer ID assigned by the broker (null if not yet initialised) */
|
||||
currentPeerId: string | null = null;
|
||||
|
||||
/** Observable connection state of this peer to the signaling broker */
|
||||
private stateSubject = new Subject<PeerConnectionState>();
|
||||
readonly state$: Observable<PeerConnectionState> =
|
||||
this.stateSubject.asObservable();
|
||||
|
||||
constructor(private ngZone: NgZone) {}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Initialisation
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Create and open a PeerJS connection to the signaling broker.
|
||||
*
|
||||
* @param peerId Optional fixed peer ID.
|
||||
* Hosts use `survey-{surveyId}` so participants can find them.
|
||||
* Participants omit this to get a random ID.
|
||||
* @returns The assigned peer ID.
|
||||
*/
|
||||
init(peerId?: string): Promise<string> {
|
||||
// Destroy any previous instance before re-initialising
|
||||
this.destroy();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.ngZone.runOutsideAngular(() => {
|
||||
this.peer = peerId ? new Peer(peerId) : new Peer();
|
||||
|
||||
this.peer.on('open', (id) => {
|
||||
this.ngZone.run(() => {
|
||||
this.currentPeerId = id;
|
||||
this.stateSubject.next('connected');
|
||||
resolve(id);
|
||||
});
|
||||
});
|
||||
|
||||
this.peer.on('connection', (conn) => {
|
||||
this.ngZone.run(() => {
|
||||
this.registerConnection(conn);
|
||||
this.connectionSubject.next(conn);
|
||||
});
|
||||
});
|
||||
|
||||
this.peer.on('disconnected', () => {
|
||||
this.ngZone.run(() => {
|
||||
this.stateSubject.next('disconnected');
|
||||
this.disconnectSubject.next();
|
||||
});
|
||||
});
|
||||
|
||||
this.peer.on('error', (err) => {
|
||||
this.ngZone.run(() => {
|
||||
this.stateSubject.next('error');
|
||||
this.errorSubject.next(err as Error);
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Outbound connections (participant side)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Connect to a remote peer (the host).
|
||||
* Returns the DataConnection immediately; caller should listen for
|
||||
* the 'open' event before sending messages.
|
||||
*
|
||||
* @param remotePeerId The host's peer ID, e.g. `survey-{surveyId}`.
|
||||
*/
|
||||
connectTo(remotePeerId: string): DataConnection {
|
||||
if (!this.peer) {
|
||||
throw new Error('PeerService: call init() before connectTo()');
|
||||
}
|
||||
|
||||
const conn = this.peer.connect(remotePeerId, { reliable: true });
|
||||
this.registerConnection(conn);
|
||||
return conn;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Sending data
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Send a message to a connected peer.
|
||||
* The message is automatically serialised (PeerJS uses JSON by default).
|
||||
*
|
||||
* @param remotePeerId Target peer ID.
|
||||
* @param data Any JSON-serialisable value.
|
||||
*/
|
||||
send(remotePeerId: string, data: unknown): void {
|
||||
const conn = this.connections.get(remotePeerId);
|
||||
if (conn && conn.open) {
|
||||
conn.send(data);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Store a connection and set up common event listeners on it.
|
||||
* Wraps all callbacks in NgZone.run() for change-detection safety.
|
||||
*/
|
||||
private registerConnection(conn: DataConnection): void {
|
||||
this.connections.set(conn.peer, conn);
|
||||
|
||||
conn.on('close', () => {
|
||||
this.ngZone.run(() => {
|
||||
this.connections.delete(conn.peer);
|
||||
});
|
||||
});
|
||||
|
||||
conn.on('error', (err) => {
|
||||
this.ngZone.run(() => {
|
||||
console.error(`PeerService: connection error with ${conn.peer}`, err);
|
||||
this.connections.delete(conn.peer);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Cleanup
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Close all connections and destroy the PeerJS instance.
|
||||
* Call this when the host navigates away from the survey page.
|
||||
*/
|
||||
destroy(): void {
|
||||
if (this.peer) {
|
||||
this.connections.forEach((conn) => conn.close());
|
||||
this.connections.clear();
|
||||
this.peer.destroy();
|
||||
this.peer = null;
|
||||
this.currentPeerId = null;
|
||||
this.stateSubject.next('idle');
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroy();
|
||||
}
|
||||
}
|
||||
@@ -1,181 +0,0 @@
|
||||
/**
|
||||
* response.service.ts
|
||||
* Read/write operations for survey responses and result aggregation.
|
||||
*
|
||||
* Responses are stored exclusively in the host's (creator's) IndexedDB.
|
||||
* Participants never store response data locally.
|
||||
*/
|
||||
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Observable, from } from 'rxjs';
|
||||
import { liveQuery } from 'dexie';
|
||||
import { db } from '../database/database';
|
||||
import type {
|
||||
Response,
|
||||
Survey,
|
||||
QuestionResult,
|
||||
SurveyResults,
|
||||
} from '../shared/models/survey.models';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ResponseService {
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Persistence
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Save (insert or update) a participant's response.
|
||||
* If a response for the given participantToken already exists, it is replaced.
|
||||
* This handles both final submissions and draft updates.
|
||||
*
|
||||
* @param surveyId The survey being answered.
|
||||
* @param participantToken The participant's unique token.
|
||||
* @param answers Map from questionId to answer value.
|
||||
*/
|
||||
async saveResponse(
|
||||
surveyId: string,
|
||||
participantToken: string,
|
||||
answers: Record<string, unknown>
|
||||
): Promise<void> {
|
||||
// Check for existing response to preserve the original submission time
|
||||
const existing = await db.responses
|
||||
.where('participantToken')
|
||||
.equals(participantToken)
|
||||
.first();
|
||||
|
||||
const now = new Date().toISOString();
|
||||
|
||||
if (existing) {
|
||||
await db.responses.update(existing.id, {
|
||||
answers,
|
||||
updatedAt: now,
|
||||
});
|
||||
} else {
|
||||
const response: Response = {
|
||||
id: crypto.randomUUID(),
|
||||
surveyId,
|
||||
participantToken,
|
||||
answers,
|
||||
submittedAt: now,
|
||||
};
|
||||
await db.responses.add(response);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Reading responses
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/** Get all responses for a survey (one-time async read) */
|
||||
async getResponses(surveyId: string): Promise<Response[]> {
|
||||
return db.responses.where('surveyId').equals(surveyId).toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reactive stream of all responses for a survey.
|
||||
* Automatically re-emits whenever a new response is saved or updated.
|
||||
* Use this on the results page for real-time updates.
|
||||
*/
|
||||
getResponses$(surveyId: string): Observable<Response[]> {
|
||||
return from(
|
||||
liveQuery(() =>
|
||||
db.responses.where('surveyId').equals(surveyId).toArray()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/** Get the single response submitted for a given participant token */
|
||||
async getResponseByToken(participantToken: string): Promise<Response | undefined> {
|
||||
return db.responses
|
||||
.where('participantToken')
|
||||
.equals(participantToken)
|
||||
.first();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Result aggregation
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Compute aggregated results for a survey.
|
||||
* This is computed on demand — the aggregation is not stored anywhere.
|
||||
*
|
||||
* @param survey The full survey definition (needed for question metadata).
|
||||
* @returns Aggregated SurveyResults object.
|
||||
*/
|
||||
async computeResults(survey: Survey): Promise<SurveyResults> {
|
||||
const responses = await this.getResponses(survey.id);
|
||||
const results: SurveyResults = {
|
||||
surveyId: survey.id,
|
||||
totalResponses: responses.length,
|
||||
answers: {},
|
||||
};
|
||||
|
||||
for (const question of survey.questions) {
|
||||
const questionResult: QuestionResult = {
|
||||
questionId: question.id,
|
||||
questionText: question.text,
|
||||
type: question.type,
|
||||
};
|
||||
|
||||
// Collect the answer for this question from every response
|
||||
const rawAnswers = responses
|
||||
.map((r) => r.answers[question.id])
|
||||
.filter((a) => a !== undefined && a !== null && a !== '');
|
||||
|
||||
switch (question.type) {
|
||||
case 'multiple_choice': {
|
||||
// Count how many times each option was selected
|
||||
const tally: Record<string, number> = {};
|
||||
// Initialise all options to 0 so even unselected options appear
|
||||
(question.options ?? []).forEach((opt) => (tally[opt] = 0));
|
||||
rawAnswers.forEach((a) => {
|
||||
const key = String(a);
|
||||
tally[key] = (tally[key] ?? 0) + 1;
|
||||
});
|
||||
questionResult.tally = tally;
|
||||
break;
|
||||
}
|
||||
|
||||
case 'yes_no': {
|
||||
const tally: Record<string, number> = { Yes: 0, No: 0 };
|
||||
rawAnswers.forEach((a) => {
|
||||
const key = a === true || a === 'Yes' ? 'Yes' : 'No';
|
||||
tally[key]++;
|
||||
});
|
||||
questionResult.tally = tally;
|
||||
break;
|
||||
}
|
||||
|
||||
case 'text': {
|
||||
questionResult.texts = rawAnswers.map(String);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'rating': {
|
||||
const nums = rawAnswers.map(Number).filter((n) => !isNaN(n));
|
||||
if (nums.length > 0) {
|
||||
questionResult.ratingAvg =
|
||||
nums.reduce((acc, n) => acc + n, 0) / nums.length;
|
||||
|
||||
// distribution[0] = count of rating 1, ..., distribution[4] = count of rating 5
|
||||
const dist = [0, 0, 0, 0, 0];
|
||||
nums.forEach((n) => {
|
||||
const idx = Math.round(n) - 1;
|
||||
if (idx >= 0 && idx <= 4) {
|
||||
dist[idx]++;
|
||||
}
|
||||
});
|
||||
questionResult.ratingDistribution = dist;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
results.answers[question.id] = questionResult;
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
||||
@@ -1,160 +0,0 @@
|
||||
/**
|
||||
* survey.service.ts
|
||||
* CRUD operations for surveys and participant tokens using Dexie (IndexedDB).
|
||||
*
|
||||
* The service exposes both async methods (for one-time reads/writes) and
|
||||
* Observable streams powered by Dexie's liveQuery for reactive UI updates.
|
||||
*/
|
||||
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Observable, from } from 'rxjs';
|
||||
import { liveQuery } from 'dexie';
|
||||
import { db } from '../database/database';
|
||||
import type { Survey, Question, Participant } from '../shared/models/survey.models';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class SurveyService {
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Survey CRUD
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Reactive stream of all surveys, ordered newest-first.
|
||||
* Automatically emits whenever the surveys table changes.
|
||||
*/
|
||||
getAllSurveys$(): Observable<Survey[]> {
|
||||
return from(
|
||||
liveQuery(() =>
|
||||
db.surveys.orderBy('createdAt').reverse().toArray()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/** Retrieve a single survey by its UUID */
|
||||
async getSurvey(id: string): Promise<Survey | undefined> {
|
||||
return db.surveys.get(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new survey and persist it to IndexedDB.
|
||||
* Assigns a UUID and sets default values.
|
||||
*
|
||||
* @param data Partial survey — title and questions are required.
|
||||
* @returns The fully populated Survey object.
|
||||
*/
|
||||
async createSurvey(data: {
|
||||
title: string;
|
||||
description?: string;
|
||||
questions: Question[];
|
||||
showResultsToParticipants?: boolean;
|
||||
}): Promise<Survey> {
|
||||
const survey: Survey = {
|
||||
id: crypto.randomUUID(),
|
||||
title: data.title,
|
||||
description: data.description,
|
||||
questions: data.questions,
|
||||
createdAt: new Date().toISOString(),
|
||||
showResultsToParticipants: data.showResultsToParticipants ?? false,
|
||||
status: 'draft',
|
||||
};
|
||||
await db.surveys.add(survey);
|
||||
return survey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Partially update an existing survey.
|
||||
* Pass only the fields you want to change.
|
||||
*/
|
||||
async updateSurvey(id: string, patch: Partial<Omit<Survey, 'id'>>): Promise<void> {
|
||||
await db.surveys.update(id, patch);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a survey and all associated participants and responses.
|
||||
* Once deleted the data is gone — there is no undo.
|
||||
*/
|
||||
async deleteSurvey(id: string): Promise<void> {
|
||||
await db.transaction('rw', [db.surveys, db.participants, db.responses], async () => {
|
||||
// Remove all participant tokens for this survey
|
||||
await db.participants.where('surveyId').equals(id).delete();
|
||||
// Remove all responses for this survey
|
||||
await db.responses.where('surveyId').equals(id).delete();
|
||||
// Remove the survey itself
|
||||
await db.surveys.delete(id);
|
||||
});
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Participant token management
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Generate `count` unique participant tokens for the given survey and
|
||||
* store them in IndexedDB.
|
||||
*
|
||||
* Each token becomes the identity embedded in a shareable link.
|
||||
* Tokens are NOT locked until the participant clicks "Submit".
|
||||
*
|
||||
* @returns The array of newly created Participant records.
|
||||
*/
|
||||
async generateParticipantTokens(
|
||||
surveyId: string,
|
||||
count: number
|
||||
): Promise<Participant[]> {
|
||||
const participants: Participant[] = Array.from({ length: count }, () => ({
|
||||
token: crypto.randomUUID(),
|
||||
surveyId,
|
||||
locked: false,
|
||||
}));
|
||||
await db.participants.bulkAdd(participants);
|
||||
return participants;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve all participant tokens for a survey (ordered by insertion).
|
||||
*/
|
||||
async getParticipants(surveyId: string): Promise<Participant[]> {
|
||||
return db.participants.where('surveyId').equals(surveyId).toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reactive stream of participants for a survey.
|
||||
* Emits whenever a participant's status changes (e.g. locked).
|
||||
*/
|
||||
getParticipants$(surveyId: string): Observable<Participant[]> {
|
||||
return from(
|
||||
liveQuery(() =>
|
||||
db.participants.where('surveyId').equals(surveyId).toArray()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/** Look up a single participant by token */
|
||||
async getParticipant(token: string): Promise<Participant | undefined> {
|
||||
return db.participants.get(token);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a participant token as "used" (first connection received).
|
||||
* Does NOT lock the token — the participant can still update answers.
|
||||
*/
|
||||
async markParticipantUsed(token: string): Promise<void> {
|
||||
await db.participants.update(token, { usedAt: new Date().toISOString() });
|
||||
}
|
||||
|
||||
/**
|
||||
* Lock a participant token after a final submission.
|
||||
* Any subsequent join attempts with this token will be rejected.
|
||||
*/
|
||||
async lockParticipantToken(token: string): Promise<void> {
|
||||
await db.participants.update(token, { locked: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the optional human-readable label for a participant token.
|
||||
*/
|
||||
async updateParticipantLabel(token: string, label: string): Promise<void> {
|
||||
await db.participants.update(token, { label });
|
||||
}
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
/**
|
||||
* survey.models.ts
|
||||
* Central TypeScript interfaces and types for the P2P Survey App.
|
||||
* All data structures for surveys, participants, responses, and
|
||||
* the P2P wire protocol are defined here.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Core data models (stored in IndexedDB)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** A single question in a survey */
|
||||
export interface Question {
|
||||
/** Unique identifier (UUID) */
|
||||
id: string;
|
||||
/** Question text displayed to participants */
|
||||
text: string;
|
||||
/** Type of question determines the input widget */
|
||||
type: 'multiple_choice' | 'text' | 'rating' | 'yes_no';
|
||||
/** Answer options — only used when type === 'multiple_choice' */
|
||||
options?: string[];
|
||||
/** Whether the participant must answer this question before submitting */
|
||||
required: boolean;
|
||||
}
|
||||
|
||||
/** A complete survey definition created by the host */
|
||||
export interface Survey {
|
||||
/** Unique identifier (UUID) */
|
||||
id: string;
|
||||
/** Short title shown in lists and headings */
|
||||
title: string;
|
||||
/** Optional longer description shown to participants */
|
||||
description?: string;
|
||||
/** Ordered list of questions */
|
||||
questions: Question[];
|
||||
/** When the survey was created (stored as ISO string in IndexedDB) */
|
||||
createdAt: string;
|
||||
/**
|
||||
* If true, participants who submit their answers will receive
|
||||
* an aggregated results summary via the P2P data channel.
|
||||
*/
|
||||
showResultsToParticipants: boolean;
|
||||
/** Lifecycle state of the survey */
|
||||
status: 'draft' | 'active' | 'closed';
|
||||
}
|
||||
|
||||
/**
|
||||
* A pre-generated participation token tied to exactly one survey.
|
||||
* Each unique share link contains one of these tokens.
|
||||
* The primary key is `token` (UUID).
|
||||
*/
|
||||
export interface Participant {
|
||||
/** UUID that is embedded in the unique share link */
|
||||
token: string;
|
||||
/** The survey this token belongs to */
|
||||
surveyId: string;
|
||||
/** Optional human-readable label (e.g. "Alice", "Respondent 3") */
|
||||
label?: string;
|
||||
/** When this token was first used to connect */
|
||||
usedAt?: string;
|
||||
/**
|
||||
* True once the participant has clicked "Submit".
|
||||
* Locked tokens reject any further submissions from that link.
|
||||
*/
|
||||
locked: boolean;
|
||||
}
|
||||
|
||||
/** A participant's answers for one survey — stored by the host in IndexedDB */
|
||||
export interface Response {
|
||||
/** Unique identifier (UUID) */
|
||||
id: string;
|
||||
/** Which survey this response belongs to */
|
||||
surveyId: string;
|
||||
/** The participant token that submitted this response */
|
||||
participantToken: string;
|
||||
/** Map from questionId → answer value */
|
||||
answers: Record<string, unknown>;
|
||||
/** When the participant clicked "Submit" */
|
||||
submittedAt: string;
|
||||
/** Updated whenever the participant sends a draft update */
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Aggregated results (computed on-the-fly, not stored)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Aggregated result for a single question */
|
||||
export interface QuestionResult {
|
||||
questionId: string;
|
||||
questionText: string;
|
||||
type: Question['type'];
|
||||
/** For 'multiple_choice' and 'yes_no': count per option string */
|
||||
tally?: Record<string, number>;
|
||||
/** For 'text': array of all free-text answers */
|
||||
texts?: string[];
|
||||
/** For 'rating': arithmetic mean of all numeric answers */
|
||||
ratingAvg?: number;
|
||||
/** For 'rating': counts per rating value [index 0 = rating 1, ..., index 4 = rating 5] */
|
||||
ratingDistribution?: number[];
|
||||
}
|
||||
|
||||
/** Aggregated results for an entire survey */
|
||||
export interface SurveyResults {
|
||||
surveyId: string;
|
||||
totalResponses: number;
|
||||
/** Map from questionId → aggregated result */
|
||||
answers: Record<string, QuestionResult>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// P2P wire protocol — discriminated union of all messages
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Every message exchanged over the PeerJS data channel must conform
|
||||
* to one of these shapes. The `type` field is the discriminant.
|
||||
*
|
||||
* Flow:
|
||||
* Participant → Host : join, submit, update
|
||||
* Host → Participant : survey, results, ack, error
|
||||
*/
|
||||
export type P2PMessage =
|
||||
/** Participant identifies itself to the host and requests the survey */
|
||||
| { type: 'join'; token: string }
|
||||
/** Host sends the survey definition to the participant */
|
||||
| { type: 'survey'; data: Survey; showResults: boolean }
|
||||
/** Participant submits final answers — host will lock the token */
|
||||
| { type: 'submit'; token: string; answers: Record<string, unknown> }
|
||||
/** Participant saves a draft — host stores answers but does NOT lock token */
|
||||
| { type: 'update'; token: string; answers: Record<string, unknown> }
|
||||
/** Host pushes aggregated results to participant (if showResults is true) */
|
||||
| { type: 'results'; data: SurveyResults }
|
||||
/** Host acknowledges a successful submit or update */
|
||||
| { type: 'ack'; status: 'submitted' | 'updated' }
|
||||
/** Host signals an error — participant should display the reason */
|
||||
| { type: 'error'; reason: 'invalid_token' | 'already_submitted' | 'survey_not_found' | 'survey_closed' | 'survey_draft' };
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 930 B |
@@ -1 +0,0 @@
|
||||
<svg width="350" height="140" xmlns="http://www.w3.org/2000/svg" style="background:#f6f7f9"><g fill="none" fill-rule="evenodd"><path fill="#F04141" style="mix-blend-mode:multiply" d="M61.905-34.23l96.194 54.51-66.982 54.512L22 34.887z"/><circle fill="#10DC60" style="mix-blend-mode:multiply" cx="155.5" cy="135.5" r="57.5"/><path fill="#3880FF" style="mix-blend-mode:multiply" d="M208.538 9.513l84.417 15.392L223.93 93.93z"/><path fill="#FFCE00" style="mix-blend-mode:multiply" d="M268.625 106.557l46.332-26.75 46.332 26.75v53.5l-46.332 26.75-46.332-26.75z"/><circle fill="#7044FF" style="mix-blend-mode:multiply" cx="299.5" cy="9.5" r="38.5"/><rect fill="#11D3EA" style="mix-blend-mode:multiply" transform="rotate(-60 148.47 37.886)" x="143.372" y="-7.056" width="10.196" height="89.884" rx="5.098"/><path d="M-25.389 74.253l84.86 8.107c5.498.525 9.53 5.407 9.004 10.905a10 10 0 0 1-.057.477l-12.36 85.671a10.002 10.002 0 0 1-11.634 8.42l-86.351-15.226c-5.44-.959-9.07-6.145-8.112-11.584l13.851-78.551a10 10 0 0 1 10.799-8.219z" fill="#7044FF" style="mix-blend-mode:multiply"/><circle fill="#0CD1E8" style="mix-blend-mode:multiply" cx="273.5" cy="106.5" r="20.5"/></g></svg>
|
||||
|
Before Width: | Height: | Size: 1.1 KiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user