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"
|
||||
|
||||
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 SubModuleCardSelection = { lessonId: number | null; practiceId: number | null }
|
||||
|
|
@ -99,6 +100,16 @@ type LessonDialogState =
|
|||
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 =
|
||||
| { open: false }
|
||||
| {
|
||||
|
|
@ -373,6 +384,12 @@ export function HumanLanguagePage() {
|
|||
const [questionDetailById, setQuestionDetailById] = useState<Record<number, QuestionDetail>>({})
|
||||
const [practiceTargetDelete, setPracticeTargetDelete] = useState<{ id: 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 [savingPractice, setSavingPractice] = useState(false)
|
||||
const [savingLesson, setSavingLesson] = useState(false)
|
||||
|
|
@ -430,6 +447,12 @@ export function HumanLanguagePage() {
|
|||
setLoading(true)
|
||||
try {
|
||||
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 {
|
||||
setLoading(false)
|
||||
}
|
||||
|
|
@ -437,6 +460,18 @@ export function HumanLanguagePage() {
|
|||
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(
|
||||
() =>
|
||||
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 () => {
|
||||
if (!questionTargetDelete) return
|
||||
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 currentPracticeId = subModuleCardSelection[smKey]?.practiceId ?? null
|
||||
const nextPracticeId = currentPracticeId === practiceId ? null : practiceId
|
||||
|
|
@ -1590,7 +1652,7 @@ export function HumanLanguagePage() {
|
|||
const smKey = `${course.course_id}-${subModule.id}`
|
||||
const panelTab = subModulePanelTab[smKey] ?? "lessons"
|
||||
const cardSel = getSubModuleSelection(smKey)
|
||||
const lessonRows = [
|
||||
const lessonRows: LessonListItem[] = [
|
||||
...(subModule.lessons ?? []).map((lesson) => ({
|
||||
id: lesson.id,
|
||||
question_set_id: lesson.question_set_id,
|
||||
|
|
@ -1619,6 +1681,8 @@ export function HumanLanguagePage() {
|
|||
cardSel.lessonId !== null
|
||||
? lessonRows.find((v) => v.id === cardSel.lessonId) ?? null
|
||||
: null
|
||||
const selectedLessonIds = selectedLessonIdsBySubModule[smKey] ?? []
|
||||
const selectedLessonRows = lessonRows.filter((row) => selectedLessonIds.includes(row.id))
|
||||
const selectedPracticeMeta =
|
||||
cardSel.practiceId !== null
|
||||
? practiceRows.find((p) => p.id === cardSel.practiceId) ?? null
|
||||
|
|
@ -1728,6 +1792,31 @@ export function HumanLanguagePage() {
|
|||
New practice
|
||||
</Button>
|
||||
) : panelTab === "lessons" ? (
|
||||
<div className="flex items-center gap-2">
|
||||
{selectedLessonRows.length > 0 ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8 border-red-200 bg-white px-2 text-[11px] text-red-600 hover:bg-red-50"
|
||||
onClick={() =>
|
||||
setLessonBulkTargetDelete({
|
||||
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"
|
||||
|
|
@ -1738,6 +1827,7 @@ export function HumanLanguagePage() {
|
|||
<Plus className="h-3.5 w-3.5" />
|
||||
New lesson
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1787,6 +1877,17 @@ export function HumanLanguagePage() {
|
|||
</div>
|
||||
{v.question_set_id > 0 ? (
|
||||
<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
|
||||
type="button"
|
||||
size="sm"
|
||||
|
|
@ -2504,6 +2605,32 @@ export function HumanLanguagePage() {
|
|||
</DialogContent>
|
||||
</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)}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user