Initial commit: Activiteiten Inventaris applicatie

This commit is contained in:
2026-01-06 01:23:45 +01:00
commit 6d26aea0cf
38 changed files with 9818 additions and 0 deletions

9
.dockerignore Normal file
View File

@@ -0,0 +1,9 @@
node_modules
npm-debug.log
.git
.gitignore
.env
*.md
data/
.DS_Store
dist/

7
.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
node_modules/
dist/
data/
*.db
.env
.DS_Store
npm-debug.log

314
DEPLOYMENT.md Normal file
View File

@@ -0,0 +1,314 @@
# Deployment naar Dockge (Home Server)
Deze handleiding beschrijft hoe je de Activiteiten Inventaris applicatie deployt naar je home server met Dockge, Gitea en Nginx Proxy Manager.
## Overzicht
- **Gitea**: Lokale Git server voor versiebeheer
- **Dockge**: Docker Compose stack manager
- **Nginx Proxy Manager**: Reverse proxy met SSL
- **Domein**: poll.hausmans.cloud
---
## Stap 1: Repository aanmaken in Gitea
### 1.1 Lokale Git repository initialiseren
Voer deze commando's uit in de project directory op je ontwikkelmachine:
```bash
cd /Users/berthausmans/Documents/Development/questionnaire
# Git repository initialiseren
git init
# Alle bestanden toevoegen
git add .
# Eerste commit maken
git commit -m "Initial commit: Activiteiten Inventaris applicatie"
```
### 1.2 Repository aanmaken in Gitea
1. Open Gitea in je browser (bijv. `http://gitea.local:3000`)
2. Klik op **"+"** → **"New Repository"**
3. Vul in:
- **Repository Name**: `questionnaire`
- **Visibility**: Private (aanbevolen)
- **Initialize repository**: ❌ Niet aanvinken (we pushen bestaande code)
4. Klik op **"Create Repository"**
### 1.3 Code pushen naar Gitea
```bash
# Remote toevoegen (vervang met je Gitea URL)
git remote add origin http://gitea.local:3000/jouw-gebruiker/questionnaire.git
# Code pushen
git push -u origin main
```
> **Tip**: Als je branch `master` heet i.p.v. `main`:
> ```bash
> git branch -M main
> git push -u origin main
> ```
---
## Stap 2: Repository clonen op de server
SSH naar je Proxmox server en clone de repository:
```bash
# Ga naar de Dockge stacks directory
cd /opt/stacks
# Clone de repository van Gitea
git clone http://gitea.local:3000/jouw-gebruiker/questionnaire.git
```
> **Authenticatie**: Je wordt gevraagd om je Gitea gebruikersnaam en wachtwoord.
> Tip: Maak een Access Token aan in Gitea (Settings → Applications → Generate New Token)
---
## Stap 3: Stack aanmaken in Dockge
1. Open Dockge in je browser (bijv. `http://server-ip:5001`)
2. Klik op **"+ Compose"** om een nieuwe stack aan te maken
3. Geef de stack een naam: `questionnaire`
4. Plak de volgende `docker-compose.yml`:
```yaml
version: '3.8'
services:
app:
build: .
container_name: questionnaire
ports:
- "4000:4000"
environment:
- NODE_ENV=production
- PORT=4000
- SESSION_SECRET=${SESSION_SECRET}
- DB_PATH=/app/data/questionnaire.db
- DEFAULT_ADMIN_USER=${DEFAULT_ADMIN_USER:-admin}
- DEFAULT_ADMIN_PASS=${DEFAULT_ADMIN_PASS}
volumes:
- ./data:/app/data
restart: unless-stopped
networks:
- proxy
networks:
proxy:
external: true
```
5. Maak een `.env` bestand aan (via Dockge's Environment tab) met:
```env
SESSION_SECRET=jouw-super-geheime-sleutel-hier-minimaal-32-tekens
DEFAULT_ADMIN_USER=admin
DEFAULT_ADMIN_PASS=jouw-veilige-wachtwoord
```
> ⚠️ **Belangrijk**: Gebruik sterke, unieke waarden voor `SESSION_SECRET` en `DEFAULT_ADMIN_PASS`!
---
## Stap 3: Docker netwerk aanmaken (eenmalig)
Als je nog geen `proxy` netwerk hebt, maak deze aan via SSH op je server:
```bash
docker network create proxy
```
Dit netwerk wordt gebruikt om de applicatie te verbinden met Nginx Proxy Manager.
---
## Stap 4: Stack deployen
1. In Dockge, klik op **"Deploy"** of **"Up"**
2. Wacht tot de image is gebouwd en de container draait
3. Test of de applicatie werkt: `http://server-ip:4000`
---
## Stap 5: Nginx Proxy Manager configureren
1. Open Nginx Proxy Manager (bijv. `http://server-ip:81`)
2. Ga naar **Proxy Hosts****Add Proxy Host**
### Details tab:
| Veld | Waarde |
|------|--------|
| Domain Names | `poll.hausmans.cloud` |
| Scheme | `http` |
| Forward Hostname / IP | `questionnaire` (container naam) |
| Forward Port | `4000` |
| Block Common Exploits | ✅ |
| Websockets Support | ❌ (niet nodig) |
### SSL tab:
| Veld | Waarde |
|------|--------|
| SSL Certificate | Request a new SSL Certificate |
| Force SSL | ✅ |
| HTTP/2 Support | ✅ |
| HSTS Enabled | ✅ (optioneel) |
| Email Address | jouw@email.com |
| I Agree... | ✅ |
3. Klik op **Save**
---
## Stap 6: DNS configureren
Zorg dat je domein `poll.hausmans.cloud` naar je home IP-adres wijst:
| Type | Naam | Waarde |
|------|------|--------|
| A | poll | jouw-publieke-ip |
Of als je een dynamisch IP hebt, gebruik CNAME met een DynDNS service.
---
## Stap 7: Router port forwarding
Zorg dat je router poorten doorstuurt naar Nginx Proxy Manager:
| Externe poort | Interne poort | Protocol | Doel IP |
|--------------|---------------|----------|---------|
| 80 | 80 | TCP | NPM server IP |
| 443 | 443 | TCP | NPM server IP |
---
## Verificatie
1. Ga naar `https://poll.hausmans.cloud`
2. Je zou de login pagina moeten zien
3. Log in met je admin credentials
---
## Troubleshooting
### Container start niet
```bash
# Bekijk logs
docker logs questionnaire
# Of in Dockge, klik op de container en bekijk logs
```
### Database permissies
```bash
# Zorg dat de data directory schrijfbaar is
chmod 755 /opt/stacks/questionnaire/data
```
### Netwerk problemen
```bash
# Controleer of container in proxy netwerk zit
docker network inspect proxy
```
### NPM kan container niet bereiken
Zorg dat beide containers (questionnaire en NPM) in hetzelfde Docker netwerk zitten (`proxy`).
---
## Updates deployen
### Vanaf je ontwikkelmachine
```bash
cd /Users/berthausmans/Documents/Development/questionnaire
# Wijzigingen toevoegen en committen
git add .
git commit -m "Beschrijving van je wijzigingen"
# Pushen naar Gitea
git push
```
### Op de server (via SSH of Dockge terminal)
```bash
cd /opt/stacks/questionnaire
# Laatste wijzigingen ophalen
git pull
```
### In Dockge
1. Open de `questionnaire` stack
2. Klik op **"Down"** (stop de stack)
3. Klik op **"Rebuild"** om de image opnieuw te bouwen
4. Klik op **"Up"** om de stack te starten
> **Tip**: Je kunt stap 2-4 ook combineren met de "Recreate" optie als beschikbaar.
---
## Backup
De database wordt opgeslagen in `./data/questionnaire.db`. Maak regelmatig backups:
```bash
cp /opt/stacks/questionnaire/data/questionnaire.db ~/backups/questionnaire-$(date +%Y%m%d).db
```
---
## Volledige workflow samenvatting
```
┌─────────────────┐ git push ┌─────────────────┐
│ Ontwikkelmachine │ ─────────────► │ Gitea │
│ (lokaal) │ │ (lokaal netwerk)│
└─────────────────┘ └────────┬────────┘
│ git pull
┌─────────────────┐
│ Dockge/Server │
│ (Proxmox) │
└────────┬────────┘
│ docker build
┌─────────────────┐
│ Docker Container│
│ (port 4000) │
└────────┬────────┘
┌─────────────────┐
│ Nginx Proxy Mgr │
│ + Let's Encrypt │
└────────┬────────┘
┌─────────────────┐
│ Internet │
│ poll.hausmans.cloud
└─────────────────┘
```

54
Dockerfile Normal file
View File

@@ -0,0 +1,54 @@
# Build stage
FROM node:20-alpine AS builder
# Install build dependencies for better-sqlite3
RUN apk add --no-cache python3 make g++
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install all dependencies (including devDependencies for build)
RUN npm install
# Copy source code
COPY . .
# Build the application
RUN npm run build
# Compile server TypeScript
RUN npx tsc -p tsconfig.server.json
# Production stage
FROM node:20-alpine
# Install build dependencies for better-sqlite3
RUN apk add --no-cache python3 make g++
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install production dependencies only
RUN npm install --omit=dev
# Copy built files from builder
COPY --from=builder /app/dist ./dist
# Create data directory
RUN mkdir -p /app/data
# Expose port
EXPOSE 4000
# Set environment variables
ENV NODE_ENV=production
ENV PORT=4000
ENV SESSION_SECRET=change-this-in-production
ENV DB_PATH=/app/data/questionnaire.db
# Start the application
CMD ["node", "dist/server/index.js"]

142
README.md Normal file
View File

@@ -0,0 +1,142 @@
# Activity Inventory - Questionnaire Voting App
A web application that allows admins to create shareable questionnaires where users can add activities and vote on them using upvotes and downvotes.
## Tech Stack
- **Frontend**: React 18, TypeScript, Vite, Tailwind CSS
- **Backend**: Express.js, TypeScript
- **Database**: SQLite with `better-sqlite3`
- **Containerization**: Docker with multi-stage build
## Features
- **Admin Dashboard**: Create and manage questionnaires with unique shareable URLs
- **User Management**: Add/remove admin users and change passwords
- **Public Voting**: Share questionnaire URLs for users to add activities and vote
- **Upvote/Downvote System**: Users can upvote or downvote activities
- **Comments**: Users can comment on activities and reply to comments
- **Name Persistence**: Visitor names are stored in cookies for 30 days
- **Docker Support**: Easy deployment with Docker and docker-compose
## Quick Start with Docker
1. Clone the repository and navigate to the project directory
2. Start the application:
```bash
docker-compose up -d --build
```
3. Access the app at `http://localhost:4000`
4. Login with default credentials:
- Username: `admin`
- Password: `admin123`
5. Create a questionnaire and share the unique URL with participants
## Development Setup
1. Install dependencies:
```bash
npm install
```
2. Start the development servers (frontend + backend):
```bash
npm run dev
```
3. Access the app at `http://localhost:5177` (Vite dev server)
- API requests are proxied to `http://localhost:4000`
## Configuration
Environment variables can be set in `docker-compose.yml` or via a `.env` file:
| Variable | Description | Default |
|----------|-------------|---------|
| `PORT` | Server port | `4000` |
| `SESSION_SECRET` | Secret for session encryption | `super-secret-session-key-change-me` |
| `DB_PATH` | SQLite database path | `/app/data/questionnaire.db` |
| `DEFAULT_ADMIN_USER` | Default admin username | `admin` |
| `DEFAULT_ADMIN_PASS` | Default admin password | `admin123` |
## Project Structure
```
questionnaire/
├── docker-compose.yml # Docker Compose configuration
├── Dockerfile # Multi-stage Docker build
├── package.json # Node.js dependencies
├── vite.config.ts # Vite configuration
├── tailwind.config.js # Tailwind CSS configuration
├── tsconfig.json # TypeScript configuration
├── index.html # HTML entry point
├── server/ # Express backend (TypeScript)
│ ├── index.ts # Server entry point
│ ├── database.ts # SQLite database operations
│ ├── middleware/
│ │ └── auth.ts
│ └── routes/
│ ├── admin.ts
│ ├── auth.ts
│ └── questionnaire.ts
└── src/ # React frontend (TypeScript)
├── main.tsx # React entry point
├── App.tsx # App router
├── index.css # Tailwind imports
├── context/
│ └── AuthContext.tsx
├── components/
│ ├── AdminLayout.tsx
│ └── ProtectedRoute.tsx
└── pages/
├── Login.tsx
├── Dashboard.tsx
├── QuestionnaireForm.tsx
├── QuestionnaireDetail.tsx
├── Users.tsx
├── ChangePassword.tsx
├── PublicQuestionnaire.tsx
└── NotFound.tsx
```
## API Endpoints
### Public Routes
- `GET /api/q/:uuid` - Get questionnaire data
- `POST /api/q/:uuid/set-name` - Set visitor name
- `POST /api/q/:uuid/activities` - Add an activity
- `POST /api/q/:uuid/activities/:id/vote` - Vote on an activity
- `GET /api/q/:uuid/activities/:id/comments` - Get comments
- `POST /api/q/:uuid/activities/:id/comments` - Add a comment
### Auth Routes
- `GET /api/auth/status` - Check authentication status
- `POST /api/auth/login` - Login
- `POST /api/auth/logout` - Logout
### Admin Routes (requires authentication)
- `GET /api/admin/questionnaires` - List all questionnaires
- `POST /api/admin/questionnaires` - Create questionnaire
- `GET /api/admin/questionnaires/:id` - Get questionnaire details
- `PUT /api/admin/questionnaires/:id` - Update questionnaire
- `DELETE /api/admin/questionnaires/:id` - Delete questionnaire
- `DELETE /api/admin/activities/:id` - Delete activity
- `GET /api/admin/users` - List all users
- `POST /api/admin/users` - Create user
- `DELETE /api/admin/users/:id` - Delete user
- `POST /api/admin/change-password` - Change password
## Security Notes
- Change the default admin password immediately after first login
- Set a strong `SESSION_SECRET` in production
- The application uses bcrypt for password hashing
- Session cookies are HTTP-only
## License
MIT

34
docker-compose.prod.yml Normal file
View File

@@ -0,0 +1,34 @@
version: '3.8'
# Productie configuratie voor Dockge deployment
# Kopieer deze inhoud naar Dockge bij het aanmaken van de stack
services:
app:
build: .
container_name: questionnaire
ports:
- "4000:4000"
environment:
- NODE_ENV=production
- PORT=4000
- SESSION_SECRET=${SESSION_SECRET}
- DB_PATH=/app/data/questionnaire.db
- DEFAULT_ADMIN_USER=${DEFAULT_ADMIN_USER:-admin}
- DEFAULT_ADMIN_PASS=${DEFAULT_ADMIN_PASS}
volumes:
- ./data:/app/data
restart: unless-stopped
networks:
- proxy
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost:4000/api/auth/status"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
networks:
proxy:
external: true

20
docker-compose.yml Normal file
View File

@@ -0,0 +1,20 @@
version: '3.8'
services:
app:
build: .
ports:
- "4000:4000"
environment:
- NODE_ENV=production
- PORT=4000
- SESSION_SECRET=${SESSION_SECRET:-super-secret-session-key-change-me}
- DB_PATH=/app/data/questionnaire.db
- DEFAULT_ADMIN_USER=${DEFAULT_ADMIN_USER:-admin}
- DEFAULT_ADMIN_PASS=${DEFAULT_ADMIN_PASS:-admin123}
volumes:
- questionnaire-data:/app/data
restart: unless-stopped
volumes:
questionnaire-data:

17
index.html Normal file
View File

@@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="nl">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Activiteiten Inventaris</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700;1,9..40,400&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

5638
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

45
package.json Normal file
View File

@@ -0,0 +1,45 @@
{
"name": "questionnaire-voting-app",
"version": "2.0.0",
"type": "module",
"scripts": {
"dev": "concurrently \"npm run dev:server\" \"npm run dev:client\"",
"dev:client": "vite",
"dev:server": "tsx watch server/index.ts",
"build": "tsc -b && vite build",
"start": "NODE_ENV=production node dist/server/index.js",
"preview": "vite preview"
},
"dependencies": {
"bcrypt": "^5.1.1",
"better-sqlite3": "^9.4.3",
"cookie-parser": "^1.4.6",
"cors": "^2.8.5",
"express": "^4.18.2",
"express-session": "^1.17.3",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.22.0",
"uuid": "^9.0.1"
},
"devDependencies": {
"@types/bcrypt": "^5.0.2",
"@types/better-sqlite3": "^7.6.8",
"@types/cookie-parser": "^1.4.6",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/express-session": "^1.18.0",
"@types/node": "^22.10.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@types/uuid": "^10.0.0",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"concurrently": "^9.1.0",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.17",
"tsx": "^4.19.0",
"typescript": "^5.7.2",
"vite": "^6.2.0"
}
}

7
postcss.config.js Normal file
View File

@@ -0,0 +1,7 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

1
public/vite.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFBD4F"></stop><stop offset="100%" stop-color="#FF980E"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

438
server/database.ts Normal file
View File

@@ -0,0 +1,438 @@
import Database from 'better-sqlite3';
import bcrypt from 'bcrypt';
import path from 'path';
import fs from 'fs';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const dbPath = process.env.DB_PATH || path.join(__dirname, '..', 'data', 'questionnaire.db');
// Ensure data directory exists
const dataDir = path.dirname(dbPath);
if (!fs.existsSync(dataDir)) {
fs.mkdirSync(dataDir, { recursive: true });
}
export const db = new Database(dbPath);
// Enable foreign keys
db.pragma('foreign_keys = ON');
// Types
export interface User {
id: number;
username: string;
password_hash: string;
created_at: string;
}
export interface Questionnaire {
id: number;
uuid: string;
slug: string;
title: string;
description: string | null;
is_private: boolean;
created_by: number;
created_at: string;
creator_name?: string;
activity_count?: number;
}
export interface Participant {
id: number;
questionnaire_id: number;
name: string;
token: string;
created_at: string;
}
export interface Activity {
id: number;
questionnaire_id: number;
name: string;
description: string | null;
added_by: string;
created_at: string;
upvotes?: number;
downvotes?: number;
net_votes?: number;
comment_count?: number;
}
export interface Vote {
id: number;
activity_id: number;
voter_name: string;
vote_type: number;
created_at: string;
}
export interface Comment {
id: number;
activity_id: number;
parent_id: number | null;
author_name: string;
content: string;
created_at: string;
replies?: Comment[];
}
// Initialize database schema
export function initializeDatabase(): void {
// Users table
db.exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
// Questionnaires table
db.exec(`
CREATE TABLE IF NOT EXISTS questionnaires (
id INTEGER PRIMARY KEY AUTOINCREMENT,
uuid TEXT UNIQUE NOT NULL,
slug TEXT UNIQUE NOT NULL,
title TEXT NOT NULL,
description TEXT,
created_by INTEGER,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (created_by) REFERENCES users(id)
)
`);
// Migration: Add slug column if it doesn't exist
try {
db.exec(`ALTER TABLE questionnaires ADD COLUMN slug TEXT`);
db.exec(`UPDATE questionnaires SET slug = uuid WHERE slug IS NULL`);
} catch (e) {
// Column already exists, ignore
}
// Migration: Add is_private column if it doesn't exist
try {
db.exec(`ALTER TABLE questionnaires ADD COLUMN is_private INTEGER DEFAULT 0`);
} catch (e) {
// Column already exists, ignore
}
// Participants table
db.exec(`
CREATE TABLE IF NOT EXISTS participants (
id INTEGER PRIMARY KEY AUTOINCREMENT,
questionnaire_id INTEGER NOT NULL,
name TEXT NOT NULL,
token TEXT UNIQUE NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (questionnaire_id) REFERENCES questionnaires(id) ON DELETE CASCADE
)
`);
db.exec(`
CREATE INDEX IF NOT EXISTS idx_participants_questionnaire ON participants(questionnaire_id);
CREATE INDEX IF NOT EXISTS idx_participants_token ON participants(token);
`);
// Activities table
db.exec(`
CREATE TABLE IF NOT EXISTS activities (
id INTEGER PRIMARY KEY AUTOINCREMENT,
questionnaire_id INTEGER NOT NULL,
name TEXT NOT NULL,
added_by TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (questionnaire_id) REFERENCES questionnaires(id) ON DELETE CASCADE
)
`);
// Migration: Add description column to activities if it doesn't exist
try {
db.exec(`ALTER TABLE activities ADD COLUMN description TEXT`);
} catch (e) {
// Column already exists, ignore
}
// Votes table
db.exec(`
CREATE TABLE IF NOT EXISTS votes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
activity_id INTEGER NOT NULL,
voter_name TEXT NOT NULL,
vote_type INTEGER NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (activity_id) REFERENCES activities(id) ON DELETE CASCADE,
UNIQUE(activity_id, voter_name)
)
`);
// Comments table
db.exec(`
CREATE TABLE IF NOT EXISTS comments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
activity_id INTEGER NOT NULL,
parent_id INTEGER,
author_name TEXT NOT NULL,
content TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (activity_id) REFERENCES activities(id) ON DELETE CASCADE,
FOREIGN KEY (parent_id) REFERENCES comments(id) ON DELETE CASCADE
)
`);
// Create indexes
db.exec(`
CREATE INDEX IF NOT EXISTS idx_questionnaires_uuid ON questionnaires(uuid);
CREATE INDEX IF NOT EXISTS idx_activities_questionnaire ON activities(questionnaire_id);
CREATE INDEX IF NOT EXISTS idx_votes_activity ON votes(activity_id);
CREATE INDEX IF NOT EXISTS idx_comments_activity ON comments(activity_id);
CREATE INDEX IF NOT EXISTS idx_comments_parent ON comments(parent_id);
`);
// Create default admin user if none exists
createDefaultAdmin();
}
function createDefaultAdmin(): void {
const adminUser = process.env.DEFAULT_ADMIN_USER || 'admin';
const adminPass = process.env.DEFAULT_ADMIN_PASS || 'admin123';
const existingUser = db.prepare('SELECT id FROM users WHERE username = ?').get(adminUser);
if (!existingUser) {
const hash = bcrypt.hashSync(adminPass, 10);
db.prepare('INSERT INTO users (username, password_hash) VALUES (?, ?)').run(adminUser, hash);
console.log(`Default admin user '${adminUser}' created.`);
}
}
// User operations
export const userOps = {
findByUsername: (username: string): User | undefined => {
return db.prepare('SELECT * FROM users WHERE username = ?').get(username) as User | undefined;
},
findById: (id: number): Omit<User, 'password_hash'> | undefined => {
return db.prepare('SELECT id, username, created_at FROM users WHERE id = ?').get(id) as Omit<User, 'password_hash'> | undefined;
},
getAll: (): Omit<User, 'password_hash'>[] => {
return db.prepare('SELECT id, username, created_at FROM users ORDER BY created_at DESC').all() as Omit<User, 'password_hash'>[];
},
create: (username: string, password: string): number => {
const hash = bcrypt.hashSync(password, 10);
const result = db.prepare('INSERT INTO users (username, password_hash) VALUES (?, ?)').run(username, hash);
return result.lastInsertRowid as number;
},
updatePassword: (id: number, newPassword: string): void => {
const hash = bcrypt.hashSync(newPassword, 10);
db.prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(hash, id);
},
delete: (id: number): void => {
db.prepare('DELETE FROM users WHERE id = ?').run(id);
},
verifyPassword: (user: User, password: string): boolean => {
return bcrypt.compareSync(password, user.password_hash);
},
count: (): number => {
const result = db.prepare('SELECT COUNT(*) as count FROM users').get() as { count: number };
return result.count;
},
};
// Questionnaire operations
export const questionnaireOps = {
create: (uuid: string, slug: string, title: string, description: string | null, isPrivate: boolean, createdBy: number): number => {
const result = db.prepare(
'INSERT INTO questionnaires (uuid, slug, title, description, is_private, created_by) VALUES (?, ?, ?, ?, ?, ?)'
).run(uuid, slug, title, description, isPrivate ? 1 : 0, createdBy);
return result.lastInsertRowid as number;
},
findBySlug: (slug: string): Questionnaire | undefined => {
return db.prepare('SELECT * FROM questionnaires WHERE slug = ?').get(slug) as Questionnaire | undefined;
},
findByUuid: (uuid: string): Questionnaire | undefined => {
return db.prepare('SELECT * FROM questionnaires WHERE uuid = ?').get(uuid) as Questionnaire | undefined;
},
findById: (id: number): Questionnaire | undefined => {
return db.prepare('SELECT * FROM questionnaires WHERE id = ?').get(id) as Questionnaire | undefined;
},
getAll: (): Questionnaire[] => {
return db.prepare(`
SELECT q.*, u.username as creator_name,
(SELECT COUNT(*) FROM activities WHERE questionnaire_id = q.id) as activity_count
FROM questionnaires q
LEFT JOIN users u ON q.created_by = u.id
ORDER BY q.created_at DESC
`).all() as Questionnaire[];
},
update: (id: number, slug: string, title: string, description: string | null, isPrivate: boolean): void => {
db.prepare('UPDATE questionnaires SET slug = ?, title = ?, description = ?, is_private = ? WHERE id = ?').run(slug, title, description, isPrivate ? 1 : 0, id);
},
delete: (id: number): void => {
db.prepare('DELETE FROM questionnaires WHERE id = ?').run(id);
},
isSlugAvailable: (slug: string, excludeId?: number): boolean => {
if (excludeId) {
const result = db.prepare('SELECT id FROM questionnaires WHERE slug = ? AND id != ?').get(slug, excludeId);
return !result;
}
const result = db.prepare('SELECT id FROM questionnaires WHERE slug = ?').get(slug);
return !result;
},
};
// Participant operations
export const participantOps = {
create: (questionnaireId: number, name: string, token: string): number => {
const result = db.prepare(
'INSERT INTO participants (questionnaire_id, name, token) VALUES (?, ?, ?)'
).run(questionnaireId, name, token);
return result.lastInsertRowid as number;
},
findByToken: (token: string): Participant | undefined => {
return db.prepare('SELECT * FROM participants WHERE token = ?').get(token) as Participant | undefined;
},
findById: (id: number): Participant | undefined => {
return db.prepare('SELECT * FROM participants WHERE id = ?').get(id) as Participant | undefined;
},
getByQuestionnaire: (questionnaireId: number): Participant[] => {
return db.prepare('SELECT * FROM participants WHERE questionnaire_id = ? ORDER BY created_at DESC').all(questionnaireId) as Participant[];
},
delete: (id: number): void => {
db.prepare('DELETE FROM participants WHERE id = ?').run(id);
},
isTokenAvailable: (token: string): boolean => {
const result = db.prepare('SELECT id FROM participants WHERE token = ?').get(token);
return !result;
},
};
// Activity operations
export const activityOps = {
create: (questionnaireId: number, name: string, addedBy: string, description: string | null = null): number => {
const result = db.prepare(
'INSERT INTO activities (questionnaire_id, name, added_by, description) VALUES (?, ?, ?, ?)'
).run(questionnaireId, name, addedBy, description);
return result.lastInsertRowid as number;
},
findById: (id: number): Activity | undefined => {
return db.prepare('SELECT * FROM activities WHERE id = ?').get(id) as Activity | undefined;
},
update: (id: number, name: string, description: string | null): void => {
db.prepare('UPDATE activities SET name = ?, description = ? WHERE id = ?').run(name, description, id);
},
getByQuestionnaire: (questionnaireId: number): Activity[] => {
const activities = db.prepare(`
SELECT a.*,
COALESCE(SUM(CASE WHEN v.vote_type = 1 THEN 1 ELSE 0 END), 0) as upvotes,
COALESCE(SUM(CASE WHEN v.vote_type = -1 THEN 1 ELSE 0 END), 0) as downvotes,
COALESCE(SUM(v.vote_type), 0) as net_votes
FROM activities a
LEFT JOIN votes v ON a.id = v.activity_id
WHERE a.questionnaire_id = ?
GROUP BY a.id
ORDER BY net_votes DESC, a.created_at ASC
`).all(questionnaireId) as Activity[];
// Add comment counts
activities.forEach(activity => {
activity.comment_count = commentOps.countByActivity(activity.id);
});
return activities;
},
delete: (id: number): void => {
db.prepare('DELETE FROM activities WHERE id = ?').run(id);
},
};
// Vote operations
export const voteOps = {
upsert: (activityId: number, voterName: string, voteType: number): { action: string; voteType: number } => {
const existing = db.prepare(
'SELECT id, vote_type FROM votes WHERE activity_id = ? AND voter_name = ?'
).get(activityId, voterName) as { id: number; vote_type: number } | undefined;
if (existing) {
if (existing.vote_type === voteType) {
db.prepare('DELETE FROM votes WHERE id = ?').run(existing.id);
return { action: 'removed', voteType: 0 };
} else {
db.prepare('UPDATE votes SET vote_type = ? WHERE id = ?').run(voteType, existing.id);
return { action: 'changed', voteType };
}
} else {
db.prepare(
'INSERT INTO votes (activity_id, voter_name, vote_type) VALUES (?, ?, ?)'
).run(activityId, voterName, voteType);
return { action: 'added', voteType };
}
},
getByQuestionnaireAndVoter: (questionnaireId: number, voterName: string): { activity_id: number; vote_type: number }[] => {
return db.prepare(`
SELECT v.activity_id, v.vote_type
FROM votes v
JOIN activities a ON v.activity_id = a.id
WHERE a.questionnaire_id = ? AND v.voter_name = ?
`).all(questionnaireId, voterName) as { activity_id: number; vote_type: number }[];
},
};
// Comment operations
export const commentOps = {
create: (activityId: number, authorName: string, content: string, parentId: number | null = null): number => {
const result = db.prepare(
'INSERT INTO comments (activity_id, author_name, content, parent_id) VALUES (?, ?, ?, ?)'
).run(activityId, authorName, content, parentId);
return result.lastInsertRowid as number;
},
getByActivity: (activityId: number): Comment[] => {
return db.prepare(`
SELECT * FROM comments
WHERE activity_id = ?
ORDER BY created_at ASC
`).all(activityId) as Comment[];
},
getById: (id: number): Comment | undefined => {
return db.prepare('SELECT * FROM comments WHERE id = ?').get(id) as Comment | undefined;
},
countByActivity: (activityId: number): number => {
const result = db.prepare('SELECT COUNT(*) as count FROM comments WHERE activity_id = ?').get(activityId) as { count: number };
return result.count;
},
delete: (id: number): void => {
db.prepare('DELETE FROM comments WHERE id = ?').run(id);
},
};

75
server/index.ts Normal file
View File

@@ -0,0 +1,75 @@
import express from 'express';
import session from 'express-session';
import cookieParser from 'cookie-parser';
import cors from 'cors';
import path from 'path';
import { fileURLToPath } from 'url';
import { initializeDatabase } from './database.js';
import authRoutes from './routes/auth.js';
import adminRoutes from './routes/admin.js';
import questionnaireRoutes from './routes/questionnaire.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Initialize database
initializeDatabase();
const app = express();
const PORT = process.env.PORT || 4000;
const isProduction = process.env.NODE_ENV === 'production';
// Middleware
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(cookieParser());
// CORS for development
if (!isProduction) {
app.use(cors({
origin: 'http://localhost:5173',
credentials: true,
}));
}
// Session configuration
declare module 'express-session' {
interface SessionData {
user?: { id: number; username: string };
returnTo?: string;
}
}
app.use(session({
secret: process.env.SESSION_SECRET || 'dev-secret-change-in-production',
resave: false,
saveUninitialized: false,
cookie: {
secure: isProduction && process.env.HTTPS === 'true',
httpOnly: true,
maxAge: 24 * 60 * 60 * 1000, // 24 hours
sameSite: isProduction ? 'strict' : 'lax',
},
}));
// API Routes
app.use('/api/auth', authRoutes);
app.use('/api/admin', adminRoutes);
app.use('/api/q', questionnaireRoutes);
// Serve static files in production
if (isProduction) {
const clientPath = path.join(__dirname, '../client');
app.use(express.static(clientPath));
// SPA fallback
app.get('*', (req, res) => {
res.sendFile(path.join(clientPath, 'index.html'));
});
}
app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
});

10
server/middleware/auth.ts Normal file
View File

@@ -0,0 +1,10 @@
import { Request, Response, NextFunction } from 'express';
export function requireAuth(req: Request, res: Response, next: NextFunction): void {
if (req.session && req.session.user) {
next();
} else {
res.status(401).json({ error: 'Authenticatie vereist' });
}
}

283
server/routes/admin.ts Normal file
View File

@@ -0,0 +1,283 @@
import { Router, Request, Response } from 'express';
import { v4 as uuidv4 } from 'uuid';
import crypto from 'crypto';
import { userOps, questionnaireOps, activityOps, participantOps } from '../database.js';
import { requireAuth } from '../middleware/auth.js';
const router = Router();
// Apply auth middleware to all admin routes
router.use(requireAuth);
// Get all questionnaires
router.get('/questionnaires', (req: Request, res: Response) => {
const questionnaires = questionnaireOps.getAll();
res.json(questionnaires);
});
// Validate slug format
function isValidSlug(slug: string): boolean {
return /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug);
}
// Create questionnaire
router.post('/questionnaires', (req: Request, res: Response) => {
const { title, description, slug, isPrivate } = req.body;
if (!title?.trim()) {
res.status(400).json({ error: 'Titel is verplicht' });
return;
}
if (!slug?.trim()) {
res.status(400).json({ error: 'Slug is verplicht' });
return;
}
const cleanSlug = slug.trim().toLowerCase();
if (!isValidSlug(cleanSlug)) {
res.status(400).json({ error: 'Slug mag alleen kleine letters, cijfers en koppeltekens bevatten' });
return;
}
if (cleanSlug.length < 3 || cleanSlug.length > 50) {
res.status(400).json({ error: 'Slug moet tussen 3 en 50 tekens zijn' });
return;
}
if (!questionnaireOps.isSlugAvailable(cleanSlug)) {
res.status(400).json({ error: 'Deze slug is al in gebruik' });
return;
}
const uuid = uuidv4();
const id = questionnaireOps.create(uuid, cleanSlug, title.trim(), description?.trim() || null, !!isPrivate, req.session.user!.id);
const questionnaire = questionnaireOps.findById(id);
res.json({ success: true, questionnaire });
});
// Get questionnaire by ID
router.get('/questionnaires/:id', (req: Request, res: Response) => {
const questionnaire = questionnaireOps.findById(parseInt(req.params.id));
if (!questionnaire) {
res.status(404).json({ error: 'Vragenlijst niet gevonden' });
return;
}
const activities = activityOps.getByQuestionnaire(questionnaire.id);
res.json({ questionnaire, activities });
});
// Update questionnaire
router.put('/questionnaires/:id', (req: Request, res: Response) => {
const { title, description, slug, isPrivate } = req.body;
const questionnaire = questionnaireOps.findById(parseInt(req.params.id));
if (!questionnaire) {
res.status(404).json({ error: 'Vragenlijst niet gevonden' });
return;
}
if (!title?.trim()) {
res.status(400).json({ error: 'Titel is verplicht' });
return;
}
if (!slug?.trim()) {
res.status(400).json({ error: 'Slug is verplicht' });
return;
}
const cleanSlug = slug.trim().toLowerCase();
if (!isValidSlug(cleanSlug)) {
res.status(400).json({ error: 'Slug mag alleen kleine letters, cijfers en koppeltekens bevatten' });
return;
}
if (cleanSlug.length < 3 || cleanSlug.length > 50) {
res.status(400).json({ error: 'Slug moet tussen 3 en 50 tekens zijn' });
return;
}
if (!questionnaireOps.isSlugAvailable(cleanSlug, questionnaire.id)) {
res.status(400).json({ error: 'Deze slug is al in gebruik' });
return;
}
questionnaireOps.update(questionnaire.id, cleanSlug, title.trim(), description?.trim() || null, !!isPrivate);
res.json({ success: true });
});
// Check slug availability
router.get('/questionnaires/check-slug/:slug', (req: Request, res: Response) => {
const { slug } = req.params;
const { excludeId } = req.query;
const cleanSlug = slug.trim().toLowerCase();
const available = questionnaireOps.isSlugAvailable(cleanSlug, excludeId ? parseInt(excludeId as string) : undefined);
res.json({ available });
});
// Get participants for a questionnaire
router.get('/questionnaires/:id/participants', (req: Request, res: Response) => {
const questionnaire = questionnaireOps.findById(parseInt(req.params.id));
if (!questionnaire) {
res.status(404).json({ error: 'Vragenlijst niet gevonden' });
return;
}
const participants = participantOps.getByQuestionnaire(questionnaire.id);
res.json(participants);
});
// Generate unique token
function generateToken(): string {
return crypto.randomBytes(16).toString('hex');
}
// Add participant to questionnaire
router.post('/questionnaires/:id/participants', (req: Request, res: Response) => {
const { name } = req.body;
const questionnaire = questionnaireOps.findById(parseInt(req.params.id));
if (!questionnaire) {
res.status(404).json({ error: 'Vragenlijst niet gevonden' });
return;
}
if (!name?.trim()) {
res.status(400).json({ error: 'Naam is verplicht' });
return;
}
// Generate unique token
let token = generateToken();
while (!participantOps.isTokenAvailable(token)) {
token = generateToken();
}
const id = participantOps.create(questionnaire.id, name.trim(), token);
const participant = participantOps.findById(id);
res.json({ success: true, participant });
});
// Delete participant
router.delete('/participants/:id', (req: Request, res: Response) => {
participantOps.delete(parseInt(req.params.id));
res.json({ success: true });
});
// Delete questionnaire
router.delete('/questionnaires/:id', (req: Request, res: Response) => {
questionnaireOps.delete(parseInt(req.params.id));
res.json({ success: true });
});
// Update activity
router.put('/activities/:id', (req: Request, res: Response) => {
const { name, description } = req.body;
const activity = activityOps.findById(parseInt(req.params.id));
if (!activity) {
res.status(404).json({ error: 'Activiteit niet gevonden' });
return;
}
if (!name?.trim()) {
res.status(400).json({ error: 'Naam activiteit is verplicht' });
return;
}
activityOps.update(activity.id, name.trim(), description?.trim() || null);
const updatedActivity = activityOps.findById(activity.id);
res.json({ success: true, activity: updatedActivity });
});
// Delete activity
router.delete('/activities/:id', (req: Request, res: Response) => {
activityOps.delete(parseInt(req.params.id));
res.json({ success: true });
});
// Get all users
router.get('/users', (req: Request, res: Response) => {
const users = userOps.getAll();
res.json(users);
});
// Create user
router.post('/users', (req: Request, res: Response) => {
const { username, password } = req.body;
if (!username || !password) {
res.status(400).json({ error: 'Gebruikersnaam en wachtwoord zijn verplicht' });
return;
}
if (password.length < 6) {
res.status(400).json({ error: 'Wachtwoord moet minimaal 6 tekens zijn' });
return;
}
const existingUser = userOps.findByUsername(username);
if (existingUser) {
res.status(400).json({ error: 'Gebruikersnaam bestaat al' });
return;
}
const id = userOps.create(username, password);
const user = userOps.findById(id);
res.json({ success: true, user });
});
// Delete user
router.delete('/users/:id', (req: Request, res: Response) => {
const userId = parseInt(req.params.id);
if (userId === req.session.user!.id) {
res.status(400).json({ error: 'Je kunt jezelf niet verwijderen' });
return;
}
if (userOps.count() <= 1) {
res.status(400).json({ error: 'Je kunt de laatste beheerder niet verwijderen' });
return;
}
userOps.delete(userId);
res.json({ success: true });
});
// Change password
router.post('/change-password', (req: Request, res: Response) => {
const { currentPassword, newPassword } = req.body;
const user = userOps.findByUsername(req.session.user!.username);
if (!user) {
res.status(404).json({ error: 'Gebruiker niet gevonden' });
return;
}
if (!userOps.verifyPassword(user, currentPassword)) {
res.status(400).json({ error: 'Huidig wachtwoord is onjuist' });
return;
}
if (newPassword.length < 6) {
res.status(400).json({ error: 'Wachtwoord moet minimaal 6 tekens zijn' });
return;
}
userOps.updatePassword(req.session.user!.id, newPassword);
res.json({ success: true });
});
export default router;

50
server/routes/auth.ts Normal file
View File

@@ -0,0 +1,50 @@
import { Router, Request, Response } from 'express';
import { userOps } from '../database.js';
const router = Router();
// Check auth status
router.get('/status', (req: Request, res: Response) => {
if (req.session.user) {
res.json({ authenticated: true, user: req.session.user });
} else {
res.json({ authenticated: false });
}
});
// Login
router.post('/login', (req: Request, res: Response) => {
const { username, password } = req.body;
if (!username || !password) {
res.status(400).json({ error: 'Gebruikersnaam en wachtwoord zijn verplicht' });
return;
}
const user = userOps.findByUsername(username);
if (!user || !userOps.verifyPassword(user, password)) {
res.status(401).json({ error: 'Ongeldige gebruikersnaam of wachtwoord' });
return;
}
req.session.user = {
id: user.id,
username: user.username,
};
res.json({ success: true, user: req.session.user });
});
// Logout
router.post('/logout', (req: Request, res: Response) => {
req.session.destroy((err) => {
if (err) {
console.error('Session destroy error:', err);
}
res.json({ success: true });
});
});
export default router;

View File

@@ -0,0 +1,366 @@
import { Router, Request, Response } from 'express';
import { questionnaireOps, activityOps, voteOps, commentOps, participantOps, Comment, Questionnaire } from '../database.js';
// Helper to check if user can participate (write access)
function canParticipate(questionnaire: Questionnaire, req: Request): { canWrite: boolean; participantName: string | null } {
// Check for participant token first
const token = req.cookies.participantToken;
if (token) {
const participant = participantOps.findByToken(token);
if (participant && participant.questionnaire_id === questionnaire.id) {
return { canWrite: true, participantName: participant.name };
}
}
// For public questionnaires, use visitor name
if (!questionnaire.is_private) {
const visitorName = req.cookies.visitorName;
return { canWrite: !!visitorName, participantName: visitorName || null };
}
// Private questionnaire without valid token
return { canWrite: false, participantName: null };
}
const router = Router();
// Access questionnaire via participant token
router.get('/token/:token', (req: Request, res: Response) => {
const participant = participantOps.findByToken(req.params.token);
if (!participant) {
res.status(404).json({ error: 'Ongeldige toegangslink' });
return;
}
const questionnaire = questionnaireOps.findById(participant.questionnaire_id);
if (!questionnaire) {
res.status(404).json({ error: 'Vragenlijst niet gevonden' });
return;
}
// Set participant token cookie
res.cookie('participantToken', participant.token, {
maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days
httpOnly: true,
sameSite: 'lax',
});
const activities = activityOps.getByQuestionnaire(questionnaire.id);
let userVotes: Record<number, number> = {};
const votes = voteOps.getByQuestionnaireAndVoter(questionnaire.id, participant.name);
votes.forEach(v => {
userVotes[v.activity_id] = v.vote_type;
});
res.json({
questionnaire,
activities,
visitorName: participant.name,
userVotes,
isParticipant: true,
canWrite: true,
});
});
// Get questionnaire by slug (public - may be read-only for private questionnaires)
router.get('/:slug', (req: Request, res: Response) => {
const questionnaire = questionnaireOps.findBySlug(req.params.slug);
if (!questionnaire) {
res.status(404).json({ error: 'Vragenlijst niet gevonden' });
return;
}
const activities = activityOps.getByQuestionnaire(questionnaire.id);
const { canWrite, participantName } = canParticipate(questionnaire, req);
let userVotes: Record<number, number> = {};
if (participantName) {
const votes = voteOps.getByQuestionnaireAndVoter(questionnaire.id, participantName);
votes.forEach(v => {
userVotes[v.activity_id] = v.vote_type;
});
}
res.json({
questionnaire,
activities,
visitorName: participantName || '',
userVotes,
isPrivate: !!questionnaire.is_private,
canWrite,
});
});
// Set visitor name (only for public questionnaires)
router.post('/:slug/set-name', (req: Request, res: Response) => {
const { name } = req.body;
const questionnaire = questionnaireOps.findBySlug(req.params.slug);
if (!questionnaire) {
res.status(404).json({ error: 'Vragenlijst niet gevonden' });
return;
}
if (questionnaire.is_private) {
res.status(403).json({ error: 'Voor deze vragenlijst is een uitnodigingslink nodig' });
return;
}
if (!name?.trim()) {
res.status(400).json({ error: 'Naam is verplicht' });
return;
}
res.cookie('visitorName', name.trim(), {
maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days
httpOnly: true,
sameSite: 'lax',
});
res.json({ success: true, name: name.trim() });
});
// Clear visitor name
router.post('/:slug/clear-name', (req: Request, res: Response) => {
res.clearCookie('visitorName');
res.clearCookie('participantToken');
res.json({ success: true });
});
// Add activity
router.post('/:slug/activities', (req: Request, res: Response) => {
const { name, description } = req.body;
const questionnaire = questionnaireOps.findBySlug(req.params.slug);
if (!questionnaire) {
res.status(404).json({ error: 'Vragenlijst niet gevonden' });
return;
}
const { canWrite, participantName } = canParticipate(questionnaire, req);
if (!canWrite || !participantName) {
if (questionnaire.is_private) {
res.status(403).json({ error: 'Je hebt een uitnodigingslink nodig om deel te nemen' });
} else {
res.status(401).json({ error: 'Voer eerst je naam in' });
}
return;
}
if (!name?.trim()) {
res.status(400).json({ error: 'Naam activiteit is verplicht' });
return;
}
const activityDescription = description?.trim() || null;
const activityId = activityOps.create(questionnaire.id, name.trim(), participantName, activityDescription);
const activities = activityOps.getByQuestionnaire(questionnaire.id);
const activity = activities.find(a => a.id === activityId);
res.json({ success: true, activity });
});
// Update activity (only by the user who added it)
router.put('/:slug/activities/:activityId', (req: Request, res: Response) => {
const { name, description } = req.body;
const questionnaire = questionnaireOps.findBySlug(req.params.slug);
if (!questionnaire) {
res.status(404).json({ error: 'Vragenlijst niet gevonden' });
return;
}
const { canWrite, participantName } = canParticipate(questionnaire, req);
if (!canWrite || !participantName) {
if (questionnaire.is_private) {
res.status(403).json({ error: 'Je hebt een uitnodigingslink nodig om te bewerken' });
} else {
res.status(401).json({ error: 'Voer eerst je naam in' });
}
return;
}
const activity = activityOps.findById(parseInt(req.params.activityId));
if (!activity || activity.questionnaire_id !== questionnaire.id) {
res.status(404).json({ error: 'Activiteit niet gevonden' });
return;
}
// Check if the user is the one who added the activity
if (activity.added_by !== participantName) {
res.status(403).json({ error: 'Je kunt alleen je eigen activiteiten bewerken' });
return;
}
if (!name?.trim()) {
res.status(400).json({ error: 'Naam activiteit is verplicht' });
return;
}
activityOps.update(activity.id, name.trim(), description?.trim() || null);
const activities = activityOps.getByQuestionnaire(questionnaire.id);
const updatedActivity = activities.find(a => a.id === activity.id);
res.json({ success: true, activity: updatedActivity });
});
// Vote on activity
router.post('/:slug/activities/:activityId/vote', (req: Request, res: Response) => {
const { voteType } = req.body;
const questionnaire = questionnaireOps.findBySlug(req.params.slug);
if (!questionnaire) {
res.status(404).json({ error: 'Vragenlijst niet gevonden' });
return;
}
const { canWrite, participantName } = canParticipate(questionnaire, req);
if (!canWrite || !participantName) {
if (questionnaire.is_private) {
res.status(403).json({ error: 'Je hebt een uitnodigingslink nodig om te stemmen' });
} else {
res.status(401).json({ error: 'Voer eerst je naam in' });
}
return;
}
const activity = activityOps.findById(parseInt(req.params.activityId));
if (!activity || activity.questionnaire_id !== questionnaire.id) {
res.status(404).json({ error: 'Activiteit niet gevonden' });
return;
}
if (voteType !== 1 && voteType !== -1) {
res.status(400).json({ error: 'Ongeldig stemtype' });
return;
}
const result = voteOps.upsert(activity.id, participantName, voteType);
const activities = activityOps.getByQuestionnaire(questionnaire.id);
const updatedActivity = activities.find(a => a.id === activity.id);
res.json({
success: true,
action: result.action,
currentVote: result.voteType,
activity: updatedActivity,
});
});
// Get activities
router.get('/:slug/activities', (req: Request, res: Response) => {
const questionnaire = questionnaireOps.findBySlug(req.params.slug);
if (!questionnaire) {
res.status(404).json({ error: 'Vragenlijst niet gevonden' });
return;
}
const activities = activityOps.getByQuestionnaire(questionnaire.id);
const visitorName = req.cookies.visitorName || '';
let userVotes: Record<number, number> = {};
if (visitorName) {
const votes = voteOps.getByQuestionnaireAndVoter(questionnaire.id, visitorName);
votes.forEach(v => {
userVotes[v.activity_id] = v.vote_type;
});
}
res.json({ activities, userVotes });
});
// Get comments for an activity
router.get('/:slug/activities/:activityId/comments', (req: Request, res: Response) => {
const questionnaire = questionnaireOps.findBySlug(req.params.slug);
if (!questionnaire) {
res.status(404).json({ error: 'Vragenlijst niet gevonden' });
return;
}
const activity = activityOps.findById(parseInt(req.params.activityId));
if (!activity || activity.questionnaire_id !== questionnaire.id) {
res.status(404).json({ error: 'Activiteit niet gevonden' });
return;
}
const comments = commentOps.getByActivity(activity.id);
// Build nested comment tree
const commentMap: Record<number, Comment> = {};
const rootComments: Comment[] = [];
comments.forEach(comment => {
comment.replies = [];
commentMap[comment.id] = comment;
});
comments.forEach(comment => {
if (comment.parent_id) {
if (commentMap[comment.parent_id]) {
commentMap[comment.parent_id].replies!.push(comment);
}
} else {
rootComments.push(comment);
}
});
res.json({ comments: rootComments, activity });
});
// Add a comment
router.post('/:slug/activities/:activityId/comments', (req: Request, res: Response) => {
const { content, parentId } = req.body;
const questionnaire = questionnaireOps.findBySlug(req.params.slug);
if (!questionnaire) {
res.status(404).json({ error: 'Vragenlijst niet gevonden' });
return;
}
const { canWrite, participantName } = canParticipate(questionnaire, req);
if (!canWrite || !participantName) {
if (questionnaire.is_private) {
res.status(403).json({ error: 'Je hebt een uitnodigingslink nodig om te reageren' });
} else {
res.status(401).json({ error: 'Voer eerst je naam in' });
}
return;
}
const activity = activityOps.findById(parseInt(req.params.activityId));
if (!activity || activity.questionnaire_id !== questionnaire.id) {
res.status(404).json({ error: 'Activiteit niet gevonden' });
return;
}
if (!content?.trim()) {
res.status(400).json({ error: 'Reactie inhoud is verplicht' });
return;
}
if (parentId) {
const parentComment = commentOps.getById(parentId);
if (!parentComment || parentComment.activity_id !== activity.id) {
res.status(400).json({ error: 'Ongeldige reactie om op te reageren' });
return;
}
}
const commentId = commentOps.create(activity.id, participantName, content.trim(), parentId || null);
const comment = commentOps.getById(commentId);
res.json({ success: true, comment: { ...comment, replies: [] } });
});
export default router;

45
src/App.tsx Normal file
View File

@@ -0,0 +1,45 @@
import { Routes, Route, Navigate } from 'react-router-dom'
import { AuthProvider } from './context/AuthContext'
import { ProtectedRoute } from './components/ProtectedRoute'
import { AdminLayout } from './components/AdminLayout'
import { Login } from './pages/Login'
import { Dashboard } from './pages/Dashboard'
import { QuestionnaireForm } from './pages/QuestionnaireForm'
import { QuestionnaireDetail } from './pages/QuestionnaireDetail'
import { Users } from './pages/Users'
import { ChangePassword } from './pages/ChangePassword'
import { PublicQuestionnaire } from './pages/PublicQuestionnaire'
import { NotFound } from './pages/NotFound'
function App() {
return (
<AuthProvider>
<Routes>
{/* Public routes */}
<Route path="/login" element={<Login />} />
<Route path="/q/:slug" element={<PublicQuestionnaire />} />
<Route path="/q/access/:token" element={<PublicQuestionnaire />} />
{/* Protected admin routes */}
<Route path="/admin" element={<ProtectedRoute><AdminLayout /></ProtectedRoute>}>
<Route index element={<Navigate to="/admin/dashboard" replace />} />
<Route path="dashboard" element={<Dashboard />} />
<Route path="questionnaires/new" element={<QuestionnaireForm />} />
<Route path="questionnaires/:id" element={<QuestionnaireDetail />} />
<Route path="questionnaires/:id/edit" element={<QuestionnaireForm />} />
<Route path="users" element={<Users />} />
<Route path="change-password" element={<ChangePassword />} />
</Route>
{/* Redirect root to login */}
<Route path="/" element={<Navigate to="/login" replace />} />
{/* 404 */}
<Route path="*" element={<NotFound />} />
</Routes>
</AuthProvider>
)
}
export default App

View File

@@ -0,0 +1,62 @@
import { Link, Outlet, useNavigate } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
export function AdminLayout() {
const { user, logout } = useAuth()
const navigate = useNavigate()
async function handleLogout() {
await logout()
navigate('/login')
}
return (
<div className="min-h-screen flex flex-col">
<nav className="bg-bg-elevated border-b border-border sticky top-0 z-50 backdrop-blur-md">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between h-14 items-center">
<Link to="/admin/dashboard" className="text-lg font-bold text-text hover:text-accent transition-colors">
Activiteiten Inventaris
</Link>
<div className="flex items-center gap-2">
<Link
to="/admin/dashboard"
className="px-3 py-1.5 text-sm text-text-muted hover:text-text hover:bg-bg-card rounded-md transition-colors"
>
Dashboard
</Link>
<Link
to="/admin/users"
className="px-3 py-1.5 text-sm text-text-muted hover:text-text hover:bg-bg-card rounded-md transition-colors"
>
Gebruikers
</Link>
<Link
to="/admin/change-password"
className="px-3 py-1.5 text-sm text-text-muted hover:text-text hover:bg-bg-card rounded-md transition-colors"
>
Wachtwoord
</Link>
<button
onClick={handleLogout}
className="px-3 py-1.5 text-sm text-danger hover:bg-danger-muted rounded-md transition-colors"
>
Uitloggen
</button>
</div>
</div>
</div>
</nav>
<main className="flex-1 max-w-7xl mx-auto w-full px-4 sm:px-6 lg:px-8 py-8">
<Outlet />
</main>
<footer className="border-t border-border-light py-6 text-center text-text-faint text-sm">
Activiteiten Inventaris Systeem
</footer>
</div>
)
}

View File

@@ -0,0 +1,22 @@
import { Navigate } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
import { ReactNode } from 'react'
export function ProtectedRoute({ children }: { children: ReactNode }) {
const { user, loading } = useAuth()
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center bg-bg">
<div className="text-text-muted">Loading...</div>
</div>
)
}
if (!user) {
return <Navigate to="/login" replace />
}
return <>{children}</>
}

View File

@@ -0,0 +1,78 @@
import { createContext, useContext, useState, useEffect, ReactNode } from 'react'
interface User {
id: number
username: string
}
interface AuthContextType {
user: User | null
loading: boolean
login: (username: string, password: string) => Promise<void>
logout: () => Promise<void>
}
const AuthContext = createContext<AuthContextType | null>(null)
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
checkAuth()
}, [])
async function checkAuth() {
try {
const res = await fetch('/api/auth/status', { credentials: 'include' })
const data = await res.json()
if (data.authenticated) {
setUser(data.user)
}
} catch (error) {
console.error('Auth check failed:', error)
} finally {
setLoading(false)
}
}
async function login(username: string, password: string) {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ username, password }),
})
const data = await res.json()
if (!res.ok) {
throw new Error(data.error || 'Inloggen mislukt')
}
setUser(data.user)
}
async function logout() {
await fetch('/api/auth/logout', {
method: 'POST',
credentials: 'include',
})
setUser(null)
}
return (
<AuthContext.Provider value={{ user, loading, login, logout }}>
{children}
</AuthContext.Provider>
)
}
export function useAuth() {
const context = useContext(AuthContext)
if (!context) {
throw new Error('useAuth must be used within an AuthProvider')
}
return context
}

26
src/index.css Normal file
View File

@@ -0,0 +1,26 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
@apply bg-bg text-text font-sans antialiased min-h-screen;
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
@apply bg-bg-elevated;
}
::-webkit-scrollbar-thumb {
@apply bg-border rounded;
}
::-webkit-scrollbar-thumb:hover {
@apply bg-text-faint;
}

14
src/main.tsx Normal file
View File

@@ -0,0 +1,14 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import App from './App'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>,
)

View File

@@ -0,0 +1,125 @@
import { useState, FormEvent } from 'react'
import { Link, useNavigate } from 'react-router-dom'
export function ChangePassword() {
const navigate = useNavigate()
const [currentPassword, setCurrentPassword] = useState('')
const [newPassword, setNewPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
async function handleSubmit(e: FormEvent) {
e.preventDefault()
setError('')
if (newPassword !== confirmPassword) {
setError('Nieuwe wachtwoorden komen niet overeen')
return
}
setLoading(true)
try {
const res = await fetch('/api/admin/change-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ currentPassword, newPassword }),
})
const data = await res.json()
if (!res.ok) {
throw new Error(data.error || 'Wachtwoord wijzigen mislukt')
}
navigate('/admin/dashboard')
} catch (err) {
setError(err instanceof Error ? err.message : 'Wachtwoord wijzigen mislukt')
} finally {
setLoading(false)
}
}
return (
<div className="max-w-md">
<Link to="/admin/dashboard" className="inline-block text-text-muted hover:text-accent text-sm mb-4 transition-colors">
Terug naar Dashboard
</Link>
<h1 className="text-2xl font-bold text-text mb-8">Wachtwoord Wijzigen</h1>
<div className="bg-bg-card border border-border rounded-xl p-6">
{error && (
<div className="mb-6 p-4 bg-danger-muted border border-danger/30 rounded-lg text-danger text-sm">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label htmlFor="currentPassword" className="block text-sm font-medium text-text mb-2">
Huidig Wachtwoord
</label>
<input
type="password"
id="currentPassword"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
className="w-full px-4 py-3 bg-bg-input border border-border rounded-lg text-text placeholder-text-faint focus:outline-none focus:border-accent focus:ring-2 focus:ring-accent/20 transition-colors"
required
/>
</div>
<div>
<label htmlFor="newPassword" className="block text-sm font-medium text-text mb-2">
Nieuw Wachtwoord
</label>
<input
type="password"
id="newPassword"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
className="w-full px-4 py-3 bg-bg-input border border-border rounded-lg text-text placeholder-text-faint focus:outline-none focus:border-accent focus:ring-2 focus:ring-accent/20 transition-colors"
placeholder="Min. 6 tekens"
minLength={6}
required
/>
</div>
<div>
<label htmlFor="confirmPassword" className="block text-sm font-medium text-text mb-2">
Bevestig Nieuw Wachtwoord
</label>
<input
type="password"
id="confirmPassword"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="w-full px-4 py-3 bg-bg-input border border-border rounded-lg text-text placeholder-text-faint focus:outline-none focus:border-accent focus:ring-2 focus:ring-accent/20 transition-colors"
required
/>
</div>
<div className="flex gap-3 justify-end pt-4">
<Link
to="/admin/dashboard"
className="px-4 py-2 border border-border rounded-lg text-text-muted hover:text-text hover:border-text-muted transition-colors"
>
Annuleren
</Link>
<button
type="submit"
disabled={loading}
className="px-4 py-2 bg-accent hover:bg-accent-hover text-white font-semibold rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? 'Bijwerken...' : 'Wachtwoord Bijwerken'}
</button>
</div>
</form>
</div>
</div>
)
}

126
src/pages/Dashboard.tsx Normal file
View File

@@ -0,0 +1,126 @@
import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
interface Questionnaire {
id: number
uuid: string
slug: string
title: string
description: string | null
is_private: boolean
creator_name: string
created_at: string
activity_count: number
}
export function Dashboard() {
const [questionnaires, setQuestionnaires] = useState<Questionnaire[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
fetchQuestionnaires()
}, [])
async function fetchQuestionnaires() {
try {
const res = await fetch('/api/admin/questionnaires', { credentials: 'include' })
const data = await res.json()
setQuestionnaires(data)
} catch (error) {
console.error('Failed to fetch questionnaires:', error)
} finally {
setLoading(false)
}
}
function formatDate(dateStr: string) {
return new Date(dateStr).toLocaleDateString('nl-NL', {
year: 'numeric',
month: 'short',
day: 'numeric',
})
}
if (loading) {
return <div className="text-text-muted">Laden...</div>
}
return (
<div>
<div className="flex justify-between items-center mb-8">
<h1 className="text-2xl font-bold text-text">Vragenlijsten</h1>
<Link
to="/admin/questionnaires/new"
className="inline-flex items-center gap-2 px-4 py-2 bg-accent hover:bg-accent-hover text-white font-semibold rounded-lg transition-all hover:-translate-y-0.5 hover:shadow-lg"
>
<span className="text-xl leading-none">+</span>
Nieuwe Vragenlijst
</Link>
</div>
{questionnaires.length > 0 ? (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{questionnaires.map((q) => (
<div
key={q.id}
className="bg-bg-card border border-border rounded-xl p-5 hover:border-accent transition-all hover:-translate-y-0.5 hover:shadow-lg"
>
<div className="flex justify-between items-start gap-3 mb-3">
<h2 className="font-semibold text-text line-clamp-2">{q.title}</h2>
<div className="flex gap-1.5 shrink-0">
{q.is_private && (
<span className="px-2 py-0.5 bg-accent/20 text-accent text-xs font-semibold rounded">Privé</span>
)}
<span className="px-2 py-0.5 bg-bg-input rounded text-xs font-semibold text-text-muted">
{q.activity_count} activiteiten
</span>
</div>
</div>
<div className="mb-3">
<span className="text-xs font-mono text-accent bg-accent/10 px-2 py-0.5 rounded">/q/{q.slug}</span>
</div>
{q.description && (
<p className="text-sm text-text-muted mb-3 line-clamp-2">{q.description}</p>
)}
<div className="flex gap-4 text-xs text-text-faint mb-4">
<span>door {q.creator_name}</span>
<span>{formatDate(q.created_at)}</span>
</div>
<div className="flex gap-2">
<Link
to={`/admin/questionnaires/${q.id}`}
className="px-3 py-1.5 bg-bg-input border border-border rounded-md text-sm font-medium text-text hover:bg-bg-elevated transition-colors"
>
Bekijken
</Link>
<Link
to={`/admin/questionnaires/${q.id}/edit`}
className="px-3 py-1.5 border border-border rounded-md text-sm font-medium text-text-muted hover:text-text hover:border-text-muted transition-colors"
>
Bewerken
</Link>
</div>
</div>
))}
</div>
) : (
<div className="text-center py-16 bg-bg-card border border-dashed border-border rounded-xl">
<div className="text-4xl mb-4 opacity-50">📋</div>
<h2 className="text-lg font-semibold text-text mb-2">Nog geen vragenlijsten</h2>
<p className="text-text-muted mb-6">Maak je eerste vragenlijst om activiteiten en stemmen te verzamelen.</p>
<Link
to="/admin/questionnaires/new"
className="inline-flex items-center gap-2 px-4 py-2 bg-accent hover:bg-accent-hover text-white font-semibold rounded-lg transition-colors"
>
Vragenlijst Maken
</Link>
</div>
)}
</div>
)
}

94
src/pages/Login.tsx Normal file
View File

@@ -0,0 +1,94 @@
import { useState, FormEvent } from 'react'
import { useNavigate } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
export function Login() {
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const { login, user } = useAuth()
const navigate = useNavigate()
// Redirect if already logged in
if (user) {
navigate('/admin/dashboard', { replace: true })
return null
}
async function handleSubmit(e: FormEvent) {
e.preventDefault()
setError('')
setLoading(true)
try {
await login(username, password)
navigate('/admin/dashboard')
} catch (err) {
setError(err instanceof Error ? err.message : 'Login failed')
} finally {
setLoading(false)
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-bg p-4">
<div className="w-full max-w-md">
<div className="bg-bg-card border border-border rounded-2xl p-8 shadow-xl">
<div className="text-center mb-8">
<h1 className="text-2xl font-bold text-text mb-2">Beheerder Login</h1>
<p className="text-text-muted">Log in om vragenlijsten te beheren</p>
</div>
{error && (
<div className="mb-6 p-4 bg-danger-muted border border-danger/30 rounded-lg text-danger text-sm">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label htmlFor="username" className="block text-sm font-medium text-text mb-2">
Gebruikersnaam
</label>
<input
type="text"
id="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="w-full px-4 py-3 bg-bg-input border border-border rounded-lg text-text placeholder-text-faint focus:outline-none focus:border-accent focus:ring-2 focus:ring-accent/20 transition-colors"
placeholder="Voer je gebruikersnaam in"
required
autoFocus
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-text mb-2">
Wachtwoord
</label>
<input
type="password"
id="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-4 py-3 bg-bg-input border border-border rounded-lg text-text placeholder-text-faint focus:outline-none focus:border-accent focus:ring-2 focus:ring-accent/20 transition-colors"
placeholder="Voer je wachtwoord in"
required
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full py-3 bg-accent hover:bg-accent-hover text-white font-semibold rounded-lg transition-all hover:-translate-y-0.5 hover:shadow-lg disabled:opacity-50 disabled:cursor-not-allowed disabled:transform-none"
>
{loading ? 'Inloggen...' : 'Inloggen'}
</button>
</form>
</div>
</div>
</div>
)
}

19
src/pages/NotFound.tsx Normal file
View File

@@ -0,0 +1,19 @@
import { Link } from 'react-router-dom'
export function NotFound() {
return (
<div className="min-h-screen flex items-center justify-center bg-bg p-4">
<div className="text-center">
<h1 className="text-4xl font-bold text-danger mb-4">Pagina Niet Gevonden</h1>
<p className="text-text-muted mb-8">De pagina die je zoekt bestaat niet.</p>
<Link
to="/"
className="inline-block px-6 py-3 bg-accent hover:bg-accent-hover text-white font-semibold rounded-lg transition-colors"
>
Naar Home
</Link>
</div>
</div>
)
}

View File

@@ -0,0 +1,673 @@
import { useState, useEffect, FormEvent } from 'react'
import { useParams } from 'react-router-dom'
interface Activity {
id: number
name: string
description: string | null
added_by: string
upvotes: number
downvotes: number
net_votes: number
comment_count: number
}
interface Comment {
id: number
author_name: string
content: string
created_at: string
replies: Comment[]
}
interface Questionnaire {
id: number
uuid: string
slug: string
title: string
description: string | null
is_private: boolean
}
export function PublicQuestionnaire({ accessToken }: { accessToken?: string } = {}) {
const { slug, token } = useParams()
const effectiveToken = accessToken || token
const [questionnaire, setQuestionnaire] = useState<Questionnaire | null>(null)
const [activities, setActivities] = useState<Activity[]>([])
const [userVotes, setUserVotes] = useState<Record<number, number>>({})
const [visitorName, setVisitorName] = useState('')
const [nameInput, setNameInput] = useState('')
const [activityInput, setActivityInput] = useState('')
const [activityDescriptionInput, setActivityDescriptionInput] = useState('')
const [showDescriptionField, setShowDescriptionField] = useState(false)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [canWrite, setCanWrite] = useState(false)
const [isPrivate, setIsPrivate] = useState(false)
const [isParticipant, setIsParticipant] = useState(false)
// Comments modal state
const [showComments, setShowComments] = useState(false)
const [selectedActivity, setSelectedActivity] = useState<Activity | null>(null)
const [comments, setComments] = useState<Comment[]>([])
const [commentInput, setCommentInput] = useState('')
const [replyTo, setReplyTo] = useState<{ id: number; name: string } | null>(null)
const [commentsLoading, setCommentsLoading] = useState(false)
// Edit activity modal state
const [showEditModal, setShowEditModal] = useState(false)
const [editingActivity, setEditingActivity] = useState<Activity | null>(null)
const [editNameInput, setEditNameInput] = useState('')
const [editDescriptionInput, setEditDescriptionInput] = useState('')
const [editLoading, setEditLoading] = useState(false)
useEffect(() => {
fetchQuestionnaire()
}, [slug, effectiveToken])
async function fetchQuestionnaire() {
try {
// Use token-based access if available
const url = effectiveToken
? `/api/q/token/${effectiveToken}`
: `/api/q/${slug}`
const res = await fetch(url, { credentials: 'include' })
if (!res.ok) {
setError(effectiveToken ? 'Ongeldige toegangslink' : 'Vragenlijst niet gevonden')
return
}
const data = await res.json()
setQuestionnaire(data.questionnaire)
setActivities(data.activities)
setUserVotes(data.userVotes)
setVisitorName(data.visitorName || '')
setCanWrite(data.canWrite ?? !data.isPrivate)
setIsPrivate(data.isPrivate ?? false)
setIsParticipant(data.isParticipant ?? false)
} catch (err) {
setError('Vragenlijst laden mislukt')
} finally {
setLoading(false)
}
}
async function handleSetName(e: FormEvent) {
e.preventDefault()
if (!nameInput.trim() || isPrivate) return
try {
const res = await fetch(`/api/q/${questionnaire?.slug}/set-name`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ name: nameInput.trim() }),
})
const data = await res.json()
if (data.success) {
setVisitorName(data.name)
setCanWrite(true)
setNameInput('')
}
} catch (err) {
console.error('Failed to set name:', err)
}
}
async function handleAddActivity(e: FormEvent) {
e.preventDefault()
if (!activityInput.trim() || !canWrite) return
try {
const res = await fetch(`/api/q/${questionnaire?.slug}/activities`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
name: activityInput.trim(),
description: activityDescriptionInput.trim() || null
}),
})
const data = await res.json()
if (data.success) {
setActivityInput('')
setActivityDescriptionInput('')
setShowDescriptionField(false)
refreshActivities()
}
} catch (err) {
console.error('Failed to add activity:', err)
}
}
async function refreshActivities() {
try {
const res = await fetch(`/api/q/${questionnaire?.slug}/activities`, { credentials: 'include' })
const data = await res.json()
setActivities(data.activities)
setUserVotes(data.userVotes)
} catch (err) {
console.error('Failed to refresh activities:', err)
}
}
function openEditModal(activity: Activity) {
setEditingActivity(activity)
setEditNameInput(activity.name)
setEditDescriptionInput(activity.description || '')
setShowEditModal(true)
}
async function handleEditActivity(e: FormEvent) {
e.preventDefault()
if (!editingActivity || !editNameInput.trim() || !canWrite) return
setEditLoading(true)
try {
const res = await fetch(`/api/q/${questionnaire?.slug}/activities/${editingActivity.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
name: editNameInput.trim(),
description: editDescriptionInput.trim() || null
}),
})
const data = await res.json()
if (data.success) {
setShowEditModal(false)
setEditingActivity(null)
refreshActivities()
} else {
alert(data.error || 'Bewerken mislukt')
}
} catch (err) {
console.error('Failed to edit activity:', err)
} finally {
setEditLoading(false)
}
}
async function handleVote(activityId: number, voteType: number) {
if (!canWrite) return
try {
const res = await fetch(`/api/q/${questionnaire?.slug}/activities/${activityId}/vote`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ voteType }),
})
const data = await res.json()
if (data.success) {
setUserVotes(prev => ({ ...prev, [activityId]: data.currentVote }))
setActivities(prev =>
prev.map(a => a.id === activityId ? data.activity : a)
.sort((a, b) => b.net_votes - a.net_votes)
)
}
} catch (err) {
console.error('Failed to vote:', err)
}
}
async function openComments(activity: Activity) {
setSelectedActivity(activity)
setShowComments(true)
setCommentsLoading(true)
setReplyTo(null)
setCommentInput('')
try {
const res = await fetch(`/api/q/${questionnaire?.slug}/activities/${activity.id}/comments`, { credentials: 'include' })
const data = await res.json()
setComments(data.comments)
} catch (err) {
console.error('Failed to load comments:', err)
} finally {
setCommentsLoading(false)
}
}
async function handleAddComment(e: FormEvent) {
e.preventDefault()
if (!commentInput.trim() || !selectedActivity || !canWrite) return
try {
const res = await fetch(`/api/q/${questionnaire?.slug}/activities/${selectedActivity.id}/comments`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
content: commentInput.trim(),
parentId: replyTo?.id || null
}),
})
const data = await res.json()
if (data.success) {
setCommentInput('')
setReplyTo(null)
// Reload comments
openComments(selectedActivity)
// Update comment count
setActivities(prev =>
prev.map(a => a.id === selectedActivity.id
? { ...a, comment_count: a.comment_count + 1 }
: a
)
)
}
} catch (err) {
console.error('Failed to add comment:', err)
}
}
function formatTime(dateStr: string) {
const date = new Date(dateStr)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffMins = Math.floor(diffMs / 60000)
const diffHours = Math.floor(diffMs / 3600000)
const diffDays = Math.floor(diffMs / 86400000)
if (diffMins < 1) return 'zojuist'
if (diffMins < 60) return `${diffMins} min geleden`
if (diffHours < 24) return `${diffHours} uur geleden`
if (diffDays < 7) return `${diffDays} dagen geleden`
return date.toLocaleDateString('nl-NL')
}
function renderComment(comment: Comment, depth = 0) {
return (
<div key={comment.id} className="mt-3 first:mt-0" style={{ marginLeft: Math.min(depth, 3) * 16 }}>
<div className="flex items-center gap-2 mb-1">
<span className="text-sm font-semibold text-accent">{comment.author_name}</span>
<span className="text-xs text-text-faint">{formatTime(comment.created_at)}</span>
</div>
<div className="text-sm text-text bg-bg-input rounded-lg px-3 py-2 mb-1">
{comment.content}
</div>
{canWrite && (
<button
onClick={() => setReplyTo({ id: comment.id, name: comment.author_name })}
className="text-xs text-text-faint hover:text-accent transition-colors"
>
Reageren
</button>
)}
{comment.replies?.map(reply => renderComment(reply, depth + 1))}
</div>
)
}
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center bg-bg">
<div className="text-text-muted">Laden...</div>
</div>
)
}
if (error || !questionnaire) {
return (
<div className="min-h-screen flex items-center justify-center bg-bg">
<div className="text-center">
<h1 className="text-2xl font-bold text-danger mb-2">Niet Gevonden</h1>
<p className="text-text-muted">{error || 'Deze vragenlijst bestaat niet.'}</p>
</div>
</div>
)
}
return (
<div className="min-h-screen bg-bg">
<div className="max-w-3xl mx-auto px-4 py-8">
{/* Header */}
<div className="text-center mb-8 pb-8 border-b border-border">
<h1 className="text-3xl sm:text-4xl font-bold bg-gradient-to-r from-text to-accent bg-clip-text text-transparent mb-2">
{questionnaire.title}
</h1>
{questionnaire.description && (
<p className="text-lg text-text-muted">{questionnaire.description}</p>
)}
{isPrivate && !canWrite && (
<div className="mt-4 inline-block px-4 py-2 bg-bg-card border border-border rounded-lg">
<span className="text-text-muted text-sm">👁 Alleen lezen</span>
</div>
)}
</div>
{/* Visitor Name Section */}
{!isPrivate && !isParticipant && (
<div className="mb-8">
{visitorName ? (
<div className="flex items-center justify-center gap-3 p-4 bg-bg-card border border-border rounded-xl">
<span className="text-text-muted">Deelnemen als: <strong className="text-text">{visitorName}</strong></span>
<button
onClick={() => setVisitorName('')}
className="px-3 py-1 text-sm border border-border rounded-md text-text-muted hover:text-text hover:border-text-muted transition-colors"
>
Wijzigen
</button>
</div>
) : (
<div className="bg-bg-card border border-border rounded-xl p-6 text-center">
<h3 className="font-semibold text-text mb-4">Voer je naam in om deel te nemen</h3>
<form onSubmit={handleSetName} className="flex gap-2 max-w-sm mx-auto">
<input
type="text"
value={nameInput}
onChange={(e) => setNameInput(e.target.value)}
placeholder="Je naam"
className="flex-1 px-4 py-2 bg-bg-input border border-border rounded-lg text-text placeholder-text-faint focus:outline-none focus:border-accent focus:ring-2 focus:ring-accent/20 transition-colors"
required
/>
<button
type="submit"
className="px-4 py-2 bg-accent hover:bg-accent-hover text-white font-semibold rounded-lg transition-colors"
>
Deelnemen
</button>
</form>
</div>
)}
</div>
)}
{/* Show participant name for token-based access or private questionnaires (name cannot be changed) */}
{(isParticipant || isPrivate) && canWrite && visitorName && (
<div className="mb-8">
<div className="flex items-center justify-center gap-3 p-4 bg-bg-card border border-border rounded-xl">
<span className="text-text-muted">Deelnemen als: <strong className="text-text">{visitorName}</strong></span>
</div>
</div>
)}
{/* Add Activity */}
{canWrite && (
<div className="bg-bg-card border border-border rounded-xl p-5 mb-8">
<h3 className="font-semibold text-text mb-3">Activiteit Toevoegen</h3>
<form onSubmit={handleAddActivity} className="space-y-3">
<div className="flex gap-2">
<input
type="text"
value={activityInput}
onChange={(e) => setActivityInput(e.target.value)}
placeholder="Naam activiteit"
className="flex-1 px-4 py-2 bg-bg-input border border-border rounded-lg text-text placeholder-text-faint focus:outline-none focus:border-accent focus:ring-2 focus:ring-accent/20 transition-colors"
required
/>
<button
type="submit"
className="px-4 py-2 bg-accent hover:bg-accent-hover text-white font-semibold rounded-lg transition-colors"
>
Toevoegen
</button>
</div>
{!showDescriptionField ? (
<button
type="button"
onClick={() => setShowDescriptionField(true)}
className="text-xs text-text-muted hover:text-accent transition-colors"
>
+ Beschrijving toevoegen
</button>
) : (
<div className="flex gap-2">
<input
type="text"
value={activityDescriptionInput}
onChange={(e) => setActivityDescriptionInput(e.target.value)}
placeholder="Korte beschrijving (optioneel)"
className="flex-1 px-4 py-2 bg-bg-input border border-border rounded-lg text-text placeholder-text-faint text-sm focus:outline-none focus:border-accent focus:ring-2 focus:ring-accent/20 transition-colors"
/>
<button
type="button"
onClick={() => {
setShowDescriptionField(false)
setActivityDescriptionInput('')
}}
className="px-2 text-text-faint hover:text-danger transition-colors"
>
×
</button>
</div>
)}
</form>
</div>
)}
{/* Activities List */}
<h2 className="font-semibold text-text mb-4">Activiteiten</h2>
{activities.length > 0 ? (
<div className="space-y-2">
{activities.map((activity) => (
<div
key={activity.id}
className="bg-bg-card border border-border rounded-lg p-3 hover:bg-bg-elevated transition-colors"
>
<div className="flex items-center gap-3">
{/* Votes */}
<div className="flex items-center gap-1">
<button
onClick={() => handleVote(activity.id, 1)}
disabled={!canWrite}
className={`w-6 h-6 flex items-center justify-center border rounded text-xs transition-colors ${
userVotes[activity.id] === 1
? 'border-success text-success bg-success-muted'
: 'border-border text-text-faint hover:border-success hover:text-success disabled:opacity-30 disabled:cursor-not-allowed'
}`}
>
</button>
<span className="w-6 text-center font-mono font-bold text-sm text-text">
{activity.net_votes}
</span>
<button
onClick={() => handleVote(activity.id, -1)}
disabled={!canWrite}
className={`w-6 h-6 flex items-center justify-center border rounded text-xs transition-colors ${
userVotes[activity.id] === -1
? 'border-danger text-danger bg-danger-muted'
: 'border-border text-text-faint hover:border-danger hover:text-danger disabled:opacity-30 disabled:cursor-not-allowed'
}`}
>
</button>
</div>
{/* Content */}
<div
className="flex-1 cursor-pointer min-w-0"
onClick={() => openComments(activity)}
>
<span className="font-medium text-text">{activity.name}</span>
<span className="text-xs text-text-faint ml-2">door {activity.added_by}</span>
</div>
{/* Edit button (only for own activities) */}
{canWrite && activity.added_by === visitorName && (
<button
onClick={() => openEditModal(activity)}
className="px-2 py-1 border border-border rounded text-xs text-text-muted hover:border-accent hover:text-accent transition-colors shrink-0"
title="Bewerken"
>
</button>
)}
{/* Comment button */}
<button
onClick={() => openComments(activity)}
className="flex items-center gap-1 px-2 py-1 border border-border rounded text-xs text-text-muted hover:border-accent hover:text-accent transition-colors shrink-0"
>
💬 <span className="font-mono font-semibold">{activity.comment_count}</span>
</button>
{/* Stats */}
<div className="flex gap-2 text-xs font-mono font-semibold shrink-0">
<span className="text-success">+{activity.upvotes}</span>
<span className="text-danger">-{activity.downvotes}</span>
</div>
</div>
{activity.description && (
<p className="mt-2 ml-[76px] text-sm text-text-muted">{activity.description}</p>
)}
</div>
))}
</div>
) : (
<div className="text-center py-8 bg-bg-card border border-dashed border-border rounded-xl">
<p className="text-text-muted">Nog geen activiteiten. Wees de eerste om er één toe te voegen!</p>
</div>
)}
</div>
{/* Comments Modal */}
{showComments && (
<div
className="fixed inset-0 bg-black/70 backdrop-blur-sm flex items-center justify-center z-50 p-4"
onClick={() => setShowComments(false)}
>
<div
className="bg-bg-card border border-border rounded-2xl w-full max-w-lg max-h-[80vh] flex flex-col"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-start justify-between p-4 border-b border-border">
<div className="pr-4 min-w-0">
<h3 className="font-semibold text-text truncate">{selectedActivity?.name}</h3>
{selectedActivity?.description && (
<p className="text-sm text-text-muted mt-1">{selectedActivity.description}</p>
)}
</div>
<button
onClick={() => setShowComments(false)}
className="text-2xl text-text-muted hover:text-text leading-none shrink-0"
>
×
</button>
</div>
{/* Comments */}
<div className="flex-1 overflow-y-auto p-4 min-h-[200px]">
{commentsLoading ? (
<div className="text-center text-text-muted py-8">Reacties laden...</div>
) : comments.length > 0 ? (
comments.map(comment => renderComment(comment))
) : (
<div className="text-center text-text-muted py-8">Nog geen reacties. Wees de eerste om te reageren!</div>
)}
</div>
{/* Add Comment */}
{canWrite && (
<div className="p-4 border-t border-border">
{replyTo && (
<div className="flex items-center gap-2 mb-2 px-3 py-2 bg-bg-input rounded text-sm text-text-muted">
<span>Reageren op <strong className="text-accent">{replyTo.name}</strong></span>
<button
onClick={() => setReplyTo(null)}
className="ml-auto text-text-faint hover:text-danger"
>
×
</button>
</div>
)}
<form onSubmit={handleAddComment} className="flex gap-2">
<input
type="text"
value={commentInput}
onChange={(e) => setCommentInput(e.target.value)}
placeholder="Schrijf een reactie..."
className="flex-1 px-3 py-2 bg-bg-input border border-border rounded-lg text-text placeholder-text-faint focus:outline-none focus:border-accent focus:ring-2 focus:ring-accent/20 transition-colors"
required
/>
<button
type="submit"
className="px-4 py-2 bg-accent hover:bg-accent-hover text-white font-semibold rounded-lg transition-colors"
>
Versturen
</button>
</form>
</div>
)}
</div>
</div>
)}
{/* Edit Activity Modal */}
{showEditModal && editingActivity && (
<div
className="fixed inset-0 bg-black/70 backdrop-blur-sm flex items-center justify-center z-50 p-4"
onClick={() => setShowEditModal(false)}
>
<div
className="bg-bg-card border border-border rounded-2xl w-full max-w-md"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-border">
<h3 className="font-semibold text-text">Activiteit Bewerken</h3>
<button
onClick={() => setShowEditModal(false)}
className="text-2xl text-text-muted hover:text-text leading-none"
>
×
</button>
</div>
{/* Form */}
<form onSubmit={handleEditActivity} className="p-4 space-y-4">
<div>
<label className="block text-sm font-medium text-text mb-2">
Naam <span className="text-danger">*</span>
</label>
<input
type="text"
value={editNameInput}
onChange={(e) => setEditNameInput(e.target.value)}
className="w-full px-4 py-2 bg-bg-input border border-border rounded-lg text-text placeholder-text-faint focus:outline-none focus:border-accent focus:ring-2 focus:ring-accent/20 transition-colors"
required
autoFocus
/>
</div>
<div>
<label className="block text-sm font-medium text-text mb-2">
Beschrijving
</label>
<input
type="text"
value={editDescriptionInput}
onChange={(e) => setEditDescriptionInput(e.target.value)}
placeholder="Korte beschrijving (optioneel)"
className="w-full px-4 py-2 bg-bg-input border border-border rounded-lg text-text placeholder-text-faint focus:outline-none focus:border-accent focus:ring-2 focus:ring-accent/20 transition-colors"
/>
</div>
<div className="flex gap-3 justify-end pt-2">
<button
type="button"
onClick={() => setShowEditModal(false)}
className="px-4 py-2 border border-border rounded-lg text-text-muted hover:text-text hover:border-text-muted transition-colors"
>
Annuleren
</button>
<button
type="submit"
disabled={editLoading}
className="px-4 py-2 bg-accent hover:bg-accent-hover text-white font-semibold rounded-lg transition-colors disabled:opacity-50"
>
{editLoading ? 'Opslaan...' : 'Opslaan'}
</button>
</div>
</form>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,453 @@
import { useState, useEffect, FormEvent } from 'react'
import { useParams, Link, useNavigate } from 'react-router-dom'
interface Activity {
id: number
name: string
description: string | null
added_by: string
upvotes: number
downvotes: number
net_votes: number
comment_count: number
}
interface Participant {
id: number
name: string
token: string
created_at: string
}
interface Questionnaire {
id: number
uuid: string
slug: string
title: string
description: string | null
is_private: boolean
created_at: string
}
export function QuestionnaireDetail() {
const { id } = useParams()
const navigate = useNavigate()
const [questionnaire, setQuestionnaire] = useState<Questionnaire | null>(null)
const [activities, setActivities] = useState<Activity[]>([])
const [participants, setParticipants] = useState<Participant[]>([])
const [loading, setLoading] = useState(true)
const [copied, setCopied] = useState('')
const [newParticipantName, setNewParticipantName] = useState('')
const [addingParticipant, setAddingParticipant] = useState(false)
// Edit activity modal state
const [showEditModal, setShowEditModal] = useState(false)
const [editingActivity, setEditingActivity] = useState<Activity | null>(null)
const [editNameInput, setEditNameInput] = useState('')
const [editDescriptionInput, setEditDescriptionInput] = useState('')
const [editLoading, setEditLoading] = useState(false)
useEffect(() => {
fetchData()
}, [id])
async function fetchData() {
try {
const res = await fetch(`/api/admin/questionnaires/${id}`, { credentials: 'include' })
const data = await res.json()
setQuestionnaire(data.questionnaire)
setActivities(data.activities)
// Fetch participants if private
if (data.questionnaire?.is_private) {
fetchParticipants()
}
} catch (error) {
console.error('Failed to fetch questionnaire:', error)
} finally {
setLoading(false)
}
}
async function fetchParticipants() {
try {
const res = await fetch(`/api/admin/questionnaires/${id}/participants`, { credentials: 'include' })
const data = await res.json()
setParticipants(data)
} catch (error) {
console.error('Failed to fetch participants:', error)
}
}
async function handleAddParticipant(e: FormEvent) {
e.preventDefault()
if (!newParticipantName.trim()) return
setAddingParticipant(true)
try {
const res = await fetch(`/api/admin/questionnaires/${id}/participants`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ name: newParticipantName.trim() }),
})
const data = await res.json()
if (data.success) {
setNewParticipantName('')
fetchParticipants()
}
} catch (error) {
console.error('Failed to add participant:', error)
} finally {
setAddingParticipant(false)
}
}
async function handleDeleteParticipant(participantId: number) {
if (!confirm('Deze deelnemer verwijderen?')) return
try {
await fetch(`/api/admin/participants/${participantId}`, {
method: 'DELETE',
credentials: 'include',
})
setParticipants(participants.filter(p => p.id !== participantId))
} catch (error) {
console.error('Failed to delete participant:', error)
}
}
async function handleDelete() {
if (!confirm('Weet je zeker dat je deze vragenlijst wilt verwijderen? Dit verwijdert ook alle activiteiten en stemmen.')) {
return
}
try {
await fetch(`/api/admin/questionnaires/${id}`, {
method: 'DELETE',
credentials: 'include',
})
navigate('/admin/dashboard')
} catch (error) {
console.error('Failed to delete:', error)
}
}
async function handleDeleteActivity(activityId: number) {
if (!confirm('Deze activiteit verwijderen?')) return
try {
await fetch(`/api/admin/activities/${activityId}`, {
method: 'DELETE',
credentials: 'include',
})
setActivities(activities.filter(a => a.id !== activityId))
} catch (error) {
console.error('Failed to delete activity:', error)
}
}
function openEditModal(activity: Activity) {
setEditingActivity(activity)
setEditNameInput(activity.name)
setEditDescriptionInput(activity.description || '')
setShowEditModal(true)
}
async function handleEditActivity(e: FormEvent) {
e.preventDefault()
if (!editingActivity || !editNameInput.trim()) return
setEditLoading(true)
try {
const res = await fetch(`/api/admin/activities/${editingActivity.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
name: editNameInput.trim(),
description: editDescriptionInput.trim() || null
}),
})
const data = await res.json()
if (data.success) {
setShowEditModal(false)
setEditingActivity(null)
// Update the activity in the list
setActivities(activities.map(a =>
a.id === editingActivity.id
? { ...a, name: editNameInput.trim(), description: editDescriptionInput.trim() || null }
: a
))
} else {
alert(data.error || 'Bewerken mislukt')
}
} catch (error) {
console.error('Failed to edit activity:', error)
} finally {
setEditLoading(false)
}
}
function copyUrl(url: string, key: string) {
navigator.clipboard.writeText(url)
setCopied(key)
setTimeout(() => setCopied(''), 2000)
}
if (loading) {
return <div className="text-text-muted">Laden...</div>
}
if (!questionnaire) {
return <div className="text-text-muted">Vragenlijst niet gevonden</div>
}
const shareUrl = `${window.location.origin}/q/${questionnaire.slug}`
return (
<div>
<Link to="/admin/dashboard" className="inline-block text-text-muted hover:text-accent text-sm mb-4 transition-colors">
Terug naar Dashboard
</Link>
<div className="flex items-center gap-3 mb-2">
<h1 className="text-2xl font-bold text-text">{questionnaire.title}</h1>
{questionnaire.is_private && (
<span className="px-2 py-0.5 bg-accent/20 text-accent text-xs font-semibold rounded">Privé</span>
)}
</div>
{questionnaire.description && (
<p className="text-text-muted mb-6">{questionnaire.description}</p>
)}
{/* Share Box */}
<div className="bg-bg-card border border-border rounded-xl p-5 mb-8">
<h3 className="font-semibold text-text mb-3">
{questionnaire.is_private ? 'Publieke URL (Alleen Lezen)' : 'Deel deze vragenlijst'}
</h3>
<div className="flex gap-2 mb-2">
<input
type="text"
value={shareUrl}
readOnly
className="flex-1 px-3 py-2 bg-bg-input border border-border rounded-lg text-sm font-mono text-text"
/>
<button
onClick={() => copyUrl(shareUrl, 'share')}
className="px-4 py-2 bg-bg-input border border-border rounded-lg text-sm font-medium text-text hover:bg-bg-elevated transition-colors"
>
{copied === 'share' ? 'Gekopieerd!' : 'Kopiëren'}
</button>
</div>
<p className="text-xs text-text-faint">
{questionnaire.is_private
? 'Iedereen met deze link kan activiteiten en stemmen bekijken, maar niet deelnemen.'
: 'Deel deze URL met mensen die activiteiten moeten toevoegen en stemmen.'}
</p>
</div>
{/* Participants Section (for private questionnaires) */}
{questionnaire.is_private && (
<div className="bg-bg-card border border-border rounded-xl p-5 mb-8">
<h3 className="font-semibold text-text mb-4">Uitgenodigde Deelnemers</h3>
<p className="text-sm text-text-muted mb-4">
Voeg mensen toe die kunnen deelnemen aan deze vragenlijst. Elke persoon krijgt een unieke link.
</p>
{/* Add Participant Form */}
<form onSubmit={handleAddParticipant} className="flex gap-2 mb-4">
<input
type="text"
value={newParticipantName}
onChange={(e) => setNewParticipantName(e.target.value)}
placeholder="Naam deelnemer"
className="flex-1 px-3 py-2 bg-bg-input border border-border rounded-lg text-sm text-text placeholder-text-faint focus:outline-none focus:border-accent"
required
/>
<button
type="submit"
disabled={addingParticipant}
className="px-4 py-2 bg-accent hover:bg-accent-hover text-white font-medium rounded-lg text-sm transition-colors disabled:opacity-50"
>
{addingParticipant ? 'Toevoegen...' : 'Toevoegen'}
</button>
</form>
{/* Participants List */}
{participants.length > 0 ? (
<div className="space-y-2">
{participants.map((participant) => {
const participantUrl = `${window.location.origin}/q/access/${participant.token}`
return (
<div key={participant.id} className="flex items-center gap-2 p-3 bg-bg-input rounded-lg">
<div className="flex-1 min-w-0">
<div className="font-medium text-text text-sm">{participant.name}</div>
<div className="text-xs font-mono text-text-faint truncate">{participantUrl}</div>
</div>
<button
onClick={() => copyUrl(participantUrl, `p-${participant.id}`)}
className="px-3 py-1 text-xs font-medium text-text-muted hover:text-text border border-border rounded transition-colors"
>
{copied === `p-${participant.id}` ? 'Gekopieerd!' : 'Link Kopiëren'}
</button>
<button
onClick={() => handleDeleteParticipant(participant.id)}
className="px-2 py-1 text-xs font-medium text-danger hover:bg-danger-muted rounded transition-colors"
>
Verwijderen
</button>
</div>
)
})}
</div>
) : (
<p className="text-sm text-text-faint italic">Nog geen deelnemers toegevoegd.</p>
)}
</div>
)}
{/* Activities */}
<h2 className="font-semibold text-text mb-4">Activiteiten ({activities.length})</h2>
{activities.length > 0 ? (
<div className="bg-bg-card border border-border rounded-xl overflow-hidden">
<table className="w-full">
<thead>
<tr className="bg-bg-elevated">
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-text-muted">Activiteit</th>
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-text-muted">Toegevoegd door</th>
<th className="px-4 py-3 text-center text-xs font-semibold uppercase tracking-wider text-text-muted">Voor</th>
<th className="px-4 py-3 text-center text-xs font-semibold uppercase tracking-wider text-text-muted">Tegen</th>
<th className="px-4 py-3 text-center text-xs font-semibold uppercase tracking-wider text-text-muted">Netto</th>
<th className="px-4 py-3 text-center text-xs font-semibold uppercase tracking-wider text-text-muted">Reacties</th>
<th className="px-4 py-3 text-right text-xs font-semibold uppercase tracking-wider text-text-muted">Acties</th>
</tr>
</thead>
<tbody className="divide-y divide-border-light">
{activities.map((activity) => (
<tr key={activity.id} className="hover:bg-bg-elevated transition-colors">
<td className="px-4 py-3">
<div className="font-medium text-text">{activity.name}</div>
{activity.description && (
<div className="text-sm text-text-muted mt-0.5">{activity.description}</div>
)}
</td>
<td className="px-4 py-3 text-text-muted">{activity.added_by}</td>
<td className="px-4 py-3 text-center font-mono font-semibold text-success">+{activity.upvotes}</td>
<td className="px-4 py-3 text-center font-mono font-semibold text-danger">-{activity.downvotes}</td>
<td className="px-4 py-3 text-center font-mono font-semibold text-text">{activity.net_votes}</td>
<td className="px-4 py-3 text-center font-mono text-text-muted">{activity.comment_count}</td>
<td className="px-4 py-3 text-right space-x-2">
<button
onClick={() => openEditModal(activity)}
className="px-3 py-1 text-xs font-medium text-text-muted hover:text-accent hover:bg-bg-input rounded transition-colors"
>
Bewerken
</button>
<button
onClick={() => handleDeleteActivity(activity.id)}
className="px-3 py-1 text-xs font-medium text-danger hover:bg-danger-muted rounded transition-colors"
>
Verwijderen
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="text-center py-8 bg-bg-card border border-dashed border-border rounded-xl">
<p className="text-text-muted">Er zijn nog geen activiteiten toegevoegd. Deel de vragenlijst URL zodat mensen activiteiten kunnen toevoegen.</p>
</div>
)}
{/* Delete Section */}
<div className="mt-8 pt-8 border-t border-border">
<button
onClick={handleDelete}
className="px-4 py-2 bg-danger hover:bg-danger-hover text-white font-medium rounded-lg transition-colors"
>
Vragenlijst Verwijderen
</button>
</div>
{/* Edit Activity Modal */}
{showEditModal && editingActivity && (
<div
className="fixed inset-0 bg-black/70 backdrop-blur-sm flex items-center justify-center z-50 p-4"
onClick={() => setShowEditModal(false)}
>
<div
className="bg-bg-card border border-border rounded-2xl w-full max-w-md"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-border">
<h3 className="font-semibold text-text">Activiteit Bewerken</h3>
<button
onClick={() => setShowEditModal(false)}
className="text-2xl text-text-muted hover:text-text leading-none"
>
×
</button>
</div>
{/* Form */}
<form onSubmit={handleEditActivity} className="p-4 space-y-4">
<div>
<label className="block text-sm font-medium text-text mb-2">
Naam <span className="text-danger">*</span>
</label>
<input
type="text"
value={editNameInput}
onChange={(e) => setEditNameInput(e.target.value)}
className="w-full px-4 py-2 bg-bg-input border border-border rounded-lg text-text placeholder-text-faint focus:outline-none focus:border-accent focus:ring-2 focus:ring-accent/20 transition-colors"
required
autoFocus
/>
</div>
<div>
<label className="block text-sm font-medium text-text mb-2">
Beschrijving
</label>
<input
type="text"
value={editDescriptionInput}
onChange={(e) => setEditDescriptionInput(e.target.value)}
placeholder="Korte beschrijving (optioneel)"
className="w-full px-4 py-2 bg-bg-input border border-border rounded-lg text-text placeholder-text-faint focus:outline-none focus:border-accent focus:ring-2 focus:ring-accent/20 transition-colors"
/>
</div>
<div className="text-xs text-text-faint">
Toegevoegd door: {editingActivity.added_by}
</div>
<div className="flex gap-3 justify-end pt-2">
<button
type="button"
onClick={() => setShowEditModal(false)}
className="px-4 py-2 border border-border rounded-lg text-text-muted hover:text-text hover:border-text-muted transition-colors"
>
Annuleren
</button>
<button
type="submit"
disabled={editLoading}
className="px-4 py-2 bg-accent hover:bg-accent-hover text-white font-semibold rounded-lg transition-colors disabled:opacity-50"
>
{editLoading ? 'Opslaan...' : 'Opslaan'}
</button>
</div>
</form>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,221 @@
import { useState, useEffect, FormEvent } from 'react'
import { useNavigate, useParams, Link } from 'react-router-dom'
export function QuestionnaireForm() {
const { id } = useParams()
const isEditing = !!id
const navigate = useNavigate()
const [title, setTitle] = useState('')
const [slug, setSlug] = useState('')
const [description, setDescription] = useState('')
const [isPrivate, setIsPrivate] = useState(false)
const [error, setError] = useState('')
const [slugError, setSlugError] = useState('')
const [loading, setLoading] = useState(false)
useEffect(() => {
if (isEditing) {
fetchQuestionnaire()
}
}, [id])
async function fetchQuestionnaire() {
try {
const res = await fetch(`/api/admin/questionnaires/${id}`, { credentials: 'include' })
const data = await res.json()
if (data.questionnaire) {
setTitle(data.questionnaire.title)
setSlug(data.questionnaire.slug)
setDescription(data.questionnaire.description || '')
setIsPrivate(!!data.questionnaire.is_private)
}
} catch (error) {
console.error('Failed to fetch questionnaire:', error)
}
}
// Auto-generate slug from title (only when creating new)
function handleTitleChange(value: string) {
setTitle(value)
if (!isEditing && !slug) {
const autoSlug = value
.toLowerCase()
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.substring(0, 50)
setSlug(autoSlug)
}
}
function handleSlugChange(value: string) {
const cleanSlug = value
.toLowerCase()
.replace(/[^a-z0-9-]/g, '')
.substring(0, 50)
setSlug(cleanSlug)
setSlugError('')
}
async function checkSlugAvailability() {
if (!slug || slug.length < 3) return
try {
const excludeParam = isEditing ? `?excludeId=${id}` : ''
const res = await fetch(`/api/admin/questionnaires/check-slug/${slug}${excludeParam}`, { credentials: 'include' })
const data = await res.json()
if (!data.available) {
setSlugError('Deze slug is al in gebruik')
}
} catch (err) {
console.error('Failed to check slug:', err)
}
}
async function handleSubmit(e: FormEvent) {
e.preventDefault()
setError('')
setLoading(true)
try {
const url = isEditing ? `/api/admin/questionnaires/${id}` : '/api/admin/questionnaires'
const method = isEditing ? 'PUT' : 'POST'
const res = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ title, slug, description, isPrivate }),
})
const data = await res.json()
if (!res.ok) {
throw new Error(data.error || 'Vragenlijst opslaan mislukt')
}
navigate('/admin/dashboard')
} catch (err) {
setError(err instanceof Error ? err.message : 'Opslaan mislukt')
} finally {
setLoading(false)
}
}
return (
<div className="max-w-2xl">
<Link to="/admin/dashboard" className="inline-block text-text-muted hover:text-accent text-sm mb-4 transition-colors">
Terug naar Dashboard
</Link>
<h1 className="text-2xl font-bold text-text mb-8">
{isEditing ? 'Vragenlijst Bewerken' : 'Vragenlijst Maken'}
</h1>
<div className="bg-bg-card border border-border rounded-xl p-6">
{error && (
<div className="mb-6 p-4 bg-danger-muted border border-danger/30 rounded-lg text-danger text-sm">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label htmlFor="title" className="block text-sm font-medium text-text mb-2">
Titel <span className="text-danger">*</span>
</label>
<input
type="text"
id="title"
value={title}
onChange={(e) => handleTitleChange(e.target.value)}
className="w-full px-4 py-3 bg-bg-input border border-border rounded-lg text-text placeholder-text-faint focus:outline-none focus:border-accent focus:ring-2 focus:ring-accent/20 transition-colors"
placeholder="bijv. Teambuilding Activiteiten"
required
autoFocus
/>
</div>
<div>
<label htmlFor="slug" className="block text-sm font-medium text-text mb-2">
URL Slug <span className="text-danger">*</span>
</label>
<div className="flex items-center gap-2">
<span className="text-text-muted text-sm">/q/</span>
<input
type="text"
id="slug"
value={slug}
onChange={(e) => handleSlugChange(e.target.value)}
onBlur={checkSlugAvailability}
className={`flex-1 px-4 py-3 bg-bg-input border rounded-lg text-text placeholder-text-faint focus:outline-none focus:ring-2 transition-colors font-mono ${
slugError ? 'border-danger focus:border-danger focus:ring-danger/20' : 'border-border focus:border-accent focus:ring-accent/20'
}`}
placeholder="teambuilding-2024"
required
minLength={3}
maxLength={50}
/>
</div>
{slugError && (
<p className="mt-1 text-sm text-danger">{slugError}</p>
)}
<p className="mt-1 text-xs text-text-faint">
Alleen kleine letters, cijfers en koppeltekens. 3-50 tekens.
</p>
</div>
<div>
<label htmlFor="description" className="block text-sm font-medium text-text mb-2">
Beschrijving
</label>
<textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={3}
className="w-full px-4 py-3 bg-bg-input border border-border rounded-lg text-text placeholder-text-faint focus:outline-none focus:border-accent focus:ring-2 focus:ring-accent/20 transition-colors resize-y"
placeholder="Optionele beschrijving voor deelnemers"
/>
</div>
<div className="flex items-center gap-3 p-4 bg-bg-input rounded-lg border border-border">
<input
type="checkbox"
id="isPrivate"
checked={isPrivate}
onChange={(e) => setIsPrivate(e.target.checked)}
className="w-5 h-5 rounded border-border text-accent focus:ring-accent focus:ring-offset-0 bg-bg-input"
/>
<div>
<label htmlFor="isPrivate" className="block font-medium text-text cursor-pointer">
Privé Vragenlijst
</label>
<p className="text-sm text-text-muted">
Alleen uitgenodigde deelnemers kunnen items toevoegen en stemmen. Anderen kunnen alleen bekijken.
</p>
</div>
</div>
<div className="flex gap-3 justify-end pt-4">
<Link
to="/admin/dashboard"
className="px-4 py-2 border border-border rounded-lg text-text-muted hover:text-text hover:border-text-muted transition-colors"
>
Annuleren
</Link>
<button
type="submit"
disabled={loading}
className="px-4 py-2 bg-accent hover:bg-accent-hover text-white font-semibold rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? 'Opslaan...' : isEditing ? 'Wijzigingen Opslaan' : 'Vragenlijst Maken'}
</button>
</div>
</form>
</div>
</div>
)
}

215
src/pages/Users.tsx Normal file
View File

@@ -0,0 +1,215 @@
import { useState, useEffect, FormEvent } from 'react'
import { useAuth } from '../context/AuthContext'
interface User {
id: number
username: string
created_at: string
}
export function Users() {
const { user: currentUser } = useAuth()
const [users, setUsers] = useState<User[]>([])
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
useEffect(() => {
fetchUsers()
}, [])
async function fetchUsers() {
try {
const res = await fetch('/api/admin/users', { credentials: 'include' })
const data = await res.json()
setUsers(data)
} catch (error) {
console.error('Failed to fetch users:', error)
}
}
async function handleSubmit(e: FormEvent) {
e.preventDefault()
setError('')
if (password !== confirmPassword) {
setError('Wachtwoorden komen niet overeen')
return
}
setLoading(true)
try {
const res = await fetch('/api/admin/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ username, password }),
})
const data = await res.json()
if (!res.ok) {
throw new Error(data.error || 'Gebruiker aanmaken mislukt')
}
setUsername('')
setPassword('')
setConfirmPassword('')
fetchUsers()
} catch (err) {
setError(err instanceof Error ? err.message : 'Gebruiker aanmaken mislukt')
} finally {
setLoading(false)
}
}
async function handleDelete(userId: number, username: string) {
if (!confirm(`Gebruiker ${username} verwijderen?`)) return
try {
const res = await fetch(`/api/admin/users/${userId}`, {
method: 'DELETE',
credentials: 'include',
})
const data = await res.json()
if (!res.ok) {
throw new Error(data.error || 'Gebruiker verwijderen mislukt')
}
fetchUsers()
} catch (err) {
alert(err instanceof Error ? err.message : 'Gebruiker verwijderen mislukt')
}
}
function formatDate(dateStr: string) {
return new Date(dateStr).toLocaleDateString('nl-NL', {
year: 'numeric',
month: 'short',
day: 'numeric',
})
}
return (
<div>
<h1 className="text-2xl font-bold text-text mb-8">Gebruikersbeheer</h1>
<div className="grid gap-8 lg:grid-cols-2">
{/* Add User Form */}
<div className="bg-bg-card border border-border rounded-xl p-6">
<h2 className="font-semibold text-text mb-4">Nieuwe Gebruiker Toevoegen</h2>
{error && (
<div className="mb-4 p-3 bg-danger-muted border border-danger/30 rounded-lg text-danger text-sm">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="username" className="block text-sm font-medium text-text mb-1.5">
Gebruikersnaam
</label>
<input
type="text"
id="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="w-full px-3 py-2 bg-bg-input border border-border rounded-lg text-text placeholder-text-faint focus:outline-none focus:border-accent focus:ring-2 focus:ring-accent/20 transition-colors"
placeholder="Voer gebruikersnaam in"
required
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-text mb-1.5">
Wachtwoord
</label>
<input
type="password"
id="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-3 py-2 bg-bg-input border border-border rounded-lg text-text placeholder-text-faint focus:outline-none focus:border-accent focus:ring-2 focus:ring-accent/20 transition-colors"
placeholder="Min. 6 tekens"
minLength={6}
required
/>
</div>
<div>
<label htmlFor="confirmPassword" className="block text-sm font-medium text-text mb-1.5">
Bevestig Wachtwoord
</label>
<input
type="password"
id="confirmPassword"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="w-full px-3 py-2 bg-bg-input border border-border rounded-lg text-text placeholder-text-faint focus:outline-none focus:border-accent focus:ring-2 focus:ring-accent/20 transition-colors"
placeholder="Herhaal wachtwoord"
required
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full py-2 bg-accent hover:bg-accent-hover text-white font-semibold rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? 'Toevoegen...' : 'Gebruiker Toevoegen'}
</button>
</form>
</div>
{/* Users List */}
<div className="bg-bg-card border border-border rounded-xl p-6">
<h2 className="font-semibold text-text mb-4">Bestaande Gebruikers</h2>
{users.length > 0 ? (
<table className="w-full">
<thead>
<tr className="border-b border-border-light">
<th className="pb-2 text-left text-xs font-semibold uppercase tracking-wider text-text-muted">Gebruikersnaam</th>
<th className="pb-2 text-left text-xs font-semibold uppercase tracking-wider text-text-muted">Aangemaakt</th>
<th className="pb-2 text-right text-xs font-semibold uppercase tracking-wider text-text-muted">Acties</th>
</tr>
</thead>
<tbody className="divide-y divide-border-light">
{users.map((user) => (
<tr key={user.id}>
<td className="py-3 text-text">
{user.username}
{user.id === currentUser?.id && (
<span className="ml-2 px-1.5 py-0.5 text-xs bg-bg-input rounded text-text-muted">Jij</span>
)}
</td>
<td className="py-3 text-text-muted text-sm">{formatDate(user.created_at)}</td>
<td className="py-3 text-right">
{user.id !== currentUser?.id && (
<button
onClick={() => handleDelete(user.id, user.username)}
className="px-2 py-1 text-xs font-medium text-danger hover:bg-danger-muted rounded transition-colors"
>
Verwijderen
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
) : (
<p className="text-text-muted italic">Geen gebruikers gevonden.</p>
)}
</div>
</div>
</div>
)
}

2
src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,2 @@
/// <reference types="vite/client" />

48
tailwind.config.js Normal file
View File

@@ -0,0 +1,48 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {
colors: {
bg: {
DEFAULT: '#0f1419',
elevated: '#1a2027',
card: '#232b35',
input: '#2a343f',
},
border: {
DEFAULT: '#38444d',
light: '#2c3640',
},
text: {
DEFAULT: '#e8ecf0',
muted: '#8899a6',
faint: '#5c6d7e',
},
accent: {
DEFAULT: '#ff6b4a',
hover: '#ff8266',
dark: '#e85a3a',
},
success: {
DEFAULT: '#17bf63',
muted: 'rgba(23, 191, 99, 0.15)',
},
danger: {
DEFAULT: '#e0245e',
hover: '#f04070',
muted: 'rgba(224, 36, 94, 0.15)',
},
},
fontFamily: {
sans: ['DM Sans', 'system-ui', 'sans-serif'],
mono: ['JetBrains Mono', 'monospace'],
},
},
},
plugins: [],
}

29
tsconfig.json Normal file
View File

@@ -0,0 +1,29 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"],
"@server/*": ["server/*"]
}
},
"include": ["src", "server"],
"references": [{ "path": "./tsconfig.node.json" }]
}

12
tsconfig.node.json Normal file
View File

@@ -0,0 +1,12 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}

18
tsconfig.server.json Normal file
View File

@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022"],
"module": "ESNext",
"moduleResolution": "bundler",
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"skipLibCheck": true,
"outDir": "dist/server",
"rootDir": "server",
"declaration": false,
"noEmit": false
},
"include": ["server/**/*"]
}

26
vite.config.ts Normal file
View File

@@ -0,0 +1,26 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
port: 5177,
proxy: {
'/api': {
target: 'http://localhost:4000',
changeOrigin: true,
},
},
},
build: {
outDir: 'dist/client',
emptyOutDir: true,
},
})