feat(classes): optimize teacher dashboard ui and implement grade management

This commit is contained in:
SpecialX
2026-01-14 13:59:11 +08:00
parent ade8d4346c
commit 9bfc621d3f
104 changed files with 12793 additions and 2309 deletions

View File

@@ -18,7 +18,7 @@ const AlertDialogOverlay = React.forwardRef<
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
"fixed inset-0 z-50 bg-black/40 backdrop-blur-[2px] data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
@@ -36,7 +36,7 @@ const AlertDialogContent = React.forwardRef<
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-6 border bg-background p-6 shadow-xl shadow-black/5 duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 sm:rounded-xl",
className
)}
{...props}
@@ -65,7 +65,7 @@ const AlertDialogFooter = ({
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-3",
className
)}
{...props}
@@ -79,7 +79,7 @@ const AlertDialogTitle = React.forwardRef<
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold", className)}
className={cn("text-xl font-semibold leading-none tracking-tight", className)}
{...props}
/>
))

View File

@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
"use client"
import * as React from "react"
@@ -109,6 +110,12 @@ const ChartTooltipContent = React.forwardRef<
indicator?: "line" | "dot" | "dashed"
nameKey?: string
labelKey?: string
payload?: any[]
label?: any
labelFormatter?: any
labelClassName?: string
formatter?: any
color?: string
}
>(
(
@@ -256,8 +263,9 @@ const ChartLegend = RechartsPrimitive.Legend
const ChartLegendContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> &
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
React.ComponentProps<"div"> & {
payload?: any[]
verticalAlign?: "top" | "middle" | "bottom"
hideIcon?: boolean
nameKey?: string
}

View File

@@ -21,7 +21,7 @@ const DialogOverlay = React.forwardRef<
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
"fixed inset-0 z-50 bg-black/40 backdrop-blur-[2px] data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
@@ -38,7 +38,7 @@ const DialogContent = React.forwardRef<
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-6 border bg-background p-6 shadow-xl shadow-black/5 duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 sm:rounded-xl",
className
)}
{...props}
@@ -59,7 +59,7 @@ const DialogHeader = ({
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
"flex flex-col space-y-2 text-center sm:text-left",
className
)}
{...props}
@@ -73,7 +73,7 @@ const DialogFooter = ({
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-3",
className
)}
{...props}
@@ -88,7 +88,7 @@ const DialogTitle = React.forwardRef<
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
"text-xl font-semibold leading-none tracking-tight",
className
)}
{...props}

View File

@@ -0,0 +1,28 @@
"use client"
import * as React from "react"
import * as ProgressPrimitive from "@radix-ui/react-progress"
import { cn } from "@/shared/lib/utils"
const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
>(({ className, value, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn(
"relative h-4 w-full overflow-hidden rounded-full bg-secondary",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
className="h-full w-full flex-1 bg-primary transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
))
Progress.displayName = ProgressPrimitive.Root.displayName
export { Progress }

View File

@@ -0,0 +1,40 @@
"use client"
import * as React from "react"
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
import { Circle } from "lucide-react"
import { cn } from "@/shared/lib/utils"
const RadioGroup = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
>(({ className, ...props }, ref) => (
<RadioGroupPrimitive.Root
className={cn("grid gap-2", className)}
{...props}
ref={ref}
/>
))
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName
const RadioGroupItem = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
>(({ className, ...props }, ref) => (
<RadioGroupPrimitive.Item
ref={ref}
className={cn(
"aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
<Circle className="h-2.5 w-2.5 fill-current text-current" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
))
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName
export { RadioGroup, RadioGroupItem }

View File

@@ -0,0 +1,208 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
"use client"
import * as React from "react"
import { useEditor, EditorContent, type Editor } from "@tiptap/react"
import StarterKit from "@tiptap/starter-kit"
import { Markdown } from "tiptap-markdown"
import Placeholder from "@tiptap/extension-placeholder"
import {
Bold,
Italic,
Strikethrough,
Code,
Heading1,
Heading2,
Heading3,
List,
ListOrdered,
Quote,
Undo,
Redo,
MoreHorizontal
} from "lucide-react"
import { cn } from "@/shared/lib/utils"
import { Button } from "@/shared/components/ui/button"
import { Separator } from "@/shared/components/ui/separator"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/shared/components/ui/dropdown-menu"
// Since we don't have Toggle component yet, let's create a local one or use Button
// We will use Button for simplicity and to avoid dependency issues if Radix Toggle isn't installed
const ToolbarButton = ({
isActive,
onClick,
icon: Icon,
title,
}: {
isActive?: boolean
onClick: () => void
icon: React.ElementType
title: string
}) => (
<Button
variant={isActive ? "secondary" : "ghost"}
size="icon"
className={cn(
"h-8 w-8 shrink-0",
isActive && "bg-muted text-foreground hover:bg-muted"
)}
onClick={(e) => {
e.preventDefault()
onClick()
}}
title={title}
>
<Icon className="h-4 w-4" />
</Button>
)
const EditorToolbar = ({ editor }: { editor: Editor }) => {
if (!editor) return null
return (
<div className="flex flex-wrap items-center gap-1 border-b bg-background/95 p-1 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<ToolbarButton
onClick={() => editor.chain().focus().toggleBold().run()}
isActive={editor.isActive("bold")}
icon={Bold}
title="Bold"
/>
<ToolbarButton
onClick={() => editor.chain().focus().toggleItalic().run()}
isActive={editor.isActive("italic")}
icon={Italic}
title="Italic"
/>
<ToolbarButton
onClick={() => editor.chain().focus().toggleStrike().run()}
isActive={editor.isActive("strike")}
icon={Strikethrough}
title="Strikethrough"
/>
<ToolbarButton
onClick={() => editor.chain().focus().toggleCode().run()}
isActive={editor.isActive("code")}
icon={Code}
title="Code"
/>
<Separator orientation="vertical" className="mx-1 h-6" />
<ToolbarButton
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
isActive={editor.isActive("heading", { level: 1 })}
icon={Heading1}
title="Heading 1"
/>
<ToolbarButton
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
isActive={editor.isActive("heading", { level: 2 })}
icon={Heading2}
title="Heading 2"
/>
<ToolbarButton
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
isActive={editor.isActive("heading", { level: 3 })}
icon={Heading3}
title="Heading 3"
/>
<Separator orientation="vertical" className="mx-1 h-6" />
<ToolbarButton
onClick={() => editor.chain().focus().toggleBulletList().run()}
isActive={editor.isActive("bulletList")}
icon={List}
title="Bullet List"
/>
<ToolbarButton
onClick={() => editor.chain().focus().toggleOrderedList().run()}
isActive={editor.isActive("orderedList")}
icon={ListOrdered}
title="Ordered List"
/>
<ToolbarButton
onClick={() => editor.chain().focus().toggleBlockquote().run()}
isActive={editor.isActive("blockquote")}
icon={Quote}
title="Blockquote"
/>
<div className="ml-auto flex items-center gap-1">
<ToolbarButton
onClick={() => editor.chain().focus().undo().run()}
isActive={false}
icon={Undo}
title="Undo"
/>
<ToolbarButton
onClick={() => editor.chain().focus().redo().run()}
isActive={false}
icon={Redo}
title="Redo"
/>
</div>
</div>
)
}
interface RichTextEditorProps {
value: string
onChange: (value: string) => void
placeholder?: string
className?: string
}
export function RichTextEditor({
value,
onChange,
placeholder = "Start writing...",
className,
}: RichTextEditorProps) {
const editor = useEditor({
immediatelyRender: false,
extensions: [
StarterKit.configure({
heading: {
levels: [1, 2, 3],
},
}),
Markdown,
Placeholder.configure({
placeholder,
emptyEditorClass: "is-editor-empty before:content-[attr(data-placeholder)] before:text-muted-foreground before:float-left before:pointer-events-none before:h-0",
}),
],
editorProps: {
attributes: {
class: "prose prose-sm dark:prose-invert max-w-none min-h-[150px] p-4 focus:outline-none",
},
},
content: value,
onUpdate: ({ editor }) => {
// Get markdown content
const markdown = (editor.storage as any).markdown.getMarkdown()
onChange(markdown)
},
})
// Sync value changes from outside (e.g. when switching chapters)
React.useEffect(() => {
if (editor && value !== (editor.storage as any).markdown.getMarkdown()) {
editor.commands.setContent(value)
}
}, [value, editor])
return (
<div className={cn("flex flex-col rounded-md border bg-background shadow-sm overflow-hidden", className)}>
<EditorToolbar editor={editor as Editor} />
<EditorContent editor={editor} className="flex-1 overflow-y-auto min-h-0" />
</div>
)
}

View File

@@ -34,7 +34,7 @@ function SheetOverlay({
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/40 backdrop-blur-[2px]",
className
)}
{...props}

View File

@@ -15,6 +15,7 @@ import {
classes,
classEnrollments,
classSchedule,
subjects,
exams,
examQuestions,
examSubmissions,
@@ -88,8 +89,8 @@ export const questionsRelations = relations(questions, ({ one, many }) => ({
relationName: "question_hierarchy",
}),
// Many-to-Many with Knowledge Points
questionsToKnowledgePoints: many(questionsToKnowledgePoints),
// Many-to-Many with Knowledge Points (Direct relation mapping)
knowledgePoints: many(questionsToKnowledgePoints),
// Usage in Exams
examQuestions: many(examQuestions),
@@ -209,6 +210,14 @@ export const examsRelations = relations(exams, ({ one, many }) => ({
fields: [exams.creatorId],
references: [users.id],
}),
subject: one(subjects, {
fields: [exams.subjectId],
references: [subjects.id],
}),
gradeEntity: one(grades, {
fields: [exams.gradeId],
references: [grades.id],
}),
questions: many(examQuestions),
submissions: many(examSubmissions),
}));

View File

@@ -184,6 +184,17 @@ export const questionsToKnowledgePoints = mysqlTable("questions_to_knowledge_poi
// --- 4. Academic / Teaching Flow ---
export const subjects = mysqlTable("subjects", {
id: id("id").primaryKey(),
name: varchar("name", { length: 100 }).notNull().unique(),
code: varchar("code", { length: 50 }).unique(),
order: int("order").default(0),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(),
}, (table) => ({
nameIdx: index("subjects_name_idx").on(table.name),
}));
export const textbooks = mysqlTable("textbooks", {
id: id("id").primaryKey(),
title: varchar("title", { length: 255 }).notNull(),
@@ -406,6 +417,10 @@ export const exams = mysqlTable("exams", {
creatorId: varchar("creator_id", { length: 128 }).notNull().references(() => users.id),
// Link to Subjects and Grades
subjectId: varchar("subject_id", { length: 128 }).references(() => subjects.id),
gradeId: varchar("grade_id", { length: 128 }).references(() => grades.id),
startTime: timestamp("start_time"),
endTime: timestamp("end_time"),
@@ -414,7 +429,10 @@ export const exams = mysqlTable("exams", {
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(),
});
}, (table) => ({
subjectIdx: index("exams_subject_idx").on(table.subjectId),
gradeIdx: index("exams_grade_idx").on(table.gradeId),
}));
// Linking Exams to Questions (Many-to-Many often, or One-to-Many if specific to exam)
// Usually questions are reused, so Many-to-Many