style: format code biome
Local Build and Deploy / deploy (push) Failing after 46s

This commit is contained in:
openhands committed 2026-07-13 21:57:41 +02:00
1 parent 8efd032cc6
commit df38dccbf1
735 files changed
+128321 -120870

No files matched your search

+117 -104
View File
@@ -9,119 +9,132 @@ import { PollQuestionsManager } from "./poll-questions";
import { PollResultsView } from "./poll-results";
export default async function PollDetailPage({
params,
params,
}: {
params: Promise<{ locale: string; id: string }>;
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 { 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 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,
},
},
},
});
const poll = await prisma.websitePoll.findUnique({
where: { id: pollId },
include: {
questions: {
orderBy: { sortOrder: "asc" },
include: {
votes: true,
},
},
},
});
if (!poll) notFound();
if (!poll) notFound();
const totalVoters = new Set(poll.questions.flatMap((q) => q.votes.map((v) => v.userId))).size;
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>
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>
{/* 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,
}}
/>
{/* 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,
}))}
/>
{/* 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>
);
{/* 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>
);
}
+209 -161
View File
@@ -2,184 +2,232 @@
import { GripVertical, Pencil, Plus, Trash2 } from "lucide-react";
import { useState } from "react";
import { addPollQuestion, deletePollQuestion, updatePollQuestion } from "@/actions/polls";
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 {
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;
id: number;
question: string;
type: string;
sortOrder: number;
options: string;
}
interface Props {
pollId: number;
questions: Question[];
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: "",
});
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 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 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();
}
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>
)}
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>
)}
{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>
);
<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>
);
}
+63 -58
View File
@@ -3,70 +3,75 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
interface QuestionResult {
id: number;
question: string;
options: string;
votes: string[]; // array of answer strings
id: number;
question: string;
options: string;
votes: string[]; // array of answer strings
}
interface Props {
questions: QuestionResult[];
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;
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);
}
// 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>
);
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>
);
}
+15 -13
View File
@@ -3,19 +3,21 @@ 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");
}
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>
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>
);
<PollForm />
</div>
);
}
+57 -55
View File
@@ -5,63 +5,65 @@ import { PERMS } from "@/lib/permissions";
import { PollsTable } from "./polls-table";
export default async function PollsPage({
params: paramsPromise,
searchParams,
params: paramsPromise,
searchParams,
}: {
params: Promise<{ locale: string }>;
searchParams: Promise<Record<string, string>>;
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,
);
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>
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>
);
<PollsTable
data={{
rows: result.rows,
total: result.total,
page: result.page,
perPage: result.perPage,
lastPage: result.lastPage,
}}
/>
</div>
);
}
+135 -111
View File
@@ -7,131 +7,155 @@ 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 {
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 };
defaultValues?: Partial<CreatePollInput> & { id?: number };
}
export function PollForm({ defaultValues }: PollFormProps) {
const { run, isPending } = useServerAction();
const isEditing = !!defaultValues?.id;
const { run, isPending } = useServerAction();
const isEditing = !!defaultValues?.id;
const form = useForm({
defaultValues: {
title: "",
description: "",
status: "draft",
showResults: 1,
multipleChoice: 0,
...defaultValues,
},
});
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}`;
}
},
});
}
}
// 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>
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="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="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 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>
);
<div className="flex justify-end">
<Button type="submit" disabled={isPending}>
{isPending
? "Saving..."
: isEditing
? "Update Poll"
: "Create Poll"}
</Button>
</div>
</CardContent>
</Card>
</form>
);
}
+68 -56
View File
@@ -8,72 +8,84 @@ 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;
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",
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>
),
},
];
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>;
data: PaginatedResult<PollRow>;
}
export function PollsTable({ data }: PollsTableProps) {
return <DataTable data={data} columns={getColumns()} searchPlaceholder="Search polls..." />;
return (
<DataTable
data={data}
columns={getColumns()}
searchPlaceholder="Search polls..."
/>
);
}