Compare commits
2 Commits
proposal-0
...
group-e072
| Author | SHA1 | Date | |
|---|---|---|---|
| bc5e2eead8 | |||
| b5cb0e83e3 |
@@ -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": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
85
.gitignore
vendored
85
.gitignore
vendored
@@ -1,71 +1,24 @@
|
||||
# Specifies intentionally untracked files to ignore when using Git
|
||||
# http://git-scm.com/docs/gitignore
|
||||
# Nuxt dev/build outputs
|
||||
.output
|
||||
.data
|
||||
.nuxt
|
||||
.nitro
|
||||
.cache
|
||||
dist
|
||||
|
||||
*~
|
||||
*.sw[mnpcod]
|
||||
.tmp
|
||||
*.tmp
|
||||
*.tmp.*
|
||||
UserInterfaceState.xcuserstate
|
||||
$RECYCLE.BIN/
|
||||
# Node dependencies
|
||||
node_modules
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.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
|
||||
# Misc
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
CLAUDE.md
|
||||
.fleet
|
||||
.idea
|
||||
|
||||
# Local env files
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
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"]
|
||||
}
|
||||
398
README.md
398
README.md
@@ -1,347 +1,129 @@
|
||||
# P2P Survey App
|
||||
# 🗳️ P2P Verified Polling 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, real-time polling application built with **Nuxt 3**, **Yjs**, and **WebRTC**. This app allows users to create and participate in polls where every vote is cryptographically signed and verified peer-to-peer, ensuring data integrity without a central authority "owning" the results.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
## 🌟 Key Features
|
||||
|
||||
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)
|
||||
* **Serverless Real-time Sync:** Uses **Yjs** (CRDTs) and **WebRTC** to sync poll data directly between browsers. No database is required for live updates.
|
||||
* **Persistence with Nitro:** While the logic is P2P, the **Nuxt/Nitro** backend provides a "Snapshot" service to ensure polls persist even after all peers go offline.
|
||||
* **Cryptographic Integrity:** Every vote is signed using **RSA-PSS (Web Crypto API)**. Each user has a unique private key (stored locally via `.pem` files) to ensure votes cannot be forged or tampered with.
|
||||
* **Chained Verification:** Implements a "History-Signing" logic where each new vote signs the entire preceding state of the poll, creating a verifiable chain of trust.
|
||||
* **Privacy First:** Users identify via UUIDs and Public/Private key pairs rather than traditional accounts.
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
## ⚙️ How It Works
|
||||
|
||||
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.
|
||||
### 1. Identity Creation
|
||||
When a new user is created, the system generates a unique **UUID (User ID)** and an **RSA Key Pair**. The user is prompted to save their **Private Key** as a `.pem` file, named after their User ID (e.g., `550e8400-e29b.pem`). This file acts as their "Passport"—it is never uploaded to the server and must be kept secure by the user.
|
||||
|
||||
### 2. Authentication
|
||||
Upon returning to the app, users load their local `.pem` file. The application extracts the Private Key for signing and the UUID for identification. No passwords or central servers are involved in this local-first login process.
|
||||
|
||||
### 3. Joining a Poll
|
||||
When a user joins a poll, the app fetches the latest binary snapshot from the server to populate a local **Y.Doc**. This ensures the user sees the current state immediately, even before connecting to other peers.
|
||||
|
||||
### 4. The P2P Mesh
|
||||
The app establishes connections to other active voters via a WebRTC signaling server. Any changes made to the poll (adding options or voting) are broadcasted instantly to all peers using Conflict-free Replicated Data Types (CRDTs) to prevent sync conflicts.
|
||||
|
||||
### 5. Casting a Signed Vote
|
||||
To ensure security, the voting process follows a strict cryptographic chain:
|
||||
* The app captures the current list of votes.
|
||||
* It appends the new vote data (User ID + Timestamp).
|
||||
* It signs the **entire array** (the previous history + the new vote) using the user's RSA private key.
|
||||
* The signed update is merged into the shared Yjs Map and broadcasted.
|
||||
|
||||
### 6. Distributed Verification
|
||||
Whenever a peer receives a new update, they fetch the voter's **Public Key** from the API. They then verify that the signature matches the current state of the poll history. If a signature is invalid or the history has been tampered with, the vote is rejected by the peer's local state.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
## 🛠️ Tech Stack
|
||||
|
||||
```
|
||||
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 } │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
* **Framework:** [Nuxt 3](https://nuxt.com/) (Vue 3 + TypeScript)
|
||||
* **Conflict-Free Replicated Data Types (CRDT):** [Yjs](https://yjs.dev/)
|
||||
* **P2P Transport:** `y-webrtc`
|
||||
* **Security:** Web Crypto API (SubtleCrypto)
|
||||
* **Backend/Storage:** Nitro (Nuxt's server engine) with filesystem storage drivers
|
||||
|
||||
### Star topology
|
||||
# AI Disclaimer
|
||||
|
||||
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.
|
||||
This App was developed with the assistance of AI.
|
||||
|
||||
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.
|
||||
# Nuxt Minimal Starter
|
||||
|
||||
---
|
||||
Look at the [Nuxt documentation](https://nuxt.com/docs/getting-started/introduction) to learn more.
|
||||
|
||||
## Features
|
||||
## Setup
|
||||
|
||||
- **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
|
||||
Make sure to install dependencies:
|
||||
|
||||
```bash
|
||||
# 1. Install Ionic CLI globally (skip if already installed)
|
||||
npm install -g @ionic/cli
|
||||
|
||||
# 2. Install project dependencies
|
||||
# npm
|
||||
npm install
|
||||
|
||||
# 3. Start the development server
|
||||
ionic serve
|
||||
# pnpm
|
||||
pnpm install
|
||||
|
||||
# yarn
|
||||
yarn install
|
||||
|
||||
# bun
|
||||
bun install
|
||||
```
|
||||
|
||||
The app opens at `http://localhost:8100`.
|
||||
## Development Server
|
||||
|
||||
### Build for production
|
||||
Start the development server on `http://localhost:3000`:
|
||||
|
||||
```bash
|
||||
ionic build --prod
|
||||
# npm
|
||||
npm run dev
|
||||
|
||||
# pnpm
|
||||
pnpm dev
|
||||
|
||||
# yarn
|
||||
yarn dev
|
||||
|
||||
# bun
|
||||
bun run dev
|
||||
```
|
||||
|
||||
Output is written to `www/`. Deploy the contents of `www/` to any static
|
||||
web host (GitHub Pages, Netlify, Vercel, Nginx, etc.).
|
||||
## Production
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
Build the application for production:
|
||||
|
||||
```bash
|
||||
npm install -g peer
|
||||
peerjs --port 9000 --key peerjs
|
||||
# npm
|
||||
npm run build
|
||||
|
||||
# pnpm
|
||||
pnpm build
|
||||
|
||||
# yarn
|
||||
yarn build
|
||||
|
||||
# bun
|
||||
bun run build
|
||||
```
|
||||
|
||||
### Option 2: Docker
|
||||
Locally preview production build:
|
||||
|
||||
```bash
|
||||
docker run -p 9000:9000 peerjs/peerjs-server
|
||||
# npm
|
||||
npm run preview
|
||||
|
||||
# pnpm
|
||||
pnpm preview
|
||||
|
||||
# yarn
|
||||
yarn preview
|
||||
|
||||
# bun
|
||||
bun run preview
|
||||
```
|
||||
|
||||
### 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
|
||||
```
|
||||
Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information.
|
||||
|
||||
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
175
app/app.vue
Normal file
175
app/app.vue
Normal file
@@ -0,0 +1,175 @@
|
||||
<style>
|
||||
/* Basic styling to make it look clean */
|
||||
body {
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
background-color: #f4f4f9;
|
||||
color: #333;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
header {
|
||||
margin-bottom: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
h1 { margin: 0 0 0.5rem 0; }
|
||||
|
||||
input {
|
||||
flex-grow: 1;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 6px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
button,
|
||||
.button {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-weight: bold;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
button:hover,
|
||||
.button:hover { background: #2563eb; }
|
||||
|
||||
.status {
|
||||
font-size: 0.85rem;
|
||||
color: #666;
|
||||
}
|
||||
.status .connected { color: #10b981; font-weight: bold; }
|
||||
|
||||
.connectionFailed { color: #FF2525; font-weight: bold; }
|
||||
|
||||
.poll-container {
|
||||
background: white;
|
||||
padding: 2rem;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
margin-left: 1rem;
|
||||
padding: 0.2rem 0.5rem;
|
||||
font-size: 0.7rem;
|
||||
background: #64748b;
|
||||
}
|
||||
|
||||
/* Hide the actual file input */
|
||||
input[type="file"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
</style>
|
||||
<template>
|
||||
<div class="poll-container">
|
||||
<header>
|
||||
<h1 @click="activePollId = null" style="cursor:pointer">P2P Polling App 🗳️</h1>
|
||||
<div class="status">
|
||||
<button v-if="activePollId" @click="activePollId = null" class="back-btn">← Back To List</button>
|
||||
<span :class="{ 'connected': isConnected }">
|
||||
● {{ isConnected ? 'Synced' : 'Waiting for other Peers...' }}
|
||||
</span>
|
||||
<span> | Peers online: {{ connectedPeers }}</span>
|
||||
<h2 v-if="connectionAttempFailed" class="connectionFailed">⚠ Connection to Signaling Server Failed!</h2>
|
||||
<div v-if="user===null" style="margin-top: 10px;">
|
||||
<button @click="createUser">Create New User</button>
|
||||
Or
|
||||
<label title="Select Key File">
|
||||
<span class="button">Login</span>
|
||||
<input
|
||||
type="file"
|
||||
accept=".pem"
|
||||
@change="loadUser"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<PollList v-if="!activePollId" :userid="user?.userid" @select-poll="selectPoll" />
|
||||
<Poll v-else :activePollId="activePollId" :userid="user?.userid" :poll-data="pollData" :addOption="addOption" :vote="vote"/>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
const activePollId = ref<string | null>(null);
|
||||
const user = shallowRef<UserData | null>(null);
|
||||
|
||||
const { pollData, isConnected, connectionAttempFailed, connectedPeers, addOption, vote } = usePoll(activePollId,user);
|
||||
|
||||
const selectPoll = (id: string) => {
|
||||
activePollId.value = id;
|
||||
};
|
||||
|
||||
|
||||
const createUser = async () => {
|
||||
try {
|
||||
const keypair : CryptoKeyPair = await generateUserKeyPair();
|
||||
console.log('keypair:', keypair);
|
||||
const uuid = uuidv4();
|
||||
user.value = {
|
||||
userid: uuid,
|
||||
private_key: keypair.privateKey,
|
||||
public_key: keypair.publicKey,
|
||||
};
|
||||
const prvKeyString = await exportPrivateKey(keypair.privateKey);
|
||||
await savePrivateKeyToFile(prvKeyString,uuid+".pem")
|
||||
|
||||
|
||||
const pubKeyString = await exportPublicKey(keypair.publicKey);
|
||||
await $fetch(`/api/users/${uuid}`, {
|
||||
method: 'POST',
|
||||
body: { public_key: pubKeyString }
|
||||
});
|
||||
} catch (err) {
|
||||
user.value = null
|
||||
console.error("Failed to create new User!", err);
|
||||
}
|
||||
};
|
||||
const loadUser = async (event: Event) => {
|
||||
const target = event.target as HTMLInputElement;
|
||||
const file = target.files?.[0];
|
||||
|
||||
if (file) {
|
||||
try {
|
||||
const content = await file.text();
|
||||
console.log("File loaded: ");
|
||||
if (file.name && content) {
|
||||
try {
|
||||
const uuid = file.name.replace(".pem", "");
|
||||
// Standardize the string for the importer
|
||||
const pkBase64 = content.replace(/-----BEGIN PRIVATE KEY-----|-----END PRIVATE KEY-----/g, "").replace(/\s+/g, "");
|
||||
|
||||
const key = await stringToCryptoKey(pkBase64, "private");
|
||||
|
||||
user.value = {
|
||||
userid: uuid,
|
||||
private_key: key,
|
||||
public_key: undefined, // Note: You might need to import a pub key too!
|
||||
};
|
||||
|
||||
console.log("Login successful for:", uuid);
|
||||
} catch (err) {
|
||||
console.error("Crypto Import Error:", err);
|
||||
alert("The file content is not a valid Private Key.");
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to read file", e);
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
83
app/components/Poll.vue
Normal file
83
app/components/Poll.vue
Normal file
@@ -0,0 +1,83 @@
|
||||
<style scoped>
|
||||
.poll-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.poll-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.poll-title {
|
||||
font-size: 1.1rem;
|
||||
color: #3b82f6;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.add-option-form {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.option-name { font-weight: 500; }
|
||||
.vote-section { display: flex; align-items: center; gap: 1rem; }
|
||||
.vote-count { font-size: 0.9rem; color: #475569; }
|
||||
.vote-btn { padding: 0.4rem 0.8rem; background: #10b981; }
|
||||
.vote-btn:hover { background: #059669; }
|
||||
|
||||
.vote-btn:disabled,
|
||||
.vote-btn[disabled] { background: #888888; }
|
||||
.vote-btn:disabled:hover,
|
||||
.vote-btn[disabled]:hover { background: #AAAAAA; }
|
||||
</style>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<h2 class="poll-title">Poll: {{ activePollId }}</h2>
|
||||
<p v-if="Object.keys(pollData).length==0">Note: Add at least one Option to save the Poll.</p>
|
||||
<form @submit.prevent="handleAddNewOption" class="add-option-form" v-if="userid">
|
||||
<input v-model="newOption" placeholder="Enter a new poll option..." required />
|
||||
<button type="submit">Add Option</button>
|
||||
</form>
|
||||
|
||||
<ul class="poll-list">
|
||||
<li v-for="(votes, optionName) in pollData" :key="optionName" class="poll-item">
|
||||
<span class="option-name">{{ optionName }}</span>
|
||||
<div class="vote-section">
|
||||
<span class="vote-count">{{ votes.length }} {{ votes.length === 1 ? 'vote' : 'votes' }}</span>
|
||||
<button @click="vote(String(optionName))" class="vote-btn" :disabled="userid==undefined || voted(votes)">+1</button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { PollProps, SignedData, VoteData } from '@/utils/types'
|
||||
const props = defineProps<PollProps>()
|
||||
|
||||
const newOption = ref('');
|
||||
const handleAddNewOption = () => {
|
||||
props.addOption(newOption.value);
|
||||
newOption.value = '';
|
||||
};
|
||||
|
||||
|
||||
const voted = (votes: SignedData<VoteData>[]) => {
|
||||
for(let vote of votes){
|
||||
if(vote.data.userid == props.userid){
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
</script>
|
||||
64
app/components/PollList.vue
Normal file
64
app/components/PollList.vue
Normal file
@@ -0,0 +1,64 @@
|
||||
<style scoped>
|
||||
.poll-list { margin-top: 1rem; }
|
||||
.empty-state { text-align: center; color: #94a3b8; font-style: italic; }
|
||||
.create-poll { display: flex; gap: 0.5rem; margin-bottom: 1.5rem; }
|
||||
.poll-links { list-style: none; padding: 0; }
|
||||
.poll-link-btn {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
background: #f1f5f9;
|
||||
color: #1e293b;
|
||||
margin-bottom: 0.5rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.poll-link-btn:hover { background: #e2e8f0; }
|
||||
</style>
|
||||
|
||||
<template>
|
||||
<div class="poll-list">
|
||||
<h3>Available Polls</h3>
|
||||
|
||||
<ul v-if="polls && polls.length > 0" class="poll-links">
|
||||
<li v-for="id in polls" :key="id">
|
||||
<button class="poll-link-btn" @click="$emit('select-poll', id)">
|
||||
{{ id }} <span>→</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else class="empty-state">No polls found. Create the first one!</p>
|
||||
<div class="create-poll" v-if="userid !== undefined">
|
||||
<input
|
||||
v-model="newPollId"
|
||||
placeholder="Enter new poll name..."
|
||||
@keyup.enter="createPoll"
|
||||
/>
|
||||
<button @click="createPoll">Create & Join</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { PollListProps } from '@/utils/types'
|
||||
const props = defineProps<PollListProps>()
|
||||
const newPollId = ref('');
|
||||
const polls = ref<string[]>([]);
|
||||
|
||||
// Fetch existing polls on mount
|
||||
const fetchPolls = async () => {
|
||||
const data = await $fetch<{ polls: string[] }>('/api/polls');
|
||||
polls.value = data.polls;
|
||||
};
|
||||
|
||||
const createPoll = () => {
|
||||
const id = newPollId.value.trim().toLowerCase().replace(/\s+/g, '-');
|
||||
if (id) {
|
||||
// In a real app, you might want to POST to create it first,
|
||||
// but here we just navigate to it and let usePoll handle the save.
|
||||
emit('select-poll', id);
|
||||
}
|
||||
};
|
||||
|
||||
const emit = defineEmits(['select-poll']);
|
||||
onMounted(fetchPolls);
|
||||
</script>
|
||||
131
app/composables/usePoll.ts
Normal file
131
app/composables/usePoll.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
// composables/usePoll.ts
|
||||
import { ref, watch, onUnmounted } from 'vue';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
export const usePoll = (pollId: Ref<string | null>, user: Ref<UserData | null>) => {
|
||||
const pollData = ref<PollData>({});
|
||||
const isConnected = ref(false);
|
||||
const connectionAttempFailed = ref(false);
|
||||
const connectedPeers = ref(1);
|
||||
|
||||
let ydoc: Y.Doc | null = null;
|
||||
let provider: any = null;
|
||||
let yMap: Y.Map<SignedData<VoteData>[]> | null = null;
|
||||
|
||||
const cleanup = () => {
|
||||
if (provider) provider.disconnect();
|
||||
if (ydoc) ydoc.destroy();
|
||||
isConnected.value = false;
|
||||
pollData.value = {};
|
||||
};
|
||||
|
||||
const initPoll = async (id: string) => {
|
||||
cleanup(); // Clear previous session
|
||||
|
||||
ydoc = new Y.Doc();
|
||||
|
||||
// 1. Fetch Snapshot from Nuxt API
|
||||
try {
|
||||
const response = await $fetch<{ update: number[] | null }>(`/api/polls/${id}`).catch((e) => {
|
||||
console.error("Failed to get poll: " + id,e)
|
||||
});
|
||||
//trust the server without verification.
|
||||
if (response?.update) {
|
||||
Y.applyUpdate(ydoc, new Uint8Array(response.update));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Persistence fetch failed', err);
|
||||
}
|
||||
|
||||
yMap = ydoc.getMap<SignedData<VoteData>[]>('shared-poll');
|
||||
|
||||
// 2. Local State Sync
|
||||
yMap.observe(async () => {
|
||||
await performUpdateAndVerify();
|
||||
saveStateToServer(id);
|
||||
});
|
||||
await performUpdateAndVerify();
|
||||
|
||||
// 3. P2P Connection
|
||||
const { WebrtcProvider } = await import('y-webrtc');
|
||||
provider = new WebrtcProvider(`nuxt-p2p-${id}`, ydoc, {
|
||||
signaling: ["ws://localhost:4444", "ws://lynxpi.ddns.net:4444"]
|
||||
});
|
||||
|
||||
provider.on('synced', (arg: {synced: boolean}) => {
|
||||
isConnected.value = arg.synced;
|
||||
console.log('Connection synced:', arg.synced) // "connected" or "disconnected"
|
||||
});
|
||||
provider.on('status', (event: { connected: boolean }) => {
|
||||
console.log('Connection status:', event.connected) // "connected" or "disconnected"
|
||||
})
|
||||
provider.on('peers', (data: any) => {
|
||||
connectedPeers.value = data.webrtcPeers.length + 1
|
||||
});
|
||||
};
|
||||
|
||||
const saveStateToServer = async (id: string) => {
|
||||
if (!ydoc) return;
|
||||
const stateUpdate = Y.encodeStateAsUpdate(ydoc);
|
||||
await $fetch(`/api/polls/${id}`, {
|
||||
method: 'POST',
|
||||
body: { update: Array.from(stateUpdate) }
|
||||
}).catch((e) => {
|
||||
console.error("Failed to update poll",e)
|
||||
});
|
||||
};
|
||||
|
||||
// Watch for ID changes (e.g., user clicks a link or goes back)
|
||||
watch(pollId, (newId) => {
|
||||
if (newId && import.meta.client) {
|
||||
initPoll(newId);
|
||||
} else {
|
||||
cleanup();
|
||||
}
|
||||
}, { immediate: true });
|
||||
|
||||
onUnmounted(cleanup);
|
||||
|
||||
const addOption = (optionName: string) => {
|
||||
if (yMap && !yMap.has(optionName)) yMap.set(optionName, []);
|
||||
};
|
||||
|
||||
const performUpdateAndVerify = async () => {
|
||||
const pollDataUpdate = yMap!.toJSON();
|
||||
console.log("Poll Data Update: ", pollDataUpdate)
|
||||
for(var option in pollDataUpdate){
|
||||
console.log("verifying votes for option: " + option);
|
||||
const votes = pollDataUpdate[option] || [];
|
||||
const verified = await verifyAllVotesForOption(votes);
|
||||
if(!verified){
|
||||
console.error("Failed to verify option: "+option)
|
||||
return;
|
||||
}
|
||||
}
|
||||
console.log("All options verified! :)")
|
||||
pollData.value = pollDataUpdate
|
||||
}
|
||||
|
||||
const vote = async (optionName: string) => {
|
||||
const currentUser = user.value;
|
||||
if (currentUser != undefined && yMap?.has(optionName)) {
|
||||
const voteData = [...(yMap.get(optionName) || [])];
|
||||
if(voteData != undefined && currentUser.private_key){
|
||||
var unsignedVoteData : VoteData = {
|
||||
userid: currentUser.userid,
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
var newVote : SignedData<VoteData> = {
|
||||
data: unsignedVoteData,
|
||||
signature: "",
|
||||
}
|
||||
voteData?.push(newVote)
|
||||
const signature = await signVote(voteData,currentUser.private_key);
|
||||
newVote.signature=signature
|
||||
yMap?.set(optionName, voteData);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return { pollData, isConnected, connectionAttempFailed, connectedPeers, addOption, vote };
|
||||
};
|
||||
2
app/composables/user.ts
Normal file
2
app/composables/user.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export const user = (user: Ref<UserData | null>) => {
|
||||
}
|
||||
187
app/utils/crypto.ts
Normal file
187
app/utils/crypto.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
// utils/crypto.ts
|
||||
export const generateUserKeyPair = async () => {
|
||||
return await window.crypto.subtle.generateKey(
|
||||
{
|
||||
name: "RSASSA-PKCS1-v1_5",
|
||||
modulusLength: 2048,
|
||||
publicExponent: new Uint8Array([1, 0, 1]), // 65537
|
||||
hash: "SHA-256",
|
||||
},
|
||||
true, // extractable
|
||||
["sign", "verify"]
|
||||
);
|
||||
};
|
||||
|
||||
export const signVote = async (data: any, privateKey: CryptoKey) => {
|
||||
const encoder = new TextEncoder();
|
||||
const encodedData = encoder.encode(JSON.stringify(data));
|
||||
|
||||
const signature = await window.crypto.subtle.sign(
|
||||
"RSASSA-PKCS1-v1_5",
|
||||
privateKey,
|
||||
encodedData
|
||||
);
|
||||
|
||||
// Convert to Base64 or Hex to store in Yjs easily
|
||||
return btoa(String.fromCharCode(...new Uint8Array(signature)));
|
||||
};
|
||||
|
||||
export const verifyVote = async (data: any, signatureStr: string, publicKey: CryptoKey) => {
|
||||
const encoder = new TextEncoder();
|
||||
const encodedData = encoder.encode(JSON.stringify(data));
|
||||
|
||||
// Convert Base64 back to Uint8Array
|
||||
const signature = Uint8Array.from(atob(signatureStr), c => c.charCodeAt(0));
|
||||
|
||||
return await window.crypto.subtle.verify(
|
||||
"RSASSA-PKCS1-v1_5",
|
||||
publicKey,
|
||||
signature,
|
||||
encodedData
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Verifies a specific vote within an array of votes by
|
||||
* reconstructing the "signed state" at that point in time.
|
||||
*/
|
||||
export const verifyChainedVote = async (
|
||||
voteData: SignedData<VoteData>[],
|
||||
index: number
|
||||
) => {
|
||||
const voteToVerify = voteData[index];
|
||||
console.log("Verifying vote: " + voteToVerify)
|
||||
if(voteToVerify) {
|
||||
// 1. Reconstruct the exact data state the user signed
|
||||
// We need the array exactly as it was when they pushed their vote
|
||||
const historicalState = voteData.slice(0, index + 1).map((v, i) => {
|
||||
if (i === index) {
|
||||
// For the current vote, the signature must be empty string
|
||||
// because it wasn't signed yet when passed to signVote
|
||||
return { ...v, signature: "" };
|
||||
}
|
||||
return v;
|
||||
});
|
||||
|
||||
try {
|
||||
// 2. Fetch public key
|
||||
const response = await $fetch<{ public_key: string }>(`/api/users/${voteToVerify.data.userid}`);
|
||||
console.log("Got key: ",response)
|
||||
const pubKey = await stringToCryptoKey(response.public_key, 'public');
|
||||
|
||||
console.log("Using pubKey to verify Vote.")
|
||||
// 3. Verify: Does this historicalState match the signature?
|
||||
return await verifyVote(historicalState, voteToVerify.signature, pubKey);
|
||||
} catch (err) {
|
||||
console.error("Verification failed")
|
||||
console.error(err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
console.error("Vote is undefined or null");
|
||||
return false;
|
||||
};
|
||||
|
||||
export const verifyAllVotesForOption = async (votes: SignedData<VoteData>[]) => {
|
||||
console.log("verifying votes for option ",votes);
|
||||
for (let i = votes.length-1; i >= 0 ; i--) {
|
||||
const isValid = await verifyChainedVote(votes, i);
|
||||
if(!isValid){
|
||||
console.error("Error! Invalid Vote at: " + i,votes)
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
// Helper to convert ArrayBuffer to Base64 string
|
||||
const bufferToBase64 = (buf: ArrayBuffer) =>
|
||||
window.btoa(String.fromCharCode(...new Uint8Array(buf)));
|
||||
|
||||
export const exportPublicKey = async (key: CryptoKey) => {
|
||||
// Export Public Key
|
||||
const exportedPublic = await window.crypto.subtle.exportKey("spki", key);
|
||||
const publicKeyString = bufferToBase64(exportedPublic);
|
||||
|
||||
return publicKeyString;
|
||||
};
|
||||
export const exportPrivateKey = async (key: CryptoKey) => {
|
||||
// Export Private Key
|
||||
const exportedPrivate = await window.crypto.subtle.exportKey("pkcs8", key);
|
||||
const privateKeyString = bufferToBase64(exportedPrivate);
|
||||
|
||||
return privateKeyString;
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts a Base64 string back into a usable CryptoKey object
|
||||
* @param keyStr The Base64 string (without PEM headers)
|
||||
* @param type 'public' or 'private'
|
||||
*/
|
||||
export const stringToCryptoKey = async (keyStr: string, type: 'public' | 'private'): Promise<CryptoKey> => {
|
||||
// 1. Convert Base64 string to a Uint8Array (binary)
|
||||
const binaryString = window.atob(keyStr);
|
||||
const bytes = new Uint8Array(binaryString.length);
|
||||
for (let i = 0; i < binaryString.length; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i);
|
||||
}
|
||||
|
||||
// 2. Identify the format based on the key type
|
||||
// Public keys usually use 'spki', Private keys use 'pkcs8'
|
||||
const format = type === 'public' ? 'spki' : 'pkcs8';
|
||||
const usages: KeyUsage[] = type === 'public' ? ['verify'] : ['sign'];
|
||||
|
||||
// 3. Import the key
|
||||
return await window.crypto.subtle.importKey(
|
||||
format,
|
||||
bytes.buffer,
|
||||
{
|
||||
name: "RSASSA-PKCS1-v1_5",
|
||||
hash: "SHA-256",
|
||||
},
|
||||
true, // extractable (set to false if you want to lock it in memory)
|
||||
usages
|
||||
);
|
||||
};
|
||||
|
||||
export const savePrivateKeyToFile = (privateKeyStr: string, filename: string) => {
|
||||
// Optional: Wrap in PEM headers for standard formatting
|
||||
const pemHeader = "-----BEGIN PRIVATE KEY-----\n";
|
||||
const pemFooter = "\n-----END PRIVATE KEY-----";
|
||||
const fileContent = pemHeader + privateKeyStr + pemFooter;
|
||||
|
||||
const blob = new Blob([fileContent], { type: "text/plain" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
|
||||
// Cleanup
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
export const loadPrivateKeyFromFile = async (file: File): Promise<string> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = (e) => {
|
||||
const content = e.target?.result as string;
|
||||
|
||||
// Clean up the string by removing PEM headers and newlines
|
||||
const cleanKey = content
|
||||
.replace("-----BEGIN PRIVATE KEY-----", "")
|
||||
.replace("-----END PRIVATE KEY-----", "")
|
||||
.replace(/\s+/g, ""); // Removes all whitespace/newlines
|
||||
|
||||
resolve(cleanKey);
|
||||
};
|
||||
|
||||
reader.onerror = () => reject("Error reading file");
|
||||
reader.readAsText(file);
|
||||
});
|
||||
};
|
||||
36
app/utils/types.ts
Normal file
36
app/utils/types.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
export interface PollProps {
|
||||
userid: string | undefined,
|
||||
activePollId: string,
|
||||
pollData: PollData,
|
||||
addOption: (name: string) => void,
|
||||
vote: (optionName: string) => void
|
||||
}
|
||||
|
||||
export interface PollListProps {
|
||||
userid: string | undefined,
|
||||
}
|
||||
|
||||
export interface PollData extends Record<string, SignedData<VoteData>[]> {
|
||||
}
|
||||
|
||||
export interface SignedData<T> {
|
||||
data: T,
|
||||
signature: string
|
||||
}
|
||||
|
||||
export interface VoteData {
|
||||
userid: string,
|
||||
timestamp: string
|
||||
}
|
||||
|
||||
export interface OptionData {
|
||||
userid: string,
|
||||
timestamp: string,
|
||||
optionName: string
|
||||
}
|
||||
|
||||
export interface UserData {
|
||||
userid: string,
|
||||
private_key: CryptoKey | undefined,
|
||||
public_key: CryptoKey | undefined
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
};
|
||||
23
nuxt.config.ts
Normal file
23
nuxt.config.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
// https://nuxt.com/docs/api/configuration/nuxt-config
|
||||
export default defineNuxtConfig({
|
||||
compatibilityDate: '2025-07-15',
|
||||
devtools: { enabled: true },
|
||||
vite: {
|
||||
optimizeDeps: {
|
||||
include: ['yjs', 'y-webrtc']
|
||||
}
|
||||
},
|
||||
// ... existing config
|
||||
nitro: {
|
||||
storage: {
|
||||
polls: {
|
||||
driver: 'fs',
|
||||
base: './.data/polls'
|
||||
},
|
||||
users: {
|
||||
driver: 'fs',
|
||||
base: './.data/users'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
20933
package-lock.json
generated
20933
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
80
package.json
80
package.json
@@ -1,68 +1,20 @@
|
||||
{
|
||||
"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"
|
||||
},
|
||||
"name": "p2p-poll",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "nuxt build",
|
||||
"dev": "PORT=4444 npx y-webrtc & nuxt dev",
|
||||
"generate": "nuxt generate",
|
||||
"preview": "nuxt preview",
|
||||
"postinstall": "nuxt prepare"
|
||||
},
|
||||
"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"
|
||||
"nuxt": "^4.1.3",
|
||||
"uuid": "^13.0.0",
|
||||
"vue": "^3.5.30",
|
||||
"vue-router": "^5.0.3",
|
||||
"y-webrtc": "^10.3.0",
|
||||
"yjs": "^13.6.30"
|
||||
}
|
||||
}
|
||||
|
||||
BIN
public/favicon.ico
Normal file
BIN
public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
2
public/robots.txt
Normal file
2
public/robots.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
User-Agent: *
|
||||
Disallow:
|
||||
68
server/api/polls/[id].ts
Normal file
68
server/api/polls/[id].ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import * as Y from 'yjs';
|
||||
// server/api/polls/[id].ts
|
||||
export default defineEventHandler(async (event) => {
|
||||
const method = event.node.req.method;
|
||||
const pollId = getRouterParam(event, 'id');
|
||||
|
||||
// We use Nitro's built-in storage.
|
||||
// 'polls' is the storage namespace.
|
||||
const storage = useStorage('polls');
|
||||
|
||||
if (!pollId) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Poll ID required' });
|
||||
}
|
||||
|
||||
// GET: Fetch the saved Yjs document state
|
||||
if (method === 'GET') {
|
||||
const data = await storage.getItem(`poll:${pollId}`);
|
||||
// Return the array of numbers (or null if it doesn't exist yet)
|
||||
return { update: data || null };
|
||||
}
|
||||
|
||||
// POST: Save a new Yjs document state
|
||||
if (method === 'POST') {
|
||||
const body = await readBody(event);
|
||||
|
||||
if (body.update && Array.isArray(body.update)) {
|
||||
// create a temp Y.Doc to encode the Data
|
||||
const tempDoc = new Y.Doc();
|
||||
Y.applyUpdate(tempDoc, new Uint8Array(body.update));
|
||||
const yMap = tempDoc.getMap('shared-poll');
|
||||
const pollData = yMap.toJSON();
|
||||
|
||||
// verify pollData
|
||||
for(var option in pollData){
|
||||
const votes = pollData[option] || [];
|
||||
var pubKeys: CryptoKey[] = [];
|
||||
|
||||
const verifyAllVotesForOption = async (votes: SignedData<VoteData>[]) => {
|
||||
console.log("verifying votes for option " + option,votes);
|
||||
// check last votes first. if there is something wrong, its likely in the last vote.
|
||||
for (let i = votes.length-1; i >= 0 ; i--) {
|
||||
const userStorage = useStorage('users');
|
||||
const votePubKeyString = await userStorage.getItem(`user:${votes[i]?.data.userid}`);
|
||||
//console.log("Using public key: "+votePubKeyString)
|
||||
const votePubKey = await stringToCryptoKey(String(votePubKeyString),'public')
|
||||
const isValid = await verifyChainedVote(votes, i,votePubKey);
|
||||
if(!isValid){
|
||||
console.error("Error! Invalid Vote at: " + i,votes)
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
const verified = await verifyAllVotesForOption(votes);
|
||||
if(!verified){
|
||||
console.error("Failed to verify option: "+option)
|
||||
throw createError({ statusCode: 400, statusMessage: 'PollData contains unverifyable content!' });
|
||||
}
|
||||
}
|
||||
|
||||
// Save the binary update (sent as an array of numbers) to storage
|
||||
await storage.setItem(`poll:${pollId}`, body.update);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
throw createError({ statusCode: 400, statusMessage: 'Invalid update payload' });
|
||||
}
|
||||
});
|
||||
15
server/api/polls/index.get.ts
Normal file
15
server/api/polls/index.get.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
// server/api/polls/index.get.ts
|
||||
export default defineEventHandler(async () => {
|
||||
const storage = useStorage('polls');
|
||||
|
||||
// Get all keys in the 'polls' namespace
|
||||
const allKeys = await storage.getKeys();
|
||||
|
||||
// Filter for our specific poll prefix and strip it for the UI
|
||||
// poll:my-id -> my-id
|
||||
const polls = allKeys
|
||||
.filter(key => key.startsWith('poll:'))
|
||||
.map(key => key.replace('poll:', ''));
|
||||
|
||||
return { polls };
|
||||
});
|
||||
41
server/api/users/[id].ts
Normal file
41
server/api/users/[id].ts
Normal file
@@ -0,0 +1,41 @@
|
||||
// server/api/users/[id].ts
|
||||
export default defineEventHandler(async (event) => {
|
||||
const method = event.node.req.method;
|
||||
const userId = getRouterParam(event, 'id');
|
||||
|
||||
// We use Nitro's built-in storage.
|
||||
// 'polls' is the storage namespace.
|
||||
const storage = useStorage('users');
|
||||
|
||||
if (!userId) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'User ID required' });
|
||||
}
|
||||
|
||||
// GET: Fetch the saved Yjs document state
|
||||
if (method === 'GET') {
|
||||
const data = await storage.getItem(`user:${userId}`);
|
||||
// Return the array of numbers (or null if it doesn't exist yet)
|
||||
return { public_key: data };
|
||||
}
|
||||
|
||||
// POST: Save a new Yjs document state
|
||||
if (method === 'POST') {
|
||||
const body = await readBody(event);
|
||||
|
||||
if (body.public_key) {
|
||||
const data = await storage.getItem(`user:${userId}`);
|
||||
|
||||
if (data == undefined || data == null) {
|
||||
// Save the binary update (sent as an array of numbers) to storage
|
||||
await storage.setItem(`user:${userId}`, body.public_key);
|
||||
console.log("New User created: " + userId)
|
||||
console.log("Public Key: " + body.public_key);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
throw createError({ statusCode: 400, statusMessage: 'User already exists.' });
|
||||
}
|
||||
|
||||
throw createError({ statusCode: 400, statusMessage: 'Invalid update payload' });
|
||||
}
|
||||
});
|
||||
86
server/utils/crypto.ts
Normal file
86
server/utils/crypto.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { SignedData, VoteData } from "./types";
|
||||
/**
|
||||
* Gets the WebCrypto API regardless of environment (Node vs Browser)
|
||||
*/
|
||||
const getCrypto = () => {
|
||||
return (globalThis as any).crypto;
|
||||
};
|
||||
|
||||
export const verifyVote = async (data: any, signatureStr: string, publicKey: CryptoKey) => {
|
||||
const encoder = new TextEncoder();
|
||||
const encodedData = encoder.encode(JSON.stringify(data));
|
||||
|
||||
// Convert Base64 back to Uint8Array
|
||||
const signature = Uint8Array.from(atob(signatureStr), c => c.charCodeAt(0));
|
||||
|
||||
return await getCrypto().subtle.verify(
|
||||
"RSASSA-PKCS1-v1_5",
|
||||
publicKey,
|
||||
signature,
|
||||
encodedData
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Verifies a specific vote within an array of votes by
|
||||
* reconstructing the "signed state" at that point in time.
|
||||
*/
|
||||
export const verifyChainedVote = async (
|
||||
voteData: SignedData<VoteData>[],
|
||||
index: number,
|
||||
pubKey: CryptoKey
|
||||
) => {
|
||||
const voteToVerify = voteData[index];
|
||||
console.log("Verifying vote: " + voteToVerify)
|
||||
if(voteToVerify) {
|
||||
// 1. Reconstruct the exact data state the user signed
|
||||
// We need the array exactly as it was when they pushed their vote
|
||||
const historicalState = voteData.slice(0, index + 1).map((v, i) => {
|
||||
if (i === index) {
|
||||
// For the current vote, the signature must be empty string
|
||||
// because it wasn't signed yet when passed to signVote
|
||||
return { ...v, signature: "" };
|
||||
}
|
||||
return v;
|
||||
});
|
||||
|
||||
try {
|
||||
// 3. Verify: Does this historicalState match the signature?
|
||||
return await verifyVote(historicalState, voteToVerify.signature, pubKey);
|
||||
} catch (err) {
|
||||
console.error("Verification failed")
|
||||
console.error(err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
console.error("Vote is undefined or null");
|
||||
return false;
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Converts a Base64 string back into a usable CryptoKey object
|
||||
* @param keyStr The Base64 string (without PEM headers)
|
||||
* @param type 'public' or 'private'
|
||||
*/
|
||||
export const stringToCryptoKey = async (keyStr: string, type: 'public' | 'private'): Promise<CryptoKey> => {
|
||||
// 1. Convert Base64 string to a Uint8Array (binary)
|
||||
const bytes = Buffer.from(keyStr, 'base64');
|
||||
|
||||
// 2. Identify the format based on the key type
|
||||
// Public keys usually use 'spki', Private keys use 'pkcs8'
|
||||
const format = type === 'public' ? 'spki' : 'pkcs8';
|
||||
const usages: KeyUsage[] = type === 'public' ? ['verify'] : ['sign'];
|
||||
|
||||
// 3. Import the key
|
||||
return await getCrypto().subtle.importKey(
|
||||
format,
|
||||
bytes,
|
||||
{
|
||||
name: "RSASSA-PKCS1-v1_5",
|
||||
hash: "SHA-256",
|
||||
},
|
||||
true, // extractable (set to false if you want to lock it in memory)
|
||||
usages
|
||||
);
|
||||
};
|
||||
36
server/utils/types.ts
Normal file
36
server/utils/types.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
export interface PollProps {
|
||||
userid: string | undefined,
|
||||
activePollId: string,
|
||||
pollData: PollData,
|
||||
addOption: (name: string) => void,
|
||||
vote: (optionName: string) => void
|
||||
}
|
||||
|
||||
export interface PollListProps {
|
||||
userid: string | undefined,
|
||||
}
|
||||
|
||||
export interface PollData extends Record<string, SignedData<VoteData>[]> {
|
||||
}
|
||||
|
||||
export interface SignedData<T> {
|
||||
data: T,
|
||||
signature: string
|
||||
}
|
||||
|
||||
export interface VoteData {
|
||||
userid: string,
|
||||
timestamp: string
|
||||
}
|
||||
|
||||
export interface OptionData {
|
||||
userid: string,
|
||||
timestamp: string,
|
||||
optionName: string
|
||||
}
|
||||
|
||||
export interface UserData {
|
||||
userid: string,
|
||||
private_key: CryptoKey | undefined,
|
||||
public_key: CryptoKey | undefined
|
||||
}
|
||||
@@ -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 |
@@ -1,3 +0,0 @@
|
||||
export const environment = {
|
||||
production: true
|
||||
};
|
||||
@@ -1,16 +0,0 @@
|
||||
// This file can be replaced during build by using the `fileReplacements` array.
|
||||
// `ng build` replaces `environment.ts` with `environment.prod.ts`.
|
||||
// The list of file replacements can be found in `angular.json`.
|
||||
|
||||
export const environment = {
|
||||
production: false
|
||||
};
|
||||
|
||||
/*
|
||||
* For easier debugging in development mode, you can import the following file
|
||||
* to ignore zone related error stack frames such as `zone.run`, `zoneDelegate.invokeTask`.
|
||||
*
|
||||
* This import should be commented out in production mode because it will have a negative impact
|
||||
* on performance if an error is thrown.
|
||||
*/
|
||||
// import 'zone.js/plugins/zone-error'; // Included with Angular CLI.
|
||||
@@ -1,37 +0,0 @@
|
||||
/*
|
||||
* App Global CSS
|
||||
* ----------------------------------------------------------------------------
|
||||
* Put style rules here that you want to apply globally. These styles are for
|
||||
* the entire app and not just one component. Additionally, this file can be
|
||||
* used as an entry point to import other CSS/Sass files to be included in the
|
||||
* output CSS.
|
||||
* For more information on global stylesheets, visit the documentation:
|
||||
* https://ionicframework.com/docs/layout/global-stylesheets
|
||||
*/
|
||||
|
||||
/* Core CSS required for Ionic components to work properly */
|
||||
@import "@ionic/angular/css/core.css";
|
||||
|
||||
/* Basic CSS for apps built with Ionic */
|
||||
@import "@ionic/angular/css/normalize.css";
|
||||
@import "@ionic/angular/css/structure.css";
|
||||
@import "@ionic/angular/css/typography.css";
|
||||
@import "@ionic/angular/css/display.css";
|
||||
|
||||
/* Optional CSS utils that can be commented out */
|
||||
@import "@ionic/angular/css/padding.css";
|
||||
@import "@ionic/angular/css/float-elements.css";
|
||||
@import "@ionic/angular/css/text-alignment.css";
|
||||
@import "@ionic/angular/css/text-transformation.css";
|
||||
@import "@ionic/angular/css/flex-utils.css";
|
||||
|
||||
/**
|
||||
* Ionic Dark Mode
|
||||
* -----------------------------------------------------
|
||||
* For more info, please see:
|
||||
* https://ionicframework.com/docs/theming/dark-mode
|
||||
*/
|
||||
|
||||
/* @import "@ionic/angular/css/palettes/dark.always.css"; */
|
||||
/* @import "@ionic/angular/css/palettes/dark.class.css"; */
|
||||
@import "@ionic/angular/css/palettes/dark.system.css";
|
||||
@@ -1,26 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Ionic App</title>
|
||||
|
||||
<base href="/" />
|
||||
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<meta name="viewport" content="viewport-fit=cover, width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<meta name="format-detection" content="telephone=no" />
|
||||
<meta name="msapplication-tap-highlight" content="no" />
|
||||
|
||||
<link rel="icon" type="image/png" href="assets/icon/favicon.png" />
|
||||
|
||||
<!-- add to homescreen for ios -->
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<app-root></app-root>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -1,6 +0,0 @@
|
||||
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
|
||||
|
||||
import { AppModule } from './app/app.module';
|
||||
|
||||
platformBrowserDynamic().bootstrapModule(AppModule)
|
||||
.catch(err => console.log(err));
|
||||
@@ -1,55 +0,0 @@
|
||||
/**
|
||||
* This file includes polyfills needed by Angular and is loaded before the app.
|
||||
* You can add your own extra polyfills to this file.
|
||||
*
|
||||
* This file is divided into 2 sections:
|
||||
* 1. Browser polyfills. These are applied before loading ZoneJS and are sorted by browsers.
|
||||
* 2. Application imports. Files imported after ZoneJS that should be loaded before your main
|
||||
* file.
|
||||
*
|
||||
* The current setup is for so-called "evergreen" browsers; the last versions of browsers that
|
||||
* automatically update themselves. This includes recent versions of Safari, Chrome (including
|
||||
* Opera), Edge on the desktop, and iOS and Chrome on mobile.
|
||||
*
|
||||
* Learn more in https://angular.io/guide/browser-support
|
||||
*/
|
||||
|
||||
/***************************************************************************************************
|
||||
* BROWSER POLYFILLS
|
||||
*/
|
||||
|
||||
/**
|
||||
* By default, zone.js will patch all possible macroTask and DomEvents
|
||||
* user can disable parts of macroTask/DomEvents patch by setting following flags
|
||||
* because those flags need to be set before `zone.js` being loaded, and webpack
|
||||
* will put import in the top of bundle, so user need to create a separate file
|
||||
* in this directory (for example: zone-flags.ts), and put the following flags
|
||||
* into that file, and then add the following code before importing zone.js.
|
||||
* import './zone-flags';
|
||||
*
|
||||
* The flags allowed in zone-flags.ts are listed here.
|
||||
*
|
||||
* The following flags will work for all browsers.
|
||||
*
|
||||
* (window as any).__Zone_disable_requestAnimationFrame = true; // disable patch requestAnimationFrame
|
||||
* (window as any).__Zone_disable_on_property = true; // disable patch onProperty such as onclick
|
||||
* (window as any).__zone_symbol__UNPATCHED_EVENTS = ['scroll', 'mousemove']; // disable patch specified eventNames
|
||||
*
|
||||
* in IE/Edge developer tools, the addEventListener will also be wrapped by zone.js
|
||||
* with the following flag, it will bypass `zone.js` patch for IE/Edge
|
||||
*
|
||||
* (window as any).__Zone_enable_cross_context_check = true;
|
||||
*
|
||||
*/
|
||||
|
||||
import './zone-flags';
|
||||
|
||||
/***************************************************************************************************
|
||||
* Zone JS is required by default for Angular itself.
|
||||
*/
|
||||
import 'zone.js'; // Included with Angular CLI.
|
||||
|
||||
|
||||
/***************************************************************************************************
|
||||
* APPLICATION IMPORTS
|
||||
*/
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"rewrites": [
|
||||
{ "source": "/**", "destination": "/index.html" }
|
||||
]
|
||||
}
|
||||
14
src/test.ts
14
src/test.ts
@@ -1,14 +0,0 @@
|
||||
// This file is required by karma.conf.js and loads recursively all the .spec and framework files
|
||||
|
||||
import 'zone.js/testing';
|
||||
import { getTestBed } from '@angular/core/testing';
|
||||
import {
|
||||
BrowserDynamicTestingModule,
|
||||
platformBrowserDynamicTesting
|
||||
} from '@angular/platform-browser-dynamic/testing';
|
||||
|
||||
// First, initialize the Angular testing environment.
|
||||
getTestBed().initTestEnvironment(
|
||||
BrowserDynamicTestingModule,
|
||||
platformBrowserDynamicTesting(),
|
||||
);
|
||||
@@ -1,2 +0,0 @@
|
||||
// For information on how to create your own theme, please refer to:
|
||||
// https://ionicframework.com/docs/theming/
|
||||
@@ -1,6 +0,0 @@
|
||||
/**
|
||||
* Prevents Angular change detection from
|
||||
* running with certain Web Component callbacks
|
||||
*/
|
||||
// eslint-disable-next-line no-underscore-dangle
|
||||
(window as any).__Zone_disable_customElements = true;
|
||||
@@ -1,15 +0,0 @@
|
||||
/* To learn more about this file see: https://angular.io/config/tsconfig. */
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./out-tsc/app",
|
||||
"types": []
|
||||
},
|
||||
"files": [
|
||||
"src/main.ts",
|
||||
"src/polyfills.ts"
|
||||
],
|
||||
"include": [
|
||||
"src/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
@@ -1,34 +1,18 @@
|
||||
/* To learn more about this file see: https://angular.io/config/tsconfig. */
|
||||
{
|
||||
"compileOnSave": false,
|
||||
"compilerOptions": {
|
||||
"baseUrl": "./",
|
||||
"outDir": "./dist/out-tsc",
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"strict": true,
|
||||
"noImplicitOverride": true,
|
||||
"noPropertyAccessFromIndexSignature": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"sourceMap": true,
|
||||
"declaration": false,
|
||||
"downlevelIteration": true,
|
||||
"experimentalDecorators": true,
|
||||
"moduleResolution": "node",
|
||||
"importHelpers": true,
|
||||
"target": "es2022",
|
||||
"module": "es2020",
|
||||
"lib": [
|
||||
"es2018",
|
||||
"dom"
|
||||
],
|
||||
"skipLibCheck": true,
|
||||
"useDefineForClassFields": false
|
||||
// https://nuxt.com/docs/guide/concepts/typescript
|
||||
"files": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./.nuxt/tsconfig.app.json"
|
||||
},
|
||||
"angularCompilerOptions": {
|
||||
"enableI18nLegacyMessageIdFormat": false,
|
||||
"strictInjectionParameters": true,
|
||||
"strictInputAccessModifiers": true,
|
||||
"strictTemplates": true
|
||||
{
|
||||
"path": "./.nuxt/tsconfig.server.json"
|
||||
},
|
||||
{
|
||||
"path": "./.nuxt/tsconfig.shared.json"
|
||||
},
|
||||
{
|
||||
"path": "./.nuxt/tsconfig.node.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
/* To learn more about this file see: https://angular.io/config/tsconfig. */
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./out-tsc/spec",
|
||||
"types": [
|
||||
"jasmine"
|
||||
]
|
||||
},
|
||||
"files": [
|
||||
"src/test.ts",
|
||||
"src/polyfills.ts"
|
||||
],
|
||||
"include": [
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user