Module Update

This commit is contained in:
SpecialX
2025-12-30 14:42:30 +08:00
parent f1797265b2
commit e7c902e8e1
148 changed files with 19317 additions and 113 deletions

View File

@@ -0,0 +1,140 @@
"use server";
import { db } from "@/shared/db";
import { questions, questionsToKnowledgePoints } from "@/shared/db/schema";
import { CreateQuestionInput, CreateQuestionSchema } from "./schema";
import { ActionState } from "@/shared/types/action-state";
import { revalidatePath } from "next/cache";
import { ZodError } from "zod";
import { eq } from "drizzle-orm";
import { createId } from "@paralleldrive/cuid2";
// --- Mock Auth Helper (Replace with actual Auth.js call) ---
async function getCurrentUser() {
// In production: const session = await auth(); return session?.user;
// Mocking a teacher user for this demonstration
return {
id: "user_teacher_123",
role: "teacher", // or "admin"
};
}
async function ensureTeacher() {
const user = await getCurrentUser();
if (!user || (user.role !== "teacher" && user.role !== "admin")) {
throw new Error("Unauthorized: Only teachers can perform this action.");
}
return user;
}
// --- Recursive Insert Helper ---
// We pass 'tx' to ensure all operations run within the same transaction
async function insertQuestionWithRelations(
tx: any, // using any or strict Drizzle Transaction type if imported
input: CreateQuestionInput,
authorId: string,
parentId: string | null = null
) {
// We generate ID explicitly here.
const newQuestionId = createId();
await tx.insert(questions).values({
id: newQuestionId,
content: input.content,
type: input.type,
difficulty: input.difficulty,
authorId: authorId,
parentId: parentId,
});
// 2. Link Knowledge Points
if (input.knowledgePointIds && input.knowledgePointIds.length > 0) {
await tx.insert(questionsToKnowledgePoints).values(
input.knowledgePointIds.map((kpId) => ({
questionId: newQuestionId,
knowledgePointId: kpId,
}))
);
}
// 3. Handle Sub-Questions (Recursion)
if (input.subQuestions && input.subQuestions.length > 0) {
for (const subQ of input.subQuestions) {
await insertQuestionWithRelations(tx, subQ, authorId, newQuestionId);
}
}
return newQuestionId;
}
// --- Main Server Action ---
export async function createNestedQuestion(
prevState: ActionState<string> | undefined,
formData: FormData | CreateQuestionInput // Support both FormData and JSON input
): Promise<ActionState<string>> {
try {
// 1. Auth Check
const user = await ensureTeacher();
// 2. Parse Input
// If formData is actual FormData, we need to convert it.
// For complex nested structures, frontend usually sends JSON string or pure JSON object if using `useServerAction` with arguments.
// Here we assume the client might send a raw object (if using direct function call) or we parse FormData.
let rawInput: any = formData;
if (formData instanceof FormData) {
// Parsing complex nested JSON from FormData is messy.
// We assume one field 'data' contains the JSON, or we expect direct object usage (common in modern Next.js RPC).
const jsonString = formData.get("json");
if (typeof jsonString === "string") {
rawInput = JSON.parse(jsonString);
} else {
return { success: false, message: "Invalid submission format. Expected JSON." };
}
}
const validatedFields = CreateQuestionSchema.safeParse(rawInput);
if (!validatedFields.success) {
return {
success: false,
message: "Validation failed",
errors: validatedFields.error.flatten().fieldErrors,
};
}
const input = validatedFields.data;
// 3. Database Transaction
await db.transaction(async (tx) => {
await insertQuestionWithRelations(tx, input, user.id, null);
});
// 4. Revalidate Cache
revalidatePath("/questions");
return {
success: true,
message: "Question created successfully",
};
} catch (error) {
console.error("Failed to create question:", error);
// Drizzle/DB Error Handling (Generic)
if (error instanceof Error) {
// Check for specific DB errors (constraints, etc.)
// e.g., if (error.message.includes("Duplicate entry")) ...
return {
success: false,
message: error.message || "Database error occurred",
};
}
return {
success: false,
message: "An unexpected error occurred",
};
}
}

View File

@@ -0,0 +1,20 @@
"use client"
import { useState } from "react"
import { Plus } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
import { CreateQuestionDialog } from "./create-question-dialog"
export function CreateQuestionButton() {
const [open, setOpen] = useState(false)
return (
<>
<Button onClick={() => setOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
Add Question
</Button>
<CreateQuestionDialog open={open} onOpenChange={setOpen} />
</>
)
}

View File

@@ -0,0 +1,286 @@
"use client"
import { useState, useEffect } from "react"
import { useForm, type SubmitHandler } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { z } from "zod"
import { Plus, Trash2, GripVertical } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/shared/components/ui/dialog"
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/shared/components/ui/form"
import { Input } from "@/shared/components/ui/input"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select"
import { Textarea } from "@/shared/components/ui/textarea"
import { BaseQuestionSchema } from "../schema"
import { createNestedQuestion } from "../actions"
import { toast } from "sonner"
import { Question } from "../types"
// Extend schema for form usage (e.g. handling options for choice questions)
const QuestionFormSchema = BaseQuestionSchema.extend({
difficulty: z.number().min(1).max(5),
content: z.string().min(1, "Question content is required"),
options: z.array(z.object({
label: z.string(),
value: z.string(),
isCorrect: z.boolean().default(false)
})).optional(),
})
type QuestionFormValues = z.input<typeof QuestionFormSchema>
interface CreateQuestionDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
initialData?: Question | null
}
export function CreateQuestionDialog({ open, onOpenChange, initialData }: CreateQuestionDialogProps) {
const [isPending, setIsPending] = useState(false)
const isEdit = !!initialData
const form = useForm<QuestionFormValues>({
resolver: zodResolver(QuestionFormSchema),
defaultValues: {
type: initialData?.type || "single_choice",
difficulty: initialData?.difficulty || 1,
content: (typeof initialData?.content === 'string' ? initialData.content : "") || "",
options: [
{ label: "Option A", value: "A", isCorrect: true },
{ label: "Option B", value: "B", isCorrect: false },
],
},
})
// Reset form when initialData changes
useEffect(() => {
if (initialData) {
form.reset({
type: initialData.type,
difficulty: initialData.difficulty,
content: typeof initialData.content === 'string' ? initialData.content : JSON.stringify(initialData.content),
options: [
{ label: "Option A", value: "A", isCorrect: true },
{ label: "Option B", value: "B", isCorrect: false },
]
})
} else {
form.reset({
type: "single_choice",
difficulty: 1,
content: "",
options: [
{ label: "Option A", value: "A", isCorrect: true },
{ label: "Option B", value: "B", isCorrect: false },
]
})
}
}, [initialData, form])
const questionType = form.watch("type")
const onSubmit: SubmitHandler<QuestionFormValues> = async (data) => {
setIsPending(true)
try {
const payload = {
type: data.type,
difficulty: data.difficulty,
content: data.content,
knowledgePointIds: [],
}
const fd = new FormData()
fd.set("json", JSON.stringify(payload))
const res = await createNestedQuestion(undefined, fd)
if (res.success) {
toast.success(isEdit ? "Updated question" : "Created question")
onOpenChange(false)
if (!isEdit) {
form.reset()
}
} else {
toast.error(res.message || "Operation failed")
}
} catch (error) {
toast.error("Unexpected error")
} finally {
setIsPending(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[600px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{isEdit ? "Edit Question" : "Create New Question"}</DialogTitle>
<DialogDescription>
{isEdit ? "Update question details." : "Add a new question to the bank. Fill in the details below."}
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="type"
render={({ field }) => (
<FormItem>
<FormLabel>Question Type</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select type" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="single_choice">Single Choice</SelectItem>
<SelectItem value="multiple_choice">Multiple Choice</SelectItem>
<SelectItem value="judgment">True/False</SelectItem>
<SelectItem value="text">Short Answer</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="difficulty"
render={({ field }) => (
<FormItem>
<FormLabel>Difficulty (1-5)</FormLabel>
<Select
onValueChange={(val) => field.onChange(parseInt(val))}
defaultValue={String(field.value)}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select difficulty" />
</SelectTrigger>
</FormControl>
<SelectContent>
{[1, 2, 3, 4, 5].map((level) => (
<SelectItem key={level} value={String(level)}>
{level} - {level === 1 ? "Easy" : level === 5 ? "Hard" : "Medium"}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="content"
render={({ field }) => (
<FormItem>
<FormLabel>Question Content</FormLabel>
<FormControl>
<Textarea
placeholder="Enter the question text here..."
className="min-h-[100px]"
{...field}
/>
</FormControl>
<FormDescription>
Supports basic text. Rich text editor coming soon.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{(questionType === "single_choice" || questionType === "multiple_choice") && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<FormLabel>Options</FormLabel>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
const currentOptions = form.getValues("options") || [];
form.setValue("options", [
...currentOptions,
{ label: `Option ${String.fromCharCode(65 + currentOptions.length)}`, value: String.fromCharCode(65 + currentOptions.length), isCorrect: false }
]);
}}
>
<Plus className="mr-2 h-3 w-3" /> Add Option
</Button>
</div>
<div className="space-y-2">
{form.watch("options")?.map((option, index) => (
<div key={index} className="flex items-center gap-2">
<div className="flex h-8 w-8 items-center justify-center text-muted-foreground">
<GripVertical className="h-4 w-4" />
</div>
<Input
value={option.label}
onChange={(e) => {
const newOptions = [...(form.getValues("options") || [])];
newOptions[index].label = e.target.value;
form.setValue("options", newOptions);
}}
placeholder={`Option ${index + 1}`}
/>
<Button
type="button"
variant="ghost"
size="icon"
className="text-destructive hover:text-destructive/90"
onClick={() => {
const newOptions = [...(form.getValues("options") || [])];
newOptions.splice(index, 1);
form.setValue("options", newOptions);
}}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
</div>
</div>
)}
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" disabled={isPending}>
{isPending ? "Creating..." : "Create Question"}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,178 @@
"use client"
import { useState } from "react"
import { MoreHorizontal, Pencil, Trash, Eye, Copy } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/shared/components/ui/dropdown-menu"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/components/ui/alert-dialog"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/shared/components/ui/dialog"
import { Question } from "../types"
import { CreateQuestionDialog } from "./create-question-dialog"
import { toast } from "sonner"
interface QuestionActionsProps {
question: Question
}
export function QuestionActions({ question }: QuestionActionsProps) {
const [showEditDialog, setShowEditDialog] = useState(false)
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const [showViewDialog, setShowViewDialog] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
const copyId = () => {
navigator.clipboard.writeText(question.id)
toast.success("Question ID copied to clipboard")
}
const handleDelete = async () => {
setIsDeleting(true)
try {
// Simulate API call
console.log("Deleting question:", question.id)
await new Promise(resolve => setTimeout(resolve, 1000))
toast.success("Question deleted successfully")
setShowDeleteDialog(false)
} catch (error) {
console.error(error)
toast.error("Failed to delete question")
} finally {
setIsDeleting(false)
}
}
return (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">Open menu</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuItem onClick={copyId}>
<Copy className="mr-2 h-4 w-4" /> Copy ID
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => setShowViewDialog(true)}>
<Eye className="mr-2 h-4 w-4" /> View Details
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setShowEditDialog(true)}>
<Pencil className="mr-2 h-4 w-4" /> Edit
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => setShowDeleteDialog(true)}
>
<Trash className="mr-2 h-4 w-4" /> Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/* Edit Dialog */}
<CreateQuestionDialog
open={showEditDialog}
onOpenChange={setShowEditDialog}
initialData={question}
/>
{/* Delete Confirmation Dialog */}
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete the question
and remove it from our servers.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
e.preventDefault()
handleDelete()
}}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
disabled={isDeleting}
>
{isDeleting ? "Deleting..." : "Delete"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* View Details Dialog (Simple Read-only View) */}
<Dialog open={showViewDialog} onOpenChange={setShowViewDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>Question Details</DialogTitle>
<DialogDescription>ID: {question.id}</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<span className="font-medium">Type:</span>
<span className="col-span-3 capitalize">{question.type.replace('_', ' ')}</span>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<span className="font-medium">Difficulty:</span>
<span className="col-span-3">{question.difficulty}</span>
</div>
<div className="grid grid-cols-4 items-start gap-4">
<span className="font-medium pt-1">Content:</span>
<div className="col-span-3 rounded-md bg-muted p-2 text-sm">
{typeof question.content === 'string' ? question.content : JSON.stringify(question.content, null, 2)}
</div>
</div>
{/* Show Author if exists */}
{question.author && (
<div className="grid grid-cols-4 items-center gap-4">
<span className="font-medium">Author:</span>
<span className="col-span-3">{question.author.name || "Unknown"}</span>
</div>
)}
{/* Show Knowledge Points */}
{question.knowledgePoints && question.knowledgePoints.length > 0 && (
<div className="grid grid-cols-4 items-center gap-4">
<span className="font-medium">Tags:</span>
<div className="col-span-3 flex flex-wrap gap-1">
{question.knowledgePoints.map(kp => (
<span key={kp.id} className="rounded-full bg-secondary px-2 py-0.5 text-xs text-secondary-foreground">
{kp.name}
</span>
))}
</div>
</div>
)}
</div>
</DialogContent>
</Dialog>
</>
)
}

View File

@@ -0,0 +1,144 @@
"use client"
import { ColumnDef } from "@tanstack/react-table"
import { Badge } from "@/shared/components/ui/badge"
import { Checkbox } from "@/shared/components/ui/checkbox"
import { Question, QuestionType } from "../types"
import { cn } from "@/shared/lib/utils"
import { QuestionActions } from "./question-actions"
// Helper for Type Colors
const getTypeColor = (type: QuestionType) => {
switch (type) {
case "single_choice":
return "default"; // Primary
case "multiple_choice":
return "secondary";
case "judgment":
return "outline";
case "text":
return "secondary"; // Changed from 'accent' which might not be a valid badge variant key in standard shadcn, usually it's default, secondary, destructive, outline.
default:
return "secondary";
}
}
const getTypeLabel = (type: QuestionType) => {
switch (type) {
case "single_choice": return "Single Choice";
case "multiple_choice": return "Multiple Choice";
case "judgment": return "True/False";
case "text": return "Short Answer";
case "composite": return "Composite";
default: return type;
}
}
export const columns: ColumnDef<Question>[] = [
{
id: "select",
header: ({ table }) => (
<Checkbox
checked={table.getIsAllPageRowsSelected()}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label="Select all"
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="Select row"
/>
),
enableSorting: false,
enableHiding: false,
},
{
accessorKey: "type",
header: "Type",
cell: ({ row }) => {
const type = row.getValue("type") as QuestionType
return (
<Badge variant={getTypeColor(type)} className="whitespace-nowrap">
{getTypeLabel(type)}
</Badge>
)
},
},
{
accessorKey: "content",
header: "Content",
cell: ({ row }) => {
const content = row.getValue("content");
let preview = "";
if (typeof content === 'string') {
preview = content;
} else if (content && typeof content === 'object') {
preview = JSON.stringify(content).slice(0, 50);
}
return (
<div className="max-w-[400px] truncate font-medium" title={preview}>
{preview}
</div>
)
},
},
{
accessorKey: "difficulty",
header: "Difficulty",
cell: ({ row }) => {
const diff = row.getValue("difficulty") as number;
// 1-5 scale
return (
<div className="flex items-center">
<span className={cn("font-medium",
diff <= 2 ? "text-green-600" :
diff === 3 ? "text-yellow-600" : "text-red-600"
)}>
{diff === 1 ? "Easy" : diff === 2 ? "Easy-Med" : diff === 3 ? "Medium" : diff === 4 ? "Med-Hard" : "Hard"}
</span>
<span className="ml-1 text-xs text-muted-foreground">({diff})</span>
</div>
)
},
},
{
accessorKey: "knowledgePoints",
header: "Knowledge Points",
cell: ({ row }) => {
const kps = row.original.knowledgePoints;
if (!kps || kps.length === 0) return <span className="text-muted-foreground">-</span>;
return (
<div className="flex flex-wrap gap-1">
{kps.slice(0, 2).map(kp => (
<Badge key={kp.id} variant="outline" className="text-xs">
{kp.name}
</Badge>
))}
{kps.length > 2 && (
<Badge variant="outline" className="text-xs">+{kps.length - 2}</Badge>
)}
</div>
)
}
},
{
accessorKey: "createdAt",
header: "Created",
cell: ({ row }) => {
return (
<span className="text-muted-foreground text-xs whitespace-nowrap">
{new Date(row.getValue("createdAt")).toLocaleDateString()}
</span>
)
},
},
{
id: "actions",
cell: ({ row }) => <QuestionActions question={row.original} />,
},
]

View File

@@ -0,0 +1,134 @@
"use client"
import * as React from "react"
import {
ColumnDef,
flexRender,
getCoreRowModel,
useReactTable,
getPaginationRowModel,
SortingState,
getSortedRowModel,
getFilteredRowModel,
RowSelectionState,
} from "@tanstack/react-table"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/shared/components/ui/table"
import { Button } from "@/shared/components/ui/button"
import { ChevronLeft, ChevronRight } from "lucide-react"
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[]
data: TData[]
}
export function QuestionDataTable<TData, TValue>({
columns,
data,
}: DataTableProps<TData, TValue>) {
const [sorting, setSorting] = React.useState<SortingState>([])
const [rowSelection, setRowSelection] = React.useState<RowSelectionState>({})
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
onSortingChange: setSorting,
getSortedRowModel: getSortedRowModel(),
onRowSelectionChange: setRowSelection,
getFilteredRowModel: getFilteredRowModel(),
state: {
sorting,
rowSelection,
},
})
return (
<div className="space-y-4">
<div className="rounded-md border">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
)
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
No results.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<div className="flex items-center justify-end space-x-2 py-4">
<div className="flex-1 text-sm text-muted-foreground">
{table.getFilteredSelectedRowModel().rows.length} of{" "}
{table.getFilteredRowModel().rows.length} row(s) selected.
</div>
<div className="space-x-2">
<Button
variant="outline"
size="sm"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<ChevronLeft className="h-4 w-4" />
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
Next
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,80 @@
"use client"
import { useQueryState, parseAsString } from "nuqs"
import { Search, X } from "lucide-react"
import { Input } from "@/shared/components/ui/input"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select"
import { Button } from "@/shared/components/ui/button"
export function QuestionFilters() {
const [search, setSearch] = useQueryState("q", parseAsString.withDefault(""))
const [type, setType] = useQueryState("type", parseAsString.withDefault("all"))
const [difficulty, setDifficulty] = useQueryState("difficulty", parseAsString.withDefault("all"))
// Debounce could be added here for search, but for simplicity we rely on 'Enter' or blur or just let it update (nuqs handles some updates well, but for text input usually we want debounce or on change).
// Actually nuqs with shallow: false (default) triggers server re-render.
// For text input, it's better to use local state and update URL on debounce or enter.
return (
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div className="flex flex-1 items-center gap-2">
<div className="relative flex-1 md:max-w-sm">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search questions..."
className="pl-8"
value={search}
onChange={(e) => setSearch(e.target.value || null)}
/>
</div>
<Select value={type} onValueChange={(val) => setType(val === "all" ? null : val)}>
<SelectTrigger className="w-[150px]">
<SelectValue placeholder="Type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Types</SelectItem>
<SelectItem value="single_choice">Single Choice</SelectItem>
<SelectItem value="multiple_choice">Multiple Choice</SelectItem>
<SelectItem value="judgment">True/False</SelectItem>
<SelectItem value="text">Short Answer</SelectItem>
</SelectContent>
</Select>
<Select value={difficulty} onValueChange={(val) => setDifficulty(val === "all" ? null : val)}>
<SelectTrigger className="w-[150px]">
<SelectValue placeholder="Difficulty" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Any Difficulty</SelectItem>
<SelectItem value="1">Easy (1)</SelectItem>
<SelectItem value="2">Easy-Med (2)</SelectItem>
<SelectItem value="3">Medium (3)</SelectItem>
<SelectItem value="4">Med-Hard (4)</SelectItem>
<SelectItem value="5">Hard (5)</SelectItem>
</SelectContent>
</Select>
{(search || type !== "all" || difficulty !== "all") && (
<Button
variant="ghost"
onClick={() => {
setSearch(null)
setType(null)
setDifficulty(null)
}}
className="h-8 px-2 lg:px-3"
>
Reset
<X className="ml-2 h-4 w-4" />
</Button>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,93 @@
import 'server-only';
import { db } from "@/shared/db";
import { questions, questionsToKnowledgePoints } from "@/shared/db/schema";
import { and, eq, inArray, count, desc, sql } from "drizzle-orm";
import { cache } from "react";
// Types for filters
export type GetQuestionsParams = {
page?: number;
pageSize?: number;
knowledgePointId?: string;
difficulty?: number;
};
// Cached Data Access Function
// Using React's cache() to deduplicate requests if called multiple times in one render pass
export const getQuestions = cache(async ({
page = 1,
pageSize = 10,
knowledgePointId,
difficulty,
}: GetQuestionsParams = {}) => {
const offset = (page - 1) * pageSize;
// Build Where Conditions
const conditions = [];
if (difficulty) {
conditions.push(eq(questions.difficulty, difficulty));
}
// Filter by Knowledge Point (using subquery pattern for Many-to-Many)
if (knowledgePointId) {
const subQuery = db
.select({ questionId: questionsToKnowledgePoints.questionId })
.from(questionsToKnowledgePoints)
.where(eq(questionsToKnowledgePoints.knowledgePointId, knowledgePointId));
conditions.push(inArray(questions.id, subQuery));
}
// Only fetch top-level questions (parent questions)
// Assuming we only want to list "root" questions, not sub-questions
conditions.push(sql`${questions.parentId} IS NULL`);
const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
// 1. Get Total Count (for Pagination)
// Optimization: separate count query is often faster than fetching all data
const [totalResult] = await db
.select({ count: count() })
.from(questions)
.where(whereClause);
const total = totalResult?.count ?? 0;
// 2. Get Data with Relations
const data = await db.query.questions.findMany({
where: whereClause,
limit: pageSize,
offset: offset,
orderBy: [desc(questions.createdAt)],
with: {
// Preload Knowledge Points
questionsToKnowledgePoints: {
with: {
knowledgePoint: true,
},
},
// Preload Author
author: {
columns: {
id: true,
name: true,
image: true,
},
},
// Preload Child Questions (first level)
children: true,
},
});
return {
data,
meta: {
page,
pageSize,
total,
totalPages: Math.ceil(total / pageSize),
},
};
});

View File

@@ -0,0 +1,124 @@
import { Question } from "./types";
export const MOCK_QUESTIONS: Question[] = [
{
id: "q-001",
content: "What is the capital of France?",
type: "single_choice",
difficulty: 1,
createdAt: new Date("2023-11-01"),
updatedAt: new Date("2023-11-01"),
author: { id: "u-1", name: "Alice Teacher", image: null },
knowledgePoints: [{ id: "kp-1", name: "Geography" }, { id: "kp-2", name: "Europe" }],
},
{
id: "q-002",
content: "Explain the theory of relativity in simple terms.",
type: "text",
difficulty: 5,
createdAt: new Date("2023-11-02"),
updatedAt: new Date("2023-11-02"),
author: { id: "u-2", name: "Bob Physicist", image: null },
knowledgePoints: [{ id: "kp-3", name: "Physics" }],
},
{
id: "q-003",
content: "True or False: The earth is flat.",
type: "judgment",
difficulty: 1,
createdAt: new Date("2023-11-03"),
updatedAt: new Date("2023-11-03"),
author: { id: "u-1", name: "Alice Teacher", image: null },
knowledgePoints: [{ id: "kp-1", name: "Geography" }],
},
{
id: "q-004",
content: "Select all prime numbers below 10.",
type: "multiple_choice",
difficulty: 2,
createdAt: new Date("2023-11-04"),
updatedAt: new Date("2023-11-04"),
author: { id: "u-3", name: "Charlie Math", image: null },
knowledgePoints: [{ id: "kp-4", name: "Math" }],
},
{
id: "q-005",
content: "Write a function to reverse a string in JavaScript.",
type: "text",
difficulty: 3,
createdAt: new Date("2023-11-05"),
updatedAt: new Date("2023-11-05"),
author: { id: "u-4", name: "Dave Coder", image: null },
knowledgePoints: [{ id: "kp-5", name: "Programming" }, { id: "kp-6", name: "JavaScript" }],
},
{
id: "q-006",
content: "Which of the following are fruits? (Apple, Carrot, Banana, Potato)",
type: "multiple_choice",
difficulty: 1,
createdAt: new Date("2023-11-06"),
updatedAt: new Date("2023-11-06"),
author: { id: "u-1", name: "Alice Teacher", image: null },
knowledgePoints: [{ id: "kp-7", name: "Biology" }],
},
{
id: "q-007",
content: "Water boils at 100 degrees Celsius at sea level.",
type: "judgment",
difficulty: 2,
createdAt: new Date("2023-11-07"),
updatedAt: new Date("2023-11-07"),
author: { id: "u-2", name: "Bob Physicist", image: null },
knowledgePoints: [{ id: "kp-3", name: "Physics" }],
},
{
id: "q-008",
content: "What is the powerhouse of the cell?",
type: "single_choice",
difficulty: 2,
createdAt: new Date("2023-11-08"),
updatedAt: new Date("2023-11-08"),
author: { id: "u-5", name: "Eve Biologist", image: null },
knowledgePoints: [{ id: "kp-7", name: "Biology" }],
},
{
id: "q-009",
content: "Solve for x: 2x + 5 = 15",
type: "single_choice",
difficulty: 2,
createdAt: new Date("2023-11-09"),
updatedAt: new Date("2023-11-09"),
author: { id: "u-3", name: "Charlie Math", image: null },
knowledgePoints: [{ id: "kp-4", name: "Math" }],
},
{
id: "q-010",
content: "Describe the impact of the Industrial Revolution.",
type: "text",
difficulty: 4,
createdAt: new Date("2023-11-10"),
updatedAt: new Date("2023-11-10"),
author: { id: "u-6", name: "Frank Historian", image: null },
knowledgePoints: [{ id: "kp-8", name: "History" }],
},
{
id: "q-011",
content: "Light travels faster than sound.",
type: "judgment",
difficulty: 1,
createdAt: new Date("2023-11-11"),
updatedAt: new Date("2023-11-11"),
author: { id: "u-2", name: "Bob Physicist", image: null },
knowledgePoints: [{ id: "kp-3", name: "Physics" }],
},
{
id: "q-012",
content: "Which element has the chemical symbol 'O'?",
type: "single_choice",
difficulty: 1,
createdAt: new Date("2023-11-12"),
updatedAt: new Date("2023-11-12"),
author: { id: "u-7", name: "Grace Chemist", image: null },
knowledgePoints: [{ id: "kp-9", name: "Chemistry" }],
},
];

View File

@@ -0,0 +1,21 @@
import { z } from "zod";
// Enum for Question Types matching DB
export const QuestionTypeEnum = z.enum(["single_choice", "multiple_choice", "text", "judgment", "composite"]);
// Base Question Schema
export const BaseQuestionSchema = z.object({
content: z.any().describe("JSON content for the question (e.g. Slate nodes)"), // Using any for JSON flexibility, strict schemas can be applied if structure is known
type: QuestionTypeEnum,
difficulty: z.number().min(1).max(5).default(1),
knowledgePointIds: z.array(z.string()).optional(),
});
// Recursive Schema for Nested Questions (e.g. Composite -> Sub Questions)
export type CreateQuestionInput = z.infer<typeof BaseQuestionSchema> & {
subQuestions?: CreateQuestionInput[];
};
export const CreateQuestionSchema: z.ZodType<CreateQuestionInput> = BaseQuestionSchema.extend({
subQuestions: z.lazy(() => CreateQuestionSchema.array().optional()),
});

View File

@@ -0,0 +1,27 @@
import { z } from "zod";
import { QuestionTypeEnum } from "./schema";
// Infer types from Zod Schema
export type QuestionType = z.infer<typeof QuestionTypeEnum>;
// UI Model for Question (matching the structure returned by data-access or mock)
export interface Question {
id: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
content: any; // Rich text content
type: QuestionType;
difficulty: number;
createdAt: Date;
updatedAt: Date;
author: {
id: string;
name: string | null;
image: string | null;
} | null;
knowledgePoints: {
id: string;
name: string;
}[];
// For UI display
childrenCount?: number;
}