完整性更新

现在已经实现了大部分基础功能
This commit is contained in:
SpecialX
2026-01-08 11:14:03 +08:00
parent 0da2eac0b4
commit 57807def37
155 changed files with 26421 additions and 1036 deletions

View File

@@ -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.

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>
)
}

View File

@@ -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",

View File

@@ -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.

View 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>
)
}

View 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>
)
}

View File

@@ -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 {