Initial commit: Activiteiten Inventaris applicatie

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

45
src/App.tsx Normal file
View File

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

View File

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

View File

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

View File

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

26
src/index.css Normal file
View File

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

14
src/main.tsx Normal file
View File

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

View File

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

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

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

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

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

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

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

View File

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

View File

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

View File

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

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

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

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

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