完整性更新
现在已经实现了大部分基础功能
This commit is contained in:
@@ -47,8 +47,7 @@ export async function createTextbookAction(
|
||||
success: true,
|
||||
message: "Textbook created successfully.",
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Failed to create textbook:", error);
|
||||
} catch {
|
||||
return {
|
||||
success: false,
|
||||
message: "Failed to create textbook.",
|
||||
@@ -83,8 +82,7 @@ export async function updateTextbookAction(
|
||||
success: true,
|
||||
message: "Textbook updated successfully.",
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Failed to update textbook:", error);
|
||||
} catch {
|
||||
return {
|
||||
success: false,
|
||||
message: "Failed to update textbook.",
|
||||
@@ -102,8 +100,7 @@ export async function deleteTextbookAction(
|
||||
success: true,
|
||||
message: "Textbook deleted successfully.",
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Failed to delete textbook:", error);
|
||||
} catch {
|
||||
return {
|
||||
success: false,
|
||||
message: "Failed to delete textbook.",
|
||||
@@ -130,7 +127,7 @@ export async function createChapterAction(
|
||||
});
|
||||
revalidatePath(`/teacher/textbooks/${textbookId}`);
|
||||
return { success: true, message: "Chapter created successfully" };
|
||||
} catch (error) {
|
||||
} catch {
|
||||
return { success: false, message: "Failed to create chapter" };
|
||||
}
|
||||
}
|
||||
@@ -144,7 +141,7 @@ export async function updateChapterContentAction(
|
||||
await updateChapterContent({ chapterId, content });
|
||||
revalidatePath(`/teacher/textbooks/${textbookId}`);
|
||||
return { success: true, message: "Content updated successfully" };
|
||||
} catch (error) {
|
||||
} catch {
|
||||
return { success: false, message: "Failed to update content" };
|
||||
}
|
||||
}
|
||||
@@ -157,7 +154,7 @@ export async function deleteChapterAction(
|
||||
await deleteChapter(chapterId);
|
||||
revalidatePath(`/teacher/textbooks/${textbookId}`);
|
||||
return { success: true, message: "Chapter deleted successfully" };
|
||||
} catch (error) {
|
||||
} catch {
|
||||
return { success: false, message: "Failed to delete chapter" };
|
||||
}
|
||||
}
|
||||
@@ -177,7 +174,7 @@ export async function createKnowledgePointAction(
|
||||
await createKnowledgePoint({ name, description, chapterId });
|
||||
revalidatePath(`/teacher/textbooks/${textbookId}`);
|
||||
return { success: true, message: "Knowledge point created successfully" };
|
||||
} catch (error) {
|
||||
} catch {
|
||||
return { success: false, message: "Failed to create knowledge point" };
|
||||
}
|
||||
}
|
||||
@@ -190,7 +187,7 @@ export async function deleteKnowledgePointAction(
|
||||
await deleteKnowledgePoint(kpId);
|
||||
revalidatePath(`/teacher/textbooks/${textbookId}`);
|
||||
return { success: true, message: "Knowledge point deleted successfully" };
|
||||
} catch (error) {
|
||||
} catch {
|
||||
return { success: false, message: "Failed to delete knowledge point" };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
"use client"
|
||||
|
||||
import ReactMarkdown from "react-markdown"
|
||||
import remarkBreaks from "remark-breaks"
|
||||
import remarkGfm from "remark-gfm"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -32,10 +35,12 @@ export function ChapterContentViewer({
|
||||
Reading Mode
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<ScrollArea className="flex-1 pr-4">
|
||||
<ScrollArea className="flex-1 pr-4 min-h-0">
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none">
|
||||
{chapter.content ? (
|
||||
<div className="whitespace-pre-wrap">{chapter.content}</div>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm, remarkBreaks]}>
|
||||
{chapter.content}
|
||||
</ReactMarkdown>
|
||||
) : (
|
||||
<div className="flex h-40 items-center justify-center text-muted-foreground italic">
|
||||
No content available for this chapter.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { ChevronRight, FileText, Folder, MoreHorizontal, Eye, Edit } from "lucide-react"
|
||||
import { ChevronRight, FileText, Folder, MoreHorizontal, Eye } from "lucide-react"
|
||||
import { Chapter } from "../types"
|
||||
import {
|
||||
Collapsible,
|
||||
@@ -22,9 +22,10 @@ interface ChapterItemProps {
|
||||
chapter: Chapter
|
||||
level?: number
|
||||
onView: (chapter: Chapter) => void
|
||||
showActions?: boolean
|
||||
}
|
||||
|
||||
function ChapterItem({ chapter, level = 0, onView }: ChapterItemProps) {
|
||||
function ChapterItem({ chapter, level = 0, onView, showActions = true }: ChapterItemProps) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const hasChildren = chapter.children && chapter.children.length > 0
|
||||
|
||||
@@ -65,28 +66,26 @@ function ChapterItem({ chapter, level = 0, onView }: ChapterItemProps) {
|
||||
)}
|
||||
<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>
|
||||
{showActions ? (
|
||||
<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>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -94,7 +93,13 @@ function ChapterItem({ chapter, level = 0, onView }: ChapterItemProps) {
|
||||
<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} />
|
||||
<ChapterItem
|
||||
key={child.id}
|
||||
chapter={child}
|
||||
level={level + 1}
|
||||
onView={onView}
|
||||
showActions={showActions}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
@@ -104,7 +109,7 @@ function ChapterItem({ chapter, level = 0, onView }: ChapterItemProps) {
|
||||
)
|
||||
}
|
||||
|
||||
export function ChapterList({ chapters }: { chapters: Chapter[] }) {
|
||||
export function ChapterList({ chapters, showActions }: { chapters: Chapter[]; showActions?: boolean }) {
|
||||
const [viewingChapter, setViewingChapter] = useState<Chapter | null>(null)
|
||||
const [isViewerOpen, setIsViewerOpen] = useState(false)
|
||||
|
||||
@@ -117,7 +122,7 @@ export function ChapterList({ chapters }: { chapters: Chapter[] }) {
|
||||
<>
|
||||
<div className="space-y-1">
|
||||
{chapters.map((chapter) => (
|
||||
<ChapterItem key={chapter.id} chapter={chapter} onView={handleView} />
|
||||
<ChapterItem key={chapter.id} chapter={chapter} onView={handleView} showActions={showActions} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { ChevronRight, FileText, Folder, MoreHorizontal } from "lucide-react"
|
||||
import { ChevronRight, FileText, Folder, MoreHorizontal, Plus, Trash2 } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
import { Chapter } from "../types"
|
||||
import {
|
||||
Collapsible,
|
||||
@@ -9,20 +10,55 @@ import {
|
||||
CollapsibleTrigger,
|
||||
} from "@/shared/components/ui/collapsible"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/shared/components/ui/dropdown-menu"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/shared/components/ui/alert-dialog"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
import { CreateChapterDialog } from "./create-chapter-dialog"
|
||||
import { deleteChapterAction } from "../actions"
|
||||
|
||||
interface ChapterItemProps {
|
||||
chapter: Chapter
|
||||
level?: number
|
||||
selectedId?: string
|
||||
onSelect: (chapter: Chapter) => void
|
||||
textbookId: string
|
||||
}
|
||||
|
||||
function ChapterItem({ chapter, level = 0, selectedId, onSelect }: ChapterItemProps) {
|
||||
function ChapterItem({ chapter, level = 0, selectedId, onSelect, textbookId }: ChapterItemProps) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [showCreateDialog, setShowCreateDialog] = useState(false)
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
const hasChildren = chapter.children && chapter.children.length > 0
|
||||
const isSelected = chapter.id === selectedId
|
||||
|
||||
const handleDelete = async () => {
|
||||
setIsDeleting(true)
|
||||
const res = await deleteChapterAction(chapter.id, textbookId)
|
||||
setIsDeleting(false)
|
||||
if (res.success) {
|
||||
toast.success(res.message)
|
||||
setShowDeleteDialog(false)
|
||||
} else {
|
||||
toast.error(res.message)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("w-full", level > 0 && "ml-4 border-l pl-2")}>
|
||||
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
|
||||
@@ -53,7 +89,7 @@ function ChapterItem({ chapter, level = 0, selectedId, onSelect }: ChapterItemPr
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-1 items-center gap-2 px-2 py-1.5 text-sm cursor-pointer",
|
||||
"flex flex-1 min-w-0 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"
|
||||
)}
|
||||
@@ -64,19 +100,33 @@ function ChapterItem({ chapter, level = 0, selectedId, onSelect }: ChapterItemPr
|
||||
) : (
|
||||
<FileText className="h-4 w-4 text-muted-foreground/50" />
|
||||
)}
|
||||
<span className="truncate">{chapter.title}</span>
|
||||
<span className="truncate flex-1 min-w-0">{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>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="ml-auto h-6 w-6 opacity-100 md:opacity-0 md: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
|
||||
onSelect={() => setShowCreateDialog(true)}
|
||||
>
|
||||
<Plus />
|
||||
Add Subchapter
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem variant="destructive" onSelect={() => setShowDeleteDialog(true)}>
|
||||
<Trash2 />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -90,12 +140,42 @@ function ChapterItem({ chapter, level = 0, selectedId, onSelect }: ChapterItemPr
|
||||
level={level + 1}
|
||||
selectedId={selectedId}
|
||||
onSelect={onSelect}
|
||||
textbookId={textbookId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
)}
|
||||
</Collapsible>
|
||||
|
||||
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete chapter?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will delete this chapter and all its subchapters and linked knowledge points.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isDeleting}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
onClick={handleDelete}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{isDeleting ? "Deleting..." : "Delete"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
<CreateChapterDialog
|
||||
textbookId={textbookId}
|
||||
parentId={chapter.id}
|
||||
trigger={null}
|
||||
open={showCreateDialog}
|
||||
onOpenChange={setShowCreateDialog}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -103,11 +183,13 @@ function ChapterItem({ chapter, level = 0, selectedId, onSelect }: ChapterItemPr
|
||||
export function ChapterSidebarList({
|
||||
chapters,
|
||||
selectedChapterId,
|
||||
onSelectChapter
|
||||
onSelectChapter,
|
||||
textbookId,
|
||||
}: {
|
||||
chapters: Chapter[],
|
||||
selectedChapterId?: string,
|
||||
onSelectChapter: (chapter: Chapter) => void
|
||||
textbookId: string
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
@@ -117,6 +199,7 @@ export function ChapterSidebarList({
|
||||
chapter={chapter}
|
||||
selectedId={selectedChapterId}
|
||||
onSelect={onSelectChapter}
|
||||
textbookId={textbookId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -30,11 +30,15 @@ function SubmitButton() {
|
||||
interface CreateChapterDialogProps {
|
||||
textbookId: string
|
||||
parentId?: string
|
||||
trigger?: React.ReactNode
|
||||
trigger?: React.ReactNode | null
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function CreateChapterDialog({ textbookId, parentId, trigger }: CreateChapterDialogProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
export function CreateChapterDialog({ textbookId, parentId, trigger, open: controlledOpen, onOpenChange }: CreateChapterDialogProps) {
|
||||
const [uncontrolledOpen, setUncontrolledOpen] = useState(false)
|
||||
const open = controlledOpen ?? uncontrolledOpen
|
||||
const setOpen = onOpenChange ?? setUncontrolledOpen
|
||||
|
||||
const handleSubmit = async (formData: FormData) => {
|
||||
const result = await createChapterAction(textbookId, parentId, null, formData)
|
||||
@@ -46,15 +50,18 @@ export function CreateChapterDialog({ textbookId, parentId, trigger }: CreateCha
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
{trigger || (
|
||||
const triggerNode =
|
||||
trigger === null
|
||||
? null
|
||||
: trigger || (
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
)
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
{triggerNode ? <DialogTrigger asChild>{triggerNode}</DialogTrigger> : null}
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add New Chapter</DialogTitle>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
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"
|
||||
@@ -9,6 +10,16 @@ import { CreateKnowledgePointDialog } from "./create-knowledge-point-dialog"
|
||||
import { deleteKnowledgePointAction } from "../actions"
|
||||
import { toast } from "sonner"
|
||||
import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/shared/components/ui/alert-dialog"
|
||||
|
||||
interface KnowledgePointPanelProps {
|
||||
knowledgePoints: KnowledgePoint[]
|
||||
@@ -21,15 +32,33 @@ export function KnowledgePointPanel({
|
||||
selectedChapterId,
|
||||
textbookId
|
||||
}: KnowledgePointPanelProps) {
|
||||
const router = useRouter()
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
||||
const [deleteTarget, setDeleteTarget] = useState<KnowledgePoint | null>(null)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
|
||||
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)
|
||||
const requestDelete = (kp: KnowledgePoint) => {
|
||||
setDeleteTarget(kp)
|
||||
setShowDeleteDialog(true)
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteTarget) return
|
||||
setIsDeleting(true)
|
||||
try {
|
||||
const result = await deleteKnowledgePointAction(deleteTarget.id, textbookId)
|
||||
if (result.success) {
|
||||
toast.success(result.message)
|
||||
setShowDeleteDialog(false)
|
||||
setDeleteTarget(null)
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(result.message)
|
||||
}
|
||||
} catch {
|
||||
toast.error("Failed to delete knowledge point")
|
||||
} finally {
|
||||
setIsDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,7 +82,7 @@ export function KnowledgePointPanel({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ScrollArea className="flex-1 -mx-2 px-2">
|
||||
<ScrollArea className="flex-1 min-h-0 -mx-2 px-2">
|
||||
{selectedChapterId ? (
|
||||
chapterKPs.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
@@ -74,8 +103,8 @@ export function KnowledgePointPanel({
|
||||
<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)}
|
||||
className="h-6 w-6 opacity-100 md:opacity-0 md:group-hover:opacity-100 transition-opacity text-destructive hover:text-destructive hover:bg-destructive/10 -mt-1 -mr-1"
|
||||
onClick={() => requestDelete(kp)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
@@ -95,6 +124,40 @@ export function KnowledgePointPanel({
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
|
||||
<AlertDialog
|
||||
open={showDeleteDialog}
|
||||
onOpenChange={(open) => {
|
||||
if (isDeleting) return
|
||||
setShowDeleteDialog(open)
|
||||
if (!open) setDeleteTarget(null)
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete knowledge point?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{deleteTarget ? (
|
||||
<>
|
||||
This will permanently delete <span className="font-medium text-foreground">{deleteTarget.name}</span>.
|
||||
</>
|
||||
) : (
|
||||
"This will permanently delete the selected knowledge point."
|
||||
)}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isDeleting}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
onClick={handleDelete}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{isDeleting ? "Deleting..." : "Delete"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -13,11 +13,13 @@ import { Textbook } from "../types";
|
||||
|
||||
interface TextbookCardProps {
|
||||
textbook: Textbook;
|
||||
hrefBase?: string;
|
||||
}
|
||||
|
||||
export function TextbookCard({ textbook }: TextbookCardProps) {
|
||||
export function TextbookCard({ textbook, hrefBase }: TextbookCardProps) {
|
||||
const base = hrefBase || "/teacher/textbooks";
|
||||
return (
|
||||
<Link href={`/teacher/textbooks/${textbook.id}`} className="block h-full">
|
||||
<Link href={`${base}/${textbook.id}`} className="block h-full">
|
||||
<Card
|
||||
className={cn(
|
||||
"group h-full overflow-hidden transition-all duration-300 ease-out",
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import ReactMarkdown from "react-markdown"
|
||||
import remarkBreaks from "remark-breaks"
|
||||
import remarkGfm from "remark-gfm"
|
||||
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 { Edit2, Save } from "lucide-react"
|
||||
import { CreateChapterDialog } from "./create-chapter-dialog"
|
||||
import { updateChapterContentAction } from "../actions"
|
||||
import { toast } from "sonner"
|
||||
@@ -54,17 +57,18 @@ export function TextbookContentLayout({ chapters, knowledgePoints, textbookId }:
|
||||
<h3 className="font-semibold">Chapters</h3>
|
||||
<CreateChapterDialog textbookId={textbookId} />
|
||||
</div>
|
||||
<ScrollArea className="flex-1 -mx-2 px-2">
|
||||
<ScrollArea className="flex-1 px-2">
|
||||
<ChapterSidebarList
|
||||
chapters={chapters}
|
||||
selectedChapterId={selectedChapter?.id}
|
||||
onSelectChapter={handleSelectChapter}
|
||||
textbookId={textbookId}
|
||||
/>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
{/* Middle: Content Viewer/Editor (6 cols) */}
|
||||
<div className="col-span-6 flex flex-col h-full px-2">
|
||||
<div className="col-span-6 flex flex-col h-full px-2 min-h-0">
|
||||
{selectedChapter ? (
|
||||
<>
|
||||
<div className="flex items-center justify-between mb-4 pb-2 border-b">
|
||||
@@ -89,8 +93,8 @@ export function TextbookContentLayout({ chapters, knowledgePoints, textbookId }:
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-4 h-full">
|
||||
<ScrollArea className="flex-1 min-h-0">
|
||||
<div className="p-4 min-h-full">
|
||||
{isEditing ? (
|
||||
<Textarea
|
||||
className="min-h-[500px] font-mono text-sm"
|
||||
@@ -101,7 +105,9 @@ export function TextbookContentLayout({ chapters, knowledgePoints, textbookId }:
|
||||
) : (
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none">
|
||||
{selectedChapter.content ? (
|
||||
<div className="whitespace-pre-wrap">{selectedChapter.content}</div>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm, remarkBreaks]}>
|
||||
{selectedChapter.content}
|
||||
</ReactMarkdown>
|
||||
) : (
|
||||
<div className="text-muted-foreground italic py-8 text-center">
|
||||
No content available. Click edit to add content.
|
||||
|
||||
82
src/modules/textbooks/components/textbook-filters.tsx
Normal file
82
src/modules/textbooks/components/textbook-filters.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
"use client"
|
||||
|
||||
import { useQueryState, parseAsString } from "nuqs"
|
||||
import { Search, Filter, X } from "lucide-react"
|
||||
|
||||
import { Input } from "@/shared/components/ui/input"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/components/ui/select"
|
||||
|
||||
export function TextbookFilters() {
|
||||
const [search, setSearch] = useQueryState("q", parseAsString.withDefault(""))
|
||||
const [subject, setSubject] = useQueryState("subject", parseAsString.withDefault("all"))
|
||||
const [grade, setGrade] = useQueryState("grade", parseAsString.withDefault("all"))
|
||||
|
||||
const hasFilters = Boolean(search || subject !== "all" || grade !== "all")
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between bg-card p-4 rounded-lg border shadow-sm">
|
||||
<div className="relative w-full md:w-96">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search textbooks..."
|
||||
className="pl-9 bg-background"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value || null)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 w-full md:w-auto">
|
||||
<Select value={subject} onValueChange={(val) => setSubject(val === "all" ? null : val)}>
|
||||
<SelectTrigger className="w-[160px] bg-background">
|
||||
<Filter className="mr-2 h-4 w-4 text-muted-foreground" />
|
||||
<SelectValue placeholder="Subject" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Subjects</SelectItem>
|
||||
<SelectItem value="Mathematics">Mathematics</SelectItem>
|
||||
<SelectItem value="Physics">Physics</SelectItem>
|
||||
<SelectItem value="Chemistry">Chemistry</SelectItem>
|
||||
<SelectItem value="English">English</SelectItem>
|
||||
<SelectItem value="History">History</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select value={grade} onValueChange={(val) => setGrade(val === "all" ? null : val)}>
|
||||
<SelectTrigger className="w-[160px] bg-background">
|
||||
<SelectValue placeholder="Grade" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Grades</SelectItem>
|
||||
<SelectItem value="Grade 9">Grade 9</SelectItem>
|
||||
<SelectItem value="Grade 10">Grade 10</SelectItem>
|
||||
<SelectItem value="Grade 11">Grade 11</SelectItem>
|
||||
<SelectItem value="Grade 12">Grade 12</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{hasFilters && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setSearch(null)
|
||||
setSubject(null)
|
||||
setGrade(null)
|
||||
}}
|
||||
className="h-10 px-3"
|
||||
>
|
||||
Reset
|
||||
<X className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
156
src/modules/textbooks/components/textbook-reader.tsx
Normal file
156
src/modules/textbooks/components/textbook-reader.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo, useState } from "react"
|
||||
import ReactMarkdown from "react-markdown"
|
||||
import remarkBreaks from "remark-breaks"
|
||||
import remarkGfm from "remark-gfm"
|
||||
import { useQueryState, parseAsString } from "nuqs"
|
||||
import { ChevronRight, FileText, Folder } from "lucide-react"
|
||||
|
||||
import type { Chapter } from "../types"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
|
||||
function buildChapterIndex(chapters: Chapter[]) {
|
||||
const index = new Map<string, Chapter>()
|
||||
|
||||
const walk = (nodes: Chapter[]) => {
|
||||
for (const node of nodes) {
|
||||
index.set(node.id, node)
|
||||
if (node.children && node.children.length > 0) walk(node.children)
|
||||
}
|
||||
}
|
||||
|
||||
walk(chapters)
|
||||
return index
|
||||
}
|
||||
|
||||
function ReaderChapterItem({
|
||||
chapter,
|
||||
level = 0,
|
||||
selectedId,
|
||||
onSelect,
|
||||
}: {
|
||||
chapter: Chapter
|
||||
level?: number
|
||||
selectedId: string | null
|
||||
onSelect: (chapterId: string) => void
|
||||
}) {
|
||||
const hasChildren = Boolean(chapter.children && chapter.children.length > 0)
|
||||
const [open, setOpen] = useState(level === 0)
|
||||
const isSelected = selectedId === chapter.id
|
||||
|
||||
return (
|
||||
<div className={cn("w-full", level > 0 && "ml-4 border-l pl-2")}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center group py-1 rounded-md transition-colors",
|
||||
isSelected ? "bg-accent text-accent-foreground" : "hover:bg-muted/50"
|
||||
)}
|
||||
>
|
||||
{hasChildren ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 shrink-0 p-0 hover:bg-muted"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
>
|
||||
<ChevronRight className={cn("h-4 w-4 text-muted-foreground transition-transform", open && "rotate-90")} />
|
||||
</Button>
|
||||
) : (
|
||||
<div className="w-6 shrink-0" />
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex flex-1 min-w-0 items-center gap-2 px-2 py-1.5 text-sm text-left cursor-pointer",
|
||||
level === 0 ? "font-medium" : "text-muted-foreground",
|
||||
isSelected && "text-accent-foreground font-medium"
|
||||
)}
|
||||
onClick={() => onSelect(chapter.id)}
|
||||
>
|
||||
{hasChildren ? (
|
||||
<Folder className={cn("h-4 w-4", open ? "text-primary" : "text-muted-foreground/70")} />
|
||||
) : (
|
||||
<FileText className="h-4 w-4 text-muted-foreground/50" />
|
||||
)}
|
||||
<span className="truncate flex-1 min-w-0">{chapter.title}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{hasChildren && open ? (
|
||||
<div className="pt-1">
|
||||
{chapter.children!.map((child) => (
|
||||
<ReaderChapterItem
|
||||
key={child.id}
|
||||
chapter={child}
|
||||
level={level + 1}
|
||||
selectedId={selectedId}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function TextbookReader({ chapters }: { chapters: Chapter[] }) {
|
||||
const [chapterId, setChapterId] = useQueryState("chapterId", parseAsString.withDefault(""))
|
||||
|
||||
const index = useMemo(() => buildChapterIndex(chapters), [chapters])
|
||||
const selected = chapterId ? index.get(chapterId) ?? null : null
|
||||
const selectedId = selected?.id ?? null
|
||||
|
||||
const handleSelect = (id: string) => setChapterId(id)
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-12 h-full">
|
||||
<div className="lg:col-span-4 lg:border-r lg:pr-6 flex flex-col min-h-0">
|
||||
<div className="flex items-center justify-between mb-4 px-2 shrink-0">
|
||||
<h3 className="font-semibold">Chapters</h3>
|
||||
</div>
|
||||
<ScrollArea className="flex-1 min-h-0 px-2">
|
||||
<div className="space-y-1">
|
||||
{chapters.map((chapter) => (
|
||||
<ReaderChapterItem
|
||||
key={chapter.id}
|
||||
chapter={chapter}
|
||||
selectedId={selectedId}
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-8 flex flex-col min-h-0">
|
||||
{selected ? (
|
||||
<>
|
||||
<div className="flex items-center justify-between mb-4 pb-2 border-b px-2 shrink-0">
|
||||
<h2 className="text-xl font-bold tracking-tight line-clamp-1">{selected.title}</h2>
|
||||
</div>
|
||||
<ScrollArea className="flex-1 min-h-0 px-2">
|
||||
<div className="p-4 min-h-full">
|
||||
{selected.content ? (
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm, remarkBreaks]}>{selected.content}</ReactMarkdown>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-muted-foreground italic py-8 text-center">No content available.</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex-1 flex items-center justify-center text-muted-foreground px-2">
|
||||
Select a chapter to start reading.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Edit, Trash2 } from "lucide-react"
|
||||
import { Edit } from "lucide-react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import {
|
||||
|
||||
@@ -1,226 +1,396 @@
|
||||
import { Textbook, Chapter, CreateTextbookInput, CreateChapterInput, UpdateChapterContentInput, KnowledgePoint, CreateKnowledgePointInput, UpdateTextbookInput } from "./types";
|
||||
import "server-only"
|
||||
|
||||
// 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)
|
||||
];
|
||||
import { cache } from "react"
|
||||
import { and, asc, eq, inArray, like, or, sql, type SQL } from "drizzle-orm"
|
||||
import { createId } from "@paralleldrive/cuid2"
|
||||
|
||||
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(),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
import { db } from "@/shared/db"
|
||||
import { chapters, knowledgePoints, textbooks } from "@/shared/db/schema"
|
||||
import type {
|
||||
Chapter,
|
||||
CreateChapterInput,
|
||||
CreateKnowledgePointInput,
|
||||
CreateTextbookInput,
|
||||
KnowledgePoint,
|
||||
Textbook,
|
||||
UpdateChapterContentInput,
|
||||
UpdateTextbookInput,
|
||||
} from "./types"
|
||||
|
||||
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",
|
||||
const normalizeOptional = (v: string | null | undefined) => {
|
||||
const trimmed = v?.trim()
|
||||
if (!trimmed) return null
|
||||
return trimmed
|
||||
}
|
||||
|
||||
const sortChapters = (a: Chapter, b: Chapter) => {
|
||||
const ao = a.order ?? 0
|
||||
const bo = b.order ?? 0
|
||||
if (ao !== bo) return ao - bo
|
||||
return a.title.localeCompare(b.title)
|
||||
}
|
||||
|
||||
const buildChapterTree = (rows: Chapter[]): Chapter[] => {
|
||||
const byId = new Map<string, Chapter & { children: Chapter[] }>()
|
||||
for (const ch of rows) {
|
||||
byId.set(ch.id, { ...ch, children: [] })
|
||||
}
|
||||
];
|
||||
|
||||
// ... (existing imports and mock data)
|
||||
const roots: Array<Chapter & { children: Chapter[] }> = []
|
||||
for (const ch of byId.values()) {
|
||||
const pid = ch.parentId
|
||||
if (pid && byId.has(pid)) {
|
||||
byId.get(pid)!.children.push(ch)
|
||||
} else {
|
||||
roots.push(ch)
|
||||
}
|
||||
}
|
||||
|
||||
export async function getTextbooks(query?: string, subject?: string, grade?: string): Promise<Textbook[]> {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
const results = [...MOCK_TEXTBOOKS];
|
||||
// ... (filtering logic)
|
||||
return results;
|
||||
const sortRecursive = (nodes: Array<Chapter & { children: Chapter[] }>) => {
|
||||
nodes.sort(sortChapters)
|
||||
for (const n of nodes) {
|
||||
sortRecursive(n.children as Array<Chapter & { children: Chapter[] }>)
|
||||
}
|
||||
}
|
||||
|
||||
sortRecursive(roots)
|
||||
return roots
|
||||
}
|
||||
|
||||
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 const getTextbooks = cache(async (query?: string, subject?: string, grade?: string): Promise<Textbook[]> => {
|
||||
const conditions: SQL[] = []
|
||||
|
||||
export async function getChaptersByTextbookId(textbookId: string): Promise<Chapter[]> {
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
return MOCK_CHAPTERS.filter((c) => c.textbookId === textbookId);
|
||||
}
|
||||
const q = query?.trim()
|
||||
if (q) {
|
||||
const needle = `%${q}%`
|
||||
conditions.push(
|
||||
or(
|
||||
like(textbooks.title, needle),
|
||||
like(textbooks.subject, needle),
|
||||
like(textbooks.grade, needle),
|
||||
like(textbooks.publisher, needle)
|
||||
)!
|
||||
)
|
||||
}
|
||||
|
||||
const s = subject?.trim()
|
||||
if (s && s !== "all") conditions.push(eq(textbooks.subject, s))
|
||||
|
||||
const g = grade?.trim()
|
||||
if (g && g !== "all") conditions.push(eq(textbooks.grade, g))
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
id: textbooks.id,
|
||||
title: textbooks.title,
|
||||
subject: textbooks.subject,
|
||||
grade: textbooks.grade,
|
||||
publisher: textbooks.publisher,
|
||||
createdAt: textbooks.createdAt,
|
||||
updatedAt: textbooks.updatedAt,
|
||||
chaptersCount: sql<number>`COUNT(${chapters.id})`,
|
||||
})
|
||||
.from(textbooks)
|
||||
.leftJoin(chapters, eq(chapters.textbookId, textbooks.id))
|
||||
.where(conditions.length ? and(...conditions) : undefined)
|
||||
.groupBy(textbooks.id, textbooks.title, textbooks.subject, textbooks.grade, textbooks.publisher, textbooks.createdAt, textbooks.updatedAt)
|
||||
.orderBy(asc(textbooks.title), asc(textbooks.subject), asc(textbooks.grade))
|
||||
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
title: r.title,
|
||||
subject: r.subject,
|
||||
grade: r.grade,
|
||||
publisher: r.publisher,
|
||||
createdAt: r.createdAt,
|
||||
updatedAt: r.updatedAt,
|
||||
_count: { chapters: Number(r.chaptersCount ?? 0) },
|
||||
}))
|
||||
})
|
||||
|
||||
export const getTextbookById = cache(async (id: string): Promise<Textbook | undefined> => {
|
||||
const [row] = await db
|
||||
.select({
|
||||
id: textbooks.id,
|
||||
title: textbooks.title,
|
||||
subject: textbooks.subject,
|
||||
grade: textbooks.grade,
|
||||
publisher: textbooks.publisher,
|
||||
createdAt: textbooks.createdAt,
|
||||
updatedAt: textbooks.updatedAt,
|
||||
chaptersCount: sql<number>`COUNT(${chapters.id})`,
|
||||
})
|
||||
.from(textbooks)
|
||||
.leftJoin(chapters, eq(chapters.textbookId, textbooks.id))
|
||||
.where(eq(textbooks.id, id))
|
||||
.groupBy(textbooks.id, textbooks.title, textbooks.subject, textbooks.grade, textbooks.publisher, textbooks.createdAt, textbooks.updatedAt)
|
||||
.limit(1)
|
||||
|
||||
if (!row) return undefined
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
subject: row.subject,
|
||||
grade: row.grade,
|
||||
publisher: row.publisher,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
_count: { chapters: Number(row.chaptersCount ?? 0) },
|
||||
}
|
||||
})
|
||||
|
||||
export const getChaptersByTextbookId = cache(async (textbookId: string): Promise<Chapter[]> => {
|
||||
const rows = await db
|
||||
.select({
|
||||
id: chapters.id,
|
||||
textbookId: chapters.textbookId,
|
||||
title: chapters.title,
|
||||
order: chapters.order,
|
||||
parentId: chapters.parentId,
|
||||
content: chapters.content,
|
||||
createdAt: chapters.createdAt,
|
||||
updatedAt: chapters.updatedAt,
|
||||
})
|
||||
.from(chapters)
|
||||
.where(eq(chapters.textbookId, textbookId))
|
||||
.orderBy(asc(chapters.order), asc(chapters.title))
|
||||
|
||||
return buildChapterTree(
|
||||
rows.map((r) => ({
|
||||
id: r.id,
|
||||
textbookId: r.textbookId,
|
||||
title: r.title,
|
||||
order: r.order,
|
||||
parentId: r.parentId,
|
||||
content: r.content ?? null,
|
||||
createdAt: r.createdAt,
|
||||
updatedAt: r.updatedAt,
|
||||
}))
|
||||
)
|
||||
})
|
||||
|
||||
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(),
|
||||
const id = createId()
|
||||
const now = new Date()
|
||||
|
||||
const row = {
|
||||
id,
|
||||
title: data.title.trim(),
|
||||
subject: data.subject.trim(),
|
||||
grade: normalizeOptional(data.grade),
|
||||
publisher: normalizeOptional(data.publisher),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}
|
||||
|
||||
await db.insert(textbooks).values(row)
|
||||
|
||||
return {
|
||||
...row,
|
||||
_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;
|
||||
await db
|
||||
.update(textbooks)
|
||||
.set({
|
||||
title: data.title.trim(),
|
||||
subject: data.subject.trim(),
|
||||
grade: normalizeOptional(data.grade),
|
||||
publisher: normalizeOptional(data.publisher),
|
||||
})
|
||||
.where(eq(textbooks.id, data.id))
|
||||
|
||||
const updated = await getTextbookById(data.id)
|
||||
if (!updated) throw new Error("Textbook not found")
|
||||
return updated
|
||||
}
|
||||
|
||||
export async function deleteTextbook(id: string): Promise<void> {
|
||||
await new Promise((resolve) => setTimeout(resolve, 800));
|
||||
MOCK_TEXTBOOKS = MOCK_TEXTBOOKS.filter((t) => t.id !== id);
|
||||
await db.delete(textbooks).where(eq(textbooks.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: []
|
||||
};
|
||||
const id = createId()
|
||||
const now = new Date()
|
||||
|
||||
// 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);
|
||||
const row = {
|
||||
id,
|
||||
textbookId: data.textbookId,
|
||||
title: data.title.trim(),
|
||||
order: data.order ?? 0,
|
||||
parentId: data.parentId ?? null,
|
||||
content: "",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}
|
||||
|
||||
await db.insert(chapters).values(row)
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
textbookId: row.textbookId,
|
||||
title: row.title,
|
||||
order: row.order,
|
||||
parentId: row.parentId,
|
||||
content: row.content,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
children: [],
|
||||
}
|
||||
|
||||
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;
|
||||
};
|
||||
await db.update(chapters).set({ content: data.content }).where(eq(chapters.id, data.chapterId))
|
||||
|
||||
const updated = updateContentRecursive(MOCK_CHAPTERS);
|
||||
if (!updated) throw new Error("Chapter not found");
|
||||
|
||||
return updated;
|
||||
const [row] = await db
|
||||
.select({
|
||||
id: chapters.id,
|
||||
textbookId: chapters.textbookId,
|
||||
title: chapters.title,
|
||||
order: chapters.order,
|
||||
parentId: chapters.parentId,
|
||||
content: chapters.content,
|
||||
createdAt: chapters.createdAt,
|
||||
updatedAt: chapters.updatedAt,
|
||||
})
|
||||
.from(chapters)
|
||||
.where(eq(chapters.id, data.chapterId))
|
||||
.limit(1)
|
||||
|
||||
if (!row) throw new Error("Chapter not found")
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
textbookId: row.textbookId,
|
||||
title: row.title,
|
||||
order: row.order,
|
||||
parentId: row.parentId,
|
||||
content: row.content ?? null,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
children: [],
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
const [target] = await db
|
||||
.select({ id: chapters.id, textbookId: chapters.textbookId })
|
||||
.from(chapters)
|
||||
.where(eq(chapters.id, id))
|
||||
.limit(1)
|
||||
|
||||
if (!target) return
|
||||
|
||||
const all = await db
|
||||
.select({ id: chapters.id, parentId: chapters.parentId })
|
||||
.from(chapters)
|
||||
.where(eq(chapters.textbookId, target.textbookId))
|
||||
|
||||
const childrenByParent = new Map<string, string[]>()
|
||||
for (const ch of all) {
|
||||
if (!ch.parentId) continue
|
||||
const arr = childrenByParent.get(ch.parentId) ?? []
|
||||
arr.push(ch.id)
|
||||
childrenByParent.set(ch.parentId, arr)
|
||||
}
|
||||
|
||||
const idsToDelete: string[] = []
|
||||
const stack = [id]
|
||||
const seen = new Set<string>()
|
||||
while (stack.length) {
|
||||
const cur = stack.pop()!
|
||||
if (seen.has(cur)) continue
|
||||
seen.add(cur)
|
||||
idsToDelete.push(cur)
|
||||
const kids = childrenByParent.get(cur)
|
||||
if (kids) stack.push(...kids)
|
||||
}
|
||||
|
||||
await db.delete(knowledgePoints).where(inArray(knowledgePoints.chapterId, idsToDelete))
|
||||
await db.delete(chapters).where(inArray(chapters.id, idsToDelete))
|
||||
}
|
||||
|
||||
// Knowledge Points
|
||||
export const getKnowledgePointsByChapterId = cache(async (chapterId: string): Promise<KnowledgePoint[]> => {
|
||||
const rows = await db
|
||||
.select({
|
||||
id: knowledgePoints.id,
|
||||
name: knowledgePoints.name,
|
||||
description: knowledgePoints.description,
|
||||
parentId: knowledgePoints.parentId,
|
||||
chapterId: knowledgePoints.chapterId,
|
||||
level: knowledgePoints.level,
|
||||
order: knowledgePoints.order,
|
||||
})
|
||||
.from(knowledgePoints)
|
||||
.where(eq(knowledgePoints.chapterId, chapterId))
|
||||
.orderBy(asc(knowledgePoints.order), asc(knowledgePoints.name))
|
||||
|
||||
export async function getKnowledgePointsByChapterId(chapterId: string): Promise<KnowledgePoint[]> {
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
return MOCK_KNOWLEDGE_POINTS.filter(kp => kp.chapterId === chapterId);
|
||||
}
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
description: r.description ?? null,
|
||||
parentId: r.parentId ?? null,
|
||||
chapterId: r.chapterId ?? undefined,
|
||||
level: r.level ?? 0,
|
||||
order: r.order ?? 0,
|
||||
}))
|
||||
})
|
||||
|
||||
export const getKnowledgePointsByTextbookId = cache(async (textbookId: string): Promise<KnowledgePoint[]> => {
|
||||
const rows = await db
|
||||
.select({
|
||||
id: knowledgePoints.id,
|
||||
name: knowledgePoints.name,
|
||||
description: knowledgePoints.description,
|
||||
parentId: knowledgePoints.parentId,
|
||||
chapterId: knowledgePoints.chapterId,
|
||||
level: knowledgePoints.level,
|
||||
order: knowledgePoints.order,
|
||||
})
|
||||
.from(knowledgePoints)
|
||||
.innerJoin(chapters, eq(chapters.id, knowledgePoints.chapterId))
|
||||
.where(eq(chapters.textbookId, textbookId))
|
||||
.orderBy(asc(chapters.order), asc(knowledgePoints.order), asc(knowledgePoints.name))
|
||||
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
description: r.description ?? null,
|
||||
parentId: r.parentId ?? null,
|
||||
chapterId: r.chapterId ?? undefined,
|
||||
level: r.level ?? 0,
|
||||
order: r.order ?? 0,
|
||||
}))
|
||||
})
|
||||
|
||||
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,
|
||||
const id = createId()
|
||||
|
||||
const row = {
|
||||
id,
|
||||
name: data.name.trim(),
|
||||
description: normalizeOptional(data.description ?? null),
|
||||
chapterId: data.chapterId,
|
||||
level: 1, // simplified
|
||||
order: 0
|
||||
};
|
||||
|
||||
MOCK_KNOWLEDGE_POINTS.push(newKP);
|
||||
return newKP;
|
||||
level: 1,
|
||||
order: 0,
|
||||
}
|
||||
|
||||
await db.insert(knowledgePoints).values(row)
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
description: row.description,
|
||||
parentId: null,
|
||||
chapterId: row.chapterId,
|
||||
level: row.level,
|
||||
order: row.order,
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
await db.delete(knowledgePoints).where(eq(knowledgePoints.id, id))
|
||||
}
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user