add bulk lesson delete and preserve scroll position
Enable selecting and deleting multiple lessons at once in the human language panel, and persist page scroll so users return to their previous position after reload. Made-with: Cursor
This commit is contained in:
parent
fe3f235fcd
commit
eee5771957
|
|
@ -72,6 +72,7 @@ import {
|
||||||
} from "../../components/content-management/PracticeQuestionEditorFields"
|
} from "../../components/content-management/PracticeQuestionEditorFields"
|
||||||
|
|
||||||
const CEFR_LEVELS = ["A1", "A2", "A3", "B1", "B2", "B3", "C1", "C2", "C3"] as const
|
const CEFR_LEVELS = ["A1", "A2", "A3", "B1", "B2", "B3", "C1", "C2", "C3"] as const
|
||||||
|
const HUMAN_LANGUAGE_SCROLL_KEY = "human-language-page:scroll-y"
|
||||||
type SubModulePanelTab = "lessons" | "practices"
|
type SubModulePanelTab = "lessons" | "practices"
|
||||||
|
|
||||||
type SubModuleCardSelection = { lessonId: number | null; practiceId: number | null }
|
type SubModuleCardSelection = { lessonId: number | null; practiceId: number | null }
|
||||||
|
|
@ -99,6 +100,16 @@ type LessonDialogState =
|
||||||
questionSetId: number
|
questionSetId: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type LessonListItem = {
|
||||||
|
id: number
|
||||||
|
question_set_id: number
|
||||||
|
title: string
|
||||||
|
display_order: number
|
||||||
|
status: string
|
||||||
|
question_count: number
|
||||||
|
intro_video_url: string
|
||||||
|
}
|
||||||
|
|
||||||
type QuestionDialogState =
|
type QuestionDialogState =
|
||||||
| { open: false }
|
| { open: false }
|
||||||
| {
|
| {
|
||||||
|
|
@ -373,6 +384,12 @@ export function HumanLanguagePage() {
|
||||||
const [questionDetailById, setQuestionDetailById] = useState<Record<number, QuestionDetail>>({})
|
const [questionDetailById, setQuestionDetailById] = useState<Record<number, QuestionDetail>>({})
|
||||||
const [practiceTargetDelete, setPracticeTargetDelete] = useState<{ id: number; title: string } | null>(null)
|
const [practiceTargetDelete, setPracticeTargetDelete] = useState<{ id: number; title: string } | null>(null)
|
||||||
const [lessonTargetDelete, setLessonTargetDelete] = useState<{ id: number; questionSetId: number; title: string } | null>(null)
|
const [lessonTargetDelete, setLessonTargetDelete] = useState<{ id: number; questionSetId: number; title: string } | null>(null)
|
||||||
|
const [lessonBulkTargetDelete, setLessonBulkTargetDelete] = useState<{
|
||||||
|
title: string
|
||||||
|
lessons: { id: number; questionSetId: number; title: string }[]
|
||||||
|
subModuleKey: string
|
||||||
|
} | null>(null)
|
||||||
|
const [selectedLessonIdsBySubModule, setSelectedLessonIdsBySubModule] = useState<Record<string, number[]>>({})
|
||||||
const [questionTargetDelete, setQuestionTargetDelete] = useState<{ id: number; practiceId: number; text: string } | null>(null)
|
const [questionTargetDelete, setQuestionTargetDelete] = useState<{ id: number; practiceId: number; text: string } | null>(null)
|
||||||
const [savingPractice, setSavingPractice] = useState(false)
|
const [savingPractice, setSavingPractice] = useState(false)
|
||||||
const [savingLesson, setSavingLesson] = useState(false)
|
const [savingLesson, setSavingLesson] = useState(false)
|
||||||
|
|
@ -430,6 +447,12 @@ export function HumanLanguagePage() {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
await loadHierarchy()
|
await loadHierarchy()
|
||||||
|
const saved = sessionStorage.getItem(HUMAN_LANGUAGE_SCROLL_KEY)
|
||||||
|
const targetY = saved ? Number(saved) : 0
|
||||||
|
if (Number.isFinite(targetY) && targetY > 0) {
|
||||||
|
window.requestAnimationFrame(() => window.scrollTo({ top: targetY, behavior: "auto" }))
|
||||||
|
setTimeout(() => window.scrollTo({ top: targetY, behavior: "auto" }), 250)
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
|
|
@ -437,6 +460,18 @@ export function HumanLanguagePage() {
|
||||||
run().catch(() => undefined)
|
run().catch(() => undefined)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const save = () => sessionStorage.setItem(HUMAN_LANGUAGE_SCROLL_KEY, String(window.scrollY || 0))
|
||||||
|
const onBeforeUnload = () => save()
|
||||||
|
window.addEventListener("scroll", save, { passive: true })
|
||||||
|
window.addEventListener("beforeunload", onBeforeUnload)
|
||||||
|
return () => {
|
||||||
|
save()
|
||||||
|
window.removeEventListener("scroll", save)
|
||||||
|
window.removeEventListener("beforeunload", onBeforeUnload)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
const filteredSubCategories = useMemo(
|
const filteredSubCategories = useMemo(
|
||||||
() =>
|
() =>
|
||||||
selectedSubCategoryId === "ALL"
|
selectedSubCategoryId === "ALL"
|
||||||
|
|
@ -1185,6 +1220,25 @@ export function HumanLanguagePage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleDeleteSelectedLessonsConfirmed = async () => {
|
||||||
|
if (!lessonBulkTargetDelete || lessonBulkTargetDelete.lessons.length === 0) return
|
||||||
|
setDeletingLesson(true)
|
||||||
|
try {
|
||||||
|
for (const lesson of lessonBulkTargetDelete.lessons) {
|
||||||
|
await deleteQuestionSet(lesson.questionSetId)
|
||||||
|
}
|
||||||
|
toast.success(`${lessonBulkTargetDelete.lessons.length} lesson(s) deleted`)
|
||||||
|
setSelectedLessonIdsBySubModule((prev) => ({ ...prev, [lessonBulkTargetDelete.subModuleKey]: [] }))
|
||||||
|
setLessonBulkTargetDelete(null)
|
||||||
|
await loadHierarchy()
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to delete selected lessons:", error)
|
||||||
|
toast.error("Failed to delete selected lessons")
|
||||||
|
} finally {
|
||||||
|
setDeletingLesson(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleDeleteQuestionConfirmed = async () => {
|
const handleDeleteQuestionConfirmed = async () => {
|
||||||
if (!questionTargetDelete) return
|
if (!questionTargetDelete) return
|
||||||
setDeletingQuestion(true)
|
setDeletingQuestion(true)
|
||||||
|
|
@ -1212,6 +1266,14 @@ export function HumanLanguagePage() {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const toggleLessonSelection = (smKey: string, lessonId: number) => {
|
||||||
|
setSelectedLessonIdsBySubModule((prev) => {
|
||||||
|
const current = prev[smKey] ?? []
|
||||||
|
const next = current.includes(lessonId) ? current.filter((id) => id !== lessonId) : [...current, lessonId]
|
||||||
|
return { ...prev, [smKey]: next }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const togglePracticeCard = (smKey: string, practiceId: number) => {
|
const togglePracticeCard = (smKey: string, practiceId: number) => {
|
||||||
const currentPracticeId = subModuleCardSelection[smKey]?.practiceId ?? null
|
const currentPracticeId = subModuleCardSelection[smKey]?.practiceId ?? null
|
||||||
const nextPracticeId = currentPracticeId === practiceId ? null : practiceId
|
const nextPracticeId = currentPracticeId === practiceId ? null : practiceId
|
||||||
|
|
@ -1590,7 +1652,7 @@ export function HumanLanguagePage() {
|
||||||
const smKey = `${course.course_id}-${subModule.id}`
|
const smKey = `${course.course_id}-${subModule.id}`
|
||||||
const panelTab = subModulePanelTab[smKey] ?? "lessons"
|
const panelTab = subModulePanelTab[smKey] ?? "lessons"
|
||||||
const cardSel = getSubModuleSelection(smKey)
|
const cardSel = getSubModuleSelection(smKey)
|
||||||
const lessonRows = [
|
const lessonRows: LessonListItem[] = [
|
||||||
...(subModule.lessons ?? []).map((lesson) => ({
|
...(subModule.lessons ?? []).map((lesson) => ({
|
||||||
id: lesson.id,
|
id: lesson.id,
|
||||||
question_set_id: lesson.question_set_id,
|
question_set_id: lesson.question_set_id,
|
||||||
|
|
@ -1619,6 +1681,8 @@ export function HumanLanguagePage() {
|
||||||
cardSel.lessonId !== null
|
cardSel.lessonId !== null
|
||||||
? lessonRows.find((v) => v.id === cardSel.lessonId) ?? null
|
? lessonRows.find((v) => v.id === cardSel.lessonId) ?? null
|
||||||
: null
|
: null
|
||||||
|
const selectedLessonIds = selectedLessonIdsBySubModule[smKey] ?? []
|
||||||
|
const selectedLessonRows = lessonRows.filter((row) => selectedLessonIds.includes(row.id))
|
||||||
const selectedPracticeMeta =
|
const selectedPracticeMeta =
|
||||||
cardSel.practiceId !== null
|
cardSel.practiceId !== null
|
||||||
? practiceRows.find((p) => p.id === cardSel.practiceId) ?? null
|
? practiceRows.find((p) => p.id === cardSel.practiceId) ?? null
|
||||||
|
|
@ -1728,16 +1792,42 @@ export function HumanLanguagePage() {
|
||||||
New practice
|
New practice
|
||||||
</Button>
|
</Button>
|
||||||
) : panelTab === "lessons" ? (
|
) : panelTab === "lessons" ? (
|
||||||
<Button
|
<div className="flex items-center gap-2">
|
||||||
type="button"
|
{selectedLessonRows.length > 0 ? (
|
||||||
size="sm"
|
<Button
|
||||||
variant="outline"
|
type="button"
|
||||||
className="h-8 border-grayScale-200 bg-white px-2 text-[11px] hover:border-brand-200 hover:bg-brand-50/40"
|
size="sm"
|
||||||
onClick={() => openCreateLessonDialog(course.course_id, subModule.id)}
|
variant="outline"
|
||||||
>
|
className="h-8 border-red-200 bg-white px-2 text-[11px] text-red-600 hover:bg-red-50"
|
||||||
<Plus className="h-3.5 w-3.5" />
|
onClick={() =>
|
||||||
New lesson
|
setLessonBulkTargetDelete({
|
||||||
</Button>
|
title: subModule.title,
|
||||||
|
subModuleKey: smKey,
|
||||||
|
lessons: selectedLessonRows
|
||||||
|
.filter((item) => item.question_set_id > 0)
|
||||||
|
.map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
questionSetId: item.question_set_id,
|
||||||
|
title: item.title,
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
Delete selected ({selectedLessonRows.length})
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="h-8 border-grayScale-200 bg-white px-2 text-[11px] hover:border-brand-200 hover:bg-brand-50/40"
|
||||||
|
onClick={() => openCreateLessonDialog(course.course_id, subModule.id)}
|
||||||
|
>
|
||||||
|
<Plus className="h-3.5 w-3.5" />
|
||||||
|
New lesson
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1787,6 +1877,17 @@ export function HumanLanguagePage() {
|
||||||
</div>
|
</div>
|
||||||
{v.question_set_id > 0 ? (
|
{v.question_set_id > 0 ? (
|
||||||
<div className="flex shrink-0 items-center gap-1">
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
|
<label
|
||||||
|
className="inline-flex items-center gap-1 rounded px-1 text-[10px] text-grayScale-600"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedLessonIds.includes(v.id)}
|
||||||
|
onChange={() => toggleLessonSelection(smKey, v.id)}
|
||||||
|
className="h-3.5 w-3.5 rounded border-grayScale-300"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|
@ -2504,6 +2605,32 @@ export function HumanLanguagePage() {
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
<Dialog open={lessonBulkTargetDelete !== null} onOpenChange={(open) => !open && setLessonBulkTargetDelete(null)}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Delete selected lessons?</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{lessonBulkTargetDelete
|
||||||
|
? `This will permanently delete ${lessonBulkTargetDelete.lessons.length} selected lesson(s) in "${lessonBulkTargetDelete.title}".`
|
||||||
|
: ""}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="outline" onClick={() => setLessonBulkTargetDelete(null)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
className="bg-red-600 hover:bg-red-700"
|
||||||
|
onClick={() => void handleDeleteSelectedLessonsConfirmed()}
|
||||||
|
disabled={deletingLesson}
|
||||||
|
>
|
||||||
|
{deletingLesson ? "Deleting..." : "Delete selected"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
<Dialog open={practiceTargetDelete !== null} onOpenChange={(open) => !open && setPracticeTargetDelete(null)}>
|
<Dialog open={practiceTargetDelete !== null} onOpenChange={(open) => !open && setPracticeTargetDelete(null)}>
|
||||||
<DialogContent className="sm:max-w-md">
|
<DialogContent className="sm:max-w-md">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user