fix lesson intro video flow and polish review layout
Auto-process intro video URLs on blur with preview support, improve local upload reliability, and refine Step 1 + Review styling for the lesson creation wizard. Made-with: Cursor
This commit is contained in:
parent
558cf11abc
commit
ea73323fce
|
|
@ -1,4 +1,4 @@
|
||||||
import { useMemo, useRef, useState, type ChangeEvent } from "react"
|
import { useMemo, useState, type ChangeEvent } from "react"
|
||||||
import { ArrowLeft, ArrowRight, Check, GripVertical, Loader2, Plus, Rocket, Trash2, Upload } from "lucide-react"
|
import { ArrowLeft, ArrowRight, Check, GripVertical, Loader2, Plus, Rocket, Trash2, Upload } from "lucide-react"
|
||||||
import { Link, useLocation, useNavigate, useParams } from "react-router-dom"
|
import { Link, useLocation, useNavigate, useParams } from "react-router-dom"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
|
@ -103,7 +103,6 @@ export function AddNewLessonPage() {
|
||||||
const { categoryId, courseId, subModuleId } = useParams()
|
const { categoryId, courseId, subModuleId } = useParams()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const introVideoFileInputRef = useRef<HTMLInputElement>(null)
|
|
||||||
|
|
||||||
const backTo = useMemo(() => {
|
const backTo = useMemo(() => {
|
||||||
if (location.pathname.includes("/content/human-language/") && location.pathname.includes("/sub-module/")) {
|
if (location.pathname.includes("/content/human-language/") && location.pathname.includes("/sub-module/")) {
|
||||||
|
|
@ -121,7 +120,6 @@ export function AddNewLessonPage() {
|
||||||
const [lessonDescription, setLessonDescription] = useState("")
|
const [lessonDescription, setLessonDescription] = useState("")
|
||||||
const [introVideoUrl, setIntroVideoUrl] = useState("")
|
const [introVideoUrl, setIntroVideoUrl] = useState("")
|
||||||
const [uploadingIntroVideo, setUploadingIntroVideo] = useState(false)
|
const [uploadingIntroVideo, setUploadingIntroVideo] = useState(false)
|
||||||
const [importingIntroVideoUrl, setImportingIntroVideoUrl] = useState(false)
|
|
||||||
const [questions, setQuestions] = useState<Question[]>([createEmptyQuestion("1")])
|
const [questions, setQuestions] = useState<Question[]>([createEmptyQuestion("1")])
|
||||||
|
|
||||||
const handleNext = () => setCurrentStep((s) => (s < 3 ? ((s + 1) as Step) : s))
|
const handleNext = () => setCurrentStep((s) => (s < 3 ? ((s + 1) as Step) : s))
|
||||||
|
|
@ -149,7 +147,7 @@ export function AddNewLessonPage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleImportIntroVideoFromUrl = async () => {
|
const handleIntroVideoUrlBlur = async () => {
|
||||||
const source = introVideoUrl.trim()
|
const source = introVideoUrl.trim()
|
||||||
if (!source || !/^https?:\/\//i.test(source)) return
|
if (!source || !/^https?:\/\//i.test(source)) return
|
||||||
const vimeoEmbed = toVimeoEmbedUrl(source)
|
const vimeoEmbed = toVimeoEmbedUrl(source)
|
||||||
|
|
@ -157,8 +155,13 @@ export function AddNewLessonPage() {
|
||||||
setIntroVideoUrl(vimeoEmbed)
|
setIntroVideoUrl(vimeoEmbed)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (isDirectVideoFile(source)) {
|
||||||
|
setIntroVideoUrl(source)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
setImportingIntroVideoUrl(true)
|
// For non-direct URLs, automatically try server-side import via /files/upload.
|
||||||
|
setUploadingIntroVideo(true)
|
||||||
try {
|
try {
|
||||||
const uploadRes = await uploadVideoFile(source, {
|
const uploadRes = await uploadVideoFile(source, {
|
||||||
title: lessonTitle.trim() || "Lesson intro",
|
title: lessonTitle.trim() || "Lesson intro",
|
||||||
|
|
@ -167,12 +170,12 @@ export function AddNewLessonPage() {
|
||||||
const finalUrl = introVideoUrlFromUploadResponse(uploadRes.data?.data)
|
const finalUrl = introVideoUrlFromUploadResponse(uploadRes.data?.data)
|
||||||
if (!finalUrl) throw new Error("Missing uploaded video url")
|
if (!finalUrl) throw new Error("Missing uploaded video url")
|
||||||
setIntroVideoUrl(finalUrl)
|
setIntroVideoUrl(finalUrl)
|
||||||
toast.success("Intro video URL imported", { description: "Processed via /files/upload." })
|
toast.success("Intro video URL imported")
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to import intro video URL:", error)
|
console.error("Failed to import intro video URL:", error)
|
||||||
toast.error("Failed to import intro video URL")
|
toast.error("Failed to import intro video URL")
|
||||||
} finally {
|
} finally {
|
||||||
setImportingIntroVideoUrl(false)
|
setUploadingIntroVideo(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -307,7 +310,8 @@ export function AddNewLessonPage() {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-5 sm:p-8 lg:p-10">
|
<div className="p-5 sm:p-8 lg:p-10">
|
||||||
<div className="mt-5 space-y-4">
|
<div className="mt-5 grid gap-8 lg:grid-cols-12">
|
||||||
|
<div className="space-y-4 lg:col-span-7">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-grayScale-700">Lesson title</label>
|
<label className="text-sm font-medium text-grayScale-700">Lesson title</label>
|
||||||
<Input
|
<Input
|
||||||
|
|
@ -331,49 +335,25 @@ export function AddNewLessonPage() {
|
||||||
<Input
|
<Input
|
||||||
value={introVideoUrl}
|
value={introVideoUrl}
|
||||||
onChange={(e) => setIntroVideoUrl(e.target.value)}
|
onChange={(e) => setIntroVideoUrl(e.target.value)}
|
||||||
onBlur={() => void handleImportIntroVideoFromUrl()}
|
onBlur={() => void handleIntroVideoUrlBlur()}
|
||||||
placeholder="https://..."
|
placeholder="https://..."
|
||||||
type="url"
|
type="url"
|
||||||
inputMode="url"
|
inputMode="url"
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
className="font-mono text-[13px]"
|
className="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">
|
||||||
|
{uploadingIntroVideo ? <Loader2 className="h-4 w-4 animate-spin" /> : <Upload className="h-4 w-4" />}
|
||||||
|
{uploadingIntroVideo ? "Uploading..." : "Upload video from computer"}
|
||||||
<input
|
<input
|
||||||
ref={introVideoFileInputRef}
|
|
||||||
type="file"
|
type="file"
|
||||||
accept="video/*"
|
accept="video/*"
|
||||||
className="hidden"
|
className="hidden"
|
||||||
onChange={handleIntroVideoFileChange}
|
onChange={handleIntroVideoFileChange}
|
||||||
disabled={uploadingIntroVideo || importingIntroVideoUrl}
|
disabled={uploadingIntroVideo}
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
</label>
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => introVideoFileInputRef.current?.click()}
|
|
||||||
disabled={uploadingIntroVideo || importingIntroVideoUrl}
|
|
||||||
className="gap-1.5"
|
|
||||||
>
|
|
||||||
{uploadingIntroVideo ? <Loader2 className="h-4 w-4 animate-spin" /> : <Upload className="h-4 w-4" />}
|
|
||||||
{uploadingIntroVideo ? "Uploading..." : "Upload video from computer"}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => void handleImportIntroVideoFromUrl()}
|
|
||||||
disabled={uploadingIntroVideo || importingIntroVideoUrl || !introVideoUrl.trim()}
|
|
||||||
>
|
|
||||||
{importingIntroVideoUrl ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
|
|
||||||
Importing URL...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
"Import video from URL"
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
{introVideoUrl.trim() ? (
|
{introVideoUrl.trim() ? (
|
||||||
<Button type="button" variant="ghost" size="sm" onClick={() => setIntroVideoUrl("")}>
|
<Button type="button" variant="ghost" size="sm" onClick={() => setIntroVideoUrl("")}>
|
||||||
Clear URL
|
Clear URL
|
||||||
|
|
@ -404,6 +384,26 @@ export function AddNewLessonPage() {
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<aside className="space-y-4 lg:col-span-5">
|
||||||
|
<div className="rounded-xl border border-grayScale-200 bg-grayScale-50/40 p-5 shadow-sm">
|
||||||
|
<h3 className="text-xs font-semibold uppercase tracking-wider text-grayScale-500">Lesson schema mapping</h3>
|
||||||
|
<div className="mt-3 space-y-2 text-sm text-grayScale-700">
|
||||||
|
<p>
|
||||||
|
<span className="font-medium">question_sets.title</span> ← Lesson title
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<span className="font-medium">question_sets.description</span> ← Description
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<span className="font-medium">question_sets.set_type</span> = QUIZ
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<span className="font-medium">sub_module_lessons.intro_video_url</span> ← Intro URL
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col-reverse items-stretch justify-between gap-3 border-t border-grayScale-100 bg-grayScale-50/30 px-5 py-4 sm:flex-row sm:items-center sm:px-8 sm:py-5">
|
<div className="flex flex-col-reverse items-stretch justify-between gap-3 border-t border-grayScale-100 bg-grayScale-50/30 px-5 py-4 sm:flex-row sm:items-center sm:px-8 sm:py-5">
|
||||||
<Button variant="ghost" onClick={() => navigate(backTo)} className="sm:w-auto">
|
<Button variant="ghost" onClick={() => navigate(backTo)} className="sm:w-auto">
|
||||||
|
|
@ -487,11 +487,19 @@ export function AddNewLessonPage() {
|
||||||
<h2 className="text-lg font-semibold tracking-tight text-grayScale-900 sm:text-xl">Step 3: Review & publish</h2>
|
<h2 className="text-lg font-semibold tracking-tight text-grayScale-900 sm:text-xl">Step 3: Review & publish</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-5 sm:p-8">
|
<div className="p-5 sm:p-8">
|
||||||
<div className="mt-4 space-y-2 text-sm text-grayScale-700">
|
<div className="mt-4 grid gap-4 sm:grid-cols-2">
|
||||||
<p><span className="font-medium">Question set title:</span> {lessonTitle || "Untitled Lesson"}</p>
|
<div className="rounded-xl border border-grayScale-200 bg-white p-4">
|
||||||
<p><span className="font-medium">Question set description:</span> {lessonDescription || "—"}</p>
|
<p className="text-xs font-semibold uppercase tracking-wide text-grayScale-500">Question set</p>
|
||||||
<p><span className="font-medium">Sub-module lesson intro video:</span> {introVideoUrl || "—"}</p>
|
<p className="mt-2 text-sm"><span className="font-medium">Title:</span> {lessonTitle || "Untitled Lesson"}</p>
|
||||||
<p><span className="font-medium">Questions:</span> {questions.length}</p>
|
<p className="mt-1 text-sm"><span className="font-medium">Description:</span> {lessonDescription || "—"}</p>
|
||||||
|
<p className="mt-1 text-sm"><span className="font-medium">Status:</span> Draft/Published (selected on save)</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border border-grayScale-200 bg-white p-4">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wide text-grayScale-500">Lesson link</p>
|
||||||
|
<p className="mt-2 text-sm"><span className="font-medium">Sub-module:</span> {subModuleId ?? "—"}</p>
|
||||||
|
<p className="mt-1 text-sm"><span className="font-medium">Intro video:</span> {introVideoUrl || "—"}</p>
|
||||||
|
<p className="mt-1 text-sm"><span className="font-medium">Questions:</span> {questions.length}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between border-t border-grayScale-100 bg-grayScale-50/30 px-5 py-4 sm:px-8 sm:py-5">
|
<div className="flex items-center justify-between border-t border-grayScale-100 bg-grayScale-50/30 px-5 py-4 sm:px-8 sm:py-5">
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user