Initial commit: Activiteiten Inventaris applicatie
This commit is contained in:
9
.dockerignore
Normal file
9
.dockerignore
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
node_modules
|
||||||
|
npm-debug.log
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.env
|
||||||
|
*.md
|
||||||
|
data/
|
||||||
|
.DS_Store
|
||||||
|
dist/
|
||||||
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
data/
|
||||||
|
*.db
|
||||||
|
.env
|
||||||
|
.DS_Store
|
||||||
|
npm-debug.log
|
||||||
314
DEPLOYMENT.md
Normal file
314
DEPLOYMENT.md
Normal 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
54
Dockerfile
Normal 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
142
README.md
Normal 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
34
docker-compose.prod.yml
Normal 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
20
docker-compose.yml
Normal 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
17
index.html
Normal 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
5638
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
45
package.json
Normal file
45
package.json
Normal 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
7
postcss.config.js
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
1
public/vite.svg
Normal file
1
public/vite.svg
Normal 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
438
server/database.ts
Normal 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
75
server/index.ts
Normal 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
10
server/middleware/auth.ts
Normal 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
283
server/routes/admin.ts
Normal 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
50
server/routes/auth.ts
Normal 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;
|
||||||
|
|
||||||
366
server/routes/questionnaire.ts
Normal file
366
server/routes/questionnaire.ts
Normal 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
45
src/App.tsx
Normal 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
|
||||||
|
|
||||||
62
src/components/AdminLayout.tsx
Normal file
62
src/components/AdminLayout.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
22
src/components/ProtectedRoute.tsx
Normal file
22
src/components/ProtectedRoute.tsx
Normal 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}</>
|
||||||
|
}
|
||||||
|
|
||||||
78
src/context/AuthContext.tsx
Normal file
78
src/context/AuthContext.tsx
Normal 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
26
src/index.css
Normal 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
14
src/main.tsx
Normal 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>,
|
||||||
|
)
|
||||||
|
|
||||||
125
src/pages/ChangePassword.tsx
Normal file
125
src/pages/ChangePassword.tsx
Normal 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
126
src/pages/Dashboard.tsx
Normal 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
94
src/pages/Login.tsx
Normal 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
19
src/pages/NotFound.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
673
src/pages/PublicQuestionnaire.tsx
Normal file
673
src/pages/PublicQuestionnaire.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
453
src/pages/QuestionnaireDetail.tsx
Normal file
453
src/pages/QuestionnaireDetail.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
221
src/pages/QuestionnaireForm.tsx
Normal file
221
src/pages/QuestionnaireForm.tsx
Normal 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
215
src/pages/Users.tsx
Normal 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
2
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
48
tailwind.config.js
Normal file
48
tailwind.config.js
Normal 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
29
tsconfig.json
Normal 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
12
tsconfig.node.json
Normal 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
18
tsconfig.server.json
Normal 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
26
vite.config.ts
Normal 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,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
Reference in New Issue
Block a user