refactor: consolidate form client components and standardize layout modularity
This commit is contained in:
+1
-1
@@ -32,7 +32,7 @@ export async function createForm(formData: FormData) {
|
||||
redirect(`/form/admin/${form.id}/edit`);
|
||||
}
|
||||
|
||||
export async function updateForm(id: string, data: { title: string; description?: string }) {
|
||||
export async function updateForm(id: string, data: { title: string; description?: string; isOpen?: boolean }) {
|
||||
const session = await getServerSession(authOptions);
|
||||
const adminIds = (process.env.ADMIN_DISCORD_IDS ?? "").split(",").map((s) => s.trim()).filter(Boolean);
|
||||
const discordId = (session?.user as { discordId?: string } | undefined)?.discordId;
|
||||
|
||||
@@ -17,6 +17,12 @@ export async function submitForm(
|
||||
const discordId = (session.user as { discordId?: string }).discordId;
|
||||
if (!discordId) throw new Error("No Discord ID found");
|
||||
|
||||
const form = await db.query.forms.findFirst({
|
||||
where: (f, { eq }) => eq(f.id, formId),
|
||||
});
|
||||
if (!form) throw new Error("Form not found");
|
||||
if (!form.isOpen) throw new Error("This form is disabled for now");
|
||||
|
||||
const existingSubmission = await db.query.submissions.findFirst({
|
||||
where: (s, { eq, and }) =>
|
||||
and(eq(s.formId, formId), eq(s.userDiscordId, discordId)),
|
||||
|
||||
@@ -37,6 +37,7 @@ interface FormEditorClientProps {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
isOpen: boolean;
|
||||
};
|
||||
initialQuestions: Question[];
|
||||
}
|
||||
@@ -45,6 +46,7 @@ export default function FormEditorClient({ form, initialQuestions }: FormEditorC
|
||||
const router = useRouter();
|
||||
const [formTitle, setFormTitle] = useState(form.title);
|
||||
const [formDescription, setFormDescription] = useState(form.description || "");
|
||||
const [formIsOpen, setFormIsOpen] = useState(form.isOpen);
|
||||
const [questions, setQuestions] = useState<Question[]>(initialQuestions);
|
||||
const [uploadingId, setUploadingId] = useState<string | null>(null);
|
||||
const [isFormSaving, startFormSaveTransition] = useTransition();
|
||||
@@ -60,6 +62,7 @@ export default function FormEditorClient({ form, initialQuestions }: FormEditorC
|
||||
await updateForm(form.id, {
|
||||
title: formTitle,
|
||||
description: formDescription,
|
||||
isOpen: formIsOpen,
|
||||
});
|
||||
toast.success("Form metadata saved!");
|
||||
router.refresh();
|
||||
@@ -210,6 +213,16 @@ export default function FormEditorClient({ form, initialQuestions }: FormEditorC
|
||||
className="min-h-20"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 pt-2">
|
||||
<Switch
|
||||
id="form-is-open"
|
||||
checked={formIsOpen}
|
||||
onCheckedChange={setFormIsOpen}
|
||||
/>
|
||||
<Label htmlFor="form-is-open" className="text-sm font-normal text-muted-foreground">
|
||||
Accepting Submissions
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex justify-end pt-2">
|
||||
<Button onClick={saveFormMetadata} disabled={isFormSaving} className="gap-2">
|
||||
{isFormSaving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { SidebarProvider } from "@/components/ui/sidebar";
|
||||
import { AdminSidebar } from "./admin-sidebar";
|
||||
import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
|
||||
import { AdminSidebar } from "@/components/admin-sidebar";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { db } from "@/db";
|
||||
import { forms } from "@/db/schema";
|
||||
import FormEditorClient from "./form-editor-client";
|
||||
import FormEditorClient from "./client";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
|
||||
@@ -3,8 +3,8 @@ import { notFound } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import SubmissionDetailClient from "./submission-detail-client";
|
||||
import { DeleteSubmissionButton } from "../delete-buttons-client";
|
||||
import SubmissionDetailClient from "./client";
|
||||
import { DeleteSubmissionButton } from "../client";
|
||||
|
||||
interface SubmissionDetailPageProps {
|
||||
params: Promise<{
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import Link from "next/link";
|
||||
import { Eye, Users } from "lucide-react";
|
||||
import { DeleteSubmissionButton, DeleteAllSubmissionsButton } from "./delete-buttons-client";
|
||||
import { DeleteSubmissionButton, DeleteAllSubmissionsButton } from "./client";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { toast } from "sonner";
|
||||
import { submitForm } from "@/app/actions/submissions";
|
||||
import { Loader2, LogOut } from "lucide-react";
|
||||
import { signOut } from "next-auth/react";
|
||||
import Image from "next/image";
|
||||
|
||||
export function FormLayoutClient({
|
||||
userName,
|
||||
userImage,
|
||||
}: {
|
||||
userName: string;
|
||||
userImage: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
{userImage && (
|
||||
<Image
|
||||
src={userImage}
|
||||
alt={userName}
|
||||
width={28}
|
||||
height={28}
|
||||
className="rounded-full"
|
||||
/>
|
||||
)}
|
||||
<span className="text-sm text-muted-foreground">{userName}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => signOut({ callbackUrl: "/auth/signin" })}
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface Question {
|
||||
id: string;
|
||||
formId: string;
|
||||
type: "text" | "textarea" | "radio" | "checkbox";
|
||||
label: string;
|
||||
imageUrl: string | null;
|
||||
displayOrder: number;
|
||||
required: boolean;
|
||||
options: string[] | null;
|
||||
}
|
||||
|
||||
interface FormFillerClientProps {
|
||||
form: {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
isOpen: boolean;
|
||||
};
|
||||
questions: Question[];
|
||||
initialAnswers?: Record<string, string>;
|
||||
}
|
||||
|
||||
export function FormFillerClient({ form, questions, initialAnswers = {} }: FormFillerClientProps) {
|
||||
const router = useRouter();
|
||||
|
||||
// Pre-process checkbox answers since they are stored as comma-separated strings
|
||||
const initialCheckboxes: Record<string, string[]> = {};
|
||||
for (const q of questions) {
|
||||
if (q.type === "checkbox" && initialAnswers[q.id]) {
|
||||
initialCheckboxes[q.id] = initialAnswers[q.id].split(", ");
|
||||
}
|
||||
}
|
||||
|
||||
const [answers, setAnswers] = useState<Record<string, string>>(initialAnswers);
|
||||
const [checkboxAnswers, setCheckboxAnswers] = useState<Record<string, string[]>>(initialCheckboxes);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const isUpdating = Object.keys(initialAnswers).length > 0;
|
||||
|
||||
const handleTextChange = (questionId: string, value: string) => {
|
||||
setAnswers((prev) => ({ ...prev, [questionId]: value }));
|
||||
};
|
||||
|
||||
const handleRadioChange = (questionId: string, value: string) => {
|
||||
setAnswers((prev) => ({ ...prev, [questionId]: value }));
|
||||
};
|
||||
|
||||
const handleCheckboxChange = (questionId: string, option: string, checked: boolean) => {
|
||||
setCheckboxAnswers((prev) => {
|
||||
const current = prev[questionId] || [];
|
||||
const updated = checked
|
||||
? [...current, option]
|
||||
: current.filter((item) => item !== option);
|
||||
return { ...prev, [questionId]: updated };
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!form.isOpen) return;
|
||||
|
||||
// Validate required questions
|
||||
for (const q of questions) {
|
||||
if (q.required) {
|
||||
if (q.type === "checkbox") {
|
||||
const selected = checkboxAnswers[q.id] || [];
|
||||
if (selected.length === 0) {
|
||||
toast.error(`"${q.label}" is required.`);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
const ans = answers[q.id] || "";
|
||||
if (!ans.trim()) {
|
||||
toast.error(`"${q.label}" is required.`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const formattedAnswers = questions.map((q) => {
|
||||
let value = "";
|
||||
if (q.type === "checkbox") {
|
||||
value = (checkboxAnswers[q.id] || []).join(", ");
|
||||
} else {
|
||||
value = answers[q.id] || "";
|
||||
}
|
||||
return {
|
||||
questionId: q.id,
|
||||
value,
|
||||
};
|
||||
});
|
||||
|
||||
const res = await submitForm(form.id, formattedAnswers);
|
||||
if (res.ok) {
|
||||
toast.success(isUpdating ? "Form updated successfully!" : "Form submitted successfully!");
|
||||
if (!isUpdating) {
|
||||
setAnswers({});
|
||||
setCheckboxAnswers({});
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
toast.error(err.message || "Failed to submit form.");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{!form.isOpen && (
|
||||
<div className="absolute inset-0 z-50 flex items-center justify-center bg-background/50 backdrop-blur-sm rounded-xl">
|
||||
<div className="bg-background border shadow-lg p-6 rounded-lg">
|
||||
<h2 className="text-xl font-bold text-center">This form disable for now</h2>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<Card className="border-t-8 border-t-primary shadow-md">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-3xl font-bold">{form.title}</CardTitle>
|
||||
{form.description && (
|
||||
<CardDescription className="text-base mt-2 whitespace-pre-wrap">
|
||||
{form.description}
|
||||
</CardDescription>
|
||||
)}
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
<div className="space-y-4">
|
||||
{questions.map((q) => (
|
||||
<Card key={q.id} className="shadow-sm">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<Label className="text-base font-semibold leading-normal">
|
||||
{q.label}
|
||||
{q.required && <span className="text-destructive ml-1">*</span>}
|
||||
</Label>
|
||||
</div>
|
||||
{q.imageUrl && (
|
||||
<div className="mt-3 relative max-w-full h-auto max-h-80 overflow-hidden rounded-md border">
|
||||
<img
|
||||
src={q.imageUrl}
|
||||
alt={q.label}
|
||||
className="object-contain max-h-80 w-auto"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{q.type === "text" && (
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Your answer"
|
||||
required={q.required}
|
||||
value={answers[q.id] || ""}
|
||||
onChange={(e) => handleTextChange(q.id, e.target.value)}
|
||||
className="w-full"
|
||||
disabled={!form.isOpen}
|
||||
/>
|
||||
)}
|
||||
|
||||
{q.type === "textarea" && (
|
||||
<Textarea
|
||||
placeholder="Your answer"
|
||||
required={q.required}
|
||||
value={answers[q.id] || ""}
|
||||
onChange={(e) => handleTextChange(q.id, e.target.value)}
|
||||
className="w-full min-h-24 resize-y"
|
||||
disabled={!form.isOpen}
|
||||
/>
|
||||
)}
|
||||
|
||||
{q.type === "radio" && q.options && (
|
||||
<RadioGroup
|
||||
required={q.required}
|
||||
value={answers[q.id] || ""}
|
||||
onValueChange={(val) => handleRadioChange(q.id, val)}
|
||||
className="space-y-2"
|
||||
disabled={!form.isOpen}
|
||||
>
|
||||
{q.options.map((opt, idx) => (
|
||||
<div key={idx} className="flex items-center space-x-2">
|
||||
<RadioGroupItem value={opt} id={`${q.id}-${idx}`} disabled={!form.isOpen} />
|
||||
<Label htmlFor={`${q.id}-${idx}`} className="font-normal cursor-pointer">
|
||||
{opt}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</RadioGroup>
|
||||
)}
|
||||
|
||||
{q.type === "checkbox" && q.options && (
|
||||
<div className="space-y-2">
|
||||
{q.options.map((opt, idx) => {
|
||||
const isChecked = (checkboxAnswers[q.id] || []).includes(opt);
|
||||
return (
|
||||
<div key={idx} className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={`${q.id}-${idx}`}
|
||||
checked={isChecked}
|
||||
onCheckedChange={(checked) =>
|
||||
handleCheckboxChange(q.id, opt, !!checked)
|
||||
}
|
||||
disabled={!form.isOpen}
|
||||
/>
|
||||
<Label htmlFor={`${q.id}-${idx}`} className="font-normal cursor-pointer">
|
||||
{opt}
|
||||
</Label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end items-center pt-4">
|
||||
<Button type="submit" disabled={submitting || !form.isOpen} className="min-w-28">
|
||||
{submitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Submitting
|
||||
</>
|
||||
) : isUpdating ? (
|
||||
"Update Submission"
|
||||
) : (
|
||||
"Submit"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,241 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { toast } from "sonner";
|
||||
import { submitForm } from "@/app/actions/submissions";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
interface Question {
|
||||
id: string;
|
||||
formId: string;
|
||||
type: "text" | "textarea" | "radio" | "checkbox";
|
||||
label: string;
|
||||
imageUrl: string | null;
|
||||
displayOrder: number;
|
||||
required: boolean;
|
||||
options: string[] | null;
|
||||
}
|
||||
|
||||
interface FormFillerClientProps {
|
||||
form: {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
};
|
||||
questions: Question[];
|
||||
initialAnswers?: Record<string, string>;
|
||||
}
|
||||
|
||||
export default function FormFillerClient({ form, questions, initialAnswers = {} }: FormFillerClientProps) {
|
||||
const router = useRouter();
|
||||
|
||||
// Pre-process checkbox answers since they are stored as comma-separated strings
|
||||
const initialCheckboxes: Record<string, string[]> = {};
|
||||
for (const q of questions) {
|
||||
if (q.type === "checkbox" && initialAnswers[q.id]) {
|
||||
initialCheckboxes[q.id] = initialAnswers[q.id].split(", ");
|
||||
}
|
||||
}
|
||||
|
||||
const [answers, setAnswers] = useState<Record<string, string>>(initialAnswers);
|
||||
const [checkboxAnswers, setCheckboxAnswers] = useState<Record<string, string[]>>(initialCheckboxes);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const isUpdating = Object.keys(initialAnswers).length > 0;
|
||||
|
||||
const handleTextChange = (questionId: string, value: string) => {
|
||||
setAnswers((prev) => ({ ...prev, [questionId]: value }));
|
||||
};
|
||||
|
||||
const handleRadioChange = (questionId: string, value: string) => {
|
||||
setAnswers((prev) => ({ ...prev, [questionId]: value }));
|
||||
};
|
||||
|
||||
const handleCheckboxChange = (questionId: string, option: string, checked: boolean) => {
|
||||
setCheckboxAnswers((prev) => {
|
||||
const current = prev[questionId] || [];
|
||||
const updated = checked
|
||||
? [...current, option]
|
||||
: current.filter((item) => item !== option);
|
||||
return { ...prev, [questionId]: updated };
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Validate required questions
|
||||
for (const q of questions) {
|
||||
if (q.required) {
|
||||
if (q.type === "checkbox") {
|
||||
const selected = checkboxAnswers[q.id] || [];
|
||||
if (selected.length === 0) {
|
||||
toast.error(`"${q.label}" is required.`);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
const ans = answers[q.id] || "";
|
||||
if (!ans.trim()) {
|
||||
toast.error(`"${q.label}" is required.`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const formattedAnswers = questions.map((q) => {
|
||||
let value = "";
|
||||
if (q.type === "checkbox") {
|
||||
value = (checkboxAnswers[q.id] || []).join(", ");
|
||||
} else {
|
||||
value = answers[q.id] || "";
|
||||
}
|
||||
return {
|
||||
questionId: q.id,
|
||||
value,
|
||||
};
|
||||
});
|
||||
|
||||
const res = await submitForm(form.id, formattedAnswers);
|
||||
if (res.ok) {
|
||||
toast.success(isUpdating ? "Form updated successfully!" : "Form submitted successfully!");
|
||||
// We don't reset answers if updating, so they can see their current state
|
||||
if (!isUpdating) {
|
||||
setAnswers({});
|
||||
setCheckboxAnswers({});
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
toast.error(err.message || "Failed to submit form.");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<Card className="border-t-8 border-t-primary shadow-md">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-3xl font-bold">{form.title}</CardTitle>
|
||||
{form.description && (
|
||||
<CardDescription className="text-base mt-2 whitespace-pre-wrap">
|
||||
{form.description}
|
||||
</CardDescription>
|
||||
)}
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
<div className="space-y-4">
|
||||
{questions.map((q) => (
|
||||
<Card key={q.id} className="shadow-sm">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<Label className="text-base font-semibold leading-normal">
|
||||
{q.label}
|
||||
{q.required && <span className="text-destructive ml-1">*</span>}
|
||||
</Label>
|
||||
</div>
|
||||
{q.imageUrl && (
|
||||
<div className="mt-3 relative max-w-full h-auto max-h-80 overflow-hidden rounded-md border">
|
||||
<img
|
||||
src={q.imageUrl}
|
||||
alt={q.label}
|
||||
className="object-contain max-h-80 w-auto"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{q.type === "text" && (
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Your answer"
|
||||
required={q.required}
|
||||
value={answers[q.id] || ""}
|
||||
onChange={(e) => handleTextChange(q.id, e.target.value)}
|
||||
className="w-full"
|
||||
/>
|
||||
)}
|
||||
|
||||
{q.type === "textarea" && (
|
||||
<Textarea
|
||||
placeholder="Your answer"
|
||||
required={q.required}
|
||||
value={answers[q.id] || ""}
|
||||
onChange={(e) => handleTextChange(q.id, e.target.value)}
|
||||
className="w-full min-h-24 resize-y"
|
||||
/>
|
||||
)}
|
||||
|
||||
{q.type === "radio" && q.options && (
|
||||
<RadioGroup
|
||||
required={q.required}
|
||||
value={answers[q.id] || ""}
|
||||
onValueChange={(val) => handleRadioChange(q.id, val)}
|
||||
className="space-y-2"
|
||||
>
|
||||
{q.options.map((opt, idx) => (
|
||||
<div key={idx} className="flex items-center space-x-2">
|
||||
<RadioGroupItem value={opt} id={`${q.id}-${idx}`} />
|
||||
<Label htmlFor={`${q.id}-${idx}`} className="font-normal cursor-pointer">
|
||||
{opt}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</RadioGroup>
|
||||
)}
|
||||
|
||||
{q.type === "checkbox" && q.options && (
|
||||
<div className="space-y-2">
|
||||
{q.options.map((opt, idx) => {
|
||||
const isChecked = (checkboxAnswers[q.id] || []).includes(opt);
|
||||
return (
|
||||
<div key={idx} className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={`${q.id}-${idx}`}
|
||||
checked={isChecked}
|
||||
onCheckedChange={(checked) =>
|
||||
handleCheckboxChange(q.id, opt, !!checked)
|
||||
}
|
||||
/>
|
||||
<Label htmlFor={`${q.id}-${idx}`} className="font-normal cursor-pointer">
|
||||
{opt}
|
||||
</Label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end items-center pt-4">
|
||||
<Button type="submit" disabled={submitting} className="min-w-28">
|
||||
{submitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Submitting
|
||||
</>
|
||||
) : isUpdating ? (
|
||||
"Update Submission"
|
||||
) : (
|
||||
"Submit"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { signOut } from "next-auth/react";
|
||||
import Image from "next/image";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { LogOut } from "lucide-react";
|
||||
|
||||
export default function FormLayoutClient({
|
||||
userName,
|
||||
userImage,
|
||||
}: {
|
||||
userName: string;
|
||||
userImage: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
{userImage && (
|
||||
<Image
|
||||
src={userImage}
|
||||
alt={userName}
|
||||
width={28}
|
||||
height={28}
|
||||
className="rounded-full"
|
||||
/>
|
||||
)}
|
||||
<span className="text-sm text-muted-foreground">{userName}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => signOut({ callbackUrl: "/auth/signin" })}
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+2
-2
@@ -1,8 +1,8 @@
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import FormLayoutClient from "./layout-client";
|
||||
import LoginDialog from "./login-dialog";
|
||||
import { FormLayoutClient } from "./client";
|
||||
import LoginDialog from "@/components/login-dialog";
|
||||
|
||||
export default async function FormLayout({
|
||||
children,
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import { db } from "@/db";
|
||||
import { forms } from "@/db/schema";
|
||||
import FormFillerClient from "./form-filler-client";
|
||||
import { FormFillerClient } from "./client";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
|
||||
|
||||
|
||||
@@ -71,6 +71,7 @@ export const forms = pgTable("form", {
|
||||
title: text("title").notNull(),
|
||||
description: text("description"),
|
||||
createdAt: timestamp("created_at", { mode: "date" }).defaultNow().notNull(),
|
||||
isOpen: boolean("is_open").notNull().default(true),
|
||||
});
|
||||
|
||||
export type QuestionType = "text" | "textarea" | "radio" | "checkbox";
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "form" ADD COLUMN "is_open" boolean DEFAULT true NOT NULL;
|
||||
@@ -0,0 +1,515 @@
|
||||
{
|
||||
"id": "1d99ab40-4e60-4255-9948-e4a4b54375eb",
|
||||
"prevId": "d9863f2d-5c3b-4d3a-8759-4170d72381a3",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.account": {
|
||||
"name": "account",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"userId": {
|
||||
"name": "userId",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"provider": {
|
||||
"name": "provider",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"providerAccountId": {
|
||||
"name": "providerAccountId",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"refresh_token": {
|
||||
"name": "refresh_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"access_token": {
|
||||
"name": "access_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"token_type": {
|
||||
"name": "token_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"scope": {
|
||||
"name": "scope",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"id_token": {
|
||||
"name": "id_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"session_state": {
|
||||
"name": "session_state",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"account_userId_user_id_fk": {
|
||||
"name": "account_userId_user_id_fk",
|
||||
"tableFrom": "account",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"userId"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"account_provider_providerAccountId_pk": {
|
||||
"name": "account_provider_providerAccountId_pk",
|
||||
"columns": [
|
||||
"provider",
|
||||
"providerAccountId"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.answer": {
|
||||
"name": "answer",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"submission_id": {
|
||||
"name": "submission_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"question_id": {
|
||||
"name": "question_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"value": {
|
||||
"name": "value",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "''"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"answer_submission_id_submission_id_fk": {
|
||||
"name": "answer_submission_id_submission_id_fk",
|
||||
"tableFrom": "answer",
|
||||
"tableTo": "submission",
|
||||
"columnsFrom": [
|
||||
"submission_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"answer_question_id_question_id_fk": {
|
||||
"name": "answer_question_id_question_id_fk",
|
||||
"tableFrom": "answer",
|
||||
"tableTo": "question",
|
||||
"columnsFrom": [
|
||||
"question_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.form": {
|
||||
"name": "form",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"is_open": {
|
||||
"name": "is_open",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.question": {
|
||||
"name": "question",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"form_id": {
|
||||
"name": "form_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'text'"
|
||||
},
|
||||
"label": {
|
||||
"name": "label",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"image_url": {
|
||||
"name": "image_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"display_order": {
|
||||
"name": "display_order",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 0
|
||||
},
|
||||
"required": {
|
||||
"name": "required",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": false
|
||||
},
|
||||
"options": {
|
||||
"name": "options",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": "'[]'::jsonb"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"question_form_id_form_id_fk": {
|
||||
"name": "question_form_id_form_id_fk",
|
||||
"tableFrom": "question",
|
||||
"tableTo": "form",
|
||||
"columnsFrom": [
|
||||
"form_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.session": {
|
||||
"name": "session",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"sessionToken": {
|
||||
"name": "sessionToken",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"userId": {
|
||||
"name": "userId",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"expires": {
|
||||
"name": "expires",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"session_userId_user_id_fk": {
|
||||
"name": "session_userId_user_id_fk",
|
||||
"tableFrom": "session",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"userId"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.submission": {
|
||||
"name": "submission",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"form_id": {
|
||||
"name": "form_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_discord_id": {
|
||||
"name": "user_discord_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"user_name": {
|
||||
"name": "user_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"submitted_at": {
|
||||
"name": "submitted_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"edit_history": {
|
||||
"name": "edit_history",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": "'[]'::jsonb"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"submission_form_id_form_id_fk": {
|
||||
"name": "submission_form_id_form_id_fk",
|
||||
"tableFrom": "submission",
|
||||
"tableTo": "form",
|
||||
"columnsFrom": [
|
||||
"form_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.user": {
|
||||
"name": "user",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"emailVerified": {
|
||||
"name": "emailVerified",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"image": {
|
||||
"name": "image",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"discord_id": {
|
||||
"name": "discord_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"user_email_unique": {
|
||||
"name": "user_email_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"email"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.verificationToken": {
|
||||
"name": "verificationToken",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"identifier": {
|
||||
"name": "identifier",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"token": {
|
||||
"name": "token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"expires": {
|
||||
"name": "expires",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {
|
||||
"verificationToken_identifier_token_pk": {
|
||||
"name": "verificationToken_identifier_token_pk",
|
||||
"columns": [
|
||||
"identifier",
|
||||
"token"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
}
|
||||
},
|
||||
"enums": {},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,13 @@
|
||||
"when": 1779964210864,
|
||||
"tag": "0001_wooden_ravenous",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "7",
|
||||
"when": 1779965278875,
|
||||
"tag": "0002_first_dragon_man",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
+2
-1
@@ -10,7 +10,8 @@
|
||||
"up": "docker compose up -d --build",
|
||||
"logs": "docker compose logs -f",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:migrate": "drizzle-kit migrate"
|
||||
"db:migrate": "drizzle-kit migrate",
|
||||
"db": "drizzle-kit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@auth/drizzle-adapter": "^1.11.2",
|
||||
|
||||
Reference in New Issue
Block a user