sync-docs-and-fixes
This commit is contained in:
@@ -224,30 +224,11 @@ export function ChapterSidebarList({ chapters, selectedChapterId, onSelectChapte
|
||||
}
|
||||
|
||||
const activeParent = findParent(chapters, active.id as string)
|
||||
const overParent = findParent(chapters, over.id as string)
|
||||
|
||||
// If parents don't match (and neither is root), we can't reorder easily in this simplified version
|
||||
// But actually, we need to check if they are in the same list.
|
||||
// If both are root items (activeParent is null), they are siblings.
|
||||
|
||||
const getSiblings = (parentId: string | null) => {
|
||||
if (!parentId) return chapters
|
||||
const parent = chapters.find(c => c.id === parentId) // This only finds root parents, we need recursive find
|
||||
|
||||
const findNode = (nodes: Chapter[], id: string): Chapter | null => {
|
||||
for (const node of nodes) {
|
||||
if (node.id === id) return node
|
||||
if (node.children) {
|
||||
const found = findNode(node.children, id)
|
||||
if (found) return found
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
return findNode(chapters, parentId)?.children || []
|
||||
}
|
||||
|
||||
// Simplified logic: We trust dnd-kit's SortableContext to only allow valid drops if we restricted it?
|
||||
// No, dnd-kit allows dropping anywhere by default unless restricted.
|
||||
|
||||
@@ -271,7 +252,6 @@ export function ChapterSidebarList({ chapters, selectedChapterId, onSelectChapte
|
||||
|
||||
// Check if over is in the same list
|
||||
if (activeList.some(c => c.id === over.id)) {
|
||||
const oldIndex = activeList.findIndex((item) => item.id === active.id)
|
||||
const newIndex = activeList.findIndex((item) => item.id === over.id)
|
||||
|
||||
await reorderChaptersAction(active.id as string, newIndex, activeParentId, textbookId)
|
||||
|
||||
@@ -24,29 +24,35 @@ interface TextbookCardProps {
|
||||
}
|
||||
|
||||
const subjectColorMap: Record<string, string> = {
|
||||
Mathematics: "from-blue-500/20 to-blue-600/20 text-blue-700 dark:text-blue-300 border-blue-200 dark:border-blue-800",
|
||||
Physics: "from-purple-500/20 to-purple-600/20 text-purple-700 dark:text-purple-300 border-purple-200 dark:border-purple-800",
|
||||
Chemistry: "from-teal-500/20 to-teal-600/20 text-teal-700 dark:text-teal-300 border-teal-200 dark:border-teal-800",
|
||||
English: "from-orange-500/20 to-orange-600/20 text-orange-700 dark:text-orange-300 border-orange-200 dark:border-orange-800",
|
||||
History: "from-amber-500/20 to-amber-600/20 text-amber-700 dark:text-amber-300 border-amber-200 dark:border-amber-800",
|
||||
Biology: "from-emerald-500/20 to-emerald-600/20 text-emerald-700 dark:text-emerald-300 border-emerald-200 dark:border-emerald-800",
|
||||
Geography: "from-sky-500/20 to-sky-600/20 text-sky-700 dark:text-sky-300 border-sky-200 dark:border-sky-800",
|
||||
Mathematics: "bg-blue-50 text-blue-700 border-blue-200/70 dark:bg-blue-950/50 dark:text-blue-200 dark:border-blue-900/60",
|
||||
Physics: "bg-purple-50 text-purple-700 border-purple-200/70 dark:bg-purple-950/50 dark:text-purple-200 dark:border-purple-900/60",
|
||||
Chemistry: "bg-teal-50 text-teal-700 border-teal-200/70 dark:bg-teal-950/50 dark:text-teal-200 dark:border-teal-900/60",
|
||||
English: "bg-orange-50 text-orange-700 border-orange-200/70 dark:bg-orange-950/50 dark:text-orange-200 dark:border-orange-900/60",
|
||||
History: "bg-amber-50 text-amber-700 border-amber-200/70 dark:bg-amber-950/50 dark:text-amber-200 dark:border-amber-900/60",
|
||||
Biology: "bg-emerald-50 text-emerald-700 border-emerald-200/70 dark:bg-emerald-950/50 dark:text-emerald-200 dark:border-emerald-900/60",
|
||||
Geography: "bg-sky-50 text-sky-700 border-sky-200/70 dark:bg-sky-950/50 dark:text-sky-200 dark:border-sky-900/60",
|
||||
};
|
||||
|
||||
export function TextbookCard({ textbook, hrefBase, hideActions }: TextbookCardProps) {
|
||||
const base = hrefBase || "/teacher/textbooks";
|
||||
const colorClass = subjectColorMap[textbook.subject] || "from-zinc-500/20 to-zinc-600/20 text-zinc-700 dark:text-zinc-300 border-zinc-200 dark:border-zinc-800";
|
||||
const colorClass = subjectColorMap[textbook.subject] || "bg-zinc-50 text-zinc-700 border-zinc-200/70 dark:bg-zinc-950/50 dark:text-zinc-200 dark:border-zinc-800/70";
|
||||
|
||||
return (
|
||||
<Card className="group flex flex-col h-full overflow-hidden transition-all duration-300 hover:shadow-md hover:border-primary/50">
|
||||
<Card className="group flex flex-col h-full overflow-hidden border-border/60 transition-all duration-300 hover:shadow-md hover:border-primary/50">
|
||||
<Link href={`${base}/${textbook.id}`} className="flex-1">
|
||||
<div className={cn("relative h-32 w-full overflow-hidden bg-gradient-to-br p-6 transition-all group-hover:scale-105", colorClass)}>
|
||||
<div className="absolute inset-0 bg-grid-black/[0.05] dark:bg-grid-white/[0.05]" />
|
||||
<div className={cn("relative h-32 w-full overflow-hidden p-5", colorClass)}>
|
||||
<div className="relative z-10 flex h-full flex-col justify-between">
|
||||
<Badge variant="secondary" className="w-fit bg-background/50 backdrop-blur-sm border-transparent shadow-none">
|
||||
<Badge variant="secondary" className="w-fit bg-background/80 border border-border/60 shadow-sm">
|
||||
{textbook.subject}
|
||||
</Badge>
|
||||
<Book className="h-8 w-8 opacity-50" />
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-background text-foreground shadow-sm ring-1 ring-border/60">
|
||||
<Book className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="text-xs font-medium text-foreground/70">
|
||||
{textbook.grade || "Grade N/A"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -74,9 +80,11 @@ export function TextbookCard({ textbook, hrefBase, hideActions }: TextbookCardPr
|
||||
</CardContent>
|
||||
</Link>
|
||||
|
||||
<CardFooter className="p-4 pt-2 mt-auto border-t bg-muted/20 flex items-center justify-between">
|
||||
<CardFooter className="p-4 pt-2 mt-auto border-t border-border/60 bg-muted/30 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground tabular-nums">
|
||||
<BookOpen className="h-3.5 w-3.5" />
|
||||
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-background/80 ring-1 ring-border/60">
|
||||
<BookOpen className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
<span>{textbook._count?.chapters || 0} Chapters</span>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { useQueryState, parseAsString } from "nuqs"
|
||||
import { Search, Filter, X } from "lucide-react"
|
||||
import { Search, X } from "lucide-react"
|
||||
|
||||
import { Input } from "@/shared/components/ui/input"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
|
||||
@@ -5,13 +5,8 @@ import ReactMarkdown from "react-markdown"
|
||||
import remarkBreaks from "remark-breaks"
|
||||
import remarkGfm from "remark-gfm"
|
||||
import { useQueryState, parseAsString } from "nuqs"
|
||||
import { Tag, List, Plus, Edit2, Save, Trash2, Pencil, PlusCircle, ChevronDown, ChevronUp } from "lucide-react"
|
||||
import { Tag, List, Plus, Edit2, Save, Trash2, Pencil, PlusCircle, Share2 } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/shared/components/ui/collapsible"
|
||||
|
||||
import type { Chapter, KnowledgePoint } from "../types"
|
||||
import { createKnowledgePointAction, updateChapterContentAction, deleteKnowledgePointAction, updateKnowledgePointAction } from "../actions"
|
||||
@@ -243,6 +238,96 @@ export function TextbookReader({ chapters, knowledgePoints = [], canEdit = false
|
||||
return knowledgePoints.filter(kp => kp.chapterId === selectedId)
|
||||
}, [knowledgePoints, selectedId])
|
||||
|
||||
const graphLayout = useMemo(() => {
|
||||
if (currentChapterKPs.length === 0) {
|
||||
return { nodes: [], edges: [], width: 0, height: 0 }
|
||||
}
|
||||
|
||||
const byId = new Map<string, KnowledgePoint>()
|
||||
for (const kp of currentChapterKPs) byId.set(kp.id, kp)
|
||||
|
||||
const children = new Map<string, string[]>()
|
||||
const roots: string[] = []
|
||||
|
||||
for (const kp of currentChapterKPs) {
|
||||
if (kp.parentId && byId.has(kp.parentId)) {
|
||||
const arr = children.get(kp.parentId) ?? []
|
||||
arr.push(kp.id)
|
||||
children.set(kp.parentId, arr)
|
||||
} else {
|
||||
roots.push(kp.id)
|
||||
}
|
||||
}
|
||||
|
||||
const levelMap = new Map<string, number>()
|
||||
const levels: string[][] = []
|
||||
const queue = [...roots].map((id) => ({ id, level: 0 }))
|
||||
|
||||
if (queue.length === 0) {
|
||||
for (const kp of currentChapterKPs) queue.push({ id: kp.id, level: 0 })
|
||||
}
|
||||
|
||||
while (queue.length > 0) {
|
||||
const item = queue.shift()
|
||||
if (!item) continue
|
||||
if (levelMap.has(item.id)) continue
|
||||
levelMap.set(item.id, item.level)
|
||||
if (!levels[item.level]) levels[item.level] = []
|
||||
levels[item.level].push(item.id)
|
||||
const kids = children.get(item.id) ?? []
|
||||
for (const kid of kids) {
|
||||
if (!levelMap.has(kid)) queue.push({ id: kid, level: item.level + 1 })
|
||||
}
|
||||
}
|
||||
|
||||
for (const kp of currentChapterKPs) {
|
||||
if (!levelMap.has(kp.id)) {
|
||||
const level = levels.length
|
||||
levelMap.set(kp.id, level)
|
||||
if (!levels[level]) levels[level] = []
|
||||
levels[level].push(kp.id)
|
||||
}
|
||||
}
|
||||
|
||||
const nodeWidth = 160
|
||||
const nodeHeight = 52
|
||||
const gapX = 40
|
||||
const gapY = 90
|
||||
const maxCount = Math.max(...levels.map((l) => l.length), 1)
|
||||
const width = maxCount * (nodeWidth + gapX) + gapX
|
||||
const height = levels.length * (nodeHeight + gapY) + gapY
|
||||
|
||||
const positions = new Map<string, { x: number; y: number }>()
|
||||
levels.forEach((ids, level) => {
|
||||
ids.forEach((id, index) => {
|
||||
const x = gapX + index * (nodeWidth + gapX)
|
||||
const y = gapY + level * (nodeHeight + gapY)
|
||||
positions.set(id, { x, y })
|
||||
})
|
||||
})
|
||||
|
||||
const nodes = currentChapterKPs.map((kp) => {
|
||||
const pos = positions.get(kp.id) ?? { x: gapX, y: gapY }
|
||||
return { ...kp, x: pos.x, y: pos.y }
|
||||
})
|
||||
|
||||
const edges = currentChapterKPs
|
||||
.filter((kp) => kp.parentId && positions.has(kp.parentId))
|
||||
.map((kp) => {
|
||||
const parentPos = positions.get(kp.parentId as string)!
|
||||
const childPos = positions.get(kp.id)!
|
||||
return {
|
||||
id: `${kp.parentId}-${kp.id}`,
|
||||
x1: parentPos.x + nodeWidth / 2,
|
||||
y1: parentPos.y + nodeHeight,
|
||||
x2: childPos.x + nodeWidth / 2,
|
||||
y2: childPos.y,
|
||||
}
|
||||
})
|
||||
|
||||
return { nodes, edges, width, height }
|
||||
}, [currentChapterKPs])
|
||||
|
||||
// Pre-process content to mark knowledge points
|
||||
const processedContent = useMemo(() => {
|
||||
if (!selected?.content) return ""
|
||||
@@ -293,7 +378,7 @@ export function TextbookReader({ chapters, knowledgePoints = [], canEdit = false
|
||||
<div className="lg:col-span-4 lg:border-r lg:pr-6 flex flex-col min-h-0">
|
||||
<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">
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="chapters" className="gap-2">
|
||||
<List className="h-4 w-4" />
|
||||
章节目录
|
||||
@@ -305,6 +390,10 @@ export function TextbookReader({ chapters, knowledgePoints = [], canEdit = false
|
||||
<Badge variant="secondary" className="ml-1 px-1 py-0 h-5 text-[10px]">{currentChapterKPs.length}</Badge>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="graph" className="gap-2" disabled={!selectedId}>
|
||||
<Share2 className="h-4 w-4" />
|
||||
图谱
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
@@ -399,6 +488,62 @@ export function TextbookReader({ chapters, knowledgePoints = [], canEdit = false
|
||||
</ScrollArea>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="graph" 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="relative"
|
||||
style={{ width: graphLayout.width, height: graphLayout.height }}
|
||||
>
|
||||
<svg
|
||||
width={graphLayout.width}
|
||||
height={graphLayout.height}
|
||||
className="absolute inset-0"
|
||||
>
|
||||
{graphLayout.edges.map((edge) => (
|
||||
<line
|
||||
key={edge.id}
|
||||
x1={edge.x1}
|
||||
y1={edge.y1}
|
||||
x2={edge.x2}
|
||||
y2={edge.y2}
|
||||
stroke="hsl(var(--border))"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
{graphLayout.nodes.map((node) => (
|
||||
<button
|
||||
key={node.id}
|
||||
type="button"
|
||||
className={cn(
|
||||
"absolute rounded-lg border bg-card px-3 py-2 text-left text-sm shadow-sm hover:bg-accent/50",
|
||||
highlightedKpId === node.id && "border-primary bg-primary/5"
|
||||
)}
|
||||
style={{ left: node.x, top: node.y, width: 160, height: 52 }}
|
||||
onClick={() => setHighlightedKpId(node.id)}
|
||||
>
|
||||
<div className="font-medium truncate">{node.name}</div>
|
||||
{node.description && (
|
||||
<div className="text-[10px] text-muted-foreground truncate">
|
||||
{node.description}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user