feat(classes): optimize teacher dashboard ui and implement grade management
This commit is contained in:
@@ -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}
|
||||
/>
|
||||
))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
28
src/shared/components/ui/progress.tsx
Normal file
28
src/shared/components/ui/progress.tsx
Normal 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 }
|
||||
40
src/shared/components/ui/radio-group.tsx
Normal file
40
src/shared/components/ui/radio-group.tsx
Normal 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 }
|
||||
208
src/shared/components/ui/rich-text-editor.tsx
Normal file
208
src/shared/components/ui/rich-text-editor.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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),
|
||||
}));
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user