sync-docs-and-fixes
This commit is contained in:
@@ -25,6 +25,20 @@ export function ExamPaperPreview({ title, subject, grade, durationMin, totalScor
|
||||
// Helper to flatten questions for continuous numbering
|
||||
let questionCounter = 0
|
||||
|
||||
const parseContent = (raw: unknown): QuestionContent => {
|
||||
if (raw && typeof raw === "object") return raw as QuestionContent
|
||||
if (typeof raw === "string") {
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as unknown
|
||||
if (parsed && typeof parsed === "object") return parsed as QuestionContent
|
||||
return { text: raw }
|
||||
} catch {
|
||||
return { text: raw }
|
||||
}
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
const renderNode = (node: ExamNode, depth: number = 0) => {
|
||||
if (node.type === 'group') {
|
||||
return (
|
||||
@@ -45,7 +59,7 @@ export function ExamPaperPreview({ title, subject, grade, durationMin, totalScor
|
||||
if (node.type === 'question' && node.question) {
|
||||
questionCounter++
|
||||
const q = node.question
|
||||
const content = q.content as QuestionContent
|
||||
const content = parseContent(q.content)
|
||||
|
||||
return (
|
||||
<div key={node.id} className="mb-6 break-inside-avoid">
|
||||
|
||||
@@ -28,13 +28,26 @@ export function QuestionBankList({ questions, onAdd, isAdded, onLoadMore, hasMor
|
||||
<div className="space-y-3 pb-4">
|
||||
{questions.map((q) => {
|
||||
const added = isAdded(q.id)
|
||||
const content = q.content as { text?: string }
|
||||
const parsedContent = (() => {
|
||||
if (q.content && typeof q.content === "object") return q.content as { text?: string }
|
||||
if (typeof q.content === "string") {
|
||||
try {
|
||||
const parsed = JSON.parse(q.content) as unknown
|
||||
if (parsed && typeof parsed === "object") return parsed as { text?: string }
|
||||
return { text: q.content }
|
||||
} catch {
|
||||
return { text: q.content }
|
||||
}
|
||||
}
|
||||
return { text: "" }
|
||||
})()
|
||||
const typeLabel = typeof q.type === "string" ? q.type.replace("_", " ") : "unknown"
|
||||
return (
|
||||
<Card key={q.id} className="p-3 flex gap-3 hover:bg-muted/50 transition-colors">
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="text-[10px] uppercase">
|
||||
{q.type.replace("_", " ")}
|
||||
{typeLabel}
|
||||
</Badge>
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
Lvl {q.difficulty}
|
||||
@@ -46,7 +59,7 @@ export function QuestionBankList({ questions, onAdd, isAdded, onLoadMore, hasMor
|
||||
))}
|
||||
</div>
|
||||
<p className="text-sm line-clamp-2 text-muted-foreground">
|
||||
{content.text || "No content preview"}
|
||||
{parsedContent.text || "No content preview"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
|
||||
@@ -118,7 +118,22 @@ function QuestionItem({ item, index, total, onRemove, onMove, onScoreChange }: {
|
||||
onMove: (dir: 'up' | 'down') => void
|
||||
onScoreChange: (score: number) => void
|
||||
}) {
|
||||
const content = item.question?.content as { text?: string }
|
||||
const rawContent = item.question?.content
|
||||
const parsedContent = (() => {
|
||||
if (rawContent && typeof rawContent === "object") return rawContent as { text?: string; options?: Array<{ id?: string; text?: string }> }
|
||||
if (typeof rawContent === "string") {
|
||||
try {
|
||||
const parsed = JSON.parse(rawContent) as unknown
|
||||
if (parsed && typeof parsed === "object") return parsed as { text?: string; options?: Array<{ id?: string; text?: string }> }
|
||||
return { text: rawContent }
|
||||
} catch {
|
||||
return { text: rawContent }
|
||||
}
|
||||
}
|
||||
return { text: "" }
|
||||
})()
|
||||
|
||||
const options = Array.isArray(parsedContent.options) ? parsedContent.options : []
|
||||
return (
|
||||
<div className="group flex flex-col gap-3 rounded-md border p-3 bg-card hover:border-primary/50 transition-colors">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
@@ -127,7 +142,7 @@ function QuestionItem({ item, index, total, onRemove, onMove, onScoreChange }: {
|
||||
{index + 1}
|
||||
</span>
|
||||
<p className="text-sm line-clamp-2 pt-0.5">
|
||||
{content?.text || "Question content"}
|
||||
{parsedContent.text || "Question content"}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
@@ -139,6 +154,16 @@ function QuestionItem({ item, index, total, onRemove, onMove, onScoreChange }: {
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{(item.question?.type === "single_choice" || item.question?.type === "multiple_choice") && options.length > 0 && (
|
||||
<div className="grid grid-cols-1 gap-1 pl-8 text-xs text-muted-foreground">
|
||||
{options.map((opt, idx) => (
|
||||
<div key={opt.id ?? idx} className="flex gap-2">
|
||||
<span className="font-medium">{opt.id ?? String.fromCharCode(65 + idx)}.</span>
|
||||
<span>{opt.text ?? ""}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between pl-8">
|
||||
<div className="flex items-center gap-1">
|
||||
|
||||
@@ -82,7 +82,22 @@ function SortableItem({
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
}
|
||||
|
||||
const content = item.question?.content as { text?: string }
|
||||
const rawContent = item.question?.content
|
||||
const parsedContent = (() => {
|
||||
if (rawContent && typeof rawContent === "object") return rawContent as { text?: string; options?: Array<{ id?: string; text?: string }> }
|
||||
if (typeof rawContent === "string") {
|
||||
try {
|
||||
const parsed = JSON.parse(rawContent) as unknown
|
||||
if (parsed && typeof parsed === "object") return parsed as { text?: string; options?: Array<{ id?: string; text?: string }> }
|
||||
return { text: rawContent }
|
||||
} catch {
|
||||
return { text: rawContent }
|
||||
}
|
||||
}
|
||||
return { text: "" }
|
||||
})()
|
||||
|
||||
const options = Array.isArray(parsedContent.options) ? parsedContent.options : []
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} style={style} className={cn("group flex flex-col gap-3 rounded-md border p-3 bg-card hover:border-primary/50 transition-colors", isDragging && "ring-2 ring-primary")}>
|
||||
@@ -92,7 +107,7 @@ function SortableItem({
|
||||
<GripVertical className="h-4 w-4" />
|
||||
</button>
|
||||
<p className="text-sm line-clamp-2 pt-0.5 select-none">
|
||||
{content?.text || "Question content"}
|
||||
{parsedContent.text || "Question content"}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
@@ -104,6 +119,16 @@ function SortableItem({
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{(item.question?.type === "single_choice" || item.question?.type === "multiple_choice") && options.length > 0 && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-1 pl-8 text-xs text-muted-foreground">
|
||||
{options.map((opt, idx) => (
|
||||
<div key={opt.id ?? idx} className="flex gap-2">
|
||||
<span className="font-medium">{opt.id ?? String.fromCharCode(65 + idx)}.</span>
|
||||
<span>{opt.text ?? ""}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-end pl-8">
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -79,7 +79,7 @@ export function ExamActions({ exam }: ExamActionsProps) {
|
||||
toast.error("Failed to load exam preview")
|
||||
setShowViewDialog(false)
|
||||
}
|
||||
} catch (e) {
|
||||
} catch {
|
||||
toast.error("Failed to load exam preview")
|
||||
setShowViewDialog(false)
|
||||
} finally {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { useDeferredValue, useMemo, useState, useTransition, useEffect, useRef } from "react"
|
||||
import { useFormStatus } from "react-dom"
|
||||
import { useCallback, useDeferredValue, useMemo, useState, useTransition, useEffect, useRef } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { toast } from "sonner"
|
||||
import { Search, Eye } from "lucide-react"
|
||||
@@ -34,15 +33,6 @@ type ExamAssemblyProps = {
|
||||
questionOptions: Question[]
|
||||
}
|
||||
|
||||
function SubmitButton({ label }: { label: string }) {
|
||||
const { pending } = useFormStatus()
|
||||
return (
|
||||
<Button type="submit" disabled={pending} className="w-full">
|
||||
{pending ? "Saving..." : label}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export function ExamAssembly(props: ExamAssemblyProps) {
|
||||
const router = useRouter()
|
||||
const [search, setSearch] = useState("")
|
||||
@@ -83,7 +73,7 @@ export function ExamAssembly(props: ExamAssemblyProps) {
|
||||
return []
|
||||
})
|
||||
|
||||
const fetchQuestions = (reset: boolean = false) => {
|
||||
const fetchQuestions = useCallback((reset: boolean = false) => {
|
||||
startBankTransition(async () => {
|
||||
const nextPage = reset ? 1 : page + 1
|
||||
try {
|
||||
@@ -107,11 +97,11 @@ export function ExamAssembly(props: ExamAssemblyProps) {
|
||||
setHasMore(result.data.length === 20)
|
||||
setPage(nextPage)
|
||||
}
|
||||
} catch (error) {
|
||||
} catch {
|
||||
toast.error("Failed to load questions")
|
||||
}
|
||||
})
|
||||
}
|
||||
}, [deferredSearch, page, startBankTransition, typeFilter, difficultyFilter])
|
||||
|
||||
const isFirstRender = useRef(true)
|
||||
|
||||
@@ -123,7 +113,7 @@ export function ExamAssembly(props: ExamAssemblyProps) {
|
||||
}
|
||||
}
|
||||
fetchQuestions(true)
|
||||
}, [deferredSearch, typeFilter, difficultyFilter])
|
||||
}, [deferredSearch, typeFilter, difficultyFilter, fetchQuestions])
|
||||
|
||||
// Recursively calculate total score
|
||||
const assignedTotal = useMemo(() => {
|
||||
|
||||
@@ -149,8 +149,8 @@ export const omitScheduledAtFromDescription = (description: string | null): stri
|
||||
try {
|
||||
const meta = JSON.parse(description)
|
||||
if (typeof meta === "object" && meta !== null) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const { scheduledAt, ...rest } = meta as any
|
||||
const rest = { ...(meta as Record<string, unknown>) }
|
||||
delete rest.scheduledAt
|
||||
return JSON.stringify(rest)
|
||||
}
|
||||
return description
|
||||
|
||||
Reference in New Issue
Block a user