Initial commit: Activiteiten Inventaris applicatie
This commit is contained in:
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" />
|
||||
|
||||
Reference in New Issue
Block a user