Compare commits
7 Commits
proposal-9
...
proposal-0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
129af44641 | ||
|
|
81f88f10f8 | ||
|
|
d27c8382de | ||
|
|
57fe63ef1f | ||
|
|
ad6ea50eb7 | ||
|
|
d05cf7d13d | ||
|
|
6b0f87199c |
15
.browserslistrc
Normal file
15
.browserslistrc
Normal file
@@ -0,0 +1,15 @@
|
||||
# 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
|
||||
16
.editorconfig
Normal file
16
.editorconfig
Normal file
@@ -0,0 +1,16 @@
|
||||
# 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
|
||||
47
.eslintrc.json
Normal file
47
.eslintrc.json
Normal file
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"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": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
72
.gitignore
vendored
72
.gitignore
vendored
@@ -1 +1,71 @@
|
||||
node_modules
|
||||
# Specifies intentionally untracked files to ignore when using Git
|
||||
# http://git-scm.com/docs/gitignore
|
||||
|
||||
*~
|
||||
*.sw[mnpcod]
|
||||
.tmp
|
||||
*.tmp
|
||||
*.tmp.*
|
||||
UserInterfaceState.xcuserstate
|
||||
$RECYCLE.BIN/
|
||||
|
||||
*.log
|
||||
log.txt
|
||||
|
||||
|
||||
/.sourcemaps
|
||||
/.versions
|
||||
/coverage
|
||||
|
||||
# Ionic
|
||||
/.ionic
|
||||
/www
|
||||
/platforms
|
||||
/plugins
|
||||
|
||||
# Compiled output
|
||||
/dist
|
||||
/tmp
|
||||
/out-tsc
|
||||
/bazel-out
|
||||
|
||||
# Node
|
||||
/node_modules
|
||||
npm-debug.log
|
||||
yarn-error.log
|
||||
|
||||
# IDEs and editors
|
||||
.idea/
|
||||
.project
|
||||
.classpath
|
||||
.c9/
|
||||
*.launch
|
||||
.settings/
|
||||
*.sublime-project
|
||||
*.sublime-workspace
|
||||
|
||||
# Visual Studio Code
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
.history/*
|
||||
|
||||
|
||||
# Miscellaneous
|
||||
/.angular
|
||||
/.angular/cache
|
||||
.sass-cache/
|
||||
/.nx
|
||||
/.nx/cache
|
||||
/connect.lock
|
||||
/coverage
|
||||
/libpeerconnection.log
|
||||
testem.log
|
||||
/typings
|
||||
|
||||
# System files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
CLAUDE.md
|
||||
|
||||
5
.vscode/extensions.json
vendored
Normal file
5
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"Webnative.webnative"
|
||||
]
|
||||
}
|
||||
3
.vscode/settings.json
vendored
Normal file
3
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"typescript.preferences.autoImportFileExcludePatterns": ["@ionic/angular/common", "@ionic/angular/standalone"]
|
||||
}
|
||||
55
CLAUDE.md
55
CLAUDE.md
@@ -1,55 +0,0 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project
|
||||
|
||||
P2P Poll App — a peer-to-peer polling application for the Evocracy democratic coding research experiment (https://demcode.evocracy.org/).
|
||||
|
||||
### Goal
|
||||
Build a simple P2P polling app where users can add poll options and vote. Data exchange works peer-to-peer between clients (no central server for data), though a signaling/sync server for connection establishment is acceptable.
|
||||
|
||||
### Research Context
|
||||
- **Phase 1 (2 weeks):** Submit individual code proposal
|
||||
- **Phase 2:** Groups of 3 merge solutions into a working prototype
|
||||
- **Phase 3:** Representatives iteratively merge until one final solution remains
|
||||
- Code must be clean, explainable, and easy to merge with others' work
|
||||
- Final code published open-source under MIT license
|
||||
- Language: JavaScript/TypeScript
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Runtime:** Bun
|
||||
- **Framework:** Waku (minimal React framework with RSC support)
|
||||
- **Styling:** Tailwind CSS v4
|
||||
- **P2P/Data sync:** Automerge with automerge-repo (CRDT-based)
|
||||
- **Networking:** `automerge-repo-network-websocket` (WebSocketClientAdapter) + `automerge-repo-network-broadcastchannel` (cross-tab sync)
|
||||
- **Storage:** `automerge-repo-storage-indexeddb` (client-side persistence)
|
||||
|
||||
**Waku documentation:** https://waku.gg/llms.txt
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
bun install # Install dependencies
|
||||
bun run dev # Start dev server
|
||||
bun run build # Production build
|
||||
bun test # Run tests (Bun's built-in test runner)
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
- Waku pages router: pages live in `src/pages/`, layouts in `_layout.tsx`
|
||||
- Client components use `'use client'` directive
|
||||
- Automerge Repo is initialized in a client-side provider component wrapping the app
|
||||
- The shared CRDT document holds the poll state (title, options, votes)
|
||||
- Peers sync via a lightweight WebSocket sync server (can use `automerge-repo` sync server or the public `wss://sync.automerge.org`)
|
||||
- `BroadcastChannelNetworkAdapter` enables cross-tab sync
|
||||
- `useDocument` hook from `@automerge/automerge-repo-react-hooks` for reactive document access
|
||||
- Every Waku page/layout must export `getConfig` specifying render mode (`'static'` or `'dynamic'`)
|
||||
|
||||
## Key Design Principles
|
||||
|
||||
- Keep it simple and merge-friendly for Phase 2 group work
|
||||
- Minimal file count, clear separation of concerns
|
||||
- No over-engineering — this is a research experiment, not production software
|
||||
392
README.md
392
README.md
@@ -1,91 +1,347 @@
|
||||
# P2P Poll App
|
||||
# P2P Survey App
|
||||
|
||||
A peer-to-peer polling application where users can create polls, add options, and vote — all without a central server. Built for the [Evocracy democratic coding research experiment](https://demcode.evocracy.org/).
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Overview](#overview)
|
||||
2. [Architecture](#architecture)
|
||||
3. [Features](#features)
|
||||
4. [Setup & Installation](#setup--installation)
|
||||
5. [How It Works](#how-it-works)
|
||||
- [Creator Flow](#creator-flow)
|
||||
- [Participant Flow](#participant-flow)
|
||||
6. [Question Types](#question-types)
|
||||
7. [Technology Stack](#technology-stack)
|
||||
8. [Limitations](#limitations)
|
||||
9. [Self-Hosting the PeerJS Signaling Server](#self-hosting-the-peerjs-signaling-server)
|
||||
10. [Development Commands](#development-commands)
|
||||
11. [Technology Choices](#technology-choices)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
P2P Survey App lets you create and distribute surveys without any server-side
|
||||
infrastructure. All survey data — questions, participant tokens, and responses —
|
||||
lives exclusively in the survey creator's browser (IndexedDB). Participants
|
||||
connect directly to the creator's browser using WebRTC peer-to-peer data
|
||||
channels, facilitated by the PeerJS library.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Creator Browser (Host)
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ Ionic/Angular App │
|
||||
│ ┌─────────────────┐ ┌───────────────────┐│
|
||||
│ │ PeerService │ │ Dexie (IndexedDB)││
|
||||
│ │ (PeerJS/WebRTC)│<─>│ surveys ││
|
||||
│ └────────┬────────┘ │ participants ││
|
||||
│ │ │ responses ││
|
||||
│ Peer ID: survey-{id}└───────────────────┘│
|
||||
└───────────┼─────────────────────────────────┘
|
||||
│
|
||||
[PeerJS Cloud – signaling only, not data relay]
|
||||
│
|
||||
Participant Browser
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ Opens: /participate?host=survey-{id} │
|
||||
│ &token={uuid} │
|
||||
│ │
|
||||
│ 1. Connect to host peer via WebRTC │
|
||||
│ 2. Send { type: 'join', token } │
|
||||
│ 3. Receive survey questions │
|
||||
│ 4. Fill in answers │
|
||||
│ 5. Send { type: 'submit', answers } │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Star topology
|
||||
|
||||
The survey creator acts as the central hub. Each participant opens a direct
|
||||
WebRTC data channel to the creator's browser. The creator's browser validates
|
||||
tokens, stores responses, and optionally pushes aggregated results back.
|
||||
|
||||
The PeerJS signaling server is only used for the initial WebRTC handshake
|
||||
(exchanging ICE candidates). Once connected, all data flows directly between
|
||||
the two browsers — the signaling server never sees response data.
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
- **Create surveys** with four question types: free text, multiple choice,
|
||||
yes/no, and 1–5 rating
|
||||
- **Generate unique participant links** — each link contains a UUID token that
|
||||
identifies one participant
|
||||
- **Token-based access control** — each token can only be submitted once;
|
||||
reuse is rejected by the host
|
||||
- **Draft saving** — participants can save answers before final submission
|
||||
without locking their token
|
||||
- **Live results** — the creator sees responses update in real time using
|
||||
Dexie's `liveQuery`
|
||||
- **Optional results sharing** — the creator can toggle whether participants
|
||||
see aggregated results after submitting
|
||||
- **CSV export** — download all responses as a comma-separated file
|
||||
- **Local-only storage** — all data lives in the creator's IndexedDB; delete
|
||||
the survey and the data is gone
|
||||
|
||||
---
|
||||
|
||||
## Setup & Installation
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js 18+ and npm 9+
|
||||
- A modern browser (Chrome, Firefox, Edge, Safari)
|
||||
|
||||
### Install
|
||||
|
||||
```bash
|
||||
# 1. Install Ionic CLI globally (skip if already installed)
|
||||
npm install -g @ionic/cli
|
||||
|
||||
# 2. Install project dependencies
|
||||
npm install
|
||||
|
||||
# 3. Start the development server
|
||||
ionic serve
|
||||
```
|
||||
|
||||
The app opens at `http://localhost:8100`.
|
||||
|
||||
### Build for production
|
||||
|
||||
```bash
|
||||
ionic build --prod
|
||||
```
|
||||
|
||||
Output is written to `www/`. Deploy the contents of `www/` to any static
|
||||
web host (GitHub Pages, Netlify, Vercel, Nginx, etc.).
|
||||
|
||||
---
|
||||
|
||||
## How It Works
|
||||
|
||||
Data is synchronized directly between peers using [Automerge](https://automerge.org/) CRDTs (Conflict-free Replicated Data Types). There is no backend database — every client holds a full copy of the poll document and changes merge automatically, even when made offline or concurrently.
|
||||
### Creator Flow
|
||||
|
||||
**Sync layers:**
|
||||
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.
|
||||
|
||||
- **WebSocket** (`wss://sync.automerge.org`) — cross-device sync via a public relay
|
||||
- **BroadcastChannel** — instant cross-tab sync within the same browser
|
||||
- **IndexedDB** — local persistence across page reloads and offline use
|
||||
### Participant Flow
|
||||
|
||||
## Requirements
|
||||
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.
|
||||
|
||||
- [Bun](https://bun.sh/) (standalone runtime, no Node.js needed)
|
||||
---
|
||||
|
||||
## Getting Started
|
||||
## Question Types
|
||||
|
||||
| Type | Input widget | Aggregation |
|
||||
|---|---|---|
|
||||
| **Free Text** | Multi-line textarea | List of all answers |
|
||||
| **Multiple Choice** | Radio buttons (creator-defined options) | Count per option + bar chart |
|
||||
| **Yes / No** | Radio buttons (Yes / No) | Count per option + bar chart |
|
||||
| **Rating** | 5 buttons (1–5) | Average score + distribution bar chart |
|
||||
|
||||
---
|
||||
|
||||
## Technology Stack
|
||||
|
||||
| Library | Version | Role |
|
||||
|---|---|---|
|
||||
| `@ionic/angular` | 8.x | Mobile-first UI components |
|
||||
| `@angular/core` | 20.x | Application framework |
|
||||
| `peerjs` | 1.5.x | WebRTC data channel wrapper |
|
||||
| `dexie` | 4.x | IndexedDB wrapper with liveQuery |
|
||||
| `qrcode` | 1.5.x | QR code generation (optional usage) |
|
||||
|
||||
---
|
||||
|
||||
## Limitations
|
||||
|
||||
### Host must be online
|
||||
|
||||
The survey creator's browser **must be open** and on the Survey Detail or
|
||||
Results page for participants to submit responses. There is no server that
|
||||
relays or buffers messages. If the creator goes offline, participants see a
|
||||
"Host is Offline" card.
|
||||
|
||||
### Data loss
|
||||
|
||||
All data is stored in the creator's browser IndexedDB. Data is lost if:
|
||||
- The creator explicitly deletes the survey
|
||||
- The browser's storage is cleared (private/incognito mode, clearing site data)
|
||||
- The browser's IndexedDB quota is exceeded
|
||||
|
||||
Export responses to CSV regularly to prevent data loss.
|
||||
|
||||
### PeerJS Cloud rate limits
|
||||
|
||||
The free PeerJS Cloud signaling server (`0.peerjs.com`) has rate limits and
|
||||
may occasionally be unavailable. For production use, self-host the signaling
|
||||
server (see below).
|
||||
|
||||
### NAT traversal
|
||||
|
||||
WebRTC uses STUN to traverse most NAT configurations. Strict corporate
|
||||
firewalls or symmetric NAT may block direct connections. For full reliability
|
||||
in such environments, add a TURN server to the PeerJS configuration in
|
||||
`src/app/services/peer.service.ts`.
|
||||
|
||||
### Browser compatibility
|
||||
|
||||
Requires a browser that supports:
|
||||
- WebRTC (DataChannels)
|
||||
- IndexedDB
|
||||
- `crypto.randomUUID()`
|
||||
|
||||
All modern browsers (Chrome 86+, Firefox 78+, Safari 15.4+, Edge 86+) satisfy
|
||||
these requirements.
|
||||
|
||||
---
|
||||
|
||||
## Self-Hosting the PeerJS Signaling Server
|
||||
|
||||
For production deployments, run your own PeerJS server to avoid rate limits.
|
||||
|
||||
### Option 1: npm
|
||||
|
||||
```bash
|
||||
bun install # Install dependencies
|
||||
bun run dev # Start dev server (http://localhost:3000)
|
||||
npm install -g peer
|
||||
peerjs --port 9000 --key peerjs
|
||||
```
|
||||
|
||||
## Usage
|
||||
### Option 2: Docker
|
||||
|
||||
1. **Create a poll** — Enter a title on the home page and click "Create"
|
||||
2. **Share it** — Copy the shareable link and send it to others
|
||||
3. **Vote** — Click an option to vote; click again to unvote
|
||||
4. **Add options** — Anyone with the link can add new poll options
|
||||
```bash
|
||||
docker run -p 9000:9000 peerjs/peerjs-server
|
||||
```
|
||||
|
||||
Each browser gets a stable peer ID (stored in localStorage) so votes are tracked per-device and double-voting is prevented.
|
||||
### Option 3: Node.js
|
||||
|
||||
## Tech Stack
|
||||
```javascript
|
||||
// server.js
|
||||
const { PeerServer } = require('peer');
|
||||
const server = PeerServer({ port: 9000, path: '/peerjs' });
|
||||
```
|
||||
|
||||
| Layer | Technology |
|
||||
|---|---|
|
||||
| Runtime | [Bun](https://bun.sh/) |
|
||||
| Framework | [Waku](https://waku.gg/) (React Server Components) |
|
||||
| Styling | Tailwind CSS v4 |
|
||||
| P2P sync | Automerge + automerge-repo |
|
||||
| Storage | IndexedDB (client-side) |
|
||||
### 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/
|
||||
├── pages/ # Waku page router
|
||||
│ ├── _root.tsx # HTML document shell
|
||||
│ ├── _layout.tsx # Root layout + Providers
|
||||
│ ├── index.tsx # Home page (create/join polls)
|
||||
│ └── poll/[id].tsx # Poll view page
|
||||
├── components/
|
||||
│ ├── Providers.tsx # Automerge Repo initialization
|
||||
│ ├── HomeClient.tsx # Create/join poll UI
|
||||
│ ├── PollPageClient.tsx # Poll ID validation
|
||||
│ ├── PollView.tsx # Poll display, voting, options
|
||||
│ └── ConnectionStatus.tsx # P2P connection indicator
|
||||
├── lib/
|
||||
│ ├── types.ts # Poll & PollOption interfaces
|
||||
│ ├── repo.ts # Automerge Repo singleton
|
||||
│ ├── poll.ts # Pure poll mutation functions
|
||||
│ ├── peer.ts # Peer ID management
|
||||
│ └── __tests__/ # Unit tests
|
||||
└── styles/
|
||||
└── global.css # Tailwind CSS
|
||||
└── 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
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
bun test
|
||||
```
|
||||
|
||||
Covers poll creation, voting/unvoting, double-vote prevention, option management, and peer ID persistence.
|
||||
|
||||
## Architecture Notes
|
||||
|
||||
- **Pure business logic** — Poll mutations in `src/lib/poll.ts` are pure functions, used inside Automerge's `changeDoc()` for CRDT-safe updates
|
||||
- **No server state** — The WebSocket relay only forwards sync messages; it never stores or processes poll data
|
||||
- **Offline-first** — The app works fully offline; changes sync when connectivity resumes
|
||||
- **Conflict-free** — Concurrent edits (e.g., two users voting at the same time) merge automatically without conflicts
|
||||
|
||||
## Built With
|
||||
|
||||
This project was built in collaboration with [Claude Code](https://claude.ai/code), Anthropic's agentic coding tool.
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
170
angular.json
Normal file
170
angular.json
Normal file
@@ -0,0 +1,170 @@
|
||||
{
|
||||
"$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"
|
||||
}
|
||||
}
|
||||
}
|
||||
588
bun.lock
588
bun.lock
@@ -1,588 +0,0 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "p2p-poll-app",
|
||||
"dependencies": {
|
||||
"@automerge/automerge": "^3.2.4",
|
||||
"@automerge/automerge-repo": "^2.5.3",
|
||||
"@automerge/automerge-repo-network-broadcastchannel": "^2.5.3",
|
||||
"@automerge/automerge-repo-network-websocket": "^2.5.3",
|
||||
"@automerge/automerge-repo-react-hooks": "^2.5.3",
|
||||
"@automerge/automerge-repo-storage-indexeddb": "^2.5.3",
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"react": "~19.2.4",
|
||||
"react-dom": "~19.2.4",
|
||||
"react-server-dom-webpack": "~19.2.4",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"vite-plugin-top-level-await": "^1.6.0",
|
||||
"vite-plugin-wasm": "^3.5.0",
|
||||
"waku": "1.0.0-alpha.5",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"typescript": "^5.9.3",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@automerge/automerge": ["@automerge/automerge@3.2.4", "", {}, "sha512-/IAShHSxme5d4ZK0Vs4A0P+tGaR/bSz6KtIJSCIPfwilCxsIqfRHAoNjmsXip6TGouadmbuw3WfLop6cal8pPQ=="],
|
||||
|
||||
"@automerge/automerge-repo": ["@automerge/automerge-repo@2.5.3", "", { "dependencies": { "@automerge/automerge": "2.2.8 - 3", "bs58check": "^3.0.1", "cbor-x": "^1.3.0", "debug": "^4.3.4", "eventemitter3": "^5.0.1", "fast-sha256": "^1.3.0", "uuid": "^9.0.0", "xstate": "^5.9.1" } }, "sha512-5ppzsUlzCs6PY+GeFlquaYLnVnYPz/hWzedep37zjb15tqZju1ukPPkBzT7KGEhEAnA99l4vfhfqSVC+1GktJg=="],
|
||||
|
||||
"@automerge/automerge-repo-network-broadcastchannel": ["@automerge/automerge-repo-network-broadcastchannel@2.5.3", "", { "dependencies": { "@automerge/automerge-repo": "2.5.3" } }, "sha512-V/JpOsOkPPqe+hJs7Zs8oSKO3dp2/4Qd6D+x3D1rda7OhyOmlJ1HnMLghycnhHDQG8lCP9ex3AH6MYe1ZqgCKw=="],
|
||||
|
||||
"@automerge/automerge-repo-network-websocket": ["@automerge/automerge-repo-network-websocket@2.5.3", "", { "dependencies": { "@automerge/automerge-repo": "2.5.3", "cbor-x": "^1.3.0", "debug": "^4.3.4", "eventemitter3": "^5.0.1", "isomorphic-ws": "^5.0.0", "ws": "^8.7.0" } }, "sha512-p6K1YLo34cyGxJ6oMWqeBbWX9rGvEPGyPdX2eq8S/iWNDjYf8lbjKSOhOD8DX87uAMZN/y1XOY/RckwS8lPJbQ=="],
|
||||
|
||||
"@automerge/automerge-repo-react-hooks": ["@automerge/automerge-repo-react-hooks@2.5.3", "", { "dependencies": { "@automerge/automerge": "2.2.8 - 3", "@automerge/automerge-repo": "2.5.3", "eventemitter3": "^5.0.1", "react-usestateref": "^1.0.8" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-573jhVX1LMiBf8zV0NaYT2PgsEuqHNz0RnKSzIp49+g+swgb+KtRhpdiF38tbQoDnkFsKpYGR6P+qoAVW8veaw=="],
|
||||
|
||||
"@automerge/automerge-repo-storage-indexeddb": ["@automerge/automerge-repo-storage-indexeddb@2.5.3", "", { "dependencies": { "@automerge/automerge-repo": "2.5.3" } }, "sha512-i4tSzY7sC8ZqurHm4Sd9H1lFL2Don9v9yaSoq0FXi4T+4Wg8eus/mtMlUCmS5XwYQL8GX+epbplrlBaBe+fYiw=="],
|
||||
|
||||
"@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="],
|
||||
|
||||
"@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="],
|
||||
|
||||
"@babel/core": ["@babel/core@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA=="],
|
||||
|
||||
"@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="],
|
||||
|
||||
"@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="],
|
||||
|
||||
"@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="],
|
||||
|
||||
"@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="],
|
||||
|
||||
"@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="],
|
||||
|
||||
"@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.28.6", "", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="],
|
||||
|
||||
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="],
|
||||
|
||||
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],
|
||||
|
||||
"@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="],
|
||||
|
||||
"@babel/helpers": ["@babel/helpers@7.28.6", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw=="],
|
||||
|
||||
"@babel/parser": ["@babel/parser@7.29.0", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww=="],
|
||||
|
||||
"@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw=="],
|
||||
|
||||
"@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw=="],
|
||||
|
||||
"@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="],
|
||||
|
||||
"@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="],
|
||||
|
||||
"@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="],
|
||||
|
||||
"@cbor-extract/cbor-extract-darwin-arm64": ["@cbor-extract/cbor-extract-darwin-arm64@2.2.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-P7swiOAdF7aSi0H+tHtHtr6zrpF3aAq/W9FXx5HektRvLTM2O89xCyXF3pk7pLc7QpaY7AoaE8UowVf9QBdh3w=="],
|
||||
|
||||
"@cbor-extract/cbor-extract-darwin-x64": ["@cbor-extract/cbor-extract-darwin-x64@2.2.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-1liF6fgowph0JxBbYnAS7ZlqNYLf000Qnj4KjqPNW4GViKrEql2MgZnAsExhY9LSy8dnvA4C0qHEBgPrll0z0w=="],
|
||||
|
||||
"@cbor-extract/cbor-extract-linux-arm": ["@cbor-extract/cbor-extract-linux-arm@2.2.0", "", { "os": "linux", "cpu": "arm" }, "sha512-QeBcBXk964zOytiedMPQNZr7sg0TNavZeuUCD6ON4vEOU/25+pLhNN6EDIKJ9VLTKaZ7K7EaAriyYQ1NQ05s/Q=="],
|
||||
|
||||
"@cbor-extract/cbor-extract-linux-arm64": ["@cbor-extract/cbor-extract-linux-arm64@2.2.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-rQvhNmDuhjTVXSPFLolmQ47/ydGOFXtbR7+wgkSY0bdOxCFept1hvg59uiLPT2fVDuJFuEy16EImo5tE2x3RsQ=="],
|
||||
|
||||
"@cbor-extract/cbor-extract-linux-x64": ["@cbor-extract/cbor-extract-linux-x64@2.2.0", "", { "os": "linux", "cpu": "x64" }, "sha512-cWLAWtT3kNLHSvP4RKDzSTX9o0wvQEEAj4SKvhWuOVZxiDAeQazr9A+PSiRILK1VYMLeDml89ohxCnUNQNQNCw=="],
|
||||
|
||||
"@cbor-extract/cbor-extract-win32-x64": ["@cbor-extract/cbor-extract-win32-x64@2.2.0", "", { "os": "win32", "cpu": "x64" }, "sha512-l2M+Z8DO2vbvADOBNLbbh9y5ST1RY5sqkWOg/58GkUPBYou/cuNZ68SGQ644f1CvZ8kcOxyZtw06+dxWHIoN/w=="],
|
||||
|
||||
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="],
|
||||
|
||||
"@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="],
|
||||
|
||||
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.3", "", { "os": "android", "cpu": "arm64" }, "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg=="],
|
||||
|
||||
"@esbuild/android-x64": ["@esbuild/android-x64@0.27.3", "", { "os": "android", "cpu": "x64" }, "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ=="],
|
||||
|
||||
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg=="],
|
||||
|
||||
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg=="],
|
||||
|
||||
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w=="],
|
||||
|
||||
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA=="],
|
||||
|
||||
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.3", "", { "os": "linux", "cpu": "arm" }, "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw=="],
|
||||
|
||||
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg=="],
|
||||
|
||||
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.3", "", { "os": "linux", "cpu": "ia32" }, "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg=="],
|
||||
|
||||
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA=="],
|
||||
|
||||
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw=="],
|
||||
|
||||
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA=="],
|
||||
|
||||
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ=="],
|
||||
|
||||
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw=="],
|
||||
|
||||
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.3", "", { "os": "linux", "cpu": "x64" }, "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA=="],
|
||||
|
||||
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA=="],
|
||||
|
||||
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.3", "", { "os": "none", "cpu": "x64" }, "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA=="],
|
||||
|
||||
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.3", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw=="],
|
||||
|
||||
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.3", "", { "os": "openbsd", "cpu": "x64" }, "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ=="],
|
||||
|
||||
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g=="],
|
||||
|
||||
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.3", "", { "os": "sunos", "cpu": "x64" }, "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA=="],
|
||||
|
||||
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA=="],
|
||||
|
||||
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q=="],
|
||||
|
||||
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="],
|
||||
|
||||
"@hono/node-server": ["@hono/node-server@1.19.11", "", { "peerDependencies": { "hono": "^4" } }, "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g=="],
|
||||
|
||||
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
||||
|
||||
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
|
||||
|
||||
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
|
||||
|
||||
"@jridgewell/source-map": ["@jridgewell/source-map@0.3.11", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25" } }, "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA=="],
|
||||
|
||||
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
|
||||
|
||||
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||
|
||||
"@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="],
|
||||
|
||||
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.3", "", {}, "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q=="],
|
||||
|
||||
"@rollup/plugin-virtual": ["@rollup/plugin-virtual@3.0.2", "", { "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-10monEYsBp3scM4/ND4LNH5Rxvh3e/cVeL3jWTgZ2SrQ+BmUoQcopVQvnaMcOnykb1VkxUFuDAN+0FnpTFRy2A=="],
|
||||
|
||||
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.59.0", "", { "os": "android", "cpu": "arm" }, "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg=="],
|
||||
|
||||
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.59.0", "", { "os": "android", "cpu": "arm64" }, "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q=="],
|
||||
|
||||
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.59.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg=="],
|
||||
|
||||
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.59.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w=="],
|
||||
|
||||
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.59.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA=="],
|
||||
|
||||
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.59.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg=="],
|
||||
|
||||
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.59.0", "", { "os": "linux", "cpu": "arm" }, "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw=="],
|
||||
|
||||
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.59.0", "", { "os": "linux", "cpu": "arm" }, "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA=="],
|
||||
|
||||
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.59.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA=="],
|
||||
|
||||
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.59.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA=="],
|
||||
|
||||
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg=="],
|
||||
|
||||
"@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q=="],
|
||||
|
||||
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.59.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA=="],
|
||||
|
||||
"@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.59.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA=="],
|
||||
|
||||
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg=="],
|
||||
|
||||
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg=="],
|
||||
|
||||
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.59.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w=="],
|
||||
|
||||
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.59.0", "", { "os": "linux", "cpu": "x64" }, "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg=="],
|
||||
|
||||
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.59.0", "", { "os": "linux", "cpu": "x64" }, "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg=="],
|
||||
|
||||
"@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.59.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ=="],
|
||||
|
||||
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.59.0", "", { "os": "none", "cpu": "arm64" }, "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA=="],
|
||||
|
||||
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.59.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A=="],
|
||||
|
||||
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.59.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA=="],
|
||||
|
||||
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.59.0", "", { "os": "win32", "cpu": "x64" }, "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA=="],
|
||||
|
||||
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.59.0", "", { "os": "win32", "cpu": "x64" }, "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA=="],
|
||||
|
||||
"@swc/core": ["@swc/core@1.15.18", "", { "dependencies": { "@swc/counter": "^0.1.3", "@swc/types": "^0.1.25" }, "optionalDependencies": { "@swc/core-darwin-arm64": "1.15.18", "@swc/core-darwin-x64": "1.15.18", "@swc/core-linux-arm-gnueabihf": "1.15.18", "@swc/core-linux-arm64-gnu": "1.15.18", "@swc/core-linux-arm64-musl": "1.15.18", "@swc/core-linux-x64-gnu": "1.15.18", "@swc/core-linux-x64-musl": "1.15.18", "@swc/core-win32-arm64-msvc": "1.15.18", "@swc/core-win32-ia32-msvc": "1.15.18", "@swc/core-win32-x64-msvc": "1.15.18" }, "peerDependencies": { "@swc/helpers": ">=0.5.17" }, "optionalPeers": ["@swc/helpers"] }, "sha512-z87aF9GphWp//fnkRsqvtY+inMVPgYW3zSlXH1kJFvRT5H/wiAn+G32qW5l3oEk63KSF1x3Ov0BfHCObAmT8RA=="],
|
||||
|
||||
"@swc/core-darwin-arm64": ["@swc/core-darwin-arm64@1.15.18", "", { "os": "darwin", "cpu": "arm64" }, "sha512-+mIv7uBuSaywN3C9LNuWaX1jJJ3SKfiJuE6Lr3bd+/1Iv8oMU7oLBjYMluX1UrEPzwN2qCdY6Io0yVicABoCwQ=="],
|
||||
|
||||
"@swc/core-darwin-x64": ["@swc/core-darwin-x64@1.15.18", "", { "os": "darwin", "cpu": "x64" }, "sha512-wZle0eaQhnzxWX5V/2kEOI6Z9vl/lTFEC6V4EWcn+5pDjhemCpQv9e/TDJ0GIoiClX8EDWRvuZwh+Z3dhL1NAg=="],
|
||||
|
||||
"@swc/core-linux-arm-gnueabihf": ["@swc/core-linux-arm-gnueabihf@1.15.18", "", { "os": "linux", "cpu": "arm" }, "sha512-ao61HGXVqrJFHAcPtF4/DegmwEkVCo4HApnotLU8ognfmU8x589z7+tcf3hU+qBiU1WOXV5fQX6W9Nzs6hjxDw=="],
|
||||
|
||||
"@swc/core-linux-arm64-gnu": ["@swc/core-linux-arm64-gnu@1.15.18", "", { "os": "linux", "cpu": "arm64" }, "sha512-3xnctOBLIq3kj8PxOCgPrGjBLP/kNOddr6f5gukYt/1IZxsITQaU9TDyjeX6jG+FiCIHjCuWuffsyQDL5Ew1bg=="],
|
||||
|
||||
"@swc/core-linux-arm64-musl": ["@swc/core-linux-arm64-musl@1.15.18", "", { "os": "linux", "cpu": "arm64" }, "sha512-0a+Lix+FSSHBSBOA0XznCcHo5/1nA6oLLjcnocvzXeqtdjnPb+SvchItHI+lfeiuj1sClYPDvPMLSLyXFaiIKw=="],
|
||||
|
||||
"@swc/core-linux-x64-gnu": ["@swc/core-linux-x64-gnu@1.15.18", "", { "os": "linux", "cpu": "x64" }, "sha512-wG9J8vReUlpaHz4KOD/5UE1AUgirimU4UFT9oZmupUDEofxJKYb1mTA/DrMj0s78bkBiNI+7Fo2EgPuvOJfuAA=="],
|
||||
|
||||
"@swc/core-linux-x64-musl": ["@swc/core-linux-x64-musl@1.15.18", "", { "os": "linux", "cpu": "x64" }, "sha512-4nwbVvCphKzicwNWRmvD5iBaZj8JYsRGa4xOxJmOyHlMDpsvvJ2OR2cODlvWyGFH6BYL1MfIAK3qph3hp0Az6g=="],
|
||||
|
||||
"@swc/core-win32-arm64-msvc": ["@swc/core-win32-arm64-msvc@1.15.18", "", { "os": "win32", "cpu": "arm64" }, "sha512-zk0RYO+LjiBCat2RTMHzAWaMky0cra9loH4oRrLKLLNuL+jarxKLFDA8xTZWEkCPLjUTwlRN7d28eDLLMgtUcQ=="],
|
||||
|
||||
"@swc/core-win32-ia32-msvc": ["@swc/core-win32-ia32-msvc@1.15.18", "", { "os": "win32", "cpu": "ia32" }, "sha512-yVuTrZ0RccD5+PEkpcLOBAuPbYBXS6rslENvIXfvJGXSdX5QGi1ehC4BjAMl5FkKLiam4kJECUI0l7Hq7T1vwg=="],
|
||||
|
||||
"@swc/core-win32-x64-msvc": ["@swc/core-win32-x64-msvc@1.15.18", "", { "os": "win32", "cpu": "x64" }, "sha512-7NRmE4hmUQNCbYU3Hn9Tz57mK9Qq4c97ZS+YlamlK6qG9Fb5g/BB3gPDe0iLlJkns/sYv2VWSkm8c3NmbEGjbg=="],
|
||||
|
||||
"@swc/counter": ["@swc/counter@0.1.3", "", {}, "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ=="],
|
||||
|
||||
"@swc/types": ["@swc/types@0.1.25", "", { "dependencies": { "@swc/counter": "^0.1.3" } }, "sha512-iAoY/qRhNH8a/hBvm3zKj9qQ4oc2+3w1unPJa2XvTK3XjeLXtzcCingVPw/9e5mn1+0yPqxcBGp9Jf0pkfMb1g=="],
|
||||
|
||||
"@swc/wasm": ["@swc/wasm@1.15.18", "", {}, "sha512-zeSORFArxqUwfVMTRHu8AN9k9LlfSn0CKDSzLhJDITpgLoS0xpnocxsgMjQjUcVYDgO47r9zLP49HEjH/iGsFg=="],
|
||||
|
||||
"@tailwindcss/node": ["@tailwindcss/node@4.2.1", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.31.1", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.1" } }, "sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg=="],
|
||||
|
||||
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.2.1", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.2.1", "@tailwindcss/oxide-darwin-arm64": "4.2.1", "@tailwindcss/oxide-darwin-x64": "4.2.1", "@tailwindcss/oxide-freebsd-x64": "4.2.1", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.1", "@tailwindcss/oxide-linux-arm64-gnu": "4.2.1", "@tailwindcss/oxide-linux-arm64-musl": "4.2.1", "@tailwindcss/oxide-linux-x64-gnu": "4.2.1", "@tailwindcss/oxide-linux-x64-musl": "4.2.1", "@tailwindcss/oxide-wasm32-wasi": "4.2.1", "@tailwindcss/oxide-win32-arm64-msvc": "4.2.1", "@tailwindcss/oxide-win32-x64-msvc": "4.2.1" } }, "sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw=="],
|
||||
|
||||
"@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.2.1", "", { "os": "android", "cpu": "arm64" }, "sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg=="],
|
||||
|
||||
"@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.2.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw=="],
|
||||
|
||||
"@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.2.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw=="],
|
||||
|
||||
"@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.2.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.2.1", "", { "os": "linux", "cpu": "arm" }, "sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.2.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.2.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.2.1", "", { "os": "linux", "cpu": "x64" }, "sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.2.1", "", { "os": "linux", "cpu": "x64" }, "sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.2.1", "", { "dependencies": { "@emnapi/core": "^1.8.1", "@emnapi/runtime": "^1.8.1", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.1.1", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q=="],
|
||||
|
||||
"@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.2.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA=="],
|
||||
|
||||
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.2.1", "", { "os": "win32", "cpu": "x64" }, "sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ=="],
|
||||
|
||||
"@tailwindcss/vite": ["@tailwindcss/vite@4.2.1", "", { "dependencies": { "@tailwindcss/node": "4.2.1", "@tailwindcss/oxide": "4.2.1", "tailwindcss": "4.2.1" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-TBf2sJjYeb28jD2U/OhwdW0bbOsxkWPwQ7SrqGf9sVcoYwZj7rkXljroBO9wKBut9XnmQLXanuDUeqQK0lGg/w=="],
|
||||
|
||||
"@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
|
||||
|
||||
"@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="],
|
||||
|
||||
"@types/babel__template": ["@types/babel__template@7.4.4", "", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="],
|
||||
|
||||
"@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
|
||||
|
||||
"@types/eslint": ["@types/eslint@9.6.1", "", { "dependencies": { "@types/estree": "*", "@types/json-schema": "*" } }, "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag=="],
|
||||
|
||||
"@types/eslint-scope": ["@types/eslint-scope@3.7.7", "", { "dependencies": { "@types/eslint": "*", "@types/estree": "*" } }, "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg=="],
|
||||
|
||||
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
||||
|
||||
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
|
||||
|
||||
"@types/node": ["@types/node@25.3.5", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-oX8xrhvpiyRCQkG1MFchB09f+cXftgIXb3a7UUa4Y3wpmZPw5tyZGTLWhlESOLq1Rq6oDlc8npVU2/9xiCuXMA=="],
|
||||
|
||||
"@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="],
|
||||
|
||||
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
|
||||
|
||||
"@vitejs/plugin-react": ["@vitejs/plugin-react@5.1.4", "", { "dependencies": { "@babel/core": "^7.29.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-rc.3", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-VIcFLdRi/VYRU8OL/puL7QXMYafHmqOnwTZY50U1JPlCNj30PxCMx65c494b1K9be9hX83KVt0+gTEwTWLqToA=="],
|
||||
|
||||
"@vitejs/plugin-rsc": ["@vitejs/plugin-rsc@0.5.21", "", { "dependencies": { "@rolldown/pluginutils": "1.0.0-rc.5", "es-module-lexer": "^2.0.0", "estree-walker": "^3.0.3", "magic-string": "^0.30.21", "periscopic": "^4.0.2", "srvx": "^0.11.7", "strip-literal": "^3.1.0", "turbo-stream": "^3.1.0", "vitefu": "^1.1.1" }, "peerDependencies": { "react": "*", "react-dom": "*", "react-server-dom-webpack": "*", "vite": "*" }, "optionalPeers": ["react-server-dom-webpack"] }, "sha512-uNayLT8IKvWoznvQyfwKuGiEFV28o7lxUDnw/Av36VCuGpDFZnMmvVCwR37gTvnSmnpul9V0tdJqY3tBKEaDqw=="],
|
||||
|
||||
"@webassemblyjs/ast": ["@webassemblyjs/ast@1.14.1", "", { "dependencies": { "@webassemblyjs/helper-numbers": "1.13.2", "@webassemblyjs/helper-wasm-bytecode": "1.13.2" } }, "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ=="],
|
||||
|
||||
"@webassemblyjs/floating-point-hex-parser": ["@webassemblyjs/floating-point-hex-parser@1.13.2", "", {}, "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA=="],
|
||||
|
||||
"@webassemblyjs/helper-api-error": ["@webassemblyjs/helper-api-error@1.13.2", "", {}, "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ=="],
|
||||
|
||||
"@webassemblyjs/helper-buffer": ["@webassemblyjs/helper-buffer@1.14.1", "", {}, "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA=="],
|
||||
|
||||
"@webassemblyjs/helper-numbers": ["@webassemblyjs/helper-numbers@1.13.2", "", { "dependencies": { "@webassemblyjs/floating-point-hex-parser": "1.13.2", "@webassemblyjs/helper-api-error": "1.13.2", "@xtuc/long": "4.2.2" } }, "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA=="],
|
||||
|
||||
"@webassemblyjs/helper-wasm-bytecode": ["@webassemblyjs/helper-wasm-bytecode@1.13.2", "", {}, "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA=="],
|
||||
|
||||
"@webassemblyjs/helper-wasm-section": ["@webassemblyjs/helper-wasm-section@1.14.1", "", { "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-buffer": "1.14.1", "@webassemblyjs/helper-wasm-bytecode": "1.13.2", "@webassemblyjs/wasm-gen": "1.14.1" } }, "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw=="],
|
||||
|
||||
"@webassemblyjs/ieee754": ["@webassemblyjs/ieee754@1.13.2", "", { "dependencies": { "@xtuc/ieee754": "^1.2.0" } }, "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw=="],
|
||||
|
||||
"@webassemblyjs/leb128": ["@webassemblyjs/leb128@1.13.2", "", { "dependencies": { "@xtuc/long": "4.2.2" } }, "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw=="],
|
||||
|
||||
"@webassemblyjs/utf8": ["@webassemblyjs/utf8@1.13.2", "", {}, "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ=="],
|
||||
|
||||
"@webassemblyjs/wasm-edit": ["@webassemblyjs/wasm-edit@1.14.1", "", { "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-buffer": "1.14.1", "@webassemblyjs/helper-wasm-bytecode": "1.13.2", "@webassemblyjs/helper-wasm-section": "1.14.1", "@webassemblyjs/wasm-gen": "1.14.1", "@webassemblyjs/wasm-opt": "1.14.1", "@webassemblyjs/wasm-parser": "1.14.1", "@webassemblyjs/wast-printer": "1.14.1" } }, "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ=="],
|
||||
|
||||
"@webassemblyjs/wasm-gen": ["@webassemblyjs/wasm-gen@1.14.1", "", { "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-wasm-bytecode": "1.13.2", "@webassemblyjs/ieee754": "1.13.2", "@webassemblyjs/leb128": "1.13.2", "@webassemblyjs/utf8": "1.13.2" } }, "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg=="],
|
||||
|
||||
"@webassemblyjs/wasm-opt": ["@webassemblyjs/wasm-opt@1.14.1", "", { "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-buffer": "1.14.1", "@webassemblyjs/wasm-gen": "1.14.1", "@webassemblyjs/wasm-parser": "1.14.1" } }, "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw=="],
|
||||
|
||||
"@webassemblyjs/wasm-parser": ["@webassemblyjs/wasm-parser@1.14.1", "", { "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-api-error": "1.13.2", "@webassemblyjs/helper-wasm-bytecode": "1.13.2", "@webassemblyjs/ieee754": "1.13.2", "@webassemblyjs/leb128": "1.13.2", "@webassemblyjs/utf8": "1.13.2" } }, "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ=="],
|
||||
|
||||
"@webassemblyjs/wast-printer": ["@webassemblyjs/wast-printer@1.14.1", "", { "dependencies": { "@webassemblyjs/ast": "1.14.1", "@xtuc/long": "4.2.2" } }, "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw=="],
|
||||
|
||||
"@xtuc/ieee754": ["@xtuc/ieee754@1.2.0", "", {}, "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA=="],
|
||||
|
||||
"@xtuc/long": ["@xtuc/long@4.2.2", "", {}, "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ=="],
|
||||
|
||||
"acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
|
||||
|
||||
"acorn-import-phases": ["acorn-import-phases@1.0.4", "", { "peerDependencies": { "acorn": "^8.14.0" } }, "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ=="],
|
||||
|
||||
"acorn-loose": ["acorn-loose@8.5.2", "", { "dependencies": { "acorn": "^8.15.0" } }, "sha512-PPvV6g8UGMGgjrMu+n/f9E/tCSkNQ2Y97eFvuVdJfG11+xdIeDcLyNdC8SHcrHbRqkfwLASdplyR6B6sKM1U4A=="],
|
||||
|
||||
"ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="],
|
||||
|
||||
"ajv-formats": ["ajv-formats@2.1.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA=="],
|
||||
|
||||
"ajv-keywords": ["ajv-keywords@5.1.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3" }, "peerDependencies": { "ajv": "^8.8.2" } }, "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw=="],
|
||||
|
||||
"base-x": ["base-x@4.0.1", "", {}, "sha512-uAZ8x6r6S3aUM9rbHGVOIsR15U/ZSc82b3ymnCPsT45Gk1DDvhDPdIgB5MrhirZWt+5K0EEPQH985kNqZgNPFw=="],
|
||||
|
||||
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.0", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA=="],
|
||||
|
||||
"browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="],
|
||||
|
||||
"bs58": ["bs58@5.0.0", "", { "dependencies": { "base-x": "^4.0.0" } }, "sha512-r+ihvQJvahgYT50JD05dyJNKlmmSlMoOGwn1lCcEzanPglg7TxYjioQUYehQ9mAR/+hOSd2jRc/Z2y5UxBymvQ=="],
|
||||
|
||||
"bs58check": ["bs58check@3.0.1", "", { "dependencies": { "@noble/hashes": "^1.2.0", "bs58": "^5.0.0" } }, "sha512-hjuuJvoWEybo7Hn/0xOrczQKKEKD63WguEjlhLExYs2wUBcebDC1jDNK17eEAD2lYfw82d5ASC1d7K3SWszjaQ=="],
|
||||
|
||||
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
|
||||
|
||||
"caniuse-lite": ["caniuse-lite@1.0.30001777", "", {}, "sha512-tmN+fJxroPndC74efCdp12j+0rk0RHwV5Jwa1zWaFVyw2ZxAuPeG8ZgWC3Wz7uSjT3qMRQ5XHZ4COgQmsCMJAQ=="],
|
||||
|
||||
"cbor-extract": ["cbor-extract@2.2.0", "", { "dependencies": { "node-gyp-build-optional-packages": "5.1.1" }, "optionalDependencies": { "@cbor-extract/cbor-extract-darwin-arm64": "2.2.0", "@cbor-extract/cbor-extract-darwin-x64": "2.2.0", "@cbor-extract/cbor-extract-linux-arm": "2.2.0", "@cbor-extract/cbor-extract-linux-arm64": "2.2.0", "@cbor-extract/cbor-extract-linux-x64": "2.2.0", "@cbor-extract/cbor-extract-win32-x64": "2.2.0" }, "bin": { "download-cbor-prebuilds": "bin/download-prebuilds.js" } }, "sha512-Ig1zM66BjLfTXpNgKpvBePq271BPOvu8MR0Jl080yG7Jsl+wAZunfrwiwA+9ruzm/WEdIV5QF/bjDZTqyAIVHA=="],
|
||||
|
||||
"cbor-x": ["cbor-x@1.6.0", "", { "optionalDependencies": { "cbor-extract": "^2.2.0" } }, "sha512-0kareyRwHSkL6ws5VXHEf8uY1liitysCVJjlmhaLG+IXLqhSaOO+t63coaso7yjwEzWZzLy8fJo06gZDVQM9Qg=="],
|
||||
|
||||
"chrome-trace-event": ["chrome-trace-event@1.0.4", "", {}, "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ=="],
|
||||
|
||||
"commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="],
|
||||
|
||||
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
|
||||
|
||||
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||
|
||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
|
||||
"dotenv": ["dotenv@17.3.1", "", {}, "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA=="],
|
||||
|
||||
"electron-to-chromium": ["electron-to-chromium@1.5.307", "", {}, "sha512-5z3uFKBWjiNR44nFcYdkcXjKMbg5KXNdciu7mhTPo9tB7NbqSNP2sSnGR+fqknZSCwKkBN+oxiiajWs4dT6ORg=="],
|
||||
|
||||
"enhanced-resolve": ["enhanced-resolve@5.20.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ=="],
|
||||
|
||||
"es-module-lexer": ["es-module-lexer@2.0.0", "", {}, "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw=="],
|
||||
|
||||
"esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="],
|
||||
|
||||
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
|
||||
|
||||
"eslint-scope": ["eslint-scope@5.1.1", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^4.1.1" } }, "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw=="],
|
||||
|
||||
"esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="],
|
||||
|
||||
"estraverse": ["estraverse@4.3.0", "", {}, "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw=="],
|
||||
|
||||
"estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="],
|
||||
|
||||
"eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="],
|
||||
|
||||
"events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="],
|
||||
|
||||
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
||||
|
||||
"fast-sha256": ["fast-sha256@1.3.0", "", {}, "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ=="],
|
||||
|
||||
"fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="],
|
||||
|
||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||
|
||||
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
||||
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
|
||||
|
||||
"glob-to-regexp": ["glob-to-regexp@0.4.1", "", {}, "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw=="],
|
||||
|
||||
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
||||
|
||||
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
|
||||
|
||||
"hono": ["hono@4.12.5", "", {}, "sha512-3qq+FUBtlTHhtYxbxheZgY8NIFnkkC/MR8u5TTsr7YZ3wixryQ3cCwn3iZbg8p8B88iDBBAYSfZDS75t8MN7Vg=="],
|
||||
|
||||
"is-reference": ["is-reference@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.6" } }, "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw=="],
|
||||
|
||||
"isomorphic-ws": ["isomorphic-ws@5.0.0", "", { "peerDependencies": { "ws": "*" } }, "sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw=="],
|
||||
|
||||
"jest-worker": ["jest-worker@27.5.1", "", { "dependencies": { "@types/node": "*", "merge-stream": "^2.0.0", "supports-color": "^8.0.0" } }, "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg=="],
|
||||
|
||||
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
|
||||
|
||||
"js-tokens": ["js-tokens@9.0.1", "", {}, "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="],
|
||||
|
||||
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
|
||||
|
||||
"json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="],
|
||||
|
||||
"json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
|
||||
|
||||
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
|
||||
|
||||
"lightningcss": ["lightningcss@1.31.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.31.1", "lightningcss-darwin-arm64": "1.31.1", "lightningcss-darwin-x64": "1.31.1", "lightningcss-freebsd-x64": "1.31.1", "lightningcss-linux-arm-gnueabihf": "1.31.1", "lightningcss-linux-arm64-gnu": "1.31.1", "lightningcss-linux-arm64-musl": "1.31.1", "lightningcss-linux-x64-gnu": "1.31.1", "lightningcss-linux-x64-musl": "1.31.1", "lightningcss-win32-arm64-msvc": "1.31.1", "lightningcss-win32-x64-msvc": "1.31.1" } }, "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ=="],
|
||||
|
||||
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.31.1", "", { "os": "android", "cpu": "arm64" }, "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg=="],
|
||||
|
||||
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.31.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg=="],
|
||||
|
||||
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.31.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA=="],
|
||||
|
||||
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.31.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A=="],
|
||||
|
||||
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.31.1", "", { "os": "linux", "cpu": "arm" }, "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g=="],
|
||||
|
||||
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.31.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg=="],
|
||||
|
||||
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.31.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg=="],
|
||||
|
||||
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.31.1", "", { "os": "linux", "cpu": "x64" }, "sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA=="],
|
||||
|
||||
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.31.1", "", { "os": "linux", "cpu": "x64" }, "sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA=="],
|
||||
|
||||
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.31.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w=="],
|
||||
|
||||
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.31.1", "", { "os": "win32", "cpu": "x64" }, "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw=="],
|
||||
|
||||
"loader-runner": ["loader-runner@4.3.1", "", {}, "sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q=="],
|
||||
|
||||
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
|
||||
|
||||
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||
|
||||
"merge-stream": ["merge-stream@2.0.0", "", {}, "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="],
|
||||
|
||||
"mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
|
||||
|
||||
"mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
||||
|
||||
"neo-async": ["neo-async@2.6.2", "", {}, "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw=="],
|
||||
|
||||
"node-gyp-build-optional-packages": ["node-gyp-build-optional-packages@5.1.1", "", { "dependencies": { "detect-libc": "^2.0.1" }, "bin": { "node-gyp-build-optional-packages": "bin.js", "node-gyp-build-optional-packages-test": "build-test.js", "node-gyp-build-optional-packages-optional": "optional.js" } }, "sha512-+P72GAjVAbTxjjwUmwjVrqrdZROD4nf8KgpBoDxqXXTiYZZt/ud60dE5yvCSr9lRO8e8yv6kgJIC0K0PfZFVQw=="],
|
||||
|
||||
"node-releases": ["node-releases@2.0.36", "", {}, "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA=="],
|
||||
|
||||
"periscopic": ["periscopic@4.0.2", "", { "dependencies": { "@types/estree": "*", "is-reference": "^3.0.2", "zimmerframe": "^1.0.0" } }, "sha512-sqpQDUy8vgB7ycLkendSKS6HnVz1Rneoc3Rc+ZBUCe2pbqlVuCC5vF52l0NJ1aiMg/r1qfYF9/myz8CZeI2rjA=="],
|
||||
|
||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
|
||||
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
|
||||
|
||||
"postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="],
|
||||
|
||||
"react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="],
|
||||
|
||||
"react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="],
|
||||
|
||||
"react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="],
|
||||
|
||||
"react-server-dom-webpack": ["react-server-dom-webpack@19.2.4", "", { "dependencies": { "acorn-loose": "^8.3.0", "neo-async": "^2.6.1", "webpack-sources": "^3.2.0" }, "peerDependencies": { "react": "^19.2.4", "react-dom": "^19.2.4", "webpack": "^5.59.0" } }, "sha512-zEhkWv6RhXDctC2N7yEUHg3751nvFg81ydHj8LTTZuukF/IF1gcOKqqAL6Ds+kS5HtDVACYPik0IvzkgYXPhlQ=="],
|
||||
|
||||
"react-usestateref": ["react-usestateref@1.0.9", "", { "peerDependencies": { "react": ">16.0.0" } }, "sha512-t8KLsI7oje0HzfzGhxFXzuwbf1z9vhBM1ptHLUIHhYqZDKFuI5tzdhEVxSNzUkYxwF8XdpOErzHlKxvP7sTERw=="],
|
||||
|
||||
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
|
||||
|
||||
"rollup": ["rollup@4.59.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.59.0", "@rollup/rollup-android-arm64": "4.59.0", "@rollup/rollup-darwin-arm64": "4.59.0", "@rollup/rollup-darwin-x64": "4.59.0", "@rollup/rollup-freebsd-arm64": "4.59.0", "@rollup/rollup-freebsd-x64": "4.59.0", "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", "@rollup/rollup-linux-arm-musleabihf": "4.59.0", "@rollup/rollup-linux-arm64-gnu": "4.59.0", "@rollup/rollup-linux-arm64-musl": "4.59.0", "@rollup/rollup-linux-loong64-gnu": "4.59.0", "@rollup/rollup-linux-loong64-musl": "4.59.0", "@rollup/rollup-linux-ppc64-gnu": "4.59.0", "@rollup/rollup-linux-ppc64-musl": "4.59.0", "@rollup/rollup-linux-riscv64-gnu": "4.59.0", "@rollup/rollup-linux-riscv64-musl": "4.59.0", "@rollup/rollup-linux-s390x-gnu": "4.59.0", "@rollup/rollup-linux-x64-gnu": "4.59.0", "@rollup/rollup-linux-x64-musl": "4.59.0", "@rollup/rollup-openbsd-x64": "4.59.0", "@rollup/rollup-openharmony-arm64": "4.59.0", "@rollup/rollup-win32-arm64-msvc": "4.59.0", "@rollup/rollup-win32-ia32-msvc": "4.59.0", "@rollup/rollup-win32-x64-gnu": "4.59.0", "@rollup/rollup-win32-x64-msvc": "4.59.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg=="],
|
||||
|
||||
"rsc-html-stream": ["rsc-html-stream@0.0.7", "", {}, "sha512-v9+fuY7usTgvXdNl8JmfXCvSsQbq2YMd60kOeeMIqCJFZ69fViuIxztHei7v5mlMMa2h3SqS+v44Gu9i9xANZA=="],
|
||||
|
||||
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
|
||||
|
||||
"schema-utils": ["schema-utils@4.3.3", "", { "dependencies": { "@types/json-schema": "^7.0.9", "ajv": "^8.9.0", "ajv-formats": "^2.1.1", "ajv-keywords": "^5.1.0" } }, "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA=="],
|
||||
|
||||
"semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||
|
||||
"source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
|
||||
|
||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
|
||||
"source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="],
|
||||
|
||||
"srvx": ["srvx@0.11.8", "", { "bin": { "srvx": "bin/srvx.mjs" } }, "sha512-2n9t0YnAXPJjinytvxccNgs7rOA5gmE7Wowt/8Dy2dx2fDC6sBhfBpbrCvjYKALlVukPS/Uq3QwkolKNa7P/2Q=="],
|
||||
|
||||
"strip-literal": ["strip-literal@3.1.0", "", { "dependencies": { "js-tokens": "^9.0.1" } }, "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg=="],
|
||||
|
||||
"supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="],
|
||||
|
||||
"tailwindcss": ["tailwindcss@4.2.1", "", {}, "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw=="],
|
||||
|
||||
"tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],
|
||||
|
||||
"terser": ["terser@5.46.0", "", { "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, "bin": { "terser": "bin/terser" } }, "sha512-jTwoImyr/QbOWFFso3YoU3ik0jBBDJ6JTOQiy/J2YxVJdZCc+5u7skhNwiOR3FQIygFqVUPHl7qbbxtjW2K3Qg=="],
|
||||
|
||||
"terser-webpack-plugin": ["terser-webpack-plugin@5.3.17", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "jest-worker": "^27.4.5", "schema-utils": "^4.3.0", "terser": "^5.31.1" }, "peerDependencies": { "webpack": "^5.1.0" } }, "sha512-YR7PtUp6GMU91BgSJmlaX/rS2lGDbAF7D+Wtq7hRO+MiljNmodYvqslzCFiYVAgW+Qoaaia/QUIP4lGXufjdZw=="],
|
||||
|
||||
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
|
||||
|
||||
"turbo-stream": ["turbo-stream@3.2.0", "", {}, "sha512-EK+bZ9UVrVh7JLslVFOV0GEMsociOqVOvEMTAd4ixMyffN5YNIEdLZWXUx5PJqDbTxSIBWw04HS9gCY4frYQDQ=="],
|
||||
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
||||
|
||||
"update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
|
||||
|
||||
"uuid": ["uuid@9.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="],
|
||||
|
||||
"vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="],
|
||||
|
||||
"vite-plugin-top-level-await": ["vite-plugin-top-level-await@1.6.0", "", { "dependencies": { "@rollup/plugin-virtual": "^3.0.2", "@swc/core": "^1.12.14", "@swc/wasm": "^1.12.14", "uuid": "10.0.0" }, "peerDependencies": { "vite": ">=2.8" } }, "sha512-bNhUreLamTIkoulCR9aDXbTbhLk6n1YE8NJUTTxl5RYskNRtzOR0ASzSjBVRtNdjIfngDXo11qOsybGLNsrdww=="],
|
||||
|
||||
"vite-plugin-wasm": ["vite-plugin-wasm@3.5.0", "", { "peerDependencies": { "vite": "^2 || ^3 || ^4 || ^5 || ^6 || ^7" } }, "sha512-X5VWgCnqiQEGb+omhlBVsvTfxikKtoOgAzQ95+BZ8gQ+VfMHIjSHr0wyvXFQCa0eKQ0fKyaL0kWcEnYqBac4lQ=="],
|
||||
|
||||
"vitefu": ["vitefu@1.1.2", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-beta.0" }, "optionalPeers": ["vite"] }, "sha512-zpKATdUbzbsycPFBN71nS2uzBUQiVnFoOrr2rvqv34S1lcAgMKKkjWleLGeiJlZ8lwCXvtWaRn7R3ZC16SYRuw=="],
|
||||
|
||||
"waku": ["waku@1.0.0-alpha.5", "", { "dependencies": { "@hono/node-server": "^1.19.0", "@vitejs/plugin-react": "^5.1.0", "@vitejs/plugin-rsc": "^0.5.19", "dotenv": "^17.3.1", "hono": "^4.10.0", "magic-string": "^0.30.21", "picocolors": "^1.1.1", "rsc-html-stream": "^0.0.7", "vite": "^7.2.0" }, "peerDependencies": { "react": "~19.2.4", "react-dom": "~19.2.4", "react-server-dom-webpack": "~19.2.4" }, "bin": { "waku": "cli.js" } }, "sha512-DCKZD3grtGvuQdAQyIfqz9PkZoppto7Ysjf5yubD0oXcSQFJSlzLTz7APGuLiGf0NH67PBw1DFGxnV3Tf3J3BA=="],
|
||||
|
||||
"watchpack": ["watchpack@2.5.1", "", { "dependencies": { "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.1.2" } }, "sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg=="],
|
||||
|
||||
"webpack": ["webpack@5.105.4", "", { "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", "@types/json-schema": "^7.0.15", "@webassemblyjs/ast": "^1.14.1", "@webassemblyjs/wasm-edit": "^1.14.1", "@webassemblyjs/wasm-parser": "^1.14.1", "acorn": "^8.16.0", "acorn-import-phases": "^1.0.3", "browserslist": "^4.28.1", "chrome-trace-event": "^1.0.2", "enhanced-resolve": "^5.20.0", "es-module-lexer": "^2.0.0", "eslint-scope": "5.1.1", "events": "^3.2.0", "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.2.11", "json-parse-even-better-errors": "^2.3.1", "loader-runner": "^4.3.1", "mime-types": "^2.1.27", "neo-async": "^2.6.2", "schema-utils": "^4.3.3", "tapable": "^2.3.0", "terser-webpack-plugin": "^5.3.17", "watchpack": "^2.5.1", "webpack-sources": "^3.3.4" }, "bin": { "webpack": "bin/webpack.js" } }, "sha512-jTywjboN9aHxFlToqb0K0Zs9SbBoW4zRUlGzI2tYNxVYcEi/IPpn+Xi4ye5jTLvX2YeLuic/IvxNot+Q1jMoOw=="],
|
||||
|
||||
"webpack-sources": ["webpack-sources@3.3.4", "", {}, "sha512-7tP1PdV4vF+lYPnkMR0jMY5/la2ub5Fc/8VQrrU+lXkiM6C4TjVfGw7iKfyhnTQOsD+6Q/iKw0eFciziRgD58Q=="],
|
||||
|
||||
"ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="],
|
||||
|
||||
"xstate": ["xstate@5.28.0", "", {}, "sha512-Iaqq6ZrUzqeUtA3hC5LQKZfR8ZLzEFTImMHJM3jWEdVvXWdKvvVLXZEiNQWm3SCA9ZbEou/n5rcsna1wb9t28A=="],
|
||||
|
||||
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
|
||||
|
||||
"zimmerframe": ["zimmerframe@1.1.4", "", {}, "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="],
|
||||
|
||||
"@babel/code-frame/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" }, "bundled": true }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"@vitejs/plugin-rsc/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.5", "", {}, "sha512-RxlLX/DPoarZ9PtxVrQgZhPoor987YtKQqCo5zkjX+0S0yLJ7Vv515Wk6+xtTL67VONKJKxETWZwuZjss2idYw=="],
|
||||
|
||||
"esrecurse/estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="],
|
||||
|
||||
"vite-plugin-top-level-await/uuid": ["uuid@10.0.0", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ=="],
|
||||
}
|
||||
}
|
||||
9
capacitor.config.ts
Normal file
9
capacitor.config.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import type { CapacitorConfig } from '@capacitor/cli';
|
||||
|
||||
const config: CapacitorConfig = {
|
||||
appId: 'io.ionic.starter',
|
||||
appName: 'IonicAngularVersion',
|
||||
webDir: 'www'
|
||||
};
|
||||
|
||||
export default config;
|
||||
210
dist/public/assets/_id_-cxsEKuSj.js
vendored
210
dist/public/assets/_id_-cxsEKuSj.js
vendored
@@ -1,210 +0,0 @@
|
||||
import { r as a, j as e, __tla as __tla_0 } from "./index-I8cR0Dsm.js";
|
||||
import { i as j, __tla as __tla_1 } from "./fullfat_bundler-C8o4MXnP.js";
|
||||
import { u as v, a as N } from "./index-ChbWTil4.js";
|
||||
import { h as p, a as w, u as C, v as k } from "./poll-BI_0HvZY.js";
|
||||
let $;
|
||||
let __tla = Promise.all([
|
||||
(() => {
|
||||
try {
|
||||
return __tla_0;
|
||||
} catch {
|
||||
}
|
||||
})(),
|
||||
(() => {
|
||||
try {
|
||||
return __tla_1;
|
||||
} catch {
|
||||
}
|
||||
})()
|
||||
]).then(async () => {
|
||||
const h = "p2p-poll-peer-id";
|
||||
function g() {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
function S() {
|
||||
if (typeof globalThis.localStorage > "u") return g();
|
||||
let s = localStorage.getItem(h);
|
||||
return s || (s = g(), localStorage.setItem(h, s)), s;
|
||||
}
|
||||
function D() {
|
||||
const s = v(), [r, d] = a.useState(false), l = a.useCallback(() => {
|
||||
d(s.peers.length > 0);
|
||||
}, [
|
||||
s
|
||||
]);
|
||||
return a.useEffect(() => {
|
||||
l();
|
||||
const o = () => l();
|
||||
return s.networkSubsystem.on("peer", o), s.networkSubsystem.on("peer-disconnected", o), () => {
|
||||
s.networkSubsystem.off("peer", o), s.networkSubsystem.off("peer-disconnected", o);
|
||||
};
|
||||
}, [
|
||||
s,
|
||||
l
|
||||
]), e.jsxs("div", {
|
||||
className: "flex items-center gap-2 text-xs text-gray-500",
|
||||
children: [
|
||||
e.jsx("span", {
|
||||
className: `inline-block h-2 w-2 rounded-full ${r ? "bg-green-500" : "bg-yellow-500"}`
|
||||
}),
|
||||
r ? "Connected" : "Connecting..."
|
||||
]
|
||||
});
|
||||
}
|
||||
function I({ docUrl: s }) {
|
||||
const [r, d] = N(s), [l, o] = a.useState(""), [b, u] = a.useState(false), i = a.useMemo(() => S(), []);
|
||||
if (!r) return e.jsx("div", {
|
||||
className: "flex items-center justify-center py-12",
|
||||
children: e.jsx("div", {
|
||||
className: "text-gray-500",
|
||||
children: "Loading poll..."
|
||||
})
|
||||
});
|
||||
const c = r.options.reduce((t, n) => t + n.votes.length, 0), x = () => {
|
||||
const t = l.trim();
|
||||
t && (d((n) => w(n, t)), o(""));
|
||||
}, f = (t) => {
|
||||
p(r, t, i) ? d((n) => C(n, t, i)) : d((n) => k(n, t, i));
|
||||
}, y = () => {
|
||||
const t = window.location.href;
|
||||
navigator.clipboard.writeText(t).then(() => {
|
||||
u(true), setTimeout(() => u(false), 2e3);
|
||||
});
|
||||
};
|
||||
return e.jsxs("div", {
|
||||
className: "space-y-6",
|
||||
children: [
|
||||
e.jsxs("div", {
|
||||
className: "flex items-start justify-between",
|
||||
children: [
|
||||
e.jsxs("div", {
|
||||
children: [
|
||||
e.jsx("h2", {
|
||||
className: "text-2xl font-bold",
|
||||
children: r.title
|
||||
}),
|
||||
e.jsxs("p", {
|
||||
className: "mt-1 text-sm text-gray-500",
|
||||
children: [
|
||||
c,
|
||||
" vote",
|
||||
c !== 1 ? "s" : "",
|
||||
" total"
|
||||
]
|
||||
})
|
||||
]
|
||||
}),
|
||||
e.jsx(D, {})
|
||||
]
|
||||
}),
|
||||
e.jsxs("div", {
|
||||
className: "space-y-3",
|
||||
children: [
|
||||
r.options.map((t) => {
|
||||
const n = p(r, t.id, i), m = c > 0 ? t.votes.length / c * 100 : 0;
|
||||
return e.jsxs("div", {
|
||||
className: "relative overflow-hidden rounded-lg border border-gray-200 bg-white",
|
||||
children: [
|
||||
e.jsx("div", {
|
||||
className: "absolute inset-y-0 left-0 bg-blue-50 transition-all duration-300",
|
||||
style: {
|
||||
width: `${m}%`
|
||||
}
|
||||
}),
|
||||
e.jsxs("div", {
|
||||
className: "relative flex items-center justify-between px-4 py-3",
|
||||
children: [
|
||||
e.jsxs("button", {
|
||||
onClick: () => f(t.id),
|
||||
className: `flex items-center gap-2 text-left text-sm font-medium ${n ? "text-blue-600" : "text-gray-700 hover:text-blue-600"}`,
|
||||
children: [
|
||||
e.jsx("span", {
|
||||
className: `flex h-5 w-5 items-center justify-center rounded border text-xs ${n ? "border-blue-600 bg-blue-600 text-white" : "border-gray-300"}`,
|
||||
children: n ? "\u2713" : ""
|
||||
}),
|
||||
t.text
|
||||
]
|
||||
}),
|
||||
e.jsxs("span", {
|
||||
className: "text-sm text-gray-500",
|
||||
children: [
|
||||
t.votes.length,
|
||||
" (",
|
||||
m.toFixed(0),
|
||||
"%)"
|
||||
]
|
||||
})
|
||||
]
|
||||
})
|
||||
]
|
||||
}, t.id);
|
||||
}),
|
||||
r.options.length === 0 && e.jsx("p", {
|
||||
className: "py-4 text-center text-sm text-gray-400",
|
||||
children: "No options yet. Add one below!"
|
||||
})
|
||||
]
|
||||
}),
|
||||
e.jsxs("div", {
|
||||
className: "flex gap-2",
|
||||
children: [
|
||||
e.jsx("input", {
|
||||
type: "text",
|
||||
value: l,
|
||||
onChange: (t) => o(t.target.value),
|
||||
onKeyDown: (t) => t.key === "Enter" && x(),
|
||||
placeholder: "Add an option...",
|
||||
className: "flex-1 rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
}),
|
||||
e.jsx("button", {
|
||||
onClick: x,
|
||||
className: "rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700",
|
||||
children: "Add"
|
||||
})
|
||||
]
|
||||
}),
|
||||
e.jsx("div", {
|
||||
className: "border-t border-gray-200 pt-4",
|
||||
children: e.jsx("button", {
|
||||
onClick: y,
|
||||
className: "rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-600 hover:bg-gray-50",
|
||||
children: b ? "Copied!" : "Copy shareable link"
|
||||
})
|
||||
})
|
||||
]
|
||||
});
|
||||
}
|
||||
function U({ id: s }) {
|
||||
const r = `automerge:${s}`;
|
||||
return j(r) ? e.jsx("div", {
|
||||
className: "rounded-lg border border-gray-200 bg-white p-6 shadow-sm",
|
||||
children: e.jsx(I, {
|
||||
docUrl: r
|
||||
})
|
||||
}) : e.jsxs("div", {
|
||||
className: "rounded-lg border border-red-200 bg-red-50 p-6 text-center",
|
||||
children: [
|
||||
e.jsx("h2", {
|
||||
className: "text-lg font-semibold text-red-800",
|
||||
children: "Invalid Poll ID"
|
||||
}),
|
||||
e.jsx("p", {
|
||||
className: "mt-2 text-sm text-red-600",
|
||||
children: "The poll ID in the URL is not valid."
|
||||
}),
|
||||
e.jsx("a", {
|
||||
href: "/",
|
||||
className: "mt-4 inline-block text-sm text-blue-600 hover:underline",
|
||||
children: "Go back home"
|
||||
})
|
||||
]
|
||||
});
|
||||
}
|
||||
$ = {
|
||||
default: U
|
||||
};
|
||||
});
|
||||
export {
|
||||
__tla,
|
||||
$ as export_4af94835fa0f
|
||||
};
|
||||
49
dist/public/assets/_layout-C9jEQBWP.js
vendored
49
dist/public/assets/_layout-C9jEQBWP.js
vendored
@@ -1,49 +0,0 @@
|
||||
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/repo-CjvObF6Z.js","assets/fullfat_bundler-C8o4MXnP.js"])))=>i.map(i=>d[i]);
|
||||
import { r as a, _ as l, j as i, __tla as __tla_0 } from "./index-I8cR0Dsm.js";
|
||||
import { R as p } from "./index-ChbWTil4.js";
|
||||
let v;
|
||||
let __tla = Promise.all([
|
||||
(() => {
|
||||
try {
|
||||
return __tla_0;
|
||||
} catch {
|
||||
}
|
||||
})()
|
||||
]).then(async () => {
|
||||
function f({ children: c }) {
|
||||
const [o, d] = a.useState(null);
|
||||
return a.useEffect(() => {
|
||||
let n, e;
|
||||
return l(async () => {
|
||||
const { getRepo: t, cleanupRepo: r } = await import("./repo-CjvObF6Z.js").then(async (m) => {
|
||||
await m.__tla;
|
||||
return m;
|
||||
});
|
||||
return {
|
||||
getRepo: t,
|
||||
cleanupRepo: r
|
||||
};
|
||||
}, __vite__mapDeps([0,1])).then(({ getRepo: t, cleanupRepo: r }) => {
|
||||
const s = t();
|
||||
d(s), n = r, e = () => {
|
||||
s.networkSubsystem.adapters.forEach((u) => u.disconnect());
|
||||
}, window.addEventListener("beforeunload", e);
|
||||
}), () => {
|
||||
e && window.removeEventListener("beforeunload", e), n == null ? void 0 : n();
|
||||
};
|
||||
}, []), o ? i.jsx(p.Provider, {
|
||||
value: o,
|
||||
children: c
|
||||
}) : i.jsx("div", {
|
||||
className: "flex min-h-screen items-center justify-center text-gray-400",
|
||||
children: "Loading..."
|
||||
});
|
||||
}
|
||||
v = {
|
||||
default: f
|
||||
};
|
||||
});
|
||||
export {
|
||||
__tla,
|
||||
v as export_125820ecd802
|
||||
};
|
||||
1
dist/public/assets/_layout-ChjUcnq2.css
vendored
1
dist/public/assets/_layout-ChjUcnq2.css
vendored
File diff suppressed because one or more lines are too long
BIN
dist/public/assets/automerge_wasm_bg-BQ4CNPIj.wasm
vendored
BIN
dist/public/assets/automerge_wasm_bg-BQ4CNPIj.wasm
vendored
Binary file not shown.
6
dist/public/assets/client-CsOmnPdF.js
vendored
6
dist/public/assets/client-CsOmnPdF.js
vendored
@@ -1,6 +0,0 @@
|
||||
import { I as r, E as o, S as e, C as t } from "./index-I8cR0Dsm.js";
|
||||
const n = { Children: t, Slot: e }, s = { ErrorBoundary: o, INTERNAL_ServerRouter: r };
|
||||
export {
|
||||
s as export_6d786e16fc6b,
|
||||
n as export_847a2b1045ef
|
||||
};
|
||||
8095
dist/public/assets/fullfat_bundler-C8o4MXnP.js
vendored
8095
dist/public/assets/fullfat_bundler-C8o4MXnP.js
vendored
File diff suppressed because it is too large
Load Diff
21
dist/public/assets/index-BQZM9uZj.js
vendored
21
dist/public/assets/index-BQZM9uZj.js
vendored
@@ -1,21 +0,0 @@
|
||||
import { r as i, j as t } from "./index-I8cR0Dsm.js";
|
||||
import { u as m } from "./index-ChbWTil4.js";
|
||||
import { c as x } from "./poll-BI_0HvZY.js";
|
||||
function p() {
|
||||
const [o, d] = i.useState(""), [r, c] = i.useState(""), u = m(), s = () => {
|
||||
if (!o.trim()) return;
|
||||
const e = u.create();
|
||||
e.change((l) => {
|
||||
const a = x(o.trim());
|
||||
l.title = a.title, l.options = a.options;
|
||||
}), window.location.href = `/poll/${e.documentId}`;
|
||||
}, n = () => {
|
||||
const e = r.trim();
|
||||
e && (window.location.href = `/poll/${e}`);
|
||||
};
|
||||
return t.jsxs("div", { className: "space-y-8", children: [t.jsx("title", { children: "P2P Poll" }), t.jsxs("section", { className: "rounded-lg border border-gray-200 bg-white p-6 shadow-sm", children: [t.jsx("h2", { className: "mb-4 text-lg font-semibold", children: "Create a New Poll" }), t.jsxs("div", { className: "flex gap-2", children: [t.jsx("input", { type: "text", value: o, onChange: (e) => d(e.target.value), onKeyDown: (e) => e.key === "Enter" && s(), placeholder: "Enter poll title...", className: "flex-1 rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500" }), t.jsx("button", { onClick: s, className: "rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700", children: "Create" })] })] }), t.jsxs("section", { className: "rounded-lg border border-gray-200 bg-white p-6 shadow-sm", children: [t.jsx("h2", { className: "mb-4 text-lg font-semibold", children: "Join an Existing Poll" }), t.jsxs("div", { className: "flex gap-2", children: [t.jsx("input", { type: "text", value: r, onChange: (e) => c(e.target.value), onKeyDown: (e) => e.key === "Enter" && n(), placeholder: "Paste poll ID or link...", className: "flex-1 rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500" }), t.jsx("button", { onClick: n, className: "rounded-md bg-gray-600 px-4 py-2 text-sm font-medium text-white hover:bg-gray-700", children: "Join" })] })] })] });
|
||||
}
|
||||
const f = { default: p };
|
||||
export {
|
||||
f as export_5ce7e027532e
|
||||
};
|
||||
2543
dist/public/assets/index-ChbWTil4.js
vendored
2543
dist/public/assets/index-ChbWTil4.js
vendored
File diff suppressed because it is too large
Load Diff
10668
dist/public/assets/index-I8cR0Dsm.js
vendored
10668
dist/public/assets/index-I8cR0Dsm.js
vendored
File diff suppressed because it is too large
Load Diff
27
dist/public/assets/poll-BI_0HvZY.js
vendored
27
dist/public/assets/poll-BI_0HvZY.js
vendored
@@ -1,27 +0,0 @@
|
||||
function u(n) {
|
||||
return { title: n, options: [] };
|
||||
}
|
||||
function c(n, t) {
|
||||
n.options.push({ id: crypto.randomUUID(), text: t, votes: [] });
|
||||
}
|
||||
function f(n, t, i) {
|
||||
const o = n.options.find((s) => s.id === t);
|
||||
return o ? o.votes.includes(i) : false;
|
||||
}
|
||||
function r(n, t, i) {
|
||||
const o = n.options.find((s) => s.id === t);
|
||||
o && (o.votes.includes(i) || o.votes.push(i));
|
||||
}
|
||||
function d(n, t, i) {
|
||||
const o = n.options.find((e) => e.id === t);
|
||||
if (!o) return;
|
||||
const s = o.votes.indexOf(i);
|
||||
s !== -1 && o.votes.splice(s, 1);
|
||||
}
|
||||
export {
|
||||
c as a,
|
||||
u as c,
|
||||
f as h,
|
||||
d as u,
|
||||
r as v
|
||||
};
|
||||
1254
dist/public/assets/repo-CjvObF6Z.js
vendored
1254
dist/public/assets/repo-CjvObF6Z.js
vendored
File diff suppressed because it is too large
Load Diff
13
dist/serve-node.js
vendored
13
dist/serve-node.js
vendored
@@ -1,13 +0,0 @@
|
||||
|
||||
import { INTERNAL_runFetch, unstable_serverEntry } from './server/index.js';
|
||||
|
||||
const { serve } = unstable_serverEntry;
|
||||
|
||||
const host = process.env.HOST;
|
||||
const port = process.env.PORT;
|
||||
|
||||
serve({
|
||||
fetch: (req, ...args) => INTERNAL_runFetch(process.env, req, ...args),
|
||||
...(host ? { hostname: host } : {}),
|
||||
...(port ? { port: parseInt(port, 10) } : {}),
|
||||
});
|
||||
54
dist/server/__vite_rsc_assets_manifest.js
vendored
54
dist/server/__vite_rsc_assets_manifest.js
vendored
@@ -1,54 +0,0 @@
|
||||
export default {
|
||||
"bootstrapScriptContent": "import(\"/assets/index-I8cR0Dsm.js\")",
|
||||
"clientReferenceDeps": {
|
||||
"847a2b1045ef": {
|
||||
"js": [
|
||||
"/assets/client-CsOmnPdF.js",
|
||||
"/assets/index-I8cR0Dsm.js"
|
||||
],
|
||||
"css": []
|
||||
},
|
||||
"6d786e16fc6b": {
|
||||
"js": [
|
||||
"/assets/client-CsOmnPdF.js",
|
||||
"/assets/index-I8cR0Dsm.js"
|
||||
],
|
||||
"css": []
|
||||
},
|
||||
"5ce7e027532e": {
|
||||
"js": [
|
||||
"/assets/index-BQZM9uZj.js",
|
||||
"/assets/index-I8cR0Dsm.js",
|
||||
"/assets/index-ChbWTil4.js",
|
||||
"/assets/poll-BI_0HvZY.js"
|
||||
],
|
||||
"css": []
|
||||
},
|
||||
"4af94835fa0f": {
|
||||
"js": [
|
||||
"/assets/_id_-cxsEKuSj.js",
|
||||
"/assets/index-I8cR0Dsm.js",
|
||||
"/assets/fullfat_bundler-C8o4MXnP.js",
|
||||
"/assets/index-ChbWTil4.js",
|
||||
"/assets/poll-BI_0HvZY.js"
|
||||
],
|
||||
"css": []
|
||||
},
|
||||
"125820ecd802": {
|
||||
"js": [
|
||||
"/assets/_layout-C9jEQBWP.js",
|
||||
"/assets/index-I8cR0Dsm.js",
|
||||
"/assets/index-ChbWTil4.js"
|
||||
],
|
||||
"css": []
|
||||
}
|
||||
},
|
||||
"serverResources": {
|
||||
"src/pages/_layout.tsx": {
|
||||
"js": [],
|
||||
"css": [
|
||||
"/assets/_layout-ChjUcnq2.css"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
1
dist/server/__waku_build_metadata.js
vendored
1
dist/server/__waku_build_metadata.js
vendored
@@ -1 +0,0 @@
|
||||
export const buildMetadata = new Map([["defineRouter:cachedElements","{}"],["defineRouter:path2moduleIds","{}"]]);
|
||||
25
dist/server/assets/_id_-D3sxE6t8.js
vendored
25
dist/server/assets/_id_-D3sxE6t8.js
vendored
@@ -1,25 +0,0 @@
|
||||
import { r as registerClientReference, a as jsxRuntime_reactServerExports } from "./server-entry-inner-DFzcLk7e.js";
|
||||
import "node:async_hooks";
|
||||
import "node:path";
|
||||
import "http";
|
||||
import "http2";
|
||||
import "stream";
|
||||
import "crypto";
|
||||
import "fs";
|
||||
import "path";
|
||||
import "process";
|
||||
import "../__vite_rsc_assets_manifest.js";
|
||||
import "../__waku_build_metadata.js";
|
||||
const PollPageClient = /* @__PURE__ */ registerClientReference(() => {
|
||||
throw new Error("Unexpectedly client reference export 'default' is called on server");
|
||||
}, "4af94835fa0f", "default");
|
||||
async function PollPage({ id }) {
|
||||
return /* @__PURE__ */ jsxRuntime_reactServerExports.jsx(PollPageClient, { id });
|
||||
}
|
||||
const getConfig = async () => {
|
||||
return { render: "dynamic" };
|
||||
};
|
||||
export {
|
||||
PollPage as default,
|
||||
getConfig
|
||||
};
|
||||
1
dist/server/assets/_layout-ChjUcnq2.css
vendored
1
dist/server/assets/_layout-ChjUcnq2.css
vendored
File diff suppressed because one or more lines are too long
58
dist/server/assets/_layout-Cnq791cx.js
vendored
58
dist/server/assets/_layout-Cnq791cx.js
vendored
@@ -1,58 +0,0 @@
|
||||
import assetsManifest from "../__vite_rsc_assets_manifest.js";
|
||||
import { _ as __vite_rsc_react__, r as registerClientReference, a as jsxRuntime_reactServerExports } from "./server-entry-inner-DFzcLk7e.js";
|
||||
import "node:async_hooks";
|
||||
import "node:path";
|
||||
import "http";
|
||||
import "http2";
|
||||
import "stream";
|
||||
import "crypto";
|
||||
import "fs";
|
||||
import "path";
|
||||
import "process";
|
||||
import "../__waku_build_metadata.js";
|
||||
const RemoveDuplicateServerCss = void 0;
|
||||
const Resources = /* @__PURE__ */ ((React, deps, RemoveDuplicateServerCss2, precedence) => {
|
||||
return function Resources2() {
|
||||
return React.createElement(React.Fragment, null, [...deps.css.map((href) => React.createElement("link", {
|
||||
key: "css:" + href,
|
||||
rel: "stylesheet",
|
||||
...{ precedence },
|
||||
href,
|
||||
"data-rsc-css-href": href
|
||||
})), RemoveDuplicateServerCss2]);
|
||||
};
|
||||
})(
|
||||
__vite_rsc_react__,
|
||||
assetsManifest.serverResources["src/pages/_layout.tsx"],
|
||||
RemoveDuplicateServerCss,
|
||||
"vite-rsc/importer-resources"
|
||||
);
|
||||
const Providers = /* @__PURE__ */ registerClientReference(() => {
|
||||
throw new Error("Unexpectedly client reference export 'default' is called on server");
|
||||
}, "125820ecd802", "default");
|
||||
const Layout = ({ children }) => /* @__PURE__ */ jsxRuntime_reactServerExports.jsx(Providers, { children: /* @__PURE__ */ jsxRuntime_reactServerExports.jsxs("div", { className: "min-h-screen bg-gray-50 text-gray-900", children: [
|
||||
/* @__PURE__ */ jsxRuntime_reactServerExports.jsx("header", { className: "border-b border-gray-200 bg-white px-4 py-3", children: /* @__PURE__ */ jsxRuntime_reactServerExports.jsx("h1", { className: "text-xl font-bold", children: /* @__PURE__ */ jsxRuntime_reactServerExports.jsx("a", { href: "/", className: "hover:text-blue-600", children: "P2P Poll" }) }) }),
|
||||
/* @__PURE__ */ jsxRuntime_reactServerExports.jsx("main", { className: "mx-auto max-w-xl px-4 py-8", children })
|
||||
] }) });
|
||||
const $$default = Layout;
|
||||
let getConfig = async () => {
|
||||
return { render: "static" };
|
||||
};
|
||||
const $$wrap_$$default = /* @__PURE__ */ __vite_rsc_wrap_css__($$default, "Layout");
|
||||
function __vite_rsc_wrap_css__(value, name) {
|
||||
if (typeof value !== "function") return value;
|
||||
function __wrapper(props) {
|
||||
return __vite_rsc_react__.createElement(
|
||||
__vite_rsc_react__.Fragment,
|
||||
null,
|
||||
__vite_rsc_react__.createElement(Resources),
|
||||
__vite_rsc_react__.createElement(value, props)
|
||||
);
|
||||
}
|
||||
Object.defineProperty(__wrapper, "name", { value: name });
|
||||
return __wrapper;
|
||||
}
|
||||
export {
|
||||
$$wrap_$$default as default,
|
||||
getConfig
|
||||
};
|
||||
28
dist/server/assets/_root-_BZAZd6T.js
vendored
28
dist/server/assets/_root-_BZAZd6T.js
vendored
@@ -1,28 +0,0 @@
|
||||
import { a as jsxRuntime_reactServerExports } from "./server-entry-inner-DFzcLk7e.js";
|
||||
import "node:async_hooks";
|
||||
import "node:path";
|
||||
import "http";
|
||||
import "http2";
|
||||
import "stream";
|
||||
import "crypto";
|
||||
import "fs";
|
||||
import "path";
|
||||
import "process";
|
||||
import "../__vite_rsc_assets_manifest.js";
|
||||
import "../__waku_build_metadata.js";
|
||||
function Root({ children }) {
|
||||
return /* @__PURE__ */ jsxRuntime_reactServerExports.jsxs("html", { lang: "en", children: [
|
||||
/* @__PURE__ */ jsxRuntime_reactServerExports.jsxs("head", { children: [
|
||||
/* @__PURE__ */ jsxRuntime_reactServerExports.jsx("meta", { charSet: "utf-8" }),
|
||||
/* @__PURE__ */ jsxRuntime_reactServerExports.jsx("meta", { name: "viewport", content: "width=device-width, initial-scale=1" })
|
||||
] }),
|
||||
/* @__PURE__ */ jsxRuntime_reactServerExports.jsx("body", { children })
|
||||
] });
|
||||
}
|
||||
const getConfig = () => {
|
||||
return { render: "dynamic" };
|
||||
};
|
||||
export {
|
||||
Root as default,
|
||||
getConfig
|
||||
};
|
||||
25
dist/server/assets/index-BU_VRdfq.js
vendored
25
dist/server/assets/index-BU_VRdfq.js
vendored
@@ -1,25 +0,0 @@
|
||||
import { r as registerClientReference, a as jsxRuntime_reactServerExports } from "./server-entry-inner-DFzcLk7e.js";
|
||||
import "node:async_hooks";
|
||||
import "node:path";
|
||||
import "http";
|
||||
import "http2";
|
||||
import "stream";
|
||||
import "crypto";
|
||||
import "fs";
|
||||
import "path";
|
||||
import "process";
|
||||
import "../__vite_rsc_assets_manifest.js";
|
||||
import "../__waku_build_metadata.js";
|
||||
const HomeClient = /* @__PURE__ */ registerClientReference(() => {
|
||||
throw new Error("Unexpectedly client reference export 'default' is called on server");
|
||||
}, "5ce7e027532e", "default");
|
||||
async function HomePage() {
|
||||
return /* @__PURE__ */ jsxRuntime_reactServerExports.jsx(HomeClient, {});
|
||||
}
|
||||
const getConfig = async () => {
|
||||
return { render: "dynamic" };
|
||||
};
|
||||
export {
|
||||
HomePage as default,
|
||||
getConfig
|
||||
};
|
||||
7899
dist/server/assets/server-entry-inner-DFzcLk7e.js
vendored
7899
dist/server/assets/server-entry-inner-DFzcLk7e.js
vendored
File diff suppressed because it is too large
Load Diff
54
dist/server/build.js
vendored
54
dist/server/build.js
vendored
@@ -1,54 +0,0 @@
|
||||
import { createRequire } from "node:module";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { I as INTERNAL_setAllEnv, s as serverEntry, j as joinPath, __tla as __tla_0 } from "./assets/server-entry-inner-DFzcLk7e.js";
|
||||
import "node:async_hooks";
|
||||
import "node:path";
|
||||
import "http";
|
||||
import "http2";
|
||||
import "stream";
|
||||
import "crypto";
|
||||
import "fs";
|
||||
import "path";
|
||||
import "process";
|
||||
import "./__vite_rsc_assets_manifest.js";
|
||||
import "./__waku_build_metadata.js";
|
||||
let INTERNAL_runBuild;
|
||||
let __tla = Promise.all([
|
||||
(() => {
|
||||
try {
|
||||
return __tla_0;
|
||||
} catch {
|
||||
}
|
||||
})()
|
||||
]).then(async () => {
|
||||
function resolveModuleId(moduleId, rootDir) {
|
||||
if (moduleId.startsWith("file://")) {
|
||||
return moduleId;
|
||||
}
|
||||
if (moduleId.startsWith("/")) {
|
||||
return pathToFileURL(joinPath(rootDir, moduleId.slice(1))).href;
|
||||
}
|
||||
const require2 = createRequire(joinPath(rootDir, "DUMMY.js"));
|
||||
const resolved = require2.resolve(moduleId);
|
||||
return pathToFileURL(resolved).href;
|
||||
}
|
||||
INTERNAL_runBuild = async function({ rootDir, emitFile }) {
|
||||
INTERNAL_setAllEnv(process.env);
|
||||
let build = serverEntry.build;
|
||||
for (const enhancer of serverEntry.buildEnhancers || []) {
|
||||
const moduleId = resolveModuleId(enhancer, rootDir);
|
||||
const mod = await import(moduleId).then(async (m) => {
|
||||
await m.__tla;
|
||||
return m;
|
||||
});
|
||||
build = await mod.default(build);
|
||||
}
|
||||
await build({
|
||||
emitFile
|
||||
}, serverEntry.buildOptions || {});
|
||||
};
|
||||
});
|
||||
export {
|
||||
INTERNAL_runBuild,
|
||||
__tla
|
||||
};
|
||||
22
dist/server/index.js
vendored
22
dist/server/index.js
vendored
@@ -1,22 +0,0 @@
|
||||
import { s as serverEntry, I as INTERNAL_setAllEnv } from "./assets/server-entry-inner-DFzcLk7e.js";
|
||||
import "node:async_hooks";
|
||||
import "node:path";
|
||||
import "http";
|
||||
import "http2";
|
||||
import "stream";
|
||||
import "crypto";
|
||||
import "fs";
|
||||
import "path";
|
||||
import "process";
|
||||
import "./__vite_rsc_assets_manifest.js";
|
||||
import "./__waku_build_metadata.js";
|
||||
async function INTERNAL_runFetch(env, req, ...args) {
|
||||
INTERNAL_setAllEnv(env);
|
||||
return serverEntry.fetch(req, ...args);
|
||||
}
|
||||
const entry_server = serverEntry.defaultExport;
|
||||
export {
|
||||
INTERNAL_runFetch,
|
||||
entry_server as default,
|
||||
serverEntry as unstable_serverEntry
|
||||
};
|
||||
54
dist/server/ssr/__vite_rsc_assets_manifest.js
vendored
54
dist/server/ssr/__vite_rsc_assets_manifest.js
vendored
@@ -1,54 +0,0 @@
|
||||
export default {
|
||||
"bootstrapScriptContent": "import(\"/assets/index-I8cR0Dsm.js\")",
|
||||
"clientReferenceDeps": {
|
||||
"847a2b1045ef": {
|
||||
"js": [
|
||||
"/assets/client-CsOmnPdF.js",
|
||||
"/assets/index-I8cR0Dsm.js"
|
||||
],
|
||||
"css": []
|
||||
},
|
||||
"6d786e16fc6b": {
|
||||
"js": [
|
||||
"/assets/client-CsOmnPdF.js",
|
||||
"/assets/index-I8cR0Dsm.js"
|
||||
],
|
||||
"css": []
|
||||
},
|
||||
"5ce7e027532e": {
|
||||
"js": [
|
||||
"/assets/index-BQZM9uZj.js",
|
||||
"/assets/index-I8cR0Dsm.js",
|
||||
"/assets/index-ChbWTil4.js",
|
||||
"/assets/poll-BI_0HvZY.js"
|
||||
],
|
||||
"css": []
|
||||
},
|
||||
"4af94835fa0f": {
|
||||
"js": [
|
||||
"/assets/_id_-cxsEKuSj.js",
|
||||
"/assets/index-I8cR0Dsm.js",
|
||||
"/assets/fullfat_bundler-C8o4MXnP.js",
|
||||
"/assets/index-ChbWTil4.js",
|
||||
"/assets/poll-BI_0HvZY.js"
|
||||
],
|
||||
"css": []
|
||||
},
|
||||
"125820ecd802": {
|
||||
"js": [
|
||||
"/assets/_layout-C9jEQBWP.js",
|
||||
"/assets/index-I8cR0Dsm.js",
|
||||
"/assets/index-ChbWTil4.js"
|
||||
],
|
||||
"css": []
|
||||
}
|
||||
},
|
||||
"serverResources": {
|
||||
"src/pages/_layout.tsx": {
|
||||
"js": [],
|
||||
"css": [
|
||||
"/assets/_layout-ChjUcnq2.css"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
192
dist/server/ssr/assets/_id_-BAEItp57.js
vendored
192
dist/server/ssr/assets/_id_-BAEItp57.js
vendored
@@ -1,192 +0,0 @@
|
||||
import { r as reactExports, j as jsxRuntimeExports } from "../index.js";
|
||||
import { i as isValidAutomergeUrl } from "./fullfat_node-75TjwUrn.js";
|
||||
import { u as useRepo, a as useDocument } from "./index-BSpyO9eA.js";
|
||||
import { h as hasVoted, a as addOption, u as unvote, v as vote } from "./poll-R5-eIJ_b.js";
|
||||
import "../__vite_rsc_assets_manifest.js";
|
||||
import "node:async_hooks";
|
||||
import "tty";
|
||||
import "util";
|
||||
import "os";
|
||||
import "node:crypto";
|
||||
import "crypto";
|
||||
import "module";
|
||||
import "fs";
|
||||
const PEER_ID_KEY = "p2p-poll-peer-id";
|
||||
function generateUUID() {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
function getPeerId() {
|
||||
if (typeof globalThis.localStorage === "undefined") {
|
||||
return generateUUID();
|
||||
}
|
||||
let id = localStorage.getItem(PEER_ID_KEY);
|
||||
if (!id) {
|
||||
id = generateUUID();
|
||||
localStorage.setItem(PEER_ID_KEY, id);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
function ConnectionStatus() {
|
||||
const repo = useRepo();
|
||||
const [connected, setConnected] = reactExports.useState(false);
|
||||
const updateStatus = reactExports.useCallback(() => {
|
||||
setConnected(repo.peers.length > 0);
|
||||
}, [repo]);
|
||||
reactExports.useEffect(() => {
|
||||
updateStatus();
|
||||
const onChange = () => updateStatus();
|
||||
repo.networkSubsystem.on("peer", onChange);
|
||||
repo.networkSubsystem.on("peer-disconnected", onChange);
|
||||
return () => {
|
||||
repo.networkSubsystem.off("peer", onChange);
|
||||
repo.networkSubsystem.off("peer-disconnected", onChange);
|
||||
};
|
||||
}, [repo, updateStatus]);
|
||||
return /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "flex items-center gap-2 text-xs text-gray-500", children: [
|
||||
/* @__PURE__ */ jsxRuntimeExports.jsx(
|
||||
"span",
|
||||
{
|
||||
className: `inline-block h-2 w-2 rounded-full ${connected ? "bg-green-500" : "bg-yellow-500"}`
|
||||
}
|
||||
),
|
||||
connected ? "Connected" : "Connecting..."
|
||||
] });
|
||||
}
|
||||
function PollView({ docUrl }) {
|
||||
const [doc, changeDoc] = useDocument(docUrl);
|
||||
const [newOption, setNewOption] = reactExports.useState("");
|
||||
const [copied, setCopied] = reactExports.useState(false);
|
||||
const peerId = reactExports.useMemo(() => getPeerId(), []);
|
||||
if (!doc) {
|
||||
return /* @__PURE__ */ jsxRuntimeExports.jsx("div", { className: "flex items-center justify-center py-12", children: /* @__PURE__ */ jsxRuntimeExports.jsx("div", { className: "text-gray-500", children: "Loading poll..." }) });
|
||||
}
|
||||
const totalVotes = doc.options.reduce((sum, o) => sum + o.votes.length, 0);
|
||||
const handleAddOption = () => {
|
||||
const text = newOption.trim();
|
||||
if (!text) return;
|
||||
changeDoc((d) => addOption(d, text));
|
||||
setNewOption("");
|
||||
};
|
||||
const handleVote = (optionId) => {
|
||||
if (hasVoted(doc, optionId, peerId)) {
|
||||
changeDoc((d) => unvote(d, optionId, peerId));
|
||||
} else {
|
||||
changeDoc((d) => vote(d, optionId, peerId));
|
||||
}
|
||||
};
|
||||
const handleCopy = () => {
|
||||
const url = window.location.href;
|
||||
navigator.clipboard.writeText(url).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2e3);
|
||||
});
|
||||
};
|
||||
return /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "space-y-6", children: [
|
||||
/* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "flex items-start justify-between", children: [
|
||||
/* @__PURE__ */ jsxRuntimeExports.jsxs("div", { children: [
|
||||
/* @__PURE__ */ jsxRuntimeExports.jsx("h2", { className: "text-2xl font-bold", children: doc.title }),
|
||||
/* @__PURE__ */ jsxRuntimeExports.jsxs("p", { className: "mt-1 text-sm text-gray-500", children: [
|
||||
totalVotes,
|
||||
" vote",
|
||||
totalVotes !== 1 ? "s" : "",
|
||||
" total"
|
||||
] })
|
||||
] }),
|
||||
/* @__PURE__ */ jsxRuntimeExports.jsx(ConnectionStatus, {})
|
||||
] }),
|
||||
/* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "space-y-3", children: [
|
||||
doc.options.map((option) => {
|
||||
const voted = hasVoted(doc, option.id, peerId);
|
||||
const pct = totalVotes > 0 ? option.votes.length / totalVotes * 100 : 0;
|
||||
return /* @__PURE__ */ jsxRuntimeExports.jsxs(
|
||||
"div",
|
||||
{
|
||||
className: "relative overflow-hidden rounded-lg border border-gray-200 bg-white",
|
||||
children: [
|
||||
/* @__PURE__ */ jsxRuntimeExports.jsx(
|
||||
"div",
|
||||
{
|
||||
className: "absolute inset-y-0 left-0 bg-blue-50 transition-all duration-300",
|
||||
style: { width: `${pct}%` }
|
||||
}
|
||||
),
|
||||
/* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "relative flex items-center justify-between px-4 py-3", children: [
|
||||
/* @__PURE__ */ jsxRuntimeExports.jsxs(
|
||||
"button",
|
||||
{
|
||||
onClick: () => handleVote(option.id),
|
||||
className: `flex items-center gap-2 text-left text-sm font-medium ${voted ? "text-blue-600" : "text-gray-700 hover:text-blue-600"}`,
|
||||
children: [
|
||||
/* @__PURE__ */ jsxRuntimeExports.jsx(
|
||||
"span",
|
||||
{
|
||||
className: `flex h-5 w-5 items-center justify-center rounded border text-xs ${voted ? "border-blue-600 bg-blue-600 text-white" : "border-gray-300"}`,
|
||||
children: voted ? "\u2713" : ""
|
||||
}
|
||||
),
|
||||
option.text
|
||||
]
|
||||
}
|
||||
),
|
||||
/* @__PURE__ */ jsxRuntimeExports.jsxs("span", { className: "text-sm text-gray-500", children: [
|
||||
option.votes.length,
|
||||
" (",
|
||||
pct.toFixed(0),
|
||||
"%)"
|
||||
] })
|
||||
] })
|
||||
]
|
||||
},
|
||||
option.id
|
||||
);
|
||||
}),
|
||||
doc.options.length === 0 && /* @__PURE__ */ jsxRuntimeExports.jsx("p", { className: "py-4 text-center text-sm text-gray-400", children: "No options yet. Add one below!" })
|
||||
] }),
|
||||
/* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "flex gap-2", children: [
|
||||
/* @__PURE__ */ jsxRuntimeExports.jsx(
|
||||
"input",
|
||||
{
|
||||
type: "text",
|
||||
value: newOption,
|
||||
onChange: (e) => setNewOption(e.target.value),
|
||||
onKeyDown: (e) => e.key === "Enter" && handleAddOption(),
|
||||
placeholder: "Add an option...",
|
||||
className: "flex-1 rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
}
|
||||
),
|
||||
/* @__PURE__ */ jsxRuntimeExports.jsx(
|
||||
"button",
|
||||
{
|
||||
onClick: handleAddOption,
|
||||
className: "rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700",
|
||||
children: "Add"
|
||||
}
|
||||
)
|
||||
] }),
|
||||
/* @__PURE__ */ jsxRuntimeExports.jsx("div", { className: "border-t border-gray-200 pt-4", children: /* @__PURE__ */ jsxRuntimeExports.jsx(
|
||||
"button",
|
||||
{
|
||||
onClick: handleCopy,
|
||||
className: "rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-600 hover:bg-gray-50",
|
||||
children: copied ? "Copied!" : "Copy shareable link"
|
||||
}
|
||||
) })
|
||||
] });
|
||||
}
|
||||
function PollPageClient({ id }) {
|
||||
const automergeUrl = `automerge:${id}`;
|
||||
if (!isValidAutomergeUrl(automergeUrl)) {
|
||||
return /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "rounded-lg border border-red-200 bg-red-50 p-6 text-center", children: [
|
||||
/* @__PURE__ */ jsxRuntimeExports.jsx("h2", { className: "text-lg font-semibold text-red-800", children: "Invalid Poll ID" }),
|
||||
/* @__PURE__ */ jsxRuntimeExports.jsx("p", { className: "mt-2 text-sm text-red-600", children: "The poll ID in the URL is not valid." }),
|
||||
/* @__PURE__ */ jsxRuntimeExports.jsx("a", { href: "/", className: "mt-4 inline-block text-sm text-blue-600 hover:underline", children: "Go back home" })
|
||||
] });
|
||||
}
|
||||
return /* @__PURE__ */ jsxRuntimeExports.jsx("div", { className: "rounded-lg border border-gray-200 bg-white p-6 shadow-sm", children: /* @__PURE__ */ jsxRuntimeExports.jsx(PollView, { docUrl: automergeUrl }) });
|
||||
}
|
||||
const export_4af94835fa0f = {
|
||||
default: PollPageClient
|
||||
};
|
||||
export {
|
||||
export_4af94835fa0f
|
||||
};
|
||||
53
dist/server/ssr/assets/_layout-DwifDpT-.js
vendored
53
dist/server/ssr/assets/_layout-DwifDpT-.js
vendored
@@ -1,53 +0,0 @@
|
||||
import { r as reactExports, j as jsxRuntimeExports, __tla as __tla_0 } from "../index.js";
|
||||
import { R as RepoContext } from "./index-BSpyO9eA.js";
|
||||
import "../__vite_rsc_assets_manifest.js";
|
||||
import "node:async_hooks";
|
||||
let export_125820ecd802;
|
||||
let __tla = Promise.all([
|
||||
(() => {
|
||||
try {
|
||||
return __tla_0;
|
||||
} catch {
|
||||
}
|
||||
})()
|
||||
]).then(async () => {
|
||||
function Providers({ children }) {
|
||||
const [repo, setRepo] = reactExports.useState(null);
|
||||
reactExports.useEffect(() => {
|
||||
let cleanup;
|
||||
let handleBeforeUnload;
|
||||
import("./repo-zy9lifAg.js").then(({ getRepo, cleanupRepo }) => {
|
||||
const r = getRepo();
|
||||
setRepo(r);
|
||||
cleanup = cleanupRepo;
|
||||
handleBeforeUnload = () => {
|
||||
r.networkSubsystem.adapters.forEach((adapter) => adapter.disconnect());
|
||||
};
|
||||
window.addEventListener("beforeunload", handleBeforeUnload);
|
||||
});
|
||||
return () => {
|
||||
if (handleBeforeUnload) {
|
||||
window.removeEventListener("beforeunload", handleBeforeUnload);
|
||||
}
|
||||
cleanup == null ? void 0 : cleanup();
|
||||
};
|
||||
}, []);
|
||||
if (!repo) {
|
||||
return jsxRuntimeExports.jsx("div", {
|
||||
className: "flex min-h-screen items-center justify-center text-gray-400",
|
||||
children: "Loading..."
|
||||
});
|
||||
}
|
||||
return jsxRuntimeExports.jsx(RepoContext.Provider, {
|
||||
value: repo,
|
||||
children
|
||||
});
|
||||
}
|
||||
export_125820ecd802 = {
|
||||
default: Providers
|
||||
};
|
||||
});
|
||||
export {
|
||||
__tla,
|
||||
export_125820ecd802
|
||||
};
|
||||
104
dist/server/ssr/assets/client-CeLGCvkj.js
vendored
104
dist/server/ssr/assets/client-CeLGCvkj.js
vendored
@@ -1,104 +0,0 @@
|
||||
import { j as jsxRuntimeExports, S as Slot, r as reactExports, C as Children } from "../index.js";
|
||||
import "../__vite_rsc_assets_manifest.js";
|
||||
import "node:async_hooks";
|
||||
const RouterContext = /* @__PURE__ */ reactExports.createContext(null);
|
||||
const notAvailableInServer = (name) => () => {
|
||||
throw new Error(`${name} is not in the server`);
|
||||
};
|
||||
function renderError(message) {
|
||||
return /* @__PURE__ */ jsxRuntimeExports.jsxs("html", {
|
||||
children: [
|
||||
/* @__PURE__ */ jsxRuntimeExports.jsx("head", {
|
||||
children: /* @__PURE__ */ jsxRuntimeExports.jsx("title", {
|
||||
children: "Unhandled Error"
|
||||
})
|
||||
}),
|
||||
/* @__PURE__ */ jsxRuntimeExports.jsxs("body", {
|
||||
style: {
|
||||
height: "100vh",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
placeContent: "center",
|
||||
placeItems: "center",
|
||||
fontSize: "16px",
|
||||
margin: 0
|
||||
},
|
||||
children: [
|
||||
/* @__PURE__ */ jsxRuntimeExports.jsx("h1", {
|
||||
children: "Caught an unexpected error"
|
||||
}),
|
||||
/* @__PURE__ */ jsxRuntimeExports.jsxs("p", {
|
||||
children: [
|
||||
"Error: ",
|
||||
message
|
||||
]
|
||||
})
|
||||
]
|
||||
})
|
||||
]
|
||||
});
|
||||
}
|
||||
class ErrorBoundary extends reactExports.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {};
|
||||
}
|
||||
static getDerivedStateFromError(error) {
|
||||
return {
|
||||
error
|
||||
};
|
||||
}
|
||||
render() {
|
||||
if ("error" in this.state) {
|
||||
if (this.state.error instanceof Error) {
|
||||
return renderError(this.state.error.message);
|
||||
}
|
||||
return renderError(String(this.state.error));
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
const getRouteSlotId = (path) => "route:" + path;
|
||||
const MOCK_ROUTE_CHANGE_LISTENER = {
|
||||
on: () => notAvailableInServer("routeChange:on"),
|
||||
off: () => notAvailableInServer("routeChange:off")
|
||||
};
|
||||
function INTERNAL_ServerRouter({ route, httpstatus }) {
|
||||
const routeElement = /* @__PURE__ */ jsxRuntimeExports.jsx(Slot, {
|
||||
id: getRouteSlotId(route.path)
|
||||
});
|
||||
const rootElement = /* @__PURE__ */ jsxRuntimeExports.jsxs(Slot, {
|
||||
id: "root",
|
||||
children: [
|
||||
/* @__PURE__ */ jsxRuntimeExports.jsx("meta", {
|
||||
name: "httpstatus",
|
||||
content: `${httpstatus}`
|
||||
}),
|
||||
routeElement
|
||||
]
|
||||
});
|
||||
return /* @__PURE__ */ jsxRuntimeExports.jsx(jsxRuntimeExports.Fragment, {
|
||||
children: /* @__PURE__ */ jsxRuntimeExports.jsx(RouterContext, {
|
||||
value: {
|
||||
route,
|
||||
changeRoute: notAvailableInServer("changeRoute"),
|
||||
prefetchRoute: notAvailableInServer("prefetchRoute"),
|
||||
routeChangeEvents: MOCK_ROUTE_CHANGE_LISTENER,
|
||||
fetchingSlices: /* @__PURE__ */ new Set()
|
||||
},
|
||||
children: rootElement
|
||||
})
|
||||
});
|
||||
}
|
||||
const export_847a2b1045ef = {
|
||||
Children,
|
||||
Slot
|
||||
};
|
||||
const export_6d786e16fc6b = {
|
||||
ErrorBoundary,
|
||||
INTERNAL_ServerRouter
|
||||
};
|
||||
export {
|
||||
export_6d786e16fc6b,
|
||||
export_847a2b1045ef
|
||||
};
|
||||
13082
dist/server/ssr/assets/fullfat_node-75TjwUrn.js
vendored
13082
dist/server/ssr/assets/fullfat_node-75TjwUrn.js
vendored
File diff suppressed because it is too large
Load Diff
5592
dist/server/ssr/assets/index-BSpyO9eA.js
vendored
5592
dist/server/ssr/assets/index-BSpyO9eA.js
vendored
File diff suppressed because it is too large
Load Diff
82
dist/server/ssr/assets/index-CNRWZdhS.js
vendored
82
dist/server/ssr/assets/index-CNRWZdhS.js
vendored
@@ -1,82 +0,0 @@
|
||||
import { r as reactExports, j as jsxRuntimeExports } from "../index.js";
|
||||
import { u as useRepo } from "./index-BSpyO9eA.js";
|
||||
import { c as createPoll } from "./poll-R5-eIJ_b.js";
|
||||
import "../__vite_rsc_assets_manifest.js";
|
||||
import "node:async_hooks";
|
||||
function HomeClient() {
|
||||
const [title, setTitle] = reactExports.useState("");
|
||||
const [joinId, setJoinId] = reactExports.useState("");
|
||||
const repo = useRepo();
|
||||
const handleCreate = () => {
|
||||
if (!title.trim()) return;
|
||||
const handle = repo.create();
|
||||
handle.change((doc) => {
|
||||
const poll = createPoll(title.trim());
|
||||
doc.title = poll.title;
|
||||
doc.options = poll.options;
|
||||
});
|
||||
window.location.href = `/poll/${handle.documentId}`;
|
||||
};
|
||||
const handleJoin = () => {
|
||||
const id = joinId.trim();
|
||||
if (!id) return;
|
||||
window.location.href = `/poll/${id}`;
|
||||
};
|
||||
return /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "space-y-8", children: [
|
||||
/* @__PURE__ */ jsxRuntimeExports.jsx("title", { children: "P2P Poll" }),
|
||||
/* @__PURE__ */ jsxRuntimeExports.jsxs("section", { className: "rounded-lg border border-gray-200 bg-white p-6 shadow-sm", children: [
|
||||
/* @__PURE__ */ jsxRuntimeExports.jsx("h2", { className: "mb-4 text-lg font-semibold", children: "Create a New Poll" }),
|
||||
/* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "flex gap-2", children: [
|
||||
/* @__PURE__ */ jsxRuntimeExports.jsx(
|
||||
"input",
|
||||
{
|
||||
type: "text",
|
||||
value: title,
|
||||
onChange: (e) => setTitle(e.target.value),
|
||||
onKeyDown: (e) => e.key === "Enter" && handleCreate(),
|
||||
placeholder: "Enter poll title...",
|
||||
className: "flex-1 rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
}
|
||||
),
|
||||
/* @__PURE__ */ jsxRuntimeExports.jsx(
|
||||
"button",
|
||||
{
|
||||
onClick: handleCreate,
|
||||
className: "rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700",
|
||||
children: "Create"
|
||||
}
|
||||
)
|
||||
] })
|
||||
] }),
|
||||
/* @__PURE__ */ jsxRuntimeExports.jsxs("section", { className: "rounded-lg border border-gray-200 bg-white p-6 shadow-sm", children: [
|
||||
/* @__PURE__ */ jsxRuntimeExports.jsx("h2", { className: "mb-4 text-lg font-semibold", children: "Join an Existing Poll" }),
|
||||
/* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "flex gap-2", children: [
|
||||
/* @__PURE__ */ jsxRuntimeExports.jsx(
|
||||
"input",
|
||||
{
|
||||
type: "text",
|
||||
value: joinId,
|
||||
onChange: (e) => setJoinId(e.target.value),
|
||||
onKeyDown: (e) => e.key === "Enter" && handleJoin(),
|
||||
placeholder: "Paste poll ID or link...",
|
||||
className: "flex-1 rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
}
|
||||
),
|
||||
/* @__PURE__ */ jsxRuntimeExports.jsx(
|
||||
"button",
|
||||
{
|
||||
onClick: handleJoin,
|
||||
className: "rounded-md bg-gray-600 px-4 py-2 text-sm font-medium text-white hover:bg-gray-700",
|
||||
children: "Join"
|
||||
}
|
||||
)
|
||||
] })
|
||||
] })
|
||||
] });
|
||||
}
|
||||
const export_5ce7e027532e = {
|
||||
default: HomeClient
|
||||
};
|
||||
export {
|
||||
export_5ce7e027532e
|
||||
};
|
||||
39
dist/server/ssr/assets/poll-R5-eIJ_b.js
vendored
39
dist/server/ssr/assets/poll-R5-eIJ_b.js
vendored
@@ -1,39 +0,0 @@
|
||||
function createPoll(title) {
|
||||
return {
|
||||
title,
|
||||
options: []
|
||||
};
|
||||
}
|
||||
function addOption(poll, text) {
|
||||
poll.options.push({
|
||||
id: crypto.randomUUID(),
|
||||
text,
|
||||
votes: []
|
||||
});
|
||||
}
|
||||
function hasVoted(poll, optionId, peerId) {
|
||||
const option = poll.options.find((o) => o.id === optionId);
|
||||
if (!option) return false;
|
||||
return option.votes.includes(peerId);
|
||||
}
|
||||
function vote(poll, optionId, peerId) {
|
||||
const option = poll.options.find((o) => o.id === optionId);
|
||||
if (!option) return;
|
||||
if (option.votes.includes(peerId)) return;
|
||||
option.votes.push(peerId);
|
||||
}
|
||||
function unvote(poll, optionId, peerId) {
|
||||
const option = poll.options.find((o) => o.id === optionId);
|
||||
if (!option) return;
|
||||
const idx = option.votes.indexOf(peerId);
|
||||
if (idx !== -1) {
|
||||
option.votes.splice(idx, 1);
|
||||
}
|
||||
}
|
||||
export {
|
||||
addOption as a,
|
||||
createPoll as c,
|
||||
hasVoted as h,
|
||||
unvote as u,
|
||||
vote as v
|
||||
};
|
||||
5494
dist/server/ssr/assets/repo-zy9lifAg.js
vendored
5494
dist/server/ssr/assets/repo-zy9lifAg.js
vendored
File diff suppressed because it is too large
Load Diff
11296
dist/server/ssr/index.js
vendored
11296
dist/server/ssr/index.js
vendored
File diff suppressed because it is too large
Load Diff
7
ionic.config.json
Normal file
7
ionic.config.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"name": "IonicAngularVersion",
|
||||
"integrations": {
|
||||
"capacitor": {}
|
||||
},
|
||||
"type": "angular"
|
||||
}
|
||||
44
karma.conf.js
Normal file
44
karma.conf.js
Normal file
@@ -0,0 +1,44 @@
|
||||
// Karma configuration file, see link for more information
|
||||
// https://karma-runner.github.io/1.0/config/configuration-file.html
|
||||
|
||||
module.exports = function (config) {
|
||||
config.set({
|
||||
basePath: '',
|
||||
frameworks: ['jasmine', '@angular-devkit/build-angular'],
|
||||
plugins: [
|
||||
require('karma-jasmine'),
|
||||
require('karma-chrome-launcher'),
|
||||
require('karma-jasmine-html-reporter'),
|
||||
require('karma-coverage'),
|
||||
require('@angular-devkit/build-angular/plugins/karma')
|
||||
],
|
||||
client: {
|
||||
jasmine: {
|
||||
// you can add configuration options for Jasmine here
|
||||
// the possible options are listed at https://jasmine.github.io/api/edge/Configuration.html
|
||||
// for example, you can disable the random execution with `random: false`
|
||||
// or set a specific seed with `seed: 4321`
|
||||
},
|
||||
clearContext: false // leave Jasmine Spec Runner output visible in browser
|
||||
},
|
||||
jasmineHtmlReporter: {
|
||||
suppressAll: true // removes the duplicated traces
|
||||
},
|
||||
coverageReporter: {
|
||||
dir: require('path').join(__dirname, './coverage/app'),
|
||||
subdir: '.',
|
||||
reporters: [
|
||||
{ type: 'html' },
|
||||
{ type: 'text-summary' }
|
||||
]
|
||||
},
|
||||
reporters: ['progress', 'kjhtml'],
|
||||
port: 9876,
|
||||
colors: true,
|
||||
logLevel: config.LOG_INFO,
|
||||
autoWatch: true,
|
||||
browsers: ['Chrome'],
|
||||
singleRun: false,
|
||||
restartOnFileChange: true
|
||||
});
|
||||
};
|
||||
19093
package-lock.json
generated
Normal file
19093
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
88
package.json
88
package.json
@@ -1,32 +1,68 @@
|
||||
{
|
||||
"name": "p2p-poll-app",
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"name": "IonicAngularVersion",
|
||||
"version": "0.0.1",
|
||||
"author": "Ionic Framework",
|
||||
"homepage": "https://ionicframework.com/",
|
||||
"scripts": {
|
||||
"dev": "waku dev",
|
||||
"build": "waku build",
|
||||
"start": "waku start"
|
||||
"ng": "ng",
|
||||
"start": "ng serve",
|
||||
"build": "ng build",
|
||||
"watch": "ng build --watch --configuration development",
|
||||
"test": "ng test",
|
||||
"lint": "ng lint"
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@automerge/automerge": "^3.2.4",
|
||||
"@automerge/automerge-repo": "^2.5.3",
|
||||
"@automerge/automerge-repo-network-broadcastchannel": "^2.5.3",
|
||||
"@automerge/automerge-repo-network-websocket": "^2.5.3",
|
||||
"@automerge/automerge-repo-react-hooks": "^2.5.3",
|
||||
"@automerge/automerge-repo-storage-indexeddb": "^2.5.3",
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"react": "~19.2.4",
|
||||
"react-dom": "~19.2.4",
|
||||
"react-server-dom-webpack": "~19.2.4",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"vite-plugin-top-level-await": "^1.6.0",
|
||||
"vite-plugin-wasm": "^3.5.0",
|
||||
"waku": "1.0.0-alpha.5"
|
||||
"@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": {
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
"@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"
|
||||
}
|
||||
|
||||
63
src/app/app-routing.module.ts
Normal file
63
src/app/app-routing.module.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
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 {}
|
||||
3
src/app/app.component.html
Normal file
3
src/app/app.component.html
Normal file
@@ -0,0 +1,3 @@
|
||||
<ion-app>
|
||||
<ion-router-outlet></ion-router-outlet>
|
||||
</ion-app>
|
||||
0
src/app/app.component.scss
Normal file
0
src/app/app.component.scss
Normal file
21
src/app/app.component.spec.ts
Normal file
21
src/app/app.component.spec.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
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();
|
||||
});
|
||||
|
||||
});
|
||||
11
src/app/app.component.ts
Normal file
11
src/app/app.component.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Component } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
templateUrl: 'app.component.html',
|
||||
styleUrls: ['app.component.scss'],
|
||||
standalone: false,
|
||||
})
|
||||
export class AppComponent {
|
||||
constructor() {}
|
||||
}
|
||||
16
src/app/app.module.ts
Normal file
16
src/app/app.module.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
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 {}
|
||||
46
src/app/database/database.ts
Normal file
46
src/app/database/database.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* 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();
|
||||
16
src/app/home/home-routing.module.ts
Normal file
16
src/app/home/home-routing.module.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
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 {}
|
||||
13
src/app/home/home.module.ts
Normal file
13
src/app/home/home.module.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
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 {}
|
||||
54
src/app/home/home.page.html
Normal file
54
src/app/home/home.page.html
Normal file
@@ -0,0 +1,54 @@
|
||||
<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>
|
||||
34
src/app/home/home.page.scss
Normal file
34
src/app/home/home.page.scss
Normal file
@@ -0,0 +1,34 @@
|
||||
.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;
|
||||
}
|
||||
24
src/app/home/home.page.spec.ts
Normal file
24
src/app/home/home.page.spec.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
124
src/app/home/home.page.ts
Normal file
124
src/app/home/home.page.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* 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';
|
||||
}
|
||||
}
|
||||
17
src/app/pages/create-survey/create-survey-routing.module.ts
Normal file
17
src/app/pages/create-survey/create-survey-routing.module.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
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 {}
|
||||
20
src/app/pages/create-survey/create-survey.module.ts
Normal file
20
src/app/pages/create-survey/create-survey.module.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
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 {}
|
||||
131
src/app/pages/create-survey/create-survey.page.html
Normal file
131
src/app/pages/create-survey/create-survey.page.html
Normal file
@@ -0,0 +1,131 @@
|
||||
<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>
|
||||
18
src/app/pages/create-survey/create-survey.page.scss
Normal file
18
src/app/pages/create-survey/create-survey.page.scss
Normal file
@@ -0,0 +1,18 @@
|
||||
.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;
|
||||
}
|
||||
203
src/app/pages/create-survey/create-survey.page.ts
Normal file
203
src/app/pages/create-survey/create-survey.page.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* 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']);
|
||||
}
|
||||
}
|
||||
}
|
||||
17
src/app/pages/participate/participate-routing.module.ts
Normal file
17
src/app/pages/participate/participate-routing.module.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
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 {}
|
||||
20
src/app/pages/participate/participate.module.ts
Normal file
20
src/app/pages/participate/participate.module.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
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 {}
|
||||
185
src/app/pages/participate/participate.page.html
Normal file
185
src/app/pages/participate/participate.page.html
Normal file
@@ -0,0 +1,185 @@
|
||||
<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>
|
||||
119
src/app/pages/participate/participate.page.scss
Normal file
119
src/app/pages/participate/participate.page.scss
Normal file
@@ -0,0 +1,119 @@
|
||||
.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);
|
||||
}
|
||||
267
src/app/pages/participate/participate.page.ts
Normal file
267
src/app/pages/participate/participate.page.ts
Normal file
@@ -0,0 +1,267 @@
|
||||
/**
|
||||
* 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.';
|
||||
}
|
||||
}
|
||||
}
|
||||
17
src/app/pages/survey-detail/survey-detail-routing.module.ts
Normal file
17
src/app/pages/survey-detail/survey-detail-routing.module.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
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 {}
|
||||
20
src/app/pages/survey-detail/survey-detail.module.ts
Normal file
20
src/app/pages/survey-detail/survey-detail.module.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
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 {}
|
||||
167
src/app/pages/survey-detail/survey-detail.page.html
Normal file
167
src/app/pages/survey-detail/survey-detail.page.html
Normal file
@@ -0,0 +1,167 @@
|
||||
<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>
|
||||
14
src/app/pages/survey-detail/survey-detail.page.scss
Normal file
14
src/app/pages/survey-detail/survey-detail.page.scss
Normal file
@@ -0,0 +1,14 @@
|
||||
.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;
|
||||
}
|
||||
323
src/app/pages/survey-detail/survey-detail.page.ts
Normal file
323
src/app/pages/survey-detail/survey-detail.page.ts
Normal file
@@ -0,0 +1,323 @@
|
||||
/**
|
||||
* 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';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
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 {}
|
||||
18
src/app/pages/survey-results/survey-results.module.ts
Normal file
18
src/app/pages/survey-results/survey-results.module.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
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 {}
|
||||
125
src/app/pages/survey-results/survey-results.page.html
Normal file
125
src/app/pages/survey-results/survey-results.page.html
Normal file
@@ -0,0 +1,125 @@
|
||||
<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>
|
||||
93
src/app/pages/survey-results/survey-results.page.scss
Normal file
93
src/app/pages/survey-results/survey-results.page.scss
Normal file
@@ -0,0 +1,93 @@
|
||||
.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;
|
||||
}
|
||||
145
src/app/pages/survey-results/survey-results.page.ts
Normal file
145
src/app/pages/survey-results/survey-results.page.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* 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]);
|
||||
}
|
||||
}
|
||||
198
src/app/services/peer.service.ts
Normal file
198
src/app/services/peer.service.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
}
|
||||
181
src/app/services/response.service.ts
Normal file
181
src/app/services/response.service.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
160
src/app/services/survey.service.ts
Normal file
160
src/app/services/survey.service.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* 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 });
|
||||
}
|
||||
}
|
||||
137
src/app/shared/models/survey.models.ts
Normal file
137
src/app/shared/models/survey.models.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* 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' };
|
||||
BIN
src/assets/icon/favicon.png
Normal file
BIN
src/assets/icon/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 930 B |
1
src/assets/shapes.svg
Normal file
1
src/assets/shapes.svg
Normal file
@@ -0,0 +1 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -1,37 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useRepo } from "@automerge/automerge-repo-react-hooks";
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
|
||||
export default function ConnectionStatus() {
|
||||
const repo = useRepo();
|
||||
const [connected, setConnected] = useState(false);
|
||||
|
||||
const updateStatus = useCallback(() => {
|
||||
setConnected(repo ? repo.peers.length > 0 : false);
|
||||
}, [repo]);
|
||||
|
||||
useEffect(() => {
|
||||
updateStatus();
|
||||
|
||||
const onChange = () => updateStatus();
|
||||
repo.networkSubsystem.on("peer", onChange);
|
||||
repo.networkSubsystem.on("peer-disconnected", onChange);
|
||||
|
||||
return () => {
|
||||
repo.networkSubsystem.off("peer", onChange);
|
||||
repo.networkSubsystem.off("peer-disconnected", onChange);
|
||||
};
|
||||
}, [repo, updateStatus]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-xs text-gray-500">
|
||||
<span
|
||||
className={`inline-block h-2 w-2 rounded-full ${
|
||||
connected ? "bg-green-500" : "bg-yellow-500"
|
||||
}`}
|
||||
/>
|
||||
{connected ? "Connected" : "Connecting..."}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "waku";
|
||||
import { useRepo } from "@automerge/automerge-repo-react-hooks";
|
||||
import { createPoll } from "../lib/poll.js";
|
||||
import type { Poll } from "../lib/types.js";
|
||||
|
||||
export default function HomeClient() {
|
||||
const [title, setTitle] = useState("");
|
||||
const [joinId, setJoinId] = useState("");
|
||||
const repo = useRepo();
|
||||
const router = useRouter();
|
||||
|
||||
const handleCreate = () => {
|
||||
if (!title.trim()) return;
|
||||
const handle = repo.create<Poll>();
|
||||
handle.change((doc) => {
|
||||
const poll = createPoll(title.trim());
|
||||
doc.title = poll.title;
|
||||
doc.options = poll.options;
|
||||
});
|
||||
router.push(`/poll/${handle.documentId}`);
|
||||
};
|
||||
|
||||
const handleJoin = () => {
|
||||
const id = joinId.trim();
|
||||
if (!id) return;
|
||||
router.push(`/poll/${id}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<title>P2P Poll</title>
|
||||
|
||||
{/* Create Poll */}
|
||||
<section className="rounded-lg border border-gray-200 bg-white p-6 shadow-sm">
|
||||
<h2 className="mb-4 text-lg font-semibold">Create a New Poll</h2>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleCreate()}
|
||||
placeholder="Enter poll title..."
|
||||
className="flex-1 rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
className="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
|
||||
>
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Join Poll */}
|
||||
<section className="rounded-lg border border-gray-200 bg-white p-6 shadow-sm">
|
||||
<h2 className="mb-4 text-lg font-semibold">Join an Existing Poll</h2>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={joinId}
|
||||
onChange={(e) => setJoinId(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleJoin()}
|
||||
placeholder="Paste poll ID or link..."
|
||||
className="flex-1 rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
<button
|
||||
onClick={handleJoin}
|
||||
className="rounded-md bg-gray-600 px-4 py-2 text-sm font-medium text-white hover:bg-gray-700"
|
||||
>
|
||||
Join
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { isValidAutomergeUrl } from "@automerge/automerge-repo";
|
||||
import PollView from "./PollView.js";
|
||||
|
||||
export default function PollPageClient({ id }: { id: string }) {
|
||||
const automergeUrl = `automerge:${id}` as const;
|
||||
|
||||
if (!isValidAutomergeUrl(automergeUrl)) {
|
||||
return (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 p-6 text-center">
|
||||
<h2 className="text-lg font-semibold text-red-800">Invalid Poll ID</h2>
|
||||
<p className="mt-2 text-sm text-red-600">
|
||||
The poll ID in the URL is not valid.
|
||||
</p>
|
||||
<a href="/" className="mt-4 inline-block text-sm text-blue-600 hover:underline">
|
||||
Go back home
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-6 shadow-sm">
|
||||
<PollView docUrl={automergeUrl} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import type { AutomergeUrl } from "@automerge/automerge-repo";
|
||||
import { useDocument } from "@automerge/automerge-repo-react-hooks";
|
||||
import { addOption, vote, unvote, hasVoted } from "../lib/poll.js";
|
||||
import { getPeerId } from "../lib/peer.js";
|
||||
import type { Poll } from "../lib/types.js";
|
||||
import ConnectionStatus from "./ConnectionStatus.js";
|
||||
|
||||
export default function PollView({ docUrl }: { docUrl: AutomergeUrl }) {
|
||||
const [doc, changeDoc] = useDocument<Poll>(docUrl);
|
||||
const [newOption, setNewOption] = useState("");
|
||||
const [copied, setCopied] = useState(false);
|
||||
const peerId = useMemo(() => getPeerId(), []);
|
||||
|
||||
if (!doc) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="text-gray-500">Loading poll...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const totalVotes = doc.options.reduce((sum, o) => sum + o.votes.length, 0);
|
||||
|
||||
const handleAddOption = () => {
|
||||
const text = newOption.trim();
|
||||
if (!text) return;
|
||||
changeDoc((d) => addOption(d, text));
|
||||
setNewOption("");
|
||||
};
|
||||
|
||||
const handleVote = (optionId: string) => {
|
||||
if (hasVoted(doc, optionId, peerId)) {
|
||||
changeDoc((d) => unvote(d, optionId, peerId));
|
||||
} else {
|
||||
changeDoc((d) => vote(d, optionId, peerId));
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopy = () => {
|
||||
const url = window.location.href;
|
||||
navigator.clipboard.writeText(url).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}).catch(() => {});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold">{doc.title}</h2>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
{totalVotes} vote{totalVotes !== 1 ? "s" : ""} total
|
||||
</p>
|
||||
</div>
|
||||
<ConnectionStatus />
|
||||
</div>
|
||||
|
||||
{/* Options list */}
|
||||
<div className="space-y-3">
|
||||
{doc.options.map((option) => {
|
||||
const voted = hasVoted(doc, option.id, peerId);
|
||||
const pct = totalVotes > 0 ? (option.votes.length / totalVotes) * 100 : 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={option.id}
|
||||
className="relative overflow-hidden rounded-lg border border-gray-200 bg-white"
|
||||
>
|
||||
{/* Vote bar */}
|
||||
<div
|
||||
className="absolute inset-y-0 left-0 bg-blue-50 transition-all duration-300"
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
<div className="relative flex items-center justify-between px-4 py-3">
|
||||
<button
|
||||
onClick={() => handleVote(option.id)}
|
||||
className={`flex items-center gap-2 text-left text-sm font-medium ${
|
||||
voted ? "text-blue-600" : "text-gray-700 hover:text-blue-600"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`flex h-5 w-5 items-center justify-center rounded border text-xs ${
|
||||
voted
|
||||
? "border-blue-600 bg-blue-600 text-white"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
>
|
||||
{voted ? "\u2713" : ""}
|
||||
</span>
|
||||
{option.text}
|
||||
</button>
|
||||
<span className="text-sm text-gray-500">
|
||||
{option.votes.length} ({pct.toFixed(0)}%)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{doc.options.length === 0 && (
|
||||
<p className="py-4 text-center text-sm text-gray-400">
|
||||
No options yet. Add one below!
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Add option form */}
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newOption}
|
||||
onChange={(e) => setNewOption(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleAddOption()}
|
||||
placeholder="Add an option..."
|
||||
className="flex-1 rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
<button
|
||||
onClick={handleAddOption}
|
||||
className="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Share */}
|
||||
<div className="border-t border-gray-200 pt-4">
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-600 hover:bg-gray-50"
|
||||
>
|
||||
{copied ? "Copied!" : "Copy shareable link"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { type ReactNode, useState, useEffect } from "react";
|
||||
import { RepoContext } from "@automerge/automerge-repo-react-hooks";
|
||||
import type { Repo } from "@automerge/automerge-repo";
|
||||
|
||||
export default function Providers({ children }: { children: ReactNode }) {
|
||||
const [repo, setRepo] = useState<Repo | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cleanup: (() => void) | undefined;
|
||||
let handleBeforeUnload: (() => void) | undefined;
|
||||
|
||||
import("../lib/repo.js").then(({ getRepo, cleanupRepo }) => {
|
||||
const r = getRepo();
|
||||
setRepo(r);
|
||||
cleanup = cleanupRepo;
|
||||
|
||||
handleBeforeUnload = () => {
|
||||
r.networkSubsystem.adapters.forEach((adapter) => adapter.disconnect());
|
||||
};
|
||||
window.addEventListener("beforeunload", handleBeforeUnload);
|
||||
}).catch(() => {
|
||||
setError("Failed to initialize. Please refresh the page.");
|
||||
});
|
||||
|
||||
return () => {
|
||||
if (handleBeforeUnload) {
|
||||
window.removeEventListener("beforeunload", handleBeforeUnload);
|
||||
}
|
||||
cleanup?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (error) {
|
||||
return <div className="flex min-h-screen items-center justify-center text-red-500">{error}</div>;
|
||||
}
|
||||
|
||||
if (!repo) {
|
||||
return <div className="flex min-h-screen items-center justify-center text-gray-400">Loading...</div>;
|
||||
}
|
||||
|
||||
return <RepoContext.Provider value={repo}>{children}</RepoContext.Provider>;
|
||||
}
|
||||
3
src/environments/environment.prod.ts
Normal file
3
src/environments/environment.prod.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export const environment = {
|
||||
production: true
|
||||
};
|
||||
16
src/environments/environment.ts
Normal file
16
src/environments/environment.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
// 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.
|
||||
37
src/global.scss
Normal file
37
src/global.scss
Normal file
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* 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";
|
||||
26
src/index.html
Normal file
26
src/index.html
Normal file
@@ -0,0 +1,26 @@
|
||||
<!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,56 +0,0 @@
|
||||
import { describe, test, expect, beforeEach } from "bun:test";
|
||||
|
||||
// Mock localStorage
|
||||
const storage = new Map<string, string>();
|
||||
const localStorageMock = {
|
||||
getItem: (key: string) => storage.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => storage.set(key, value),
|
||||
removeItem: (key: string) => storage.delete(key),
|
||||
clear: () => storage.clear(),
|
||||
get length() {
|
||||
return storage.size;
|
||||
},
|
||||
key: (_index: number) => null,
|
||||
};
|
||||
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
value: localStorageMock,
|
||||
writable: true,
|
||||
});
|
||||
|
||||
// Import after mock is set up
|
||||
const { getPeerId } = await import("../peer.js");
|
||||
|
||||
describe("getPeerId", () => {
|
||||
beforeEach(() => {
|
||||
storage.clear();
|
||||
});
|
||||
|
||||
test("persists and returns the same ID on subsequent calls", () => {
|
||||
const id1 = getPeerId();
|
||||
const id2 = getPeerId();
|
||||
expect(id1).toBe(id2);
|
||||
});
|
||||
|
||||
test("stores the ID in localStorage", () => {
|
||||
const id = getPeerId();
|
||||
expect(storage.get("p2p-poll-peer-id")).toBe(id);
|
||||
});
|
||||
|
||||
test("returns a new UUID each time when localStorage is unavailable", () => {
|
||||
const saved = globalThis.localStorage;
|
||||
// @ts-expect-error — deliberately removing localStorage to test fallback
|
||||
globalThis.localStorage = undefined;
|
||||
|
||||
const id1 = getPeerId();
|
||||
const id2 = getPeerId();
|
||||
|
||||
expect(id1).not.toBe(id2);
|
||||
expect(id1).toMatch(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
|
||||
);
|
||||
|
||||
// Restore
|
||||
globalThis.localStorage = saved;
|
||||
});
|
||||
});
|
||||
@@ -1,94 +0,0 @@
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import { createPoll, addOption, vote, unvote, hasVoted } from "../poll.js";
|
||||
|
||||
describe("vote", () => {
|
||||
test("prevents double-vote (no duplicate entries)", () => {
|
||||
const poll = createPoll("Test");
|
||||
addOption(poll, "Red");
|
||||
const optionId = poll.options[0]!.id;
|
||||
|
||||
vote(poll, optionId, "peer-1");
|
||||
vote(poll, optionId, "peer-1");
|
||||
|
||||
expect(poll.options[0]!.votes).toEqual(["peer-1"]);
|
||||
expect(poll.options[0]!.votes).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("allows different peers to vote", () => {
|
||||
const poll = createPoll("Test");
|
||||
addOption(poll, "Red");
|
||||
const optionId = poll.options[0]!.id;
|
||||
|
||||
vote(poll, optionId, "peer-1");
|
||||
vote(poll, optionId, "peer-2");
|
||||
|
||||
expect(poll.options[0]!.votes).toEqual(["peer-1", "peer-2"]);
|
||||
});
|
||||
|
||||
test("is a no-op for non-existent option ID", () => {
|
||||
const poll = createPoll("Test");
|
||||
addOption(poll, "Red");
|
||||
|
||||
vote(poll, "non-existent-id", "peer-1");
|
||||
|
||||
expect(poll.options[0]!.votes).toEqual([]);
|
||||
});
|
||||
|
||||
test("voting on one option does not affect another option", () => {
|
||||
const poll = createPoll("Test");
|
||||
addOption(poll, "Red");
|
||||
addOption(poll, "Blue");
|
||||
const redId = poll.options[0]!.id;
|
||||
const blueId = poll.options[1]!.id;
|
||||
|
||||
vote(poll, redId, "peer-1");
|
||||
|
||||
expect(poll.options[0]!.votes).toEqual(["peer-1"]);
|
||||
expect(poll.options[1]!.votes).toEqual([]);
|
||||
|
||||
vote(poll, blueId, "peer-2");
|
||||
|
||||
expect(poll.options[0]!.votes).toEqual(["peer-1"]);
|
||||
expect(poll.options[1]!.votes).toEqual(["peer-2"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("unvote", () => {
|
||||
test("removes peer ID from votes array", () => {
|
||||
const poll = createPoll("Test");
|
||||
addOption(poll, "Red");
|
||||
const optionId = poll.options[0]!.id;
|
||||
|
||||
vote(poll, optionId, "peer-1");
|
||||
unvote(poll, optionId, "peer-1");
|
||||
|
||||
expect(poll.options[0]!.votes).toEqual([]);
|
||||
});
|
||||
|
||||
test("does nothing if peer has not voted", () => {
|
||||
const poll = createPoll("Test");
|
||||
addOption(poll, "Red");
|
||||
const optionId = poll.options[0]!.id;
|
||||
|
||||
unvote(poll, optionId, "peer-1");
|
||||
|
||||
expect(poll.options[0]!.votes).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasVoted", () => {
|
||||
test("returns true when peer has voted", () => {
|
||||
const poll = createPoll("Test");
|
||||
addOption(poll, "Red");
|
||||
const optionId = poll.options[0]!.id;
|
||||
|
||||
vote(poll, optionId, "peer-1");
|
||||
|
||||
expect(hasVoted(poll, optionId, "peer-1")).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false for non-existent option", () => {
|
||||
const poll = createPoll("Test");
|
||||
expect(hasVoted(poll, "non-existent", "peer-1")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,22 +0,0 @@
|
||||
const PEER_ID_KEY = "p2p-poll-peer-id";
|
||||
|
||||
/** Generate a UUID v4 */
|
||||
function generateUUID(): string {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create a persistent peer ID.
|
||||
* Stored in localStorage so each browser tab/device gets a stable identity.
|
||||
*/
|
||||
export function getPeerId(): string {
|
||||
if (typeof globalThis.localStorage === "undefined") {
|
||||
return generateUUID();
|
||||
}
|
||||
let id = localStorage.getItem(PEER_ID_KEY);
|
||||
if (!id) {
|
||||
id = generateUUID();
|
||||
localStorage.setItem(PEER_ID_KEY, id);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import type { Poll } from "./types.js";
|
||||
|
||||
/** Create a new poll document with the given title and no options */
|
||||
export function createPoll(title: string): Poll {
|
||||
return {
|
||||
title,
|
||||
options: [],
|
||||
};
|
||||
}
|
||||
|
||||
/** Add a new option to the poll */
|
||||
export function addOption(poll: Poll, text: string): void {
|
||||
poll.options.push({
|
||||
id: crypto.randomUUID(),
|
||||
text,
|
||||
votes: [],
|
||||
});
|
||||
}
|
||||
|
||||
/** Check if a peer has already voted on an option */
|
||||
export function hasVoted(poll: Poll, optionId: string, peerId: string): boolean {
|
||||
const option = poll.options.find((o) => o.id === optionId);
|
||||
if (!option) return false;
|
||||
return option.votes.includes(peerId);
|
||||
}
|
||||
|
||||
/** Add a vote for the given option. Prevents double-voting. */
|
||||
export function vote(poll: Poll, optionId: string, peerId: string): void {
|
||||
const option = poll.options.find((o) => o.id === optionId);
|
||||
if (!option) return;
|
||||
if (option.votes.includes(peerId)) return;
|
||||
option.votes.push(peerId);
|
||||
}
|
||||
|
||||
/** Remove a vote from the given option */
|
||||
export function unvote(poll: Poll, optionId: string, peerId: string): void {
|
||||
const option = poll.options.find((o) => o.id === optionId);
|
||||
if (!option) return;
|
||||
const idx = option.votes.indexOf(peerId);
|
||||
if (idx !== -1) {
|
||||
option.votes.splice(idx, 1);
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import { Repo } from "@automerge/automerge-repo";
|
||||
import { BroadcastChannelNetworkAdapter } from "@automerge/automerge-repo-network-broadcastchannel";
|
||||
import { IndexedDBStorageAdapter } from "@automerge/automerge-repo-storage-indexeddb";
|
||||
import { BrowserWebSocketClientAdapter } from "@automerge/automerge-repo-network-websocket";
|
||||
|
||||
let repo: Repo | null = null;
|
||||
|
||||
/** Get or create the singleton Automerge Repo instance (browser-only) */
|
||||
export function getRepo(): Repo {
|
||||
if (repo) return repo;
|
||||
|
||||
repo = new Repo({
|
||||
network: [
|
||||
new BrowserWebSocketClientAdapter("wss://sync.automerge.org"),
|
||||
new BroadcastChannelNetworkAdapter(),
|
||||
],
|
||||
storage: new IndexedDBStorageAdapter("p2p-poll-app"),
|
||||
});
|
||||
|
||||
return repo;
|
||||
}
|
||||
|
||||
/** Shut down the repo and reset the singleton */
|
||||
export function cleanupRepo(): void {
|
||||
if (repo) {
|
||||
repo.shutdown();
|
||||
repo = null;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user