enhance human language practice editing and collapsible hierarchy
Expand edit-practice modal to include full question-set metadata fields, raise recorder modal overlay, and add module/sub-module collapse toggles to match path and level expand/collapse behavior. Made-with: Cursor
This commit is contained in:
parent
0dc7aa81ba
commit
06af3a97f2
|
|
@ -242,6 +242,9 @@ export const getQuestionSetQuestions = (questionSetId: number) =>
|
||||||
export const createQuestionSet = (data: CreateQuestionSetRequest) =>
|
export const createQuestionSet = (data: CreateQuestionSetRequest) =>
|
||||||
http.post<CreateQuestionSetResponse>("/question-sets", data)
|
http.post<CreateQuestionSetResponse>("/question-sets", data)
|
||||||
|
|
||||||
|
export const updateQuestionSet = (questionSetId: number, data: Partial<CreateQuestionSetRequest>) =>
|
||||||
|
http.put(`/question-sets/${questionSetId}`, data)
|
||||||
|
|
||||||
export const addQuestionToSet = (questionSetId: number, data: AddQuestionToSetRequest) =>
|
export const addQuestionToSet = (questionSetId: number, data: AddQuestionToSetRequest) =>
|
||||||
http.post(`/question-sets/${questionSetId}/questions`, data)
|
http.post(`/question-sets/${questionSetId}/questions`, data)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -909,7 +909,7 @@ export function PracticeQuestionEditorFields({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{recordingModal ? (
|
{recordingModal ? (
|
||||||
<div className="fixed inset-0 z-[70] flex items-center justify-center bg-black/40 backdrop-blur-sm">
|
<div className="fixed inset-0 z-[120] flex items-center justify-center bg-black/45 backdrop-blur-sm">
|
||||||
<div className="mx-4 w-full max-w-md overflow-hidden rounded-2xl border border-grayScale-200/80 bg-white p-6 shadow-2xl">
|
<div className="mx-4 w-full max-w-md overflow-hidden rounded-2xl border border-grayScale-200/80 bg-white p-6 shadow-2xl">
|
||||||
<p className="text-center text-base font-semibold text-grayScale-900">Recording {recordingModal.label}</p>
|
<p className="text-center text-base font-semibold text-grayScale-900">Recording {recordingModal.label}</p>
|
||||||
<p className="mt-1 text-center text-xs text-grayScale-500">
|
<p className="mt-1 text-center text-xs text-grayScale-500">
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useEffect, useMemo, useState } from "react"
|
import { useEffect, useMemo, useState, type ChangeEvent } from "react"
|
||||||
import { Link, useNavigate } from "react-router-dom"
|
import { Link, useNavigate } from "react-router-dom"
|
||||||
import {
|
import {
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
|
|
@ -42,7 +42,8 @@ import {
|
||||||
getQuestionById,
|
getQuestionById,
|
||||||
getPracticeQuestions,
|
getPracticeQuestions,
|
||||||
getPracticeQuestionsByPractice,
|
getPracticeQuestionsByPractice,
|
||||||
updatePractice,
|
getQuestionSetById,
|
||||||
|
updateQuestionSet,
|
||||||
updateQuestion,
|
updateQuestion,
|
||||||
} from "../../api/courses.api"
|
} from "../../api/courses.api"
|
||||||
import { Badge } from "../../components/ui/badge"
|
import { Badge } from "../../components/ui/badge"
|
||||||
|
|
@ -58,6 +59,7 @@ import type {
|
||||||
import { cn } from "../../lib/utils"
|
import { cn } from "../../lib/utils"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { Input } from "../../components/ui/input"
|
import { Input } from "../../components/ui/input"
|
||||||
|
import { uploadVideoFile } from "../../api/files.api"
|
||||||
import { resolveMediaPreviewUrl } from "../../lib/practiceMedia"
|
import { resolveMediaPreviewUrl } from "../../lib/practiceMedia"
|
||||||
import {
|
import {
|
||||||
createEmptyPracticeQuestionDraft,
|
createEmptyPracticeQuestionDraft,
|
||||||
|
|
@ -309,6 +311,8 @@ export function HumanLanguagePage() {
|
||||||
const [selectedCourseId, setSelectedCourseId] = useState<number | "ALL">("ALL")
|
const [selectedCourseId, setSelectedCourseId] = useState<number | "ALL">("ALL")
|
||||||
const [selectedLevel, setSelectedLevel] = useState<CefrLevel | "ALL">("ALL")
|
const [selectedLevel, setSelectedLevel] = useState<CefrLevel | "ALL">("ALL")
|
||||||
const [collapsedLevels, setCollapsedLevels] = useState<string[]>([])
|
const [collapsedLevels, setCollapsedLevels] = useState<string[]>([])
|
||||||
|
const [collapsedModuleIds, setCollapsedModuleIds] = useState<number[]>([])
|
||||||
|
const [collapsedSubModuleIds, setCollapsedSubModuleIds] = useState<number[]>([])
|
||||||
const [creatingKey, setCreatingKey] = useState<string | null>(null)
|
const [creatingKey, setCreatingKey] = useState<string | null>(null)
|
||||||
const [quickSubCategoryName, setQuickSubCategoryName] = useState("")
|
const [quickSubCategoryName, setQuickSubCategoryName] = useState("")
|
||||||
const [quickCourseName, setQuickCourseName] = useState("")
|
const [quickCourseName, setQuickCourseName] = useState("")
|
||||||
|
|
@ -325,7 +329,15 @@ export function HumanLanguagePage() {
|
||||||
const [practiceQuestionsState, setPracticeQuestionsState] = useState<Record<number, PracticeQuestionsFetchState>>({})
|
const [practiceQuestionsState, setPracticeQuestionsState] = useState<Record<number, PracticeQuestionsFetchState>>({})
|
||||||
const [practiceDialog, setPracticeDialog] = useState<PracticeDialogState>({ open: false })
|
const [practiceDialog, setPracticeDialog] = useState<PracticeDialogState>({ open: false })
|
||||||
const [questionDialog, setQuestionDialog] = useState<QuestionDialogState>({ open: false })
|
const [questionDialog, setQuestionDialog] = useState<QuestionDialogState>({ open: false })
|
||||||
const [practiceForm, setPracticeForm] = useState({ title: "", description: "", persona: "" })
|
const [practiceForm, setPracticeForm] = useState({
|
||||||
|
title: "",
|
||||||
|
description: "",
|
||||||
|
persona: "",
|
||||||
|
introVideoUrl: "",
|
||||||
|
passingScore: 50,
|
||||||
|
timeLimitMinutes: 60,
|
||||||
|
shuffleQuestions: false,
|
||||||
|
})
|
||||||
const [questionDraft, setQuestionDraft] = useState<PracticeQuestionEditorValue>(() => createEmptyPracticeQuestionDraft())
|
const [questionDraft, setQuestionDraft] = useState<PracticeQuestionEditorValue>(() => createEmptyPracticeQuestionDraft())
|
||||||
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)
|
||||||
|
|
@ -341,6 +353,8 @@ export function HumanLanguagePage() {
|
||||||
const [questionSubmitAttempted, setQuestionSubmitAttempted] = useState(false)
|
const [questionSubmitAttempted, setQuestionSubmitAttempted] = useState(false)
|
||||||
const [practiceFormTouched, setPracticeFormTouched] = useState(false)
|
const [practiceFormTouched, setPracticeFormTouched] = useState(false)
|
||||||
const [questionFormTouched, setQuestionFormTouched] = useState(false)
|
const [questionFormTouched, setQuestionFormTouched] = useState(false)
|
||||||
|
const [loadingPracticeForm, setLoadingPracticeForm] = useState(false)
|
||||||
|
const [uploadingPracticeIntroVideo, setUploadingPracticeIntroVideo] = useState(false)
|
||||||
|
|
||||||
const renderMediaPreview = (
|
const renderMediaPreview = (
|
||||||
urlRaw: string,
|
urlRaw: string,
|
||||||
|
|
@ -428,6 +442,18 @@ export function HumanLanguagePage() {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const toggleModuleCollapsed = (moduleId: number) => {
|
||||||
|
setCollapsedModuleIds((prev) =>
|
||||||
|
prev.includes(moduleId) ? prev.filter((id) => id !== moduleId) : [...prev, moduleId],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleSubModuleCollapsed = (subModuleId: number) => {
|
||||||
|
setCollapsedSubModuleIds((prev) =>
|
||||||
|
prev.includes(subModuleId) ? prev.filter((id) => id !== subModuleId) : [...prev, subModuleId],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const levelsWithContentForCourse = (course: HumanLanguageCourseTree) =>
|
const levelsWithContentForCourse = (course: HumanLanguageCourseTree) =>
|
||||||
course.levels.filter((l) => (l.modules?.length ?? 0) > 0).map((l) => l.level.toUpperCase())
|
course.levels.filter((l) => (l.modules?.length ?? 0) > 0).map((l) => l.level.toUpperCase())
|
||||||
|
|
||||||
|
|
@ -643,7 +669,16 @@ export function HumanLanguagePage() {
|
||||||
const getSubModuleSelection = (smKey: string): SubModuleCardSelection =>
|
const getSubModuleSelection = (smKey: string): SubModuleCardSelection =>
|
||||||
subModuleCardSelection[smKey] ?? { lessonId: null, practiceId: null }
|
subModuleCardSelection[smKey] ?? { lessonId: null, practiceId: null }
|
||||||
|
|
||||||
const resetPracticeForm = () => setPracticeForm({ title: "", description: "", persona: "" })
|
const resetPracticeForm = () =>
|
||||||
|
setPracticeForm({
|
||||||
|
title: "",
|
||||||
|
description: "",
|
||||||
|
persona: "",
|
||||||
|
introVideoUrl: "",
|
||||||
|
passingScore: 50,
|
||||||
|
timeLimitMinutes: 60,
|
||||||
|
shuffleQuestions: false,
|
||||||
|
})
|
||||||
const resetQuestionForm = () => {
|
const resetQuestionForm = () => {
|
||||||
setQuestionDraft(createEmptyPracticeQuestionDraft())
|
setQuestionDraft(createEmptyPracticeQuestionDraft())
|
||||||
}
|
}
|
||||||
|
|
@ -656,11 +691,37 @@ export function HumanLanguagePage() {
|
||||||
navigate(`/content/human-language/${categoryId}/${courseId}/sub-module/${subModuleId}/add-practice`)
|
navigate(`/content/human-language/${categoryId}/${courseId}/sub-module/${subModuleId}/add-practice`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const openEditPracticeDialog = (subModuleId: number, p: LearningPathPractice) => {
|
const openEditPracticeDialog = async (subModuleId: number, p: LearningPathPractice) => {
|
||||||
setPracticeSubmitAttempted(false)
|
setPracticeSubmitAttempted(false)
|
||||||
setPracticeFormTouched(false)
|
setPracticeFormTouched(false)
|
||||||
setPracticeForm({ title: p.title ?? "", description: "", persona: "" })
|
|
||||||
setPracticeDialog({ open: true, mode: "edit", subModuleId, practiceId: p.id })
|
setPracticeDialog({ open: true, mode: "edit", subModuleId, practiceId: p.id })
|
||||||
|
setLoadingPracticeForm(true)
|
||||||
|
try {
|
||||||
|
const detail = (await getQuestionSetById(p.id)).data?.data
|
||||||
|
setPracticeForm({
|
||||||
|
title: detail?.title ?? p.title ?? "",
|
||||||
|
description: detail?.description ?? "",
|
||||||
|
persona: detail?.persona ?? "",
|
||||||
|
introVideoUrl: detail?.intro_video_url ?? "",
|
||||||
|
passingScore: detail?.passing_score ?? 50,
|
||||||
|
timeLimitMinutes: detail?.time_limit_minutes ?? 60,
|
||||||
|
shuffleQuestions: detail?.shuffle_questions ?? false,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load practice detail:", error)
|
||||||
|
setPracticeForm({
|
||||||
|
title: p.title ?? "",
|
||||||
|
description: "",
|
||||||
|
persona: "",
|
||||||
|
introVideoUrl: "",
|
||||||
|
passingScore: 50,
|
||||||
|
timeLimitMinutes: 60,
|
||||||
|
shuffleQuestions: false,
|
||||||
|
})
|
||||||
|
toast.error("Could not load full practice details")
|
||||||
|
} finally {
|
||||||
|
setLoadingPracticeForm(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const practiceFieldErrors = useMemo(() => {
|
const practiceFieldErrors = useMemo(() => {
|
||||||
|
|
@ -689,10 +750,14 @@ export function HumanLanguagePage() {
|
||||||
})
|
})
|
||||||
toast.success("Practice created")
|
toast.success("Practice created")
|
||||||
} else if (practiceDialog.practiceId) {
|
} else if (practiceDialog.practiceId) {
|
||||||
await updatePractice(practiceDialog.practiceId, {
|
await updateQuestionSet(practiceDialog.practiceId, {
|
||||||
title: practiceForm.title.trim(),
|
title: practiceForm.title.trim(),
|
||||||
description: practiceForm.description.trim(),
|
description: practiceForm.description.trim() || undefined,
|
||||||
persona: practiceForm.persona.trim() || undefined,
|
persona: practiceForm.persona.trim() || undefined,
|
||||||
|
intro_video_url: practiceForm.introVideoUrl.trim() || undefined,
|
||||||
|
passing_score: Number.isFinite(practiceForm.passingScore) ? practiceForm.passingScore : undefined,
|
||||||
|
time_limit_minutes: Number.isFinite(practiceForm.timeLimitMinutes) ? practiceForm.timeLimitMinutes : undefined,
|
||||||
|
shuffle_questions: practiceForm.shuffleQuestions,
|
||||||
})
|
})
|
||||||
toast.success("Practice updated")
|
toast.success("Practice updated")
|
||||||
}
|
}
|
||||||
|
|
@ -709,6 +774,30 @@ export function HumanLanguagePage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handlePracticeIntroVideoFileChange = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = event.target.files?.[0]
|
||||||
|
event.target.value = ""
|
||||||
|
if (!file) return
|
||||||
|
setUploadingPracticeIntroVideo(true)
|
||||||
|
try {
|
||||||
|
const uploadRes = await uploadVideoFile(file, {
|
||||||
|
title: practiceForm.title.trim() || file.name.replace(/\.[^.]+$/, "") || "Practice intro",
|
||||||
|
description: practiceForm.description.trim() || undefined,
|
||||||
|
})
|
||||||
|
const finalUrl = uploadRes.data?.data?.embed_url?.trim()
|
||||||
|
? `${uploadRes.data.data.embed_url}?h=${uploadRes.data.data.url?.split("/").filter(Boolean).at(-1) ?? ""}`
|
||||||
|
: uploadRes.data?.data?.url?.trim()
|
||||||
|
if (!finalUrl) throw new Error("Missing uploaded video url")
|
||||||
|
setPracticeForm((prev) => ({ ...prev, introVideoUrl: finalUrl }))
|
||||||
|
toast.success("Intro video uploaded")
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to upload intro video:", error)
|
||||||
|
toast.error("Failed to upload intro video")
|
||||||
|
} finally {
|
||||||
|
setUploadingPracticeIntroVideo(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const openCreateQuestionDialog = (practiceId: number) => {
|
const openCreateQuestionDialog = (practiceId: number) => {
|
||||||
setQuestionSubmitAttempted(false)
|
setQuestionSubmitAttempted(false)
|
||||||
setQuestionFormTouched(false)
|
setQuestionFormTouched(false)
|
||||||
|
|
@ -1215,8 +1304,26 @@ export function HumanLanguagePage() {
|
||||||
) : (
|
) : (
|
||||||
modules.map((module) => (
|
modules.map((module) => (
|
||||||
<div key={module.id} className="rounded-xl border border-grayScale-100 bg-gradient-to-b from-grayScale-50/70 to-white p-3.5">
|
<div key={module.id} className="rounded-xl border border-grayScale-100 bg-gradient-to-b from-grayScale-50/70 to-white p-3.5">
|
||||||
|
{(() => {
|
||||||
|
const moduleCollapsed = collapsedModuleIds.includes(module.id)
|
||||||
|
return (
|
||||||
|
<>
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<p className="text-sm font-semibold text-grayScale-900">Module: {module.title}</p>
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleModuleCollapsed(module.id)}
|
||||||
|
className="flex min-w-0 flex-1 items-center gap-2 text-left"
|
||||||
|
>
|
||||||
|
{moduleCollapsed ? (
|
||||||
|
<ChevronRight className="h-4 w-4 shrink-0 text-grayScale-500" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="h-4 w-4 shrink-0 text-grayScale-500" />
|
||||||
|
)}
|
||||||
|
<p className="truncate text-sm font-semibold text-grayScale-900">Module: {module.title}</p>
|
||||||
|
<span className="rounded-md bg-brand-100 px-2 py-0.5 text-xs font-medium text-brand-700">
|
||||||
|
{module.sub_modules.length} sub-module(s)
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|
@ -1256,7 +1363,8 @@ export function HumanLanguagePage() {
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{module.sub_modules.map((subModule) => {
|
{!moduleCollapsed ? module.sub_modules.map((subModule) => {
|
||||||
|
const subModuleCollapsed = collapsedSubModuleIds.includes(subModule.id)
|
||||||
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)
|
||||||
|
|
@ -1282,9 +1390,20 @@ export function HumanLanguagePage() {
|
||||||
className="mt-2 overflow-hidden rounded-xl border border-grayScale-200/90 bg-white shadow-sm"
|
className="mt-2 overflow-hidden rounded-xl border border-grayScale-200/90 bg-white shadow-sm"
|
||||||
>
|
>
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-grayScale-100 bg-gradient-to-r from-grayScale-50/90 to-white px-3 py-2.5">
|
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-grayScale-100 bg-gradient-to-r from-grayScale-50/90 to-white px-3 py-2.5">
|
||||||
<p className="text-sm font-semibold text-grayScale-800">
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleSubModuleCollapsed(subModule.id)}
|
||||||
|
className="flex min-w-0 flex-1 items-center gap-2 text-left"
|
||||||
|
>
|
||||||
|
{subModuleCollapsed ? (
|
||||||
|
<ChevronRight className="h-4 w-4 shrink-0 text-grayScale-500" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="h-4 w-4 shrink-0 text-grayScale-500" />
|
||||||
|
)}
|
||||||
|
<p className="truncate text-sm font-semibold text-grayScale-800">
|
||||||
Sub-module: {subModule.title}
|
Sub-module: {subModule.title}
|
||||||
</p>
|
</p>
|
||||||
|
</button>
|
||||||
{categoryId ? (
|
{categoryId ? (
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<Link
|
<Link
|
||||||
|
|
@ -1317,7 +1436,8 @@ export function HumanLanguagePage() {
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
{!subModuleCollapsed ? (
|
||||||
|
<>
|
||||||
<div className="border-b border-grayScale-100 bg-white px-3">
|
<div className="border-b border-grayScale-100 bg-white px-3">
|
||||||
<div className="-mb-px flex items-center justify-between gap-4">
|
<div className="-mb-px flex items-center justify-between gap-4">
|
||||||
<div className="flex gap-6">
|
<div className="flex gap-6">
|
||||||
|
|
@ -1580,15 +1700,6 @@ export function HumanLanguagePage() {
|
||||||
{practiceFetch.questions.length} loaded
|
{practiceFetch.questions.length} loaded
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
{categoryId ? (
|
|
||||||
<Button type="button" variant="outline" size="sm" className="h-8 text-xs" asChild>
|
|
||||||
<Link
|
|
||||||
to={`/content/human-language/${categoryId}/${course.course_id}/sub-module/${subModule.id}/practices/${selectedPracticeMeta.id}/questions`}
|
|
||||||
>
|
|
||||||
Edit in full view
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1623,8 +1734,7 @@ export function HumanLanguagePage() {
|
||||||
No questions in this practice yet.
|
No questions in this practice yet.
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-xs text-grayScale-500">
|
<p className="mt-1 text-xs text-grayScale-500">
|
||||||
Add them via <span className="font-medium text-grayScale-700">Open editor</span>{" "}
|
Add them via <span className="font-medium text-grayScale-700">Open editor</span>.
|
||||||
or <span className="font-medium text-grayScale-700">Edit in full view</span>.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -1785,9 +1895,7 @@ export function HumanLanguagePage() {
|
||||||
practiceFetch.totalCount > practiceFetch.questions.length ? (
|
practiceFetch.totalCount > practiceFetch.questions.length ? (
|
||||||
<div className="mt-4 rounded-lg border border-grayScale-100 bg-white/80 px-3 py-2 text-center text-xs text-grayScale-600">
|
<div className="mt-4 rounded-lg border border-grayScale-100 bg-white/80 px-3 py-2 text-center text-xs text-grayScale-600">
|
||||||
Showing <span className="font-semibold">{practiceFetch.questions.length}</span> of{" "}
|
Showing <span className="font-semibold">{practiceFetch.questions.length}</span> of{" "}
|
||||||
<span className="font-semibold">{practiceFetch.totalCount}</span> questions. Open{" "}
|
<span className="font-semibold">{practiceFetch.totalCount}</span> questions.
|
||||||
<span className="font-medium text-grayScale-800">Edit in full view</span> for the
|
|
||||||
rest.
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1800,9 +1908,14 @@ export function HumanLanguagePage() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
}) : null}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|
@ -1848,17 +1961,23 @@ export function HumanLanguagePage() {
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DialogContent className="sm:max-w-lg">
|
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{practiceDialog.open && practiceDialog.mode === "edit" ? "Edit Practice" : "Create Practice"}</DialogTitle>
|
<DialogTitle>{practiceDialog.open && practiceDialog.mode === "edit" ? "Edit Practice" : "Create Practice"}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Manage practice metadata directly from this page.
|
Manage full practice (question set) metadata directly from this page.
|
||||||
{!practiceCanSave ? (
|
{!practiceCanSave ? (
|
||||||
<span className="mt-1 block text-amber-700/90">Required fields must be completed before you can save.</span>
|
<span className="mt-1 block text-amber-700/90">Required fields must be completed before you can save.</span>
|
||||||
) : null}
|
) : null}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="space-y-3">
|
{loadingPracticeForm ? (
|
||||||
|
<div className="flex items-center gap-2 rounded-lg border border-grayScale-200 bg-grayScale-50 px-3 py-3 text-sm text-grayScale-600">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
Loading practice details...
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<label className="text-xs font-medium text-grayScale-600">Title</label>
|
<label className="text-xs font-medium text-grayScale-600">Title</label>
|
||||||
<input
|
<input
|
||||||
|
|
@ -1906,12 +2025,89 @@ export function HumanLanguagePage() {
|
||||||
placeholder="Optional persona"
|
placeholder="Optional persona"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-xs font-medium text-grayScale-600">Intro video URL</label>
|
||||||
|
<Input
|
||||||
|
value={practiceForm.introVideoUrl}
|
||||||
|
onChange={(e) => {
|
||||||
|
setPracticeFormTouched(true)
|
||||||
|
setPracticeForm((p) => ({ ...p, introVideoUrl: e.target.value }))
|
||||||
|
}}
|
||||||
|
placeholder="https://..."
|
||||||
|
className="h-10 font-mono text-[13px]"
|
||||||
|
/>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<label className="inline-flex cursor-pointer items-center gap-2 rounded-md border border-grayScale-200 px-3 py-2 text-xs text-grayScale-700 hover:bg-grayScale-50">
|
||||||
|
{uploadingPracticeIntroVideo ? <Loader2 className="h-4 w-4 animate-spin" /> : <Video className="h-4 w-4" />}
|
||||||
|
{uploadingPracticeIntroVideo ? "Uploading..." : "Upload intro video"}
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="video/*"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => void handlePracticeIntroVideoFileChange(e)}
|
||||||
|
disabled={uploadingPracticeIntroVideo || savingPractice}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
{practiceForm.introVideoUrl.trim()
|
||||||
|
? renderMediaPreview(practiceForm.introVideoUrl, "video", "", "Intro video")
|
||||||
|
: null}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs font-medium text-grayScale-600">Passing score</label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
value={practiceForm.passingScore}
|
||||||
|
onChange={(e) => {
|
||||||
|
setPracticeFormTouched(true)
|
||||||
|
setPracticeForm((p) => ({ ...p, passingScore: Number(e.target.value) || 0 }))
|
||||||
|
}}
|
||||||
|
className="h-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs font-medium text-grayScale-600">Time limit (minutes)</label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
value={practiceForm.timeLimitMinutes}
|
||||||
|
onChange={(e) => {
|
||||||
|
setPracticeFormTouched(true)
|
||||||
|
setPracticeForm((p) => ({ ...p, timeLimitMinutes: Number(e.target.value) || 0 }))
|
||||||
|
}}
|
||||||
|
className="h-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between rounded-lg border border-grayScale-200 px-3 py-2.5">
|
||||||
|
<label className="text-sm font-medium text-grayScale-700">Shuffle questions</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setPracticeFormTouched(true)
|
||||||
|
setPracticeForm((p) => ({ ...p, shuffleQuestions: !p.shuffleQuestions }))
|
||||||
|
}}
|
||||||
|
className={`relative inline-flex h-6 w-11 rounded-full transition-colors ${
|
||||||
|
practiceForm.shuffleQuestions ? "bg-brand-500" : "bg-grayScale-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`inline-block h-5 w-5 transform rounded-full bg-white shadow transition ${
|
||||||
|
practiceForm.shuffleQuestions ? "translate-x-5" : "translate-x-0"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button type="button" variant="outline" onClick={() => setPracticeDialog({ open: false })}>
|
<Button type="button" variant="outline" onClick={() => setPracticeDialog({ open: false })}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="button" onClick={() => void handleSavePractice()} disabled={savingPractice || !practiceCanSave}>
|
<Button type="button" onClick={() => void handleSavePractice()} disabled={savingPractice || !practiceCanSave || loadingPracticeForm}>
|
||||||
{savingPractice ? "Saving..." : "Save"}
|
{savingPractice ? "Saving..." : "Save"}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user