Module Update
This commit is contained in:
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user