refactor: move AdminSidebar component to client file and update form layout and submission date formatting

This commit is contained in:
2026-06-20 19:53:51 +07:00 Unverified
parent 1b56937109
commit c7e431d99e
6 changed files with 136 additions and 188 deletions
+78 -3
View File
@@ -1,7 +1,8 @@
"use client"; "use client";
import React, { useState, useTransition, useRef, useCallback, useEffect } from "react"; import React, { useState, useTransition, useRef, useCallback, useEffect } from "react";
import { useRouter } from "next/navigation"; import { useRouter, usePathname } from "next/navigation";
import Link from "next/link";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@@ -18,10 +19,21 @@ import {
createQuestion, createQuestion,
updateQuestion, updateQuestion,
deleteQuestion, deleteQuestion,
reorderQuestions,
bulkUpdateQuestions, bulkUpdateQuestions,
} from "@/app/actions/questions"; } from "@/app/actions/questions";
import { Plus, Trash, ArrowUp, ArrowDown, Loader2, Save, Image as ImageIcon, X, Check } from "lucide-react"; import {
Sidebar,
SidebarContent,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarHeader,
SidebarFooter,
} from "@/components/ui/sidebar";
import { Plus, Trash, ArrowUp, ArrowDown, Loader2, Save, Image as ImageIcon, X, Check, LayoutDashboard, ClipboardList, ArrowLeft, Webhook } from "lucide-react";
import Image from 'next/image'; import Image from 'next/image';
import { RichTextEditor } from "@/components/rich-text-editor"; import { RichTextEditor } from "@/components/rich-text-editor";
import { FormattedText } from "@/components/formatted-text"; import { FormattedText } from "@/components/formatted-text";
@@ -596,3 +608,66 @@ function AnswerPreview({ question }: { question: Question }) {
</div> </div>
); );
} }
export function AdminSidebar() {
const pathname = usePathname();
return (
<Sidebar variant="sidebar" collapsible="icon">
<SidebarHeader className="border-b border-border py-4 px-4 flex items-center justify-between">
<span className="font-bold tracking-wider text-primary text-sm uppercase">
Form admin
</span>
</SidebarHeader>
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel>Navigation</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton asChild isActive={pathname === "/form/admin"}>
<Link href="/form/admin">
<LayoutDashboard className="w-4 h-4" />
<span>Dashboard</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton asChild isActive={pathname.startsWith("/form/admin/result")}>
<Link href="/form/admin/result">
<ClipboardList className="w-4 h-4" />
<span>Response</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton asChild isActive={pathname.startsWith("/form/admin/extra")}>
<Link href="/form/admin/extra">
<Webhook className="w-4 h-4" />
<span>Extra</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
<SidebarFooter className="border-t border-border p-4">
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton asChild>
<Link href="/form">
<ArrowLeft className="w-4 h-4" />
<span>Go public</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarFooter>
</Sidebar>
);
}
+5 -5
View File
@@ -1,8 +1,8 @@
import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
import { AdminSidebar } from "@/components/admin-sidebar";
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import { authOptions } from "@/app/api/auth/[...nextauth]/route"; import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { SidebarProvider } from "@/components/ui/sidebar";
import { AdminSidebar } from "./client";
export default async function AdminLayout({ export default async function AdminLayout({
children, children,
@@ -22,11 +22,11 @@ export default async function AdminLayout({
return ( return (
<SidebarProvider> <SidebarProvider>
<div className="flex min-h-screen w-full bg-background text-foreground dark"> <div className="flex w-full bg-background text-foreground dark">
<AdminSidebar /> <AdminSidebar />
<main className="flex-1 p-6 md:p-8 overflow-y-auto max-w-6xl mx-auto"> <div className="flex-1 overflow-hidden">
{children} {children}
</main> </div>
</div> </div>
</SidebarProvider> </SidebarProvider>
); );
+2 -2
View File
@@ -77,14 +77,14 @@ export default function SubmissionDetailClient({
<div className="flex items-center gap-2 text-muted-foreground"> <div className="flex items-center gap-2 text-muted-foreground">
<Calendar className="w-4 h-4 shrink-0" /> <Calendar className="w-4 h-4 shrink-0" />
<span className="font-semibold text-foreground">Initial Submission:</span> <span className="font-semibold text-foreground">Initial Submission:</span>
<span>{new Date(submission.submittedAt).toLocaleString()}</span> <span>{new Date(submission.submittedAt).toLocaleString(undefined, { hour12: false })}</span>
</div> </div>
{editHistory.length > 0 && ( {editHistory.length > 0 && (
<div className="flex flex-col mt-2 pt-2 border-t"> <div className="flex flex-col mt-2 pt-2 border-t">
<span className="font-semibold text-foreground mb-1">Edit History:</span> <span className="font-semibold text-foreground mb-1">Edit History:</span>
<ul className="list-disc list-inside text-xs text-muted-foreground space-y-1"> <ul className="list-disc list-inside text-xs text-muted-foreground space-y-1">
{editHistory.map((edit, idx) => ( {editHistory.map((edit, idx) => (
<li key={idx}>Edited at {new Date(edit.editedAt).toLocaleString()}</li> <li key={idx}>Edited at {new Date(edit.editedAt).toLocaleString(undefined, { hour12: false })}</li>
))} ))}
</ul> </ul>
</div> </div>
+46 -94
View File
@@ -16,7 +16,7 @@ import { Dialog, DialogContent, DialogTrigger, DialogTitle } from "@/components/
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog";
import { toast } from "sonner"; import { toast } from "sonner";
import { submitForm, deleteOwnSubmission } from "@/app/actions/submissions"; import { submitForm, deleteOwnSubmission } from "@/app/actions/submissions";
import { Loader2, LogOut, ChevronRight, ChevronLeft } from "lucide-react"; import { Loader2, LogOut } from "lucide-react";
import { signOut } from "next-auth/react"; import { signOut } from "next-auth/react";
import Image from "next/image"; import Image from "next/image";
import { z } from "zod"; import { z } from "zod";
@@ -87,7 +87,6 @@ interface FormFillerClientProps {
} }
const LS_KEY = (formId: string) => `form-draft-${formId}`; const LS_KEY = (formId: string) => `form-draft-${formId}`;
const QUESTIONS_PER_STEP = 3;
const buildSchema = (questions: Question[]) => { const buildSchema = (questions: Question[]) => {
const shape: Record<string, z.ZodTypeAny> = {}; const shape: Record<string, z.ZodTypeAny> = {};
@@ -177,27 +176,7 @@ export function FormFillerClient({ form, questions, initialAnswers = {} }: FormF
return () => subscription.unsubscribe(); return () => subscription.unsubscribe();
}, [watch, form.id]); }, [watch, form.id]);
const [currentStep, setCurrentStep] = useState(0);
const totalSteps = Math.ceil(questions.length / QUESTIONS_PER_STEP);
const currentQuestions = questions.slice(
currentStep * QUESTIONS_PER_STEP,
(currentStep + 1) * QUESTIONS_PER_STEP
);
const nextStep = async () => {
const fieldsToValidate = currentQuestions.map(q => q.id);
const isStepValid = await trigger(fieldsToValidate);
if (isStepValid) {
setCurrentStep(prev => Math.min(prev + 1, totalSteps - 1));
window.scrollTo(0, 0);
}
};
const prevStep = () => {
setCurrentStep(prev => Math.max(prev - 1, 0));
window.scrollTo(0, 0);
};
const onSubmit = async (values: z.infer<typeof formSchema>) => { const onSubmit = async (values: z.infer<typeof formSchema>) => {
if (!form.isOpen) return; if (!form.isOpen) return;
@@ -276,7 +255,7 @@ export function FormFillerClient({ form, questions, initialAnswers = {} }: FormF
<Form {...methods}> <Form {...methods}>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6"> <form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
<div className="space-y-4"> <div className="space-y-4">
{currentQuestions.map((q) => ( {questions.map((q) => (
<Card key={q.id} className="shadow-sm"> <Card key={q.id} className="shadow-sm">
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
@@ -453,82 +432,55 @@ export function FormFillerClient({ form, questions, initialAnswers = {} }: FormF
))} ))}
</div> </div>
<div className="flex flex-col sm:flex-row items-center justify-between gap-4 pt-4"> <div className="flex justify-end gap-3 pt-4">
<div className="flex items-center gap-2 text-sm text-muted-foreground font-medium w-full sm:w-auto justify-center sm:justify-start"> {isUpdating && (
หน้า {currentStep + 1} จาก {totalSteps || 1} <AlertDialog>
</div> <AlertDialogTrigger asChild>
<Button
<div className="flex justify-between sm:justify-end items-center gap-3 w-full sm:w-auto"> type="button"
{currentStep > 0 && ( variant="destructive"
<Button disabled={isSubmitting || !form.isOpen}
type="button" >
variant="outline" ลบ
onClick={prevStep}
disabled={isSubmitting || !form.isOpen}
>
<ChevronLeft className="w-4 h-4 mr-1" /> ย้อนกลับ
</Button>
)}
{currentStep < totalSteps - 1 ? (
<Button
type="button"
onClick={nextStep}
disabled={isSubmitting || !form.isOpen}
>
ถัดไป <ChevronRight className="w-4 h-4 ml-1" />
</Button>
) : (
<div className="flex gap-3">
{isUpdating && (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
type="button"
variant="destructive"
disabled={isSubmitting || !form.isOpen}
>
ลบ
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>ยืนยันการลบข้อมูล?</AlertDialogTitle>
<AlertDialogDescription>
คุณแน่ใจหรือไม่ว่าต้องการลบข้อมูลที่ส่งไปแล้ว? การกระทำนี้ไม่สามารถย้อนกลับได้
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isSubmitting}>ยกเลิก</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={handleDelete}
disabled={isSubmitting}
>
ยืนยันการลบ
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
<Button type="submit" disabled={isSubmitting || !form.isOpen} className="min-w-28">
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
กำลังส่ง
</>
) : isUpdating ? (
"อัปเดตข้อมูล"
) : (
"ส่ง"
)}
</Button> </Button>
</div> </AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>ยืนยันการลบข้อมูล?</AlertDialogTitle>
<AlertDialogDescription>
คุณแน่ใจหรือไม่ว่าต้องการลบข้อมูลที่ส่งไปแล้ว? การกระทำนี้ไม่สามารถย้อนกลับได้
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isSubmitting}>ยกเลิก</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={handleDelete}
disabled={isSubmitting}
>
ยืนยันการลบ
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
<Button type="submit" disabled={isSubmitting || !form.isOpen} className="min-w-28">
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
กำลังส่ง
</>
) : isUpdating ? (
"อัปเดตข้อมูล"
) : (
"ส่ง"
)} )}
</div> </Button>
</div> </div>
</form> </form>
</Form> </Form>
</div> </div>
); );
} }
+5 -3
View File
@@ -19,8 +19,8 @@ export default async function FormLayout({
const session = await getServerSession(authOptions); const session = await getServerSession(authOptions);
return ( return (
<div className="min-h-screen flex flex-col"> <div className="flex min-h-screen flex-col flex-1 w-full bg-background text-foreground dark">
<header className="border-b border-border px-6 py-3 flex items-center justify-between"> <header className="border-b border-border px-6 py-3 flex items-center justify-between shrink-0">
<span className="font-semibold text-sm"> <span className="font-semibold text-sm">
ฟอร์ม ฟอร์ม
</span> </span>
@@ -31,7 +31,9 @@ export default async function FormLayout({
/> />
)} )}
</header> </header>
<main className="flex-1">{children}</main> <main className="flex-1 p-6 md:p-8 overflow-y-auto max-w-6xl mx-auto w-full">
{children}
</main>
{!session && <LoginDialog />} {!session && <LoginDialog />}
</div> </div>
); );
-81
View File
@@ -1,81 +0,0 @@
"use client";
import * as React from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import {
Sidebar,
SidebarContent,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarHeader,
SidebarFooter,
} from "@/components/ui/sidebar";
import { LayoutDashboard, ClipboardList, ArrowLeft, Webhook } from "lucide-react";
export function AdminSidebar() {
const pathname = usePathname();
return (
<Sidebar variant="sidebar" collapsible="icon">
<SidebarHeader className="border-b border-border py-4 px-4 flex items-center justify-between">
<span className="font-bold tracking-wider text-primary text-sm uppercase">
Erika Admin
</span>
</SidebarHeader>
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel>Navigation</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton asChild isActive={pathname === "/form/admin"}>
<Link href="/form/admin">
<LayoutDashboard className="w-4 h-4" />
<span>Dashboard</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton asChild isActive={pathname.startsWith("/form/admin/result")}>
<Link href="/form/admin/result">
<ClipboardList className="w-4 h-4" />
<span>Response</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton asChild isActive={pathname.startsWith("/form/admin/extra")}>
<Link href="/form/admin/extra">
<Webhook className="w-4 h-4" />
<span>Extra</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
<SidebarFooter className="border-t border-border p-4">
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton asChild>
<Link href="/form">
<ArrowLeft className="w-4 h-4" />
<span>Go public</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarFooter>
</Sidebar>
);
}