feat: enhance textbook reader with anchor text support and improve knowledge point management
This commit is contained in:
@@ -8,6 +8,7 @@ import {
|
||||
deleteChapter,
|
||||
createKnowledgePoint,
|
||||
deleteKnowledgePoint,
|
||||
updateKnowledgePoint,
|
||||
updateTextbook,
|
||||
deleteTextbook,
|
||||
reorderChapters
|
||||
@@ -185,11 +186,12 @@ export async function createKnowledgePointAction(
|
||||
): Promise<ActionState> {
|
||||
const name = formData.get("name") as string;
|
||||
const description = formData.get("description") as string;
|
||||
const anchorText = formData.get("anchorText") as string;
|
||||
|
||||
if (!name) return { success: false, message: "Name is required" };
|
||||
|
||||
try {
|
||||
await createKnowledgePoint({ name, description, chapterId });
|
||||
await createKnowledgePoint({ name, description, anchorText, chapterId });
|
||||
revalidatePath(`/teacher/textbooks/${textbookId}`);
|
||||
return { success: true, message: "Knowledge point created successfully" };
|
||||
} catch {
|
||||
@@ -209,3 +211,24 @@ export async function deleteKnowledgePointAction(
|
||||
return { success: false, message: "Failed to delete knowledge point" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateKnowledgePointAction(
|
||||
kpId: string,
|
||||
textbookId: string,
|
||||
prevState: ActionState | null,
|
||||
formData: FormData
|
||||
): Promise<ActionState> {
|
||||
const name = formData.get("name") as string;
|
||||
const description = formData.get("description") as string;
|
||||
const anchorText = formData.get("anchorText") as string;
|
||||
|
||||
if (!name) return { success: false, message: "Name is required" };
|
||||
|
||||
try {
|
||||
await updateKnowledgePoint({ id: kpId, name, description, anchorText });
|
||||
revalidatePath(`/teacher/textbooks/${textbookId}`);
|
||||
return { success: true, message: "Knowledge point updated successfully" };
|
||||
} catch {
|
||||
return { success: false, message: "Failed to update knowledge point" };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import ReactMarkdown from "react-markdown"
|
||||
import remarkBreaks from "remark-breaks"
|
||||
import remarkGfm from "remark-gfm"
|
||||
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 min-h-0">
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none">
|
||||
{chapter.content ? (
|
||||
<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.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { ChevronRight, FileText, Folder, MoreHorizontal, Eye } 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
|
||||
showActions?: boolean
|
||||
}
|
||||
|
||||
function ChapterItem({ chapter, level = 0, onView, showActions = true }: ChapterItemProps) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const hasChildren = chapter.children && chapter.children.length > 0
|
||||
|
||||
return (
|
||||
<div className={cn(level > 0 && "ml-2 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>
|
||||
|
||||
{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>
|
||||
|
||||
{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}
|
||||
showActions={showActions}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
)}
|
||||
</Collapsible>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ChapterList({ chapters, showActions }: { chapters: Chapter[]; showActions?: boolean }) {
|
||||
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} showActions={showActions} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<ChapterContentViewer
|
||||
chapter={viewingChapter}
|
||||
open={isViewerOpen}
|
||||
onOpenChange={setIsViewerOpen}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -35,9 +35,10 @@ interface SortableChapterItemProps {
|
||||
textbookId: string
|
||||
onDelete: (chapter: Chapter) => void
|
||||
onCreateSub: (parentId: string) => void
|
||||
canEdit?: boolean
|
||||
}
|
||||
|
||||
function SortableChapterItem({ chapter, level, selectedId, onSelect, textbookId, onDelete, onCreateSub }: SortableChapterItemProps) {
|
||||
function SortableChapterItem({ chapter, level, selectedId, onSelect, textbookId, onDelete, onCreateSub, canEdit = true }: SortableChapterItemProps) {
|
||||
const [isOpen, setIsOpen] = useState(level === 0)
|
||||
const hasChildren = chapter.children && chapter.children.length > 0
|
||||
const isSelected = chapter.id === selectedId
|
||||
@@ -49,7 +50,7 @@ function SortableChapterItem({ chapter, level, selectedId, onSelect, textbookId,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: chapter.id })
|
||||
} = useSortable({ id: chapter.id, disabled: !canEdit })
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
@@ -66,9 +67,11 @@ function SortableChapterItem({ chapter, level, selectedId, onSelect, textbookId,
|
||||
isSelected ? "bg-accent text-accent-foreground font-medium" : "hover:bg-muted/50 text-muted-foreground hover:text-foreground",
|
||||
isDragging && "opacity-50"
|
||||
)}>
|
||||
<div {...attributes} {...listeners} className="mr-1 cursor-grab opacity-0 group-hover:opacity-100 transition-opacity p-1 hover:bg-muted rounded">
|
||||
<GripVertical className="h-3.5 w-3.5 text-muted-foreground/50" />
|
||||
</div>
|
||||
{canEdit && (
|
||||
<div {...attributes} {...listeners} className="mr-1 cursor-grab opacity-0 group-hover:opacity-100 transition-opacity p-1 hover:bg-muted rounded">
|
||||
<GripVertical className="h-3.5 w-3.5 text-muted-foreground/50" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasChildren ? (
|
||||
<CollapsibleTrigger asChild>
|
||||
@@ -103,7 +106,7 @@ function SortableChapterItem({ chapter, level, selectedId, onSelect, textbookId,
|
||||
<span className="truncate text-sm">{chapter.title}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center opacity-0 group-hover:opacity-100 transition-opacity ml-2">
|
||||
<div className={cn("flex items-center opacity-0 group-hover:opacity-100 transition-opacity ml-2", !canEdit && "hidden")}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -142,6 +145,7 @@ function SortableChapterItem({ chapter, level, selectedId, onSelect, textbookId,
|
||||
textbookId={textbookId}
|
||||
onDelete={onDelete}
|
||||
onCreateSub={onCreateSub}
|
||||
canEdit={canEdit}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -151,14 +155,15 @@ function SortableChapterItem({ chapter, level, selectedId, onSelect, textbookId,
|
||||
)
|
||||
}
|
||||
|
||||
function RecursiveSortableList({ items, level, selectedId, onSelect, textbookId, onDelete, onCreateSub }: {
|
||||
function RecursiveSortableList({ items, level, selectedId, onSelect, textbookId, onDelete, onCreateSub, canEdit = true }: {
|
||||
items: Chapter[],
|
||||
level: number,
|
||||
selectedId?: string,
|
||||
onSelect: (c: Chapter) => void,
|
||||
textbookId: string,
|
||||
onDelete: (c: Chapter) => void,
|
||||
onCreateSub: (pid: string) => void
|
||||
onCreateSub: (pid: string) => void,
|
||||
canEdit?: boolean
|
||||
}) {
|
||||
return (
|
||||
<SortableContext items={items.map(i => i.id)} strategy={verticalListSortingStrategy}>
|
||||
@@ -172,6 +177,7 @@ function RecursiveSortableList({ items, level, selectedId, onSelect, textbookId,
|
||||
textbookId={textbookId}
|
||||
onDelete={onDelete}
|
||||
onCreateSub={onCreateSub}
|
||||
canEdit={canEdit}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
@@ -183,9 +189,10 @@ interface ChapterSidebarListProps {
|
||||
selectedChapterId?: string
|
||||
onSelectChapter: (chapter: Chapter) => void
|
||||
textbookId: string
|
||||
canEdit?: boolean
|
||||
}
|
||||
|
||||
export function ChapterSidebarList({ chapters, selectedChapterId, onSelectChapter, textbookId }: ChapterSidebarListProps) {
|
||||
export function ChapterSidebarList({ chapters, selectedChapterId, onSelectChapter, textbookId, canEdit = true }: ChapterSidebarListProps) {
|
||||
const [showCreateDialog, setShowCreateDialog] = useState(false)
|
||||
const [createParentId, setCreateParentId] = useState<string | undefined>(undefined)
|
||||
|
||||
@@ -300,8 +307,9 @@ export function ChapterSidebarList({ chapters, selectedChapterId, onSelectChapte
|
||||
setShowCreateDialog(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
// If not editable, we can skip dnd logic
|
||||
if (!canEdit) {
|
||||
return (
|
||||
<RecursiveSortableList
|
||||
items={chapters}
|
||||
level={0}
|
||||
@@ -310,6 +318,22 @@ export function ChapterSidebarList({ chapters, selectedChapterId, onSelectChapte
|
||||
textbookId={textbookId}
|
||||
onDelete={handleDeleteRequest}
|
||||
onCreateSub={handleCreateSubRequest}
|
||||
canEdit={false}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DndContext id="chapter-sidebar-dnd" sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
<RecursiveSortableList
|
||||
items={chapters}
|
||||
level={0}
|
||||
selectedId={selectedChapterId}
|
||||
onSelect={onSelectChapter}
|
||||
textbookId={textbookId}
|
||||
onDelete={handleDeleteRequest}
|
||||
onCreateSub={handleCreateSubRequest}
|
||||
canEdit={true}
|
||||
/>
|
||||
|
||||
<CreateChapterDialog
|
||||
|
||||
@@ -1,148 +0,0 @@
|
||||
"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, Folder } from "lucide-react"
|
||||
import { CreateChapterDialog } from "./create-chapter-dialog"
|
||||
import { updateChapterContentAction } from "../actions"
|
||||
import { toast } from "sonner"
|
||||
import { RichTextEditor } from "@/shared/components/ui/rich-text-editor"
|
||||
|
||||
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)
|
||||
setSelectedChapter((prev) => (prev ? { ...prev, content: editContent } : prev))
|
||||
} else {
|
||||
toast.error(result.message)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-12 h-[calc(100vh-8rem)]">
|
||||
{/* Left Sidebar: TOC (3 cols) */}
|
||||
<div className="col-span-3 border-r flex flex-col h-full bg-muted/10">
|
||||
<div className="flex items-center justify-between p-4 border-b">
|
||||
<h3 className="font-semibold text-sm uppercase tracking-wider text-muted-foreground">Contents</h3>
|
||||
<CreateChapterDialog textbookId={textbookId} />
|
||||
</div>
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-3">
|
||||
<ChapterSidebarList
|
||||
chapters={chapters}
|
||||
selectedChapterId={selectedChapter?.id}
|
||||
onSelectChapter={handleSelectChapter}
|
||||
textbookId={textbookId}
|
||||
/>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
{/* Middle: Content Viewer/Editor (6 cols) */}
|
||||
<div className="col-span-6 flex flex-col h-full min-h-0 bg-background">
|
||||
{selectedChapter ? (
|
||||
<>
|
||||
<div className="flex items-center justify-between px-8 py-4 border-b sticky top-0 bg-background/95 backdrop-blur z-10">
|
||||
<h2 className="text-2xl 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="max-w-3xl mx-auto px-8 py-8 min-h-full">
|
||||
{isEditing ? (
|
||||
<RichTextEditor
|
||||
value={editContent}
|
||||
onChange={setEditContent}
|
||||
className="min-h-[500px] border-none shadow-none"
|
||||
/>
|
||||
) : (
|
||||
<div className="prose prose-zinc dark:prose-invert max-w-none prose-headings:font-bold prose-h1:text-3xl prose-h2:text-2xl prose-h3:text-xl prose-p:leading-relaxed">
|
||||
{selectedChapter.content ? (
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm, remarkBreaks]}>
|
||||
{selectedChapter.content}
|
||||
</ReactMarkdown>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground space-y-4">
|
||||
<div className="p-4 rounded-full bg-muted">
|
||||
<Edit2 className="h-8 w-8 opacity-50" />
|
||||
</div>
|
||||
<p className="italic">No content available yet.</p>
|
||||
<Button variant="outline" size="sm" onClick={() => setIsEditing(true)}>
|
||||
Start Writing
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</>
|
||||
) : (
|
||||
<div className="h-full flex flex-col items-center justify-center text-muted-foreground space-y-4">
|
||||
<div className="p-6 rounded-full bg-muted/30">
|
||||
<Folder className="h-12 w-12 opacity-20" />
|
||||
</div>
|
||||
<p>Select a chapter to view or edit content</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right Sidebar: Knowledge Points (3 cols) */}
|
||||
<div className="col-span-3 border-l flex flex-col h-full bg-muted/10">
|
||||
<KnowledgePointPanel
|
||||
knowledgePoints={knowledgePoints}
|
||||
selectedChapterId={selectedChapter?.id || null}
|
||||
textbookId={textbookId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,16 +1,47 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo, useState } from "react"
|
||||
import { useMemo, useState, useEffect, useRef } 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 { Tag, List, Plus, Edit2, Save, Trash2, Pencil, PlusCircle, ChevronDown, ChevronUp } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/shared/components/ui/collapsible"
|
||||
|
||||
import type { Chapter } from "../types"
|
||||
import type { Chapter, KnowledgePoint } from "../types"
|
||||
import { createKnowledgePointAction, updateChapterContentAction, deleteKnowledgePointAction, updateKnowledgePointAction } from "../actions"
|
||||
import { CreateQuestionDialog } from "@/modules/questions/components/create-question-dialog"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shared/components/ui/tabs"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} 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 {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuTrigger,
|
||||
} from "@/shared/components/ui/context-menu"
|
||||
|
||||
import { ChapterSidebarList } from "./chapter-sidebar-list"
|
||||
import { RichTextEditor } from "@/shared/components/ui/rich-text-editor"
|
||||
|
||||
function buildChapterIndex(chapters: Chapter[]) {
|
||||
const index = new Map<string, Chapter>()
|
||||
@@ -26,131 +57,543 @@ function buildChapterIndex(chapters: Chapter[]) {
|
||||
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(level > 0 && "ml-2 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[] }) {
|
||||
export function TextbookReader({ chapters, knowledgePoints = [], canEdit = false, textbookId }: { chapters: Chapter[]; knowledgePoints?: KnowledgePoint[]; canEdit?: boolean; textbookId?: string }) {
|
||||
const [chapterId, setChapterId] = useQueryState("chapterId", parseAsString.withDefault(""))
|
||||
const [activeTab, setActiveTab] = useState("chapters")
|
||||
const [highlightedKpId, setHighlightedKpId] = useState<string | null>(null)
|
||||
|
||||
// Selection & Creation State
|
||||
const [selectedText, setSelectedText] = useState("")
|
||||
const selectionRef = useRef("") // Store selection temporarily to avoid re-renders on pointer down
|
||||
const [createDialogOpen, setCreateDialogOpen] = useState(false)
|
||||
const [isCreating, setIsCreating] = useState(false)
|
||||
const contentRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Editing State
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [editContent, setEditContent] = useState("")
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
|
||||
// Knowledge Point Edit State
|
||||
const [editingKp, setEditingKp] = useState<KnowledgePoint | null>(null)
|
||||
const [editKpDialogOpen, setEditKpDialogOpen] = useState(false)
|
||||
const [isUpdatingKp, setIsUpdatingKp] = useState(false)
|
||||
|
||||
// Question Creation State
|
||||
const [questionDialogOpen, setQuestionDialogOpen] = useState(false)
|
||||
const [targetKpForQuestion, setTargetKpForQuestion] = useState<KnowledgePoint | null>(null)
|
||||
|
||||
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)
|
||||
const handleSelect = (chapter: Chapter) => {
|
||||
setChapterId(chapter.id)
|
||||
setIsEditing(false)
|
||||
}
|
||||
|
||||
// Handle Text Selection via Context Menu
|
||||
// We capture selection on PointerDown (Right Click) to ensure we get the state before any context menu logic runs.
|
||||
// Using onContextMenu directly caused conflicts with Radix UI's ContextMenuTrigger in some cases.
|
||||
const handleContentPointerDown = (e: React.PointerEvent) => {
|
||||
// Only capture on right click (button 2)
|
||||
if (e.button !== 2) return
|
||||
|
||||
const selection = window.getSelection()
|
||||
if (!selection || selection.isCollapsed) {
|
||||
selectionRef.current = ""
|
||||
return
|
||||
}
|
||||
|
||||
// Check if selection is within content area
|
||||
if (contentRef.current && contentRef.current.contains(selection.anchorNode)) {
|
||||
// Store in ref, don't trigger re-render yet
|
||||
selectionRef.current = selection.toString().trim()
|
||||
} else {
|
||||
selectionRef.current = ""
|
||||
}
|
||||
}
|
||||
|
||||
const handleContextMenuChange = (open: boolean) => {
|
||||
if (!open) return
|
||||
|
||||
// When menu opens, sync ref to state to update UI
|
||||
if (selectionRef.current) {
|
||||
setSelectedText(selectionRef.current)
|
||||
} else {
|
||||
// Fallback: If pointer down didn't capture (e.g. keyboard), try now
|
||||
const selection = window.getSelection()
|
||||
if (selection && !selection.isCollapsed && contentRef.current && contentRef.current.contains(selection.anchorNode)) {
|
||||
const text = selection.toString().trim()
|
||||
selectionRef.current = text
|
||||
setSelectedText(text)
|
||||
} else {
|
||||
setSelectedText("")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreateKnowledgePoint = async (formData: FormData) => {
|
||||
if (!selectedId || !selected) return
|
||||
setIsCreating(true)
|
||||
|
||||
try {
|
||||
const result = await createKnowledgePointAction(
|
||||
selectedId,
|
||||
selected.textbookId,
|
||||
null,
|
||||
formData
|
||||
)
|
||||
|
||||
if (result.success) {
|
||||
toast.success("知识点已创建")
|
||||
setCreateDialogOpen(false)
|
||||
setActiveTab("knowledge")
|
||||
// Clear selection
|
||||
window.getSelection()?.removeAllRanges()
|
||||
setSelectedText("")
|
||||
} else {
|
||||
toast.error(result.message || "创建知识点失败")
|
||||
}
|
||||
} catch {
|
||||
toast.error("发生错误")
|
||||
} finally {
|
||||
setIsCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSaveContent = async () => {
|
||||
if (!selectedId || !textbookId) return
|
||||
setIsSaving(true)
|
||||
const result = await updateChapterContentAction(selectedId, editContent, textbookId)
|
||||
setIsSaving(false)
|
||||
|
||||
if (result.success) {
|
||||
toast.success(result.message)
|
||||
setIsEditing(false)
|
||||
// Optimistic update might be tricky here without full reload, but let's assume parent revalidates or we rely on router refresh
|
||||
// For now, we manually update the local state if needed, but since we use `chapters` prop which comes from server,
|
||||
// we ideally want to trigger a refresh.
|
||||
// However, for this component, we can just let the user see the new content if we render `editContent` or rely on props update.
|
||||
// But `chapters` prop won't update automatically unless we router.refresh().
|
||||
// Let's rely on the fact that `selected` comes from `chapters` which might be stale until refresh.
|
||||
// A full solution would use `router.refresh()`.
|
||||
// For now, we can update the `selected.content` in place? No, it's a prop.
|
||||
// We will rely on router refresh in the parent or just simple UI feedback.
|
||||
// Actually, let's trigger a router refresh if possible, but we don't have router here.
|
||||
// We'll just exit edit mode. The content might look old until refresh.
|
||||
// To fix this, we can locally override content.
|
||||
if (selected) selected.content = editContent
|
||||
} else {
|
||||
toast.error(result.message)
|
||||
}
|
||||
}
|
||||
|
||||
const startEditing = () => {
|
||||
if (selected) {
|
||||
setEditContent(selected.content || "")
|
||||
setIsEditing(true)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteKnowledgePoint = async (kpId: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
if (!confirm("确定要删除这个知识点吗?")) return
|
||||
|
||||
if (!textbookId) return
|
||||
|
||||
try {
|
||||
const result = await deleteKnowledgePointAction(kpId, textbookId)
|
||||
if (result.success) {
|
||||
toast.success(result.message)
|
||||
if (highlightedKpId === kpId) {
|
||||
setHighlightedKpId(null)
|
||||
}
|
||||
} else {
|
||||
toast.error(result.message)
|
||||
}
|
||||
} catch {
|
||||
toast.error("删除失败")
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpdateKnowledgePoint = async (formData: FormData) => {
|
||||
if (!editingKp || !textbookId) return
|
||||
setIsUpdatingKp(true)
|
||||
|
||||
try {
|
||||
const result = await updateKnowledgePointAction(editingKp.id, textbookId, null, formData)
|
||||
if (result.success) {
|
||||
toast.success(result.message)
|
||||
setEditKpDialogOpen(false)
|
||||
setEditingKp(null)
|
||||
} else {
|
||||
toast.error(result.message)
|
||||
}
|
||||
} catch {
|
||||
toast.error("更新失败")
|
||||
} finally {
|
||||
setIsUpdatingKp(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Filter KPs for the current chapter
|
||||
const currentChapterKPs = useMemo(() => {
|
||||
if (!selectedId) return []
|
||||
return knowledgePoints.filter(kp => kp.chapterId === selectedId)
|
||||
}, [knowledgePoints, selectedId])
|
||||
|
||||
// Pre-process content to mark knowledge points
|
||||
const processedContent = useMemo(() => {
|
||||
if (!selected?.content) return ""
|
||||
let content = selected.content
|
||||
|
||||
// Sort KPs by name length descending to handle overlapping names
|
||||
const sortedKPs = [...currentChapterKPs].sort((a, b) => b.name.length - a.name.length)
|
||||
|
||||
// We use a temporary replacement strategy to avoid nested replacements
|
||||
// This is simple but works for most cases
|
||||
// We replace "Name" with "[Name](kp://id)"
|
||||
|
||||
for (const kp of sortedKPs) {
|
||||
// Escape regex special characters
|
||||
const escapedName = kp.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
// Case insensitive match, but preserve original text casing
|
||||
// We use a simplified lookahead to avoid replacing inside existing links if possible,
|
||||
// but perfect markdown parsing is hard with regex.
|
||||
// For now, we assume KPs don't overlap in a way that breaks things often.
|
||||
const regex = new RegExp(`(${escapedName})`, 'gi')
|
||||
|
||||
// We only replace if not already part of a link (simplified check)
|
||||
// A robust parser would be better, but regex is acceptable for this level
|
||||
content = content.replace(regex, `[$1](#kp-${kp.id})`)
|
||||
}
|
||||
|
||||
return content
|
||||
}, [selected?.content, currentChapterKPs])
|
||||
|
||||
// Scroll to highlighted KP
|
||||
useEffect(() => {
|
||||
if (highlightedKpId) {
|
||||
// Find first element by data attribute
|
||||
const el = document.querySelector(`[data-kp-id="${highlightedKpId}"]`)
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: "smooth", block: "center" })
|
||||
// Add temporary highlight effect
|
||||
el.classList.add("ring-2", "ring-primary", "ring-offset-2")
|
||||
setTimeout(() => {
|
||||
el.classList.remove("ring-2", "ring-primary", "ring-offset-2")
|
||||
}, 2000)
|
||||
}
|
||||
}
|
||||
}, [highlightedKpId])
|
||||
|
||||
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}
|
||||
/>
|
||||
))}
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex flex-col h-full">
|
||||
<div className="flex items-center justify-between mb-4 px-2 shrink-0">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="chapters" className="gap-2">
|
||||
<List className="h-4 w-4" />
|
||||
章节目录
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="knowledge" className="gap-2" disabled={!selectedId}>
|
||||
<Tag className="h-4 w-4" />
|
||||
知识点
|
||||
{currentChapterKPs.length > 0 && (
|
||||
<Badge variant="secondary" className="ml-1 px-1 py-0 h-5 text-[10px]">{currentChapterKPs.length}</Badge>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<TabsContent value="chapters" className="flex-1 min-h-0 mt-0">
|
||||
<ScrollArea className="flex-1 h-full px-2">
|
||||
<div className="space-y-1 pb-4">
|
||||
<ChapterSidebarList
|
||||
chapters={chapters}
|
||||
selectedChapterId={selectedId || undefined}
|
||||
onSelectChapter={handleSelect}
|
||||
textbookId={textbookId || ""}
|
||||
canEdit={canEdit}
|
||||
/>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="knowledge" className="flex-1 min-h-0 mt-0">
|
||||
{!selectedId ? (
|
||||
<div className="flex h-full items-center justify-center text-muted-foreground p-4 text-center text-sm">
|
||||
请选择一个章节查看知识点。
|
||||
</div>
|
||||
) : currentChapterKPs.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center text-muted-foreground p-4 text-center text-sm">
|
||||
该章节暂无知识点。
|
||||
</div>
|
||||
) : (
|
||||
<ScrollArea className="flex-1 h-full px-2">
|
||||
<div className="space-y-2 pb-4">
|
||||
{currentChapterKPs.map((kp) => (
|
||||
<div
|
||||
key={kp.id}
|
||||
className={cn(
|
||||
"p-3 rounded-lg border bg-card hover:bg-accent/50 transition-colors cursor-pointer",
|
||||
highlightedKpId === kp.id && "border-primary bg-primary/5"
|
||||
)}
|
||||
onClick={() => setHighlightedKpId(kp.id)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<h4 className="text-sm font-medium leading-none">{kp.name}</h4>
|
||||
<div className="flex items-center gap-1">
|
||||
<Badge variant="outline" className="text-[10px] h-5 px-1">Lv.{kp.level}</Badge>
|
||||
{canEdit && (
|
||||
<div className="flex items-center gap-1 ml-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5 text-muted-foreground hover:text-foreground"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setTargetKpForQuestion(kp)
|
||||
setQuestionDialogOpen(true)
|
||||
}}
|
||||
title="创建相关题目"
|
||||
>
|
||||
<PlusCircle className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5 text-muted-foreground hover:text-foreground"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setEditingKp(kp)
|
||||
setEditKpDialogOpen(true)
|
||||
}}
|
||||
title="编辑知识点"
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5 text-muted-foreground hover:text-destructive"
|
||||
onClick={(e) => handleDeleteKnowledgePoint(kp.id, e)}
|
||||
title="删除知识点"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{kp.description && (
|
||||
<p className="mt-2 text-xs text-muted-foreground line-clamp-2">
|
||||
{kp.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-8 flex flex-col min-h-0">
|
||||
<div className="lg:col-span-8 flex flex-col min-h-0 relative">
|
||||
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>添加知识点</DialogTitle>
|
||||
<DialogDescription>
|
||||
从选中的文本创建知识点。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form action={handleCreateKnowledgePoint}>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="name">名称</Label>
|
||||
<Input id="name" name="name" defaultValue={selectedText} required />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="description">描述(可选)</Label>
|
||||
<Textarea id="description" name="description" placeholder="请输入描述..." />
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setCreateDialogOpen(false)} disabled={isCreating}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="submit" disabled={isCreating}>
|
||||
{isCreating ? "创建中..." : "创建"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={editKpDialogOpen} onOpenChange={setEditKpDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>编辑知识点</DialogTitle>
|
||||
<DialogDescription>
|
||||
修改知识点的名称和描述。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form action={handleUpdateKnowledgePoint}>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="edit-name">显示名称</Label>
|
||||
<Input id="edit-name" name="name" defaultValue={editingKp?.name} required />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="edit-description">描述(可选)</Label>
|
||||
<Textarea id="edit-description" name="description" defaultValue={editingKp?.description || ""} placeholder="请输入描述..." />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 border rounded-md p-3 bg-muted/20">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="edit-anchorText" className="text-muted-foreground text-xs flex items-center gap-1">
|
||||
高级:关联文本 (影响文中高亮)
|
||||
</Label>
|
||||
</div>
|
||||
<div className="pt-2">
|
||||
<Input
|
||||
key={editingKp?.id} // Force re-render when kp changes
|
||||
id="edit-anchorText"
|
||||
name="anchorText"
|
||||
defaultValue={editingKp?.anchorText || editingKp?.name}
|
||||
className="text-sm font-mono"
|
||||
required
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground mt-1">
|
||||
修改此字段会改变文中被高亮匹配的文字。通常保持与原文一致。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setEditKpDialogOpen(false)} disabled={isUpdatingKp}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="submit" disabled={isUpdatingKp}>
|
||||
{isUpdatingKp ? "保存中..." : "保存"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<CreateQuestionDialog
|
||||
open={questionDialogOpen}
|
||||
onOpenChange={setQuestionDialogOpen}
|
||||
defaultKnowledgePointIds={targetKpForQuestion ? [targetKpForQuestion.id] : []}
|
||||
defaultContent={targetKpForQuestion ? `Please explain the knowledge point: ${targetKpForQuestion.name}` : ""}
|
||||
defaultType="text"
|
||||
/>
|
||||
|
||||
{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>
|
||||
{canEdit && (
|
||||
<div className="flex gap-2">
|
||||
{isEditing ? (
|
||||
<>
|
||||
<Button size="sm" variant="ghost" onClick={() => setIsEditing(false)} disabled={isSaving}>
|
||||
取消
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleSaveContent} disabled={isSaving}>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
{isSaving ? "保存中..." : "保存"}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button size="sm" variant="outline" onClick={startEditing}>
|
||||
<Edit2 className="mr-2 h-4 w-4" />
|
||||
编辑内容
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</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>
|
||||
{isEditing ? (
|
||||
<div className="h-full">
|
||||
<RichTextEditor
|
||||
value={editContent}
|
||||
onChange={setEditContent}
|
||||
className="min-h-[500px] border-none shadow-none"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-muted-foreground italic py-8 text-center">No content available.</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<ContextMenu onOpenChange={handleContextMenuChange}>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div
|
||||
className="p-4 min-h-full"
|
||||
ref={contentRef}
|
||||
onPointerDown={handleContentPointerDown}
|
||||
>
|
||||
{selected.content ? (
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm, remarkBreaks]}
|
||||
components={{
|
||||
a: ({ href, children, ...props }) => {
|
||||
if (href?.startsWith("#kp-")) {
|
||||
const id = href.replace("#kp-", "")
|
||||
const isHighlighted = highlightedKpId === id
|
||||
return (
|
||||
<span
|
||||
data-kp-id={id}
|
||||
className={cn(
|
||||
"font-medium text-primary cursor-pointer hover:underline decoration-dashed underline-offset-4 transition-all duration-300",
|
||||
isHighlighted && "bg-yellow-300 dark:bg-yellow-600 text-black dark:text-white rounded px-1 py-0.5 shadow-sm scale-110 inline-block mx-0.5 font-bold ring-2 ring-yellow-400/50 dark:ring-yellow-500/50"
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
setHighlightedKpId(id)
|
||||
setActiveTab("knowledge")
|
||||
}}
|
||||
title="点击查看知识点详情"
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return <a href={href} {...props}>{children}</a>
|
||||
}
|
||||
}}
|
||||
>
|
||||
{processedContent}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-muted-foreground italic py-8 text-center">暂无内容</div>
|
||||
)}
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem
|
||||
disabled={!selectedText}
|
||||
onClick={() => setCreateDialogOpen(true)}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
添加知识点
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex-1 flex items-center justify-center text-muted-foreground px-2">
|
||||
Select a chapter to start reading.
|
||||
请选择一个章节开始阅读。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -366,29 +366,28 @@ export const getKnowledgePointsByTextbookId = cache(async (textbookId: string):
|
||||
}))
|
||||
})
|
||||
|
||||
export async function createKnowledgePoint(data: CreateKnowledgePointInput): Promise<KnowledgePoint> {
|
||||
const id = createId()
|
||||
|
||||
const row = {
|
||||
id,
|
||||
name: data.name.trim(),
|
||||
description: normalizeOptional(data.description ?? null),
|
||||
export async function createKnowledgePoint(data: { name: string; description?: string; anchorText?: string; chapterId?: string; parentId?: string }): Promise<void> {
|
||||
await db.insert(knowledgePoints).values({
|
||||
id: createId(),
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
anchorText: data.anchorText,
|
||||
chapterId: data.chapterId,
|
||||
level: 1,
|
||||
order: 0,
|
||||
}
|
||||
parentId: data.parentId,
|
||||
level: 0, // Default level
|
||||
order: 0, // Default order
|
||||
})
|
||||
}
|
||||
|
||||
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 updateKnowledgePoint(data: { id: string; name: string; description?: string; anchorText?: string }): Promise<void> {
|
||||
await db
|
||||
.update(knowledgePoints)
|
||||
.set({
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
anchorText: data.anchorText,
|
||||
})
|
||||
.where(eq(knowledgePoints.id, data.id))
|
||||
}
|
||||
|
||||
export async function deleteKnowledgePoint(id: string): Promise<void> {
|
||||
|
||||
@@ -48,6 +48,7 @@ export type KnowledgePoint = {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
anchorText?: string | null;
|
||||
parentId?: string | null;
|
||||
chapterId?: string; // Logic link for this module context
|
||||
level: number;
|
||||
@@ -69,5 +70,14 @@ export type UpdateChapterContentInput = {
|
||||
export type CreateKnowledgePointInput = {
|
||||
name: string;
|
||||
description?: string;
|
||||
chapterId: string;
|
||||
anchorText?: string;
|
||||
parentId?: string;
|
||||
chapterId?: string;
|
||||
};
|
||||
|
||||
export type UpdateKnowledgePointInput = {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
anchorText?: string;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user