Module Update
This commit is contained in:
196
src/modules/textbooks/actions.ts
Normal file
196
src/modules/textbooks/actions.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import {
|
||||
createTextbook,
|
||||
createChapter,
|
||||
updateChapterContent,
|
||||
deleteChapter,
|
||||
createKnowledgePoint,
|
||||
deleteKnowledgePoint,
|
||||
updateTextbook,
|
||||
deleteTextbook
|
||||
} from "./data-access";
|
||||
import { CreateTextbookInput, UpdateTextbookInput } from "./types";
|
||||
|
||||
export type ActionState = {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
errors?: Record<string, string[]>;
|
||||
};
|
||||
|
||||
// ... existing createTextbookAction ...
|
||||
|
||||
export async function createTextbookAction(
|
||||
prevState: ActionState | null,
|
||||
formData: FormData
|
||||
): Promise<ActionState> {
|
||||
// ... implementation same as before
|
||||
const rawData: CreateTextbookInput = {
|
||||
title: formData.get("title") as string,
|
||||
subject: formData.get("subject") as string,
|
||||
grade: formData.get("grade") as string,
|
||||
publisher: formData.get("publisher") as string,
|
||||
};
|
||||
|
||||
if (!rawData.title || !rawData.subject || !rawData.grade) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Please fill in all required fields.",
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
await createTextbook(rawData);
|
||||
revalidatePath("/teacher/textbooks");
|
||||
return {
|
||||
success: true,
|
||||
message: "Textbook created successfully.",
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Failed to create textbook:", error);
|
||||
return {
|
||||
success: false,
|
||||
message: "Failed to create textbook.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateTextbookAction(
|
||||
textbookId: string,
|
||||
prevState: ActionState | null,
|
||||
formData: FormData
|
||||
): Promise<ActionState> {
|
||||
const rawData: UpdateTextbookInput = {
|
||||
id: textbookId,
|
||||
title: formData.get("title") as string,
|
||||
subject: formData.get("subject") as string,
|
||||
grade: formData.get("grade") as string,
|
||||
publisher: formData.get("publisher") as string,
|
||||
};
|
||||
|
||||
if (!rawData.title || !rawData.subject || !rawData.grade) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Please fill in all required fields.",
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
await updateTextbook(rawData);
|
||||
revalidatePath(`/teacher/textbooks/${textbookId}`);
|
||||
return {
|
||||
success: true,
|
||||
message: "Textbook updated successfully.",
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Failed to update textbook:", error);
|
||||
return {
|
||||
success: false,
|
||||
message: "Failed to update textbook.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteTextbookAction(
|
||||
textbookId: string
|
||||
): Promise<ActionState> {
|
||||
try {
|
||||
await deleteTextbook(textbookId);
|
||||
revalidatePath("/teacher/textbooks");
|
||||
return {
|
||||
success: true,
|
||||
message: "Textbook deleted successfully.",
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Failed to delete textbook:", error);
|
||||
return {
|
||||
success: false,
|
||||
message: "Failed to delete textbook.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function createChapterAction(
|
||||
textbookId: string,
|
||||
parentId: string | undefined,
|
||||
prevState: ActionState | null,
|
||||
formData: FormData
|
||||
): Promise<ActionState> {
|
||||
const title = formData.get("title") as string;
|
||||
|
||||
if (!title) return { success: false, message: "Title is required" };
|
||||
|
||||
try {
|
||||
await createChapter({
|
||||
textbookId,
|
||||
title,
|
||||
parentId,
|
||||
order: 0 // Default order
|
||||
});
|
||||
revalidatePath(`/teacher/textbooks/${textbookId}`);
|
||||
return { success: true, message: "Chapter created successfully" };
|
||||
} catch (error) {
|
||||
return { success: false, message: "Failed to create chapter" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateChapterContentAction(
|
||||
chapterId: string,
|
||||
content: string,
|
||||
textbookId: string
|
||||
): Promise<ActionState> {
|
||||
try {
|
||||
await updateChapterContent({ chapterId, content });
|
||||
revalidatePath(`/teacher/textbooks/${textbookId}`);
|
||||
return { success: true, message: "Content updated successfully" };
|
||||
} catch (error) {
|
||||
return { success: false, message: "Failed to update content" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteChapterAction(
|
||||
chapterId: string,
|
||||
textbookId: string
|
||||
): Promise<ActionState> {
|
||||
try {
|
||||
await deleteChapter(chapterId);
|
||||
revalidatePath(`/teacher/textbooks/${textbookId}`);
|
||||
return { success: true, message: "Chapter deleted successfully" };
|
||||
} catch (error) {
|
||||
return { success: false, message: "Failed to delete chapter" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function createKnowledgePointAction(
|
||||
chapterId: string,
|
||||
textbookId: string,
|
||||
prevState: ActionState | null,
|
||||
formData: FormData
|
||||
): Promise<ActionState> {
|
||||
const name = formData.get("name") as string;
|
||||
const description = formData.get("description") as string;
|
||||
|
||||
if (!name) return { success: false, message: "Name is required" };
|
||||
|
||||
try {
|
||||
await createKnowledgePoint({ name, description, chapterId });
|
||||
revalidatePath(`/teacher/textbooks/${textbookId}`);
|
||||
return { success: true, message: "Knowledge point created successfully" };
|
||||
} catch (error) {
|
||||
return { success: false, message: "Failed to create knowledge point" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteKnowledgePointAction(
|
||||
kpId: string,
|
||||
textbookId: string
|
||||
): Promise<ActionState> {
|
||||
try {
|
||||
await deleteKnowledgePoint(kpId);
|
||||
revalidatePath(`/teacher/textbooks/${textbookId}`);
|
||||
return { success: true, message: "Knowledge point deleted successfully" };
|
||||
} catch (error) {
|
||||
return { success: false, message: "Failed to delete knowledge point" };
|
||||
}
|
||||
}
|
||||
49
src/modules/textbooks/components/chapter-content-viewer.tsx
Normal file
49
src/modules/textbooks/components/chapter-content-viewer.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from "@/shared/components/ui/dialog"
|
||||
import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
||||
import { Chapter } from "../types"
|
||||
|
||||
interface ChapterContentViewerProps {
|
||||
chapter: Chapter | null
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function ChapterContentViewer({
|
||||
chapter,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: ChapterContentViewerProps) {
|
||||
if (!chapter) return null
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-3xl h-[80vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{chapter.title}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Reading Mode
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<ScrollArea className="flex-1 pr-4">
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none">
|
||||
{chapter.content ? (
|
||||
<div className="whitespace-pre-wrap">{chapter.content}</div>
|
||||
) : (
|
||||
<div className="flex h-40 items-center justify-center text-muted-foreground italic">
|
||||
No content available for this chapter.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
131
src/modules/textbooks/components/chapter-list.tsx
Normal file
131
src/modules/textbooks/components/chapter-list.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { ChevronRight, FileText, Folder, MoreHorizontal, Eye, Edit } from "lucide-react"
|
||||
import { Chapter } from "../types"
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/shared/components/ui/collapsible"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/shared/components/ui/dropdown-menu"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
import { ChapterContentViewer } from "./chapter-content-viewer"
|
||||
|
||||
interface ChapterItemProps {
|
||||
chapter: Chapter
|
||||
level?: number
|
||||
onView: (chapter: Chapter) => void
|
||||
}
|
||||
|
||||
function ChapterItem({ chapter, level = 0, onView }: ChapterItemProps) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const hasChildren = chapter.children && chapter.children.length > 0
|
||||
|
||||
return (
|
||||
<div className={cn("w-full", level > 0 && "ml-4 border-l pl-2")}>
|
||||
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
|
||||
<div className="flex items-center group py-1">
|
||||
{hasChildren ? (
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 shrink-0 p-0 hover:bg-muted"
|
||||
>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
"h-4 w-4 transition-transform duration-200 text-muted-foreground",
|
||||
isOpen && "rotate-90"
|
||||
)}
|
||||
/>
|
||||
<span className="sr-only">Toggle</span>
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
) : (
|
||||
<div className="w-6 shrink-0" />
|
||||
)}
|
||||
|
||||
<div className={cn(
|
||||
"flex flex-1 items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-muted/50 cursor-pointer transition-colors",
|
||||
level === 0 ? "font-medium text-foreground" : "text-muted-foreground"
|
||||
)}
|
||||
onClick={() => !hasChildren && onView(chapter)}
|
||||
>
|
||||
{hasChildren ? (
|
||||
<Folder className={cn("h-4 w-4", isOpen ? "text-primary" : "text-muted-foreground/70")} />
|
||||
) : (
|
||||
<FileText className="h-4 w-4 text-muted-foreground/50" />
|
||||
)}
|
||||
<span className="truncate">{chapter.title}</span>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="ml-auto h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity focus:opacity-100"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => onView(chapter)}>
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
View Content
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasChildren && (
|
||||
<CollapsibleContent className="overflow-hidden data-[state=closed]:animate-collapsible-up data-[state=open]:animate-collapsible-down">
|
||||
<div className="pt-1">
|
||||
{chapter.children!.map((child) => (
|
||||
<ChapterItem key={child.id} chapter={child} level={level + 1} onView={onView} />
|
||||
))}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
)}
|
||||
</Collapsible>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ChapterList({ chapters }: { chapters: Chapter[] }) {
|
||||
const [viewingChapter, setViewingChapter] = useState<Chapter | null>(null)
|
||||
const [isViewerOpen, setIsViewerOpen] = useState(false)
|
||||
|
||||
const handleView = (chapter: Chapter) => {
|
||||
setViewingChapter(chapter)
|
||||
setIsViewerOpen(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-1">
|
||||
{chapters.map((chapter) => (
|
||||
<ChapterItem key={chapter.id} chapter={chapter} onView={handleView} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<ChapterContentViewer
|
||||
chapter={viewingChapter}
|
||||
open={isViewerOpen}
|
||||
onOpenChange={setIsViewerOpen}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
124
src/modules/textbooks/components/chapter-sidebar-list.tsx
Normal file
124
src/modules/textbooks/components/chapter-sidebar-list.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { ChevronRight, FileText, Folder, MoreHorizontal } from "lucide-react"
|
||||
import { Chapter } from "../types"
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/shared/components/ui/collapsible"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
|
||||
interface ChapterItemProps {
|
||||
chapter: Chapter
|
||||
level?: number
|
||||
selectedId?: string
|
||||
onSelect: (chapter: Chapter) => void
|
||||
}
|
||||
|
||||
function ChapterItem({ chapter, level = 0, selectedId, onSelect }: ChapterItemProps) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const hasChildren = chapter.children && chapter.children.length > 0
|
||||
const isSelected = chapter.id === selectedId
|
||||
|
||||
return (
|
||||
<div className={cn("w-full", level > 0 && "ml-4 border-l pl-2")}>
|
||||
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
|
||||
<div className={cn(
|
||||
"flex items-center group py-1 rounded-md transition-colors",
|
||||
isSelected ? "bg-accent text-accent-foreground" : "hover:bg-muted/50"
|
||||
)}>
|
||||
{hasChildren ? (
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 shrink-0 p-0 hover:bg-muted"
|
||||
onClick={(e) => e.stopPropagation()} // Prevent selecting parent when toggling
|
||||
>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
"h-4 w-4 transition-transform duration-200 text-muted-foreground",
|
||||
isOpen && "rotate-90"
|
||||
)}
|
||||
/>
|
||||
<span className="sr-only">Toggle</span>
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
) : (
|
||||
<div className="w-6 shrink-0" />
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-1 items-center gap-2 px-2 py-1.5 text-sm cursor-pointer",
|
||||
level === 0 ? "font-medium" : "text-muted-foreground",
|
||||
isSelected && "text-accent-foreground font-medium"
|
||||
)}
|
||||
onClick={() => onSelect(chapter)}
|
||||
>
|
||||
{hasChildren ? (
|
||||
<Folder className={cn("h-4 w-4", isOpen ? "text-primary" : "text-muted-foreground/70")} />
|
||||
) : (
|
||||
<FileText className="h-4 w-4 text-muted-foreground/50" />
|
||||
)}
|
||||
<span className="truncate">{chapter.title}</span>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="ml-auto h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
// Dropdown menu logic here
|
||||
}}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasChildren && (
|
||||
<CollapsibleContent className="overflow-hidden data-[state=closed]:animate-collapsible-up data-[state=open]:animate-collapsible-down">
|
||||
<div className="pt-1">
|
||||
{chapter.children!.map((child) => (
|
||||
<ChapterItem
|
||||
key={child.id}
|
||||
chapter={child}
|
||||
level={level + 1}
|
||||
selectedId={selectedId}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
)}
|
||||
</Collapsible>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ChapterSidebarList({
|
||||
chapters,
|
||||
selectedChapterId,
|
||||
onSelectChapter
|
||||
}: {
|
||||
chapters: Chapter[],
|
||||
selectedChapterId?: string,
|
||||
onSelectChapter: (chapter: Chapter) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{chapters.map((chapter) => (
|
||||
<ChapterItem
|
||||
key={chapter.id}
|
||||
chapter={chapter}
|
||||
selectedId={selectedChapterId}
|
||||
onSelect={onSelectChapter}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
87
src/modules/textbooks/components/create-chapter-dialog.tsx
Normal file
87
src/modules/textbooks/components/create-chapter-dialog.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Plus } from "lucide-react"
|
||||
import { useFormStatus } from "react-dom"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/shared/components/ui/dialog"
|
||||
import { Input } from "@/shared/components/ui/input"
|
||||
import { Label } from "@/shared/components/ui/label"
|
||||
import { createChapterAction } from "../actions"
|
||||
import { toast } from "sonner"
|
||||
|
||||
function SubmitButton() {
|
||||
const { pending } = useFormStatus()
|
||||
return (
|
||||
<Button type="submit" disabled={pending}>
|
||||
{pending ? "Creating..." : "Create Chapter"}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
interface CreateChapterDialogProps {
|
||||
textbookId: string
|
||||
parentId?: string
|
||||
trigger?: React.ReactNode
|
||||
}
|
||||
|
||||
export function CreateChapterDialog({ textbookId, parentId, trigger }: CreateChapterDialogProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const handleSubmit = async (formData: FormData) => {
|
||||
const result = await createChapterAction(textbookId, parentId, null, formData)
|
||||
if (result.success) {
|
||||
toast.success(result.message)
|
||||
setOpen(false)
|
||||
} else {
|
||||
toast.error(result.message)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
{trigger || (
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add New Chapter</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a new chapter or section.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form action={handleSubmit}>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="title" className="text-right">
|
||||
Title
|
||||
</Label>
|
||||
<Input
|
||||
id="title"
|
||||
name="title"
|
||||
placeholder="e.g. Chapter 1: Introduction"
|
||||
className="col-span-3"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<SubmitButton />
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Plus } from "lucide-react"
|
||||
import { useFormStatus } from "react-dom"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/shared/components/ui/dialog"
|
||||
import { Input } from "@/shared/components/ui/input"
|
||||
import { Label } from "@/shared/components/ui/label"
|
||||
import { Textarea } from "@/shared/components/ui/textarea"
|
||||
import { createKnowledgePointAction } from "../actions"
|
||||
import { toast } from "sonner"
|
||||
|
||||
function SubmitButton() {
|
||||
const { pending } = useFormStatus()
|
||||
return (
|
||||
<Button type="submit" disabled={pending}>
|
||||
{pending ? "Adding..." : "Add Point"}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
interface CreateKnowledgePointDialogProps {
|
||||
chapterId: string
|
||||
textbookId: string
|
||||
}
|
||||
|
||||
export function CreateKnowledgePointDialog({ chapterId, textbookId }: CreateKnowledgePointDialogProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const handleSubmit = async (formData: FormData) => {
|
||||
const result = await createKnowledgePointAction(chapterId, textbookId, null, formData)
|
||||
if (result.success) {
|
||||
toast.success(result.message)
|
||||
setOpen(false)
|
||||
} else {
|
||||
toast.error(result.message)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Knowledge Point</DialogTitle>
|
||||
<DialogDescription>
|
||||
Link a key concept to this chapter.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form action={handleSubmit}>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="name">
|
||||
Name
|
||||
</Label>
|
||||
<Input
|
||||
id="name"
|
||||
name="name"
|
||||
placeholder="e.g. Pythagorean Theorem"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="description">
|
||||
Description
|
||||
</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
name="description"
|
||||
placeholder="Brief explanation..."
|
||||
className="h-20"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<SubmitButton />
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
100
src/modules/textbooks/components/knowledge-point-panel.tsx
Normal file
100
src/modules/textbooks/components/knowledge-point-panel.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
"use client"
|
||||
|
||||
import { Card, CardContent } from "@/shared/components/ui/card"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Tag, Trash2 } from "lucide-react"
|
||||
import { KnowledgePoint } from "../types"
|
||||
import { CreateKnowledgePointDialog } from "./create-knowledge-point-dialog"
|
||||
import { deleteKnowledgePointAction } from "../actions"
|
||||
import { toast } from "sonner"
|
||||
import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
||||
|
||||
interface KnowledgePointPanelProps {
|
||||
knowledgePoints: KnowledgePoint[]
|
||||
selectedChapterId: string | null
|
||||
textbookId: string
|
||||
}
|
||||
|
||||
export function KnowledgePointPanel({
|
||||
knowledgePoints,
|
||||
selectedChapterId,
|
||||
textbookId
|
||||
}: KnowledgePointPanelProps) {
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm("Are you sure you want to delete this knowledge point?")) return
|
||||
|
||||
const result = await deleteKnowledgePointAction(id, textbookId)
|
||||
if (result.success) {
|
||||
toast.success(result.message)
|
||||
} else {
|
||||
toast.error(result.message)
|
||||
}
|
||||
}
|
||||
|
||||
// Filter KPs for the selected chapter
|
||||
const chapterKPs = selectedChapterId
|
||||
? knowledgePoints.filter(kp => kp.chapterId === selectedChapterId)
|
||||
: []
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col space-y-4">
|
||||
<div className="flex items-center justify-between px-2">
|
||||
<h3 className="font-semibold flex items-center gap-2">
|
||||
<Tag className="h-4 w-4" />
|
||||
Knowledge Points
|
||||
</h3>
|
||||
{selectedChapterId && (
|
||||
<CreateKnowledgePointDialog
|
||||
chapterId={selectedChapterId}
|
||||
textbookId={textbookId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ScrollArea className="flex-1 -mx-2 px-2">
|
||||
{selectedChapterId ? (
|
||||
chapterKPs.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{chapterKPs.map((kp) => (
|
||||
<Card key={kp.id} className="relative group">
|
||||
<CardContent className="p-3">
|
||||
<div className="flex justify-between items-start gap-2">
|
||||
<div className="space-y-1">
|
||||
<div className="font-medium text-sm leading-tight">
|
||||
{kp.name}
|
||||
</div>
|
||||
{kp.description && (
|
||||
<p className="text-xs text-muted-foreground line-clamp-2">
|
||||
{kp.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity text-destructive hover:text-destructive hover:bg-destructive/10 -mt-1 -mr-1"
|
||||
onClick={() => handleDelete(kp.id)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-muted-foreground text-center py-8 border rounded-md border-dashed bg-muted/30">
|
||||
No knowledge points linked to this chapter yet.
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div className="text-sm text-muted-foreground text-center py-8">
|
||||
Select a chapter to manage its knowledge points.
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
75
src/modules/textbooks/components/textbook-card.tsx
Normal file
75
src/modules/textbooks/components/textbook-card.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import Link from "next/link";
|
||||
import { GraduationCap, Building2, BookOpen } from "lucide-react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/shared/components/ui/card";
|
||||
import { Badge } from "@/shared/components/ui/badge";
|
||||
import { cn } from "@/shared/lib/utils";
|
||||
import { Textbook } from "../types";
|
||||
|
||||
interface TextbookCardProps {
|
||||
textbook: Textbook;
|
||||
}
|
||||
|
||||
export function TextbookCard({ textbook }: TextbookCardProps) {
|
||||
return (
|
||||
<Link href={`/teacher/textbooks/${textbook.id}`} className="block h-full">
|
||||
<Card
|
||||
className={cn(
|
||||
"group h-full overflow-hidden transition-all duration-300 ease-out",
|
||||
"hover:-translate-y-1 hover:shadow-md hover:border-primary/50"
|
||||
)}
|
||||
>
|
||||
<div className="relative aspect-[4/3] w-full overflow-hidden bg-muted/30 p-6 flex items-center justify-center">
|
||||
{/* Fallback Cover Visualization */}
|
||||
<div className="relative z-10 flex h-24 w-20 flex-col items-center justify-center rounded-sm bg-background shadow-sm border transition-transform duration-300 group-hover:scale-110">
|
||||
<div className="h-full w-full bg-gradient-to-br from-primary/10 to-primary/5 p-2">
|
||||
<div className="h-1 w-full rounded-full bg-primary/20 mb-1" />
|
||||
<div className="h-1 w-2/3 rounded-full bg-primary/20" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Decorative Background Pattern */}
|
||||
<div className="absolute inset-0 bg-grid-black/[0.02] dark:bg-grid-white/[0.02]" />
|
||||
</div>
|
||||
|
||||
<CardHeader className="p-4 pb-2">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="space-y-1">
|
||||
<Badge variant="outline" className="w-fit text-[10px] h-5 px-1.5 font-normal border-primary/20 text-primary bg-primary/5">
|
||||
{textbook.subject}
|
||||
</Badge>
|
||||
<CardTitle className="line-clamp-2 text-base leading-tight">
|
||||
{textbook.title}
|
||||
</CardTitle>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="p-4 pt-0 text-sm text-muted-foreground">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<GraduationCap className="h-3.5 w-3.5 text-muted-foreground/70" />
|
||||
<span>{textbook.grade}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<Building2 className="h-3.5 w-3.5 text-muted-foreground/70" />
|
||||
<span className="line-clamp-1">{textbook.publisher || "Unknown Publisher"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="p-4 pt-0 mt-auto">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground/80 bg-muted/30 px-2 py-1 rounded-md w-full">
|
||||
<BookOpen className="h-3.5 w-3.5" />
|
||||
<span>{textbook._count?.chapters || 0} Chapters</span>
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
133
src/modules/textbooks/components/textbook-content-layout.tsx
Normal file
133
src/modules/textbooks/components/textbook-content-layout.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Chapter, KnowledgePoint } from "../types"
|
||||
import { ChapterSidebarList } from "./chapter-sidebar-list"
|
||||
import { KnowledgePointPanel } from "./knowledge-point-panel"
|
||||
import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Edit2, Save, Plus } from "lucide-react"
|
||||
import { CreateChapterDialog } from "./create-chapter-dialog"
|
||||
import { updateChapterContentAction } from "../actions"
|
||||
import { toast } from "sonner"
|
||||
import { Textarea } from "@/shared/components/ui/textarea"
|
||||
|
||||
interface TextbookContentLayoutProps {
|
||||
chapters: Chapter[]
|
||||
knowledgePoints: KnowledgePoint[]
|
||||
textbookId: string
|
||||
}
|
||||
|
||||
export function TextbookContentLayout({ chapters, knowledgePoints, textbookId }: TextbookContentLayoutProps) {
|
||||
const [selectedChapter, setSelectedChapter] = useState<Chapter | null>(null)
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [editContent, setEditContent] = useState("")
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
|
||||
// Sync edit content when selection changes
|
||||
const handleSelectChapter = (chapter: Chapter) => {
|
||||
setSelectedChapter(chapter)
|
||||
setEditContent(chapter.content || "")
|
||||
setIsEditing(false)
|
||||
}
|
||||
|
||||
const handleSaveContent = async () => {
|
||||
if (!selectedChapter) return
|
||||
setIsSaving(true)
|
||||
const result = await updateChapterContentAction(selectedChapter.id, editContent, textbookId)
|
||||
setIsSaving(false)
|
||||
|
||||
if (result.success) {
|
||||
toast.success(result.message)
|
||||
setIsEditing(false)
|
||||
// Update local state to reflect change immediately (optimistic-like)
|
||||
selectedChapter.content = editContent
|
||||
} else {
|
||||
toast.error(result.message)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-12 gap-6 h-[calc(100vh-140px)]">
|
||||
{/* Left Sidebar: TOC (3 cols) */}
|
||||
<div className="col-span-3 border-r pr-6 flex flex-col h-full">
|
||||
<div className="flex items-center justify-between mb-4 px-2">
|
||||
<h3 className="font-semibold">Chapters</h3>
|
||||
<CreateChapterDialog textbookId={textbookId} />
|
||||
</div>
|
||||
<ScrollArea className="flex-1 -mx-2 px-2">
|
||||
<ChapterSidebarList
|
||||
chapters={chapters}
|
||||
selectedChapterId={selectedChapter?.id}
|
||||
onSelectChapter={handleSelectChapter}
|
||||
/>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
{/* Middle: Content Viewer/Editor (6 cols) */}
|
||||
<div className="col-span-6 flex flex-col h-full px-2">
|
||||
{selectedChapter ? (
|
||||
<>
|
||||
<div className="flex items-center justify-between mb-4 pb-2 border-b">
|
||||
<h2 className="text-xl font-bold tracking-tight">{selectedChapter.title}</h2>
|
||||
<div className="flex gap-2">
|
||||
{isEditing ? (
|
||||
<>
|
||||
<Button size="sm" variant="ghost" onClick={() => setIsEditing(false)} disabled={isSaving}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleSaveContent} disabled={isSaving}>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
{isSaving ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button size="sm" variant="outline" onClick={() => setIsEditing(true)}>
|
||||
<Edit2 className="mr-2 h-4 w-4" />
|
||||
Edit Content
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-4 h-full">
|
||||
{isEditing ? (
|
||||
<Textarea
|
||||
className="min-h-[500px] font-mono text-sm"
|
||||
value={editContent}
|
||||
onChange={(e) => setEditContent(e.target.value)}
|
||||
placeholder="# Write markdown content here..."
|
||||
/>
|
||||
) : (
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none">
|
||||
{selectedChapter.content ? (
|
||||
<div className="whitespace-pre-wrap">{selectedChapter.content}</div>
|
||||
) : (
|
||||
<div className="text-muted-foreground italic py-8 text-center">
|
||||
No content available. Click edit to add content.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</>
|
||||
) : (
|
||||
<div className="h-full flex items-center justify-center text-muted-foreground">
|
||||
Select a chapter from the left sidebar to view its content.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right Sidebar: Knowledge Points (3 cols) */}
|
||||
<div className="col-span-3 border-l pl-6 flex flex-col h-full">
|
||||
<KnowledgePointPanel
|
||||
knowledgePoints={knowledgePoints}
|
||||
selectedChapterId={selectedChapter?.id || null}
|
||||
textbookId={textbookId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
132
src/modules/textbooks/components/textbook-form-dialog.tsx
Normal file
132
src/modules/textbooks/components/textbook-form-dialog.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Plus } from "lucide-react"
|
||||
import { useFormStatus } from "react-dom"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/shared/components/ui/dialog"
|
||||
import { Input } from "@/shared/components/ui/input"
|
||||
import { Label } from "@/shared/components/ui/label"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/components/ui/select"
|
||||
import { createTextbookAction } from "../actions"
|
||||
import { toast } from "sonner"
|
||||
|
||||
function SubmitButton() {
|
||||
const { pending } = useFormStatus()
|
||||
return (
|
||||
<Button type="submit" disabled={pending}>
|
||||
{pending ? "Saving..." : "Save changes"}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export function TextbookFormDialog() {
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
// Using simple form action without useActionState hook for simplicity in this demo environment
|
||||
// In production with React 19/Next 15, we'd use useActionState
|
||||
const handleSubmit = async (formData: FormData) => {
|
||||
const result = await createTextbookAction(null, formData)
|
||||
if (result.success) {
|
||||
toast.success(result.message)
|
||||
setOpen(false)
|
||||
} else {
|
||||
toast.error(result.message)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Textbook
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add New Textbook</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a new digital textbook. Click save when you're done.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form action={handleSubmit}>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="title" className="text-right">
|
||||
Title
|
||||
</Label>
|
||||
<Input
|
||||
id="title"
|
||||
name="title"
|
||||
placeholder="e.g. Advanced Calculus"
|
||||
className="col-span-3"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="subject" className="text-right">
|
||||
Subject
|
||||
</Label>
|
||||
<Select name="subject" required>
|
||||
<SelectTrigger className="col-span-3">
|
||||
<SelectValue placeholder="Select subject" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Mathematics">Mathematics</SelectItem>
|
||||
<SelectItem value="Physics">Physics</SelectItem>
|
||||
<SelectItem value="History">History</SelectItem>
|
||||
<SelectItem value="English">English</SelectItem>
|
||||
<SelectItem value="Chemistry">Chemistry</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="grade" className="text-right">
|
||||
Grade
|
||||
</Label>
|
||||
<Select name="grade" required>
|
||||
<SelectTrigger className="col-span-3">
|
||||
<SelectValue placeholder="Select grade" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Grade 10">Grade 10</SelectItem>
|
||||
<SelectItem value="Grade 11">Grade 11</SelectItem>
|
||||
<SelectItem value="Grade 12">Grade 12</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="publisher" className="text-right">
|
||||
Publisher
|
||||
</Label>
|
||||
<Input
|
||||
id="publisher"
|
||||
name="publisher"
|
||||
placeholder="e.g. Next Education"
|
||||
className="col-span-3"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<SubmitButton />
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
160
src/modules/textbooks/components/textbook-settings-dialog.tsx
Normal file
160
src/modules/textbooks/components/textbook-settings-dialog.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Edit, Trash2 } from "lucide-react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/shared/components/ui/dialog"
|
||||
import { Input } from "@/shared/components/ui/input"
|
||||
import { Label } from "@/shared/components/ui/label"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/components/ui/select"
|
||||
import { updateTextbookAction, deleteTextbookAction } from "../actions"
|
||||
import { toast } from "sonner"
|
||||
import { Textbook } from "../types"
|
||||
|
||||
interface TextbookSettingsDialogProps {
|
||||
textbook: Textbook
|
||||
trigger?: React.ReactNode
|
||||
}
|
||||
|
||||
export function TextbookSettingsDialog({ textbook, trigger }: TextbookSettingsDialogProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const router = useRouter()
|
||||
|
||||
const handleUpdate = async (formData: FormData) => {
|
||||
setLoading(true)
|
||||
const result = await updateTextbookAction(textbook.id, null, formData)
|
||||
setLoading(false)
|
||||
if (result.success) {
|
||||
toast.success(result.message)
|
||||
setOpen(false)
|
||||
} else {
|
||||
toast.error(result.message)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!confirm("Are you sure you want to delete this textbook? This action cannot be undone.")) return
|
||||
|
||||
setLoading(true)
|
||||
const result = await deleteTextbookAction(textbook.id)
|
||||
|
||||
if (result.success) {
|
||||
toast.success(result.message)
|
||||
router.push("/teacher/textbooks") // Redirect after delete
|
||||
} else {
|
||||
setLoading(false)
|
||||
toast.error(result.message)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
{trigger || (
|
||||
<Button variant="outline" size="sm">
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Settings
|
||||
</Button>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Textbook Settings</DialogTitle>
|
||||
<DialogDescription>
|
||||
Update textbook details or delete this textbook.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form action={handleUpdate}>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="title" className="text-right">
|
||||
Title
|
||||
</Label>
|
||||
<Input
|
||||
id="title"
|
||||
name="title"
|
||||
defaultValue={textbook.title}
|
||||
className="col-span-3"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="subject" className="text-right">
|
||||
Subject
|
||||
</Label>
|
||||
<Select name="subject" defaultValue={textbook.subject} required>
|
||||
<SelectTrigger className="col-span-3">
|
||||
<SelectValue placeholder="Select subject" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Mathematics">Mathematics</SelectItem>
|
||||
<SelectItem value="Physics">Physics</SelectItem>
|
||||
<SelectItem value="History">History</SelectItem>
|
||||
<SelectItem value="English">English</SelectItem>
|
||||
<SelectItem value="Chemistry">Chemistry</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="grade" className="text-right">
|
||||
Grade
|
||||
</Label>
|
||||
<Select name="grade" defaultValue={textbook.grade || undefined} required>
|
||||
<SelectTrigger className="col-span-3">
|
||||
<SelectValue placeholder="Select grade" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Grade 10">Grade 10</SelectItem>
|
||||
<SelectItem value="Grade 11">Grade 11</SelectItem>
|
||||
<SelectItem value="Grade 12">Grade 12</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="publisher" className="text-right">
|
||||
Publisher
|
||||
</Label>
|
||||
<Input
|
||||
id="publisher"
|
||||
name="publisher"
|
||||
defaultValue={textbook.publisher || ""}
|
||||
className="col-span-3"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex justify-between sm:justify-between">
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={handleDelete}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? "Processing..." : "Delete Textbook"}
|
||||
</Button>
|
||||
<Button type="submit" disabled={loading}>
|
||||
{loading ? "Saving..." : "Save Changes"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
226
src/modules/textbooks/data-access.ts
Normal file
226
src/modules/textbooks/data-access.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
import { Textbook, Chapter, CreateTextbookInput, CreateChapterInput, UpdateChapterContentInput, KnowledgePoint, CreateKnowledgePointInput, UpdateTextbookInput } from "./types";
|
||||
|
||||
// Mock Data (Moved from data/mock-data.ts and enhanced)
|
||||
let MOCK_TEXTBOOKS: Textbook[] = [
|
||||
// ... (previous textbooks remain same, keeping for brevity)
|
||||
{
|
||||
id: "tb_01",
|
||||
title: "Advanced Mathematics Grade 10",
|
||||
subject: "Mathematics",
|
||||
grade: "Grade 10",
|
||||
publisher: "Next Education Press",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
_count: { chapters: 12 },
|
||||
},
|
||||
// ... (other textbooks)
|
||||
];
|
||||
|
||||
let MOCK_CHAPTERS: Chapter[] = [
|
||||
// ... (previous chapters)
|
||||
{
|
||||
id: "ch_01",
|
||||
textbookId: "tb_01",
|
||||
title: "Chapter 1: Real Numbers",
|
||||
order: 1,
|
||||
parentId: null,
|
||||
content: "# Chapter 1: Real Numbers\n\nIn this chapter, we will explore the properties of real numbers...",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
children: [
|
||||
{
|
||||
id: "ch_01_01",
|
||||
textbookId: "tb_01",
|
||||
title: "1.1 Introduction to Real Numbers",
|
||||
order: 1,
|
||||
parentId: "ch_01",
|
||||
content: "## 1.1 Introduction\n\nReal numbers include rational and irrational numbers.",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
let MOCK_KNOWLEDGE_POINTS: KnowledgePoint[] = [
|
||||
{
|
||||
id: "kp_01",
|
||||
name: "Real Numbers",
|
||||
description: "Definition and properties of real numbers",
|
||||
level: 1,
|
||||
order: 1,
|
||||
chapterId: "ch_01",
|
||||
},
|
||||
{
|
||||
id: "kp_02",
|
||||
name: "Rational Numbers",
|
||||
description: "Numbers that can be expressed as a fraction",
|
||||
level: 2,
|
||||
order: 1,
|
||||
chapterId: "ch_01_01",
|
||||
}
|
||||
];
|
||||
|
||||
// ... (existing imports and mock data)
|
||||
|
||||
export async function getTextbooks(query?: string, subject?: string, grade?: string): Promise<Textbook[]> {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
let results = [...MOCK_TEXTBOOKS];
|
||||
// ... (filtering logic)
|
||||
return results;
|
||||
}
|
||||
|
||||
export async function getTextbookById(id: string): Promise<Textbook | undefined> {
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
return MOCK_TEXTBOOKS.find((t) => t.id === id);
|
||||
}
|
||||
|
||||
export async function getChaptersByTextbookId(textbookId: string): Promise<Chapter[]> {
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
return MOCK_CHAPTERS.filter((c) => c.textbookId === textbookId);
|
||||
}
|
||||
|
||||
export async function createTextbook(data: CreateTextbookInput): Promise<Textbook> {
|
||||
await new Promise((resolve) => setTimeout(resolve, 800));
|
||||
const newTextbook: Textbook = {
|
||||
id: `tb_${Math.random().toString(36).substr(2, 9)}`,
|
||||
...data,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
_count: { chapters: 0 },
|
||||
};
|
||||
MOCK_TEXTBOOKS = [newTextbook, ...MOCK_TEXTBOOKS];
|
||||
return newTextbook;
|
||||
}
|
||||
|
||||
export async function updateTextbook(data: UpdateTextbookInput): Promise<Textbook> {
|
||||
await new Promise((resolve) => setTimeout(resolve, 800));
|
||||
|
||||
const index = MOCK_TEXTBOOKS.findIndex((t) => t.id === data.id);
|
||||
if (index === -1) throw new Error("Textbook not found");
|
||||
|
||||
const updatedTextbook = {
|
||||
...MOCK_TEXTBOOKS[index],
|
||||
...data,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
MOCK_TEXTBOOKS[index] = updatedTextbook;
|
||||
return updatedTextbook;
|
||||
}
|
||||
|
||||
export async function deleteTextbook(id: string): Promise<void> {
|
||||
await new Promise((resolve) => setTimeout(resolve, 800));
|
||||
MOCK_TEXTBOOKS = MOCK_TEXTBOOKS.filter((t) => t.id !== id);
|
||||
}
|
||||
|
||||
// ... (rest of the file)
|
||||
|
||||
export async function createChapter(data: CreateChapterInput): Promise<Chapter> {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
const newChapter: Chapter = {
|
||||
id: `ch_${Math.random().toString(36).substr(2, 9)}`,
|
||||
textbookId: data.textbookId,
|
||||
title: data.title,
|
||||
order: data.order || 0,
|
||||
parentId: data.parentId || null,
|
||||
content: "",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
children: []
|
||||
};
|
||||
|
||||
// Logic to add to nested structure (simplified for mock: add to root or find parent)
|
||||
// For deep nesting in mock, we'd need recursive search.
|
||||
// Here we just push to root or try to find parent in top level for simplicity of demo.
|
||||
|
||||
if (data.parentId) {
|
||||
const parent = MOCK_CHAPTERS.find(c => c.id === data.parentId);
|
||||
if (parent) {
|
||||
if (!parent.children) parent.children = [];
|
||||
parent.children.push(newChapter);
|
||||
} else {
|
||||
// Try searching one level deep
|
||||
for (const ch of MOCK_CHAPTERS) {
|
||||
if (ch.children) {
|
||||
const subParent = ch.children.find(c => c.id === data.parentId);
|
||||
if (subParent) {
|
||||
if (!subParent.children) subParent.children = [];
|
||||
subParent.children.push(newChapter);
|
||||
return newChapter;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
MOCK_CHAPTERS.push(newChapter);
|
||||
}
|
||||
|
||||
return newChapter;
|
||||
}
|
||||
|
||||
export async function updateChapterContent(data: UpdateChapterContentInput): Promise<Chapter> {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
// Recursive find and update
|
||||
const updateContentRecursive = (chapters: Chapter[]): Chapter | null => {
|
||||
for (const ch of chapters) {
|
||||
if (ch.id === data.chapterId) {
|
||||
ch.content = data.content;
|
||||
ch.updatedAt = new Date();
|
||||
return ch;
|
||||
}
|
||||
if (ch.children) {
|
||||
const found = updateContentRecursive(ch.children);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const updated = updateContentRecursive(MOCK_CHAPTERS);
|
||||
if (!updated) throw new Error("Chapter not found");
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
export async function deleteChapter(id: string): Promise<void> {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
// Recursive delete
|
||||
MOCK_CHAPTERS = MOCK_CHAPTERS.filter(c => c.id !== id);
|
||||
MOCK_CHAPTERS.forEach(c => {
|
||||
if (c.children) {
|
||||
c.children = c.children.filter(child => child.id !== id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Knowledge Points
|
||||
|
||||
export async function getKnowledgePointsByChapterId(chapterId: string): Promise<KnowledgePoint[]> {
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
return MOCK_KNOWLEDGE_POINTS.filter(kp => kp.chapterId === chapterId);
|
||||
}
|
||||
|
||||
export async function createKnowledgePoint(data: CreateKnowledgePointInput): Promise<KnowledgePoint> {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
const newKP: KnowledgePoint = {
|
||||
id: `kp_${Math.random().toString(36).substr(2, 9)}`,
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
chapterId: data.chapterId,
|
||||
level: 1, // simplified
|
||||
order: 0
|
||||
};
|
||||
|
||||
MOCK_KNOWLEDGE_POINTS.push(newKP);
|
||||
return newKP;
|
||||
}
|
||||
|
||||
export async function deleteKnowledgePoint(id: string): Promise<void> {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
MOCK_KNOWLEDGE_POINTS = MOCK_KNOWLEDGE_POINTS.filter(kp => kp.id !== id);
|
||||
}
|
||||
76
src/modules/textbooks/types.ts
Normal file
76
src/modules/textbooks/types.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { type InferSelectModel } from "drizzle-orm";
|
||||
import { textbooks, chapters } from "@/shared/db/schema";
|
||||
|
||||
// Define types based on Drizzle Schema
|
||||
// In a real app, we would infer these from the schema, but since we might not have the full schema setup running locally with DB,
|
||||
// we will define interfaces that match the schema description in ARCHITECTURE.md and schema.ts
|
||||
|
||||
export type Textbook = {
|
||||
id: string;
|
||||
title: string;
|
||||
subject: string;
|
||||
grade: string | null;
|
||||
publisher: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
// Computed/Joined fields
|
||||
_count?: {
|
||||
chapters: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type Chapter = {
|
||||
id: string;
|
||||
textbookId: string;
|
||||
title: string;
|
||||
order: number | null;
|
||||
parentId: string | null;
|
||||
content?: string | null; // Added for content viewing
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
// Recursive structure for UI
|
||||
children?: Chapter[];
|
||||
};
|
||||
|
||||
export type CreateTextbookInput = {
|
||||
title: string;
|
||||
subject: string;
|
||||
grade: string;
|
||||
publisher: string;
|
||||
};
|
||||
|
||||
export type UpdateTextbookInput = {
|
||||
id: string;
|
||||
title: string;
|
||||
subject: string;
|
||||
grade: string;
|
||||
publisher: string;
|
||||
};
|
||||
|
||||
export type KnowledgePoint = {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
parentId?: string | null;
|
||||
chapterId?: string; // Logic link for this module context
|
||||
level: number;
|
||||
order: number;
|
||||
};
|
||||
|
||||
export type CreateChapterInput = {
|
||||
textbookId: string;
|
||||
title: string;
|
||||
parentId?: string;
|
||||
order?: number;
|
||||
};
|
||||
|
||||
export type UpdateChapterContentInput = {
|
||||
chapterId: string;
|
||||
content: string;
|
||||
};
|
||||
|
||||
export type CreateKnowledgePointInput = {
|
||||
name: string;
|
||||
description?: string;
|
||||
chapterId: string;
|
||||
};
|
||||
Reference in New Issue
Block a user