feat: add admin content module pages
Local Build and Deploy / deploy (push) Successful in 47s

This commit is contained in:
Simo committed 2026-07-12 15:27:07 +02:00
1 parent f422bb4a0b
commit 2606092337
24 files changed
+2892

No files matched your search

+133
View File
@@ -0,0 +1,133 @@
import { notFound, redirect } from 'next/navigation'
import { AdminBreadcrumb } from '@/components/admin/admin-breadcrumb'
import { Badge } from '@/components/ui/badge'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { canAccess, getAdminContext, PERMS } from '@/lib/permissions'
import { prisma } from '@/lib/prisma'
import { PollForm } from '../poll-form'
import { PollQuestionsManager } from './poll-questions'
import { PollResultsView } from './poll-results'
export default async function PollDetailPage({
params,
}: {
params: Promise<{ locale: string; id: string }>
}) {
const { id } = await params
const { session, permissions } = await getAdminContext()
if (!canAccess(permissions, PERMS.POLLS_EDIT, session.user.rank)) {
redirect('/admin/polls')
}
const pollId = parseInt(id, 10)
if (Number.isNaN(pollId)) notFound()
const poll = await prisma.websitePoll.findUnique({
where: { id: pollId },
include: {
questions: {
orderBy: { sortOrder: 'asc' },
include: {
votes: true,
},
},
},
})
if (!poll) notFound()
const totalVoters = new Set(poll.questions.flatMap((q) => q.votes.map((v) => v.userId))).size
return (
<div className="space-y-6">
<div>
<AdminBreadcrumb
items={[{ label: 'Polls', href: '/admin/polls' }, { label: poll.title }]}
/>
<div className="flex items-center gap-3">
<h1 className="text-3xl font-bold tracking-tight">{poll.title}</h1>
<Badge
variant={
poll.status === 'active'
? 'default'
: poll.status === 'closed'
? 'outline'
: 'secondary'
}
>
{poll.status}
</Badge>
</div>
{poll.description && <p className="text-muted-foreground">{poll.description}</p>}
</div>
{/* Stats */}
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Total Voters</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{totalVoters}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Questions</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{poll.questions.length}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Total Votes</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{poll.questions.reduce((sum, q) => sum + q.votes.length, 0)}
</div>
</CardContent>
</Card>
</div>
{/* Edit form */}
<PollForm
defaultValues={{
id: poll.id,
title: poll.title,
description: poll.description,
status: poll.status as 'draft' | 'active' | 'closed',
showResults: poll.showResults,
multipleChoice: poll.multipleChoice,
startsAt: poll.startsAt,
endsAt: poll.endsAt,
}}
/>
{/* Questions */}
<PollQuestionsManager
pollId={poll.id}
questions={poll.questions.map((q) => ({
id: q.id,
question: q.question,
type: q.type,
sortOrder: q.sortOrder,
options: q.options,
}))}
/>
{/* Results */}
{poll.questions.length > 0 && (
<PollResultsView
questions={poll.questions.map((q) => ({
id: q.id,
question: q.question,
options: q.options,
votes: q.votes.map((v) => v.answer),
}))}
/>
)}
</div>
)
}
+199
View File
@@ -0,0 +1,199 @@
'use client'
import { GripVertical, Pencil, Plus, Trash2 } from 'lucide-react'
import { useState } from 'react'
import { addPollQuestion, deletePollQuestion, updatePollQuestion } from '@/actions/polls'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { useServerAction } from '@/hooks/use-server-action'
interface Question {
id: number
question: string
type: string
sortOrder: number
options: string
}
interface Props {
pollId: number
questions: Question[]
}
export function PollQuestionsManager({ pollId, questions }: Props) {
const { run, isPending } = useServerAction()
const [showForm, setShowForm] = useState(false)
const [editingId, setEditingId] = useState<number | null>(null)
const [form, setForm] = useState({
question: '',
type: 'single',
sortOrder: questions.length,
options: '',
})
function resetForm() {
setForm({ question: '', type: 'single', sortOrder: questions.length, options: '' })
setEditingId(null)
setShowForm(false)
}
function handleEdit(q: Question) {
setForm({ question: q.question, type: q.type, sortOrder: q.sortOrder, options: q.options })
setEditingId(q.id)
setShowForm(true)
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (editingId) {
// biome-ignore lint/suspicious/noExplicitAny: explicitly chosen here, see surrounding code
run(() => updatePollQuestion({ id: editingId, ...form } as any), {
successMessage: 'Question updated!',
})
} else {
// biome-ignore lint/suspicious/noExplicitAny: explicitly chosen here, see surrounding code
run(() => addPollQuestion({ pollId, ...form } as any), {
successMessage: 'Question added!',
})
}
resetForm()
}
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Questions ({questions.length})</CardTitle>
<Button
size="sm"
onClick={() => {
resetForm()
setShowForm(!showForm)
}}
>
<Plus className="h-4 w-4 mr-1" /> Add Question
</Button>
</CardHeader>
<CardContent className="space-y-4">
{showForm && (
<form onSubmit={handleSubmit} className="border rounded-lg p-4 space-y-3">
<div className="space-y-1">
<Label>Question</Label>
<Input
value={form.question}
onChange={(e) => setForm({ ...form, question: e.target.value })}
required
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label>Type</Label>
<Select value={form.type} onValueChange={(v) => setForm({ ...form, type: v })}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="single">Single Choice</SelectItem>
<SelectItem value="multiple">Multiple Choice</SelectItem>
<SelectItem value="text">Free Text</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label>Sort Order</Label>
<Input
type="number"
value={form.sortOrder}
onChange={(e) => setForm({ ...form, sortOrder: Number(e.target.value) })}
/>
</div>
</div>
<div className="space-y-1">
<Label>Options (one per line)</Label>
<textarea
className="w-full rounded-md border bg-transparent px-3 py-2 text-sm min-h-[80px]"
value={form.options}
onChange={(e) => setForm({ ...form, options: e.target.value })}
placeholder={'Option A\nOption B\nOption C'}
/>
</div>
<div className="flex gap-2 justify-end">
<Button type="button" variant="outline" size="sm" onClick={resetForm}>
Cancel
</Button>
<Button type="submit" size="sm" disabled={isPending}>
{editingId ? 'Update' : 'Add'}
</Button>
</div>
</form>
)}
{questions.length === 0 && !showForm && (
<p className="text-sm text-muted-foreground text-center py-4">
No questions yet. Add your first question above.
</p>
)}
<div className="space-y-2">
{questions.map((q, i) => (
<div key={q.id} className="flex items-start gap-3 border rounded-lg px-4 py-3">
<GripVertical className="h-4 w-4 text-muted-foreground mt-0.5 shrink-0" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-xs text-muted-foreground">Q{i + 1}</span>
<span className="text-xs bg-muted px-1.5 py-0.5 rounded">{q.type}</span>
</div>
<p className="text-sm font-medium">{q.question}</p>
{q.options && (
<div className="mt-1 text-xs text-muted-foreground">
{q.options
.split('\n')
.filter(Boolean)
.map((opt) => (
<span
key={opt}
className="inline-block mr-2 bg-muted px-1.5 py-0.5 rounded mb-1"
>
{opt}
</span>
))}
</div>
)}
</div>
<div className="flex gap-1 shrink-0">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => handleEdit(q)}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() =>
run(() => deletePollQuestion({ id: q.id }), {
successMessage: 'Question deleted',
})
}
>
<Trash2 className="h-3.5 w-3.5 text-destructive" />
</Button>
</div>
</div>
))}
</div>
</CardContent>
</Card>
)
}
+74
View File
@@ -0,0 +1,74 @@
'use client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
interface QuestionResult {
id: number
question: string
options: string
votes: string[] // array of answer strings
}
interface Props {
questions: QuestionResult[]
}
export function PollResultsView({ questions }: Props) {
return (
<Card>
<CardHeader>
<CardTitle>Results</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{questions.map((q) => {
const options = q.options.split('\n').filter(Boolean)
const totalVotes = q.votes.length
// Count votes per option
const voteCounts = new Map<string, number>()
for (const opt of options) voteCounts.set(opt, 0)
for (const vote of q.votes) {
voteCounts.set(vote, (voteCounts.get(vote) ?? 0) + 1)
}
return (
<div key={q.id}>
<h4 className="text-sm font-medium mb-3">{q.question}</h4>
{options.length > 0 ? (
<div className="space-y-2">
{options.map((opt) => {
const count = voteCounts.get(opt) ?? 0
const pct = totalVotes > 0 ? Math.round((count / totalVotes) * 100) : 0
return (
<div key={opt}>
<div className="flex justify-between text-xs mb-1">
<span>{opt}</span>
<span className="text-muted-foreground">
{count} votes ({pct}%)
</span>
</div>
<div className="h-2 w-full rounded-full bg-muted overflow-hidden">
<div
className="h-full rounded-full bg-primary transition-all"
style={{ width: `${pct}%` }}
/>
</div>
</div>
)
})}
</div>
) : (
<div className="text-sm text-muted-foreground">
{totalVotes} free-text responses
</div>
)}
<p className="text-xs text-muted-foreground mt-2">
{totalVotes} total vote{totalVotes !== 1 ? 's' : ''}
</p>
</div>
)
})}
</CardContent>
</Card>
)
}
+21
View File
@@ -0,0 +1,21 @@
import { redirect } from 'next/navigation'
import { canAccess, getAdminContext, PERMS } from '@/lib/permissions'
import { PollForm } from '../poll-form'
export default async function CreatePollPage() {
const { session, permissions } = await getAdminContext()
if (!canAccess(permissions, PERMS.POLLS_EDIT, session.user.rank)) {
redirect('/admin/polls')
}
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">Create Poll</h1>
<p className="text-muted-foreground">Create a new community poll or survey</p>
</div>
<PollForm />
</div>
)
}
+67
View File
@@ -0,0 +1,67 @@
import Link from 'next/link'
import { Button } from '@/components/ui/button'
import { fetchAdminList } from '@/lib/admin-list'
import { PERMS } from '@/lib/permissions'
import { PollsTable } from './polls-table'
export default async function PollsPage({
params: paramsPromise,
searchParams,
}: {
params: Promise<{ locale: string }>
searchParams: Promise<Record<string, string>>
}) {
const result = await fetchAdminList(
{
permission: PERMS.POLLS_VIEW,
model: 'websitePoll',
searchFields: ['title'],
defaultSort: { createdAt: 'desc' },
mapRow: (p: {
id: number
title: string
status: string
showResults: number
multipleChoice: number
startsAt: Date | null
endsAt: Date | null
createdAt: Date
}) => ({
id: p.id,
title: p.title,
status: p.status,
showResults: p.showResults,
multipleChoice: p.multipleChoice,
startsAt: p.startsAt,
endsAt: p.endsAt,
createdAt: p.createdAt,
}),
},
paramsPromise,
searchParams,
)
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Polls</h1>
<p className="text-muted-foreground">Manage community polls and surveys</p>
</div>
<Button asChild>
<Link href="/admin/polls/create">New Poll</Link>
</Button>
</div>
<PollsTable
data={{
rows: result.rows,
total: result.total,
page: result.page,
perPage: result.perPage,
lastPage: result.lastPage,
}}
/>
</div>
)
}
+143
View File
@@ -0,0 +1,143 @@
'use client'
import { useForm } from 'react-hook-form'
import { createPoll, updatePoll } from '@/actions/polls'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { DateTimePicker } from '@/components/ui/date-time-picker'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Switch } from '@/components/ui/switch'
import { Textarea } from '@/components/ui/textarea'
import { useServerAction } from '@/hooks/use-server-action'
import type { CreatePollInput } from '@/lib/validators/poll'
interface PollFormProps {
defaultValues?: Partial<CreatePollInput> & { id?: number }
}
export function PollForm({ defaultValues }: PollFormProps) {
const { run, isPending } = useServerAction()
const isEditing = !!defaultValues?.id
const form = useForm({
defaultValues: {
title: '',
description: '',
status: 'draft',
showResults: 1,
multipleChoice: 0,
...defaultValues,
},
})
// biome-ignore lint/suspicious/noExplicitAny: explicitly chosen here, see surrounding code
function onSubmit(data: any) {
if (isEditing) {
run(() => updatePoll({ id: defaultValues!.id!, ...data }), {
successMessage: 'Poll updated!',
})
} else {
run(() => createPoll(data), {
successMessage: 'Poll created!',
onSuccess: (result) => {
const data = result.data as Record<string, unknown> | undefined
if (data?.id) {
window.location.href = `/admin/polls/${data.id}`
}
},
})
}
}
return (
<form onSubmit={form.handleSubmit(onSubmit)}>
<Card>
<CardHeader>
<CardTitle>{isEditing ? 'Edit Poll' : 'Poll Details'}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="title">Title</Label>
<Input id="title" {...form.register('title')} />
{form.formState.errors.title && (
<p className="text-sm text-destructive">{form.formState.errors.title.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea id="description" rows={3} {...form.register('description')} />
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="space-y-2">
<Label htmlFor="status">Status</Label>
<Select
value={form.watch('status')}
onValueChange={(v) => form.setValue('status', v as CreatePollInput['status'])}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="draft">Draft</SelectItem>
<SelectItem value="active">Active</SelectItem>
<SelectItem value="closed">Closed</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Starts At</Label>
<DateTimePicker
value={form.watch('startsAt')}
// biome-ignore lint/suspicious/noExplicitAny: form value type inferred loose; runtime validated by zod on submit
onChange={(d) => form.setValue('startsAt', d as any, { shouldDirty: true })}
placeholder="Select start date"
/>
</div>
<div className="space-y-2">
<Label>Ends At</Label>
<DateTimePicker
value={form.watch('endsAt')}
// biome-ignore lint/suspicious/noExplicitAny: form value type inferred loose; runtime validated by zod on submit
onChange={(d) => form.setValue('endsAt', d as any, { shouldDirty: true })}
placeholder="Select end date"
/>
</div>
</div>
<div className="flex gap-6">
<div className="flex items-center gap-2">
<Switch
checked={form.watch('showResults') === 1}
onCheckedChange={(v) => form.setValue('showResults', v ? 1 : 0)}
/>
<Label>Show results to voters</Label>
</div>
<div className="flex items-center gap-2">
<Switch
checked={form.watch('multipleChoice') === 1}
onCheckedChange={(v) => form.setValue('multipleChoice', v ? 1 : 0)}
/>
<Label>Allow multiple answers</Label>
</div>
</div>
<div className="flex justify-end">
<Button type="submit" disabled={isPending}>
{isPending ? 'Saving...' : isEditing ? 'Update Poll' : 'Create Poll'}
</Button>
</div>
</CardContent>
</Card>
</form>
)
}
+79
View File
@@ -0,0 +1,79 @@
'use client'
import { Pencil } from 'lucide-react'
import Link from 'next/link'
import { DataTable } from '@/components/admin/data-table'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import type { DataTableColumn, PaginatedResult } from '@/types'
interface PollRow {
id: number
title: string
status: string
showResults: number
multipleChoice: number
startsAt: Date | null
endsAt: Date | null
createdAt: Date
}
const statusVariants: Record<string, 'default' | 'secondary' | 'outline'> = {
active: 'default',
draft: 'secondary',
closed: 'outline',
}
function getColumns(): DataTableColumn<PollRow>[] {
return [
{ key: 'id', label: 'ID', sortable: true },
{
key: 'title',
label: 'Title',
sortable: true,
render: (value) => <span className="font-medium">{String(value)}</span>,
},
{
key: 'status',
label: 'Status',
sortable: true,
render: (value) => (
<Badge variant={statusVariants[String(value)] ?? 'secondary'}>{String(value)}</Badge>
),
},
{
key: 'multipleChoice',
label: 'Type',
render: (value) => (
<span className="text-sm">{Number(value) ? 'Multiple choice' : 'Single choice'}</span>
),
},
{
key: 'createdAt',
label: 'Created',
sortable: true,
render: (value) => (
<span className="text-sm">{new Date(value as unknown as string).toLocaleDateString()}</span>
),
},
{
key: 'actions',
label: 'Actions',
render: (_, row) => (
<Button variant="ghost" size="icon" asChild>
<Link href={`/admin/polls/${row.id}`}>
<Pencil className="h-4 w-4" />
</Link>
</Button>
),
},
]
}
interface PollsTableProps {
data: PaginatedResult<PollRow>
}
export function PollsTable({ data }: PollsTableProps) {
return <DataTable data={data} columns={getColumns()} searchPlaceholder="Search polls..." />
}