add files-upload URL import and intro video preview for lessons
Update the lesson wizard intro video section to support both file upload and general URL import via /files/upload, with Vimeo-safe normalization and inline video preview. Made-with: Cursor
This commit is contained in:
parent
5ddfed8d28
commit
0cc2e4ce4e
|
|
@ -90,6 +90,29 @@ function introVideoUrlFromUploadResponse(data: { url?: string; embed_url?: strin
|
|||
return pageUrl || null
|
||||
}
|
||||
|
||||
function toVimeoEmbedUrl(rawUrl: string): string | null {
|
||||
try {
|
||||
const parsed = new URL(rawUrl.trim())
|
||||
const host = parsed.hostname.toLowerCase()
|
||||
if (!host.includes("vimeo.com")) return null
|
||||
if (host.includes("player.vimeo.com") && parsed.pathname.includes("/video/")) return parsed.toString()
|
||||
const segments = parsed.pathname.split("/").filter(Boolean)
|
||||
const videoId = segments.find((segment) => /^\d+$/.test(segment))
|
||||
if (!videoId) return null
|
||||
const hash = parsed.searchParams.get("h")
|
||||
return hash
|
||||
? `https://player.vimeo.com/video/${videoId}?h=${encodeURIComponent(hash)}`
|
||||
: `https://player.vimeo.com/video/${videoId}`
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function isDirectVideoFile(url: string): boolean {
|
||||
const clean = url.split("?")[0].toLowerCase()
|
||||
return /\.(mp4|webm|ogg|mov|m4v)$/.test(clean)
|
||||
}
|
||||
|
||||
export function AddNewLessonPage() {
|
||||
const { categoryId, courseId, subModuleId } = useParams()
|
||||
const navigate = useNavigate()
|
||||
|
|
@ -112,6 +135,7 @@ export function AddNewLessonPage() {
|
|||
const [lessonDescription, setLessonDescription] = useState("")
|
||||
const [introVideoUrl, setIntroVideoUrl] = useState("")
|
||||
const [uploadingIntroVideo, setUploadingIntroVideo] = useState(false)
|
||||
const [importingIntroVideoUrl, setImportingIntroVideoUrl] = useState(false)
|
||||
const [shuffleQuestions, setShuffleQuestions] = useState(false)
|
||||
const [passingScore, setPassingScore] = useState(50)
|
||||
const [timeLimitMinutes, setTimeLimitMinutes] = useState(60)
|
||||
|
|
@ -143,6 +167,44 @@ export function AddNewLessonPage() {
|
|||
}
|
||||
}
|
||||
|
||||
const handleImportIntroVideoFromUrl = async () => {
|
||||
const source = introVideoUrl.trim()
|
||||
if (!source || !/^https?:\/\//i.test(source)) return
|
||||
const vimeoEmbed = toVimeoEmbedUrl(source)
|
||||
// Vimeo page URLs can be protected by anti-bot checks when server-side fetched.
|
||||
// For those links, prefer local normalization to player URL instead of failing import.
|
||||
if (vimeoEmbed) {
|
||||
setIntroVideoUrl(vimeoEmbed)
|
||||
return
|
||||
}
|
||||
|
||||
setImportingIntroVideoUrl(true)
|
||||
try {
|
||||
const uploadRes = await uploadVideoFile(source, {
|
||||
title: lessonTitle.trim() || "Lesson intro",
|
||||
description: lessonDescription.trim() || undefined,
|
||||
})
|
||||
const finalUrl = introVideoUrlFromUploadResponse(uploadRes.data?.data)
|
||||
if (!finalUrl) throw new Error("Missing uploaded video url")
|
||||
setIntroVideoUrl(finalUrl)
|
||||
toast.success("Intro video URL imported", { description: "Processed via /files/upload." })
|
||||
} catch (error) {
|
||||
console.error("Failed to import intro video URL:", error)
|
||||
toast.error("Failed to import intro video URL")
|
||||
} finally {
|
||||
setImportingIntroVideoUrl(false)
|
||||
}
|
||||
}
|
||||
|
||||
const introVideoPreview = useMemo(() => {
|
||||
const raw = introVideoUrl.trim()
|
||||
if (!raw) return null
|
||||
const vimeoEmbedUrl = toVimeoEmbedUrl(raw)
|
||||
if (vimeoEmbedUrl) return { kind: "vimeo" as const, url: vimeoEmbedUrl }
|
||||
if (isDirectVideoFile(raw)) return { kind: "video" as const, url: raw }
|
||||
return null
|
||||
}, [introVideoUrl])
|
||||
|
||||
const addQuestion = () => setQuestions((prev) => [...prev, createEmptyQuestion(String(Date.now()))])
|
||||
const removeQuestion = (id: string) => setQuestions((prev) => (prev.length > 1 ? prev.filter((q) => q.id !== id) : prev))
|
||||
const updateQuestion = (id: string, updates: Partial<Question>) =>
|
||||
|
|
@ -279,25 +341,83 @@ export function AddNewLessonPage() {
|
|||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-grayScale-700">Intro video URL (optional)</label>
|
||||
<Input value={introVideoUrl} onChange={(e) => setIntroVideoUrl(e.target.value)} placeholder="https://..." />
|
||||
<Input
|
||||
value={introVideoUrl}
|
||||
onChange={(e) => setIntroVideoUrl(e.target.value)}
|
||||
onBlur={() => void handleImportIntroVideoFromUrl()}
|
||||
placeholder="https://..."
|
||||
type="url"
|
||||
inputMode="url"
|
||||
autoComplete="off"
|
||||
className="font-mono text-[13px]"
|
||||
/>
|
||||
<input
|
||||
ref={introVideoFileInputRef}
|
||||
type="file"
|
||||
accept="video/*"
|
||||
className="hidden"
|
||||
onChange={handleIntroVideoFileChange}
|
||||
disabled={uploadingIntroVideo}
|
||||
disabled={uploadingIntroVideo || importingIntroVideoUrl}
|
||||
/>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => introVideoFileInputRef.current?.click()}
|
||||
disabled={uploadingIntroVideo}
|
||||
disabled={uploadingIntroVideo || importingIntroVideoUrl}
|
||||
className="gap-1.5"
|
||||
>
|
||||
{uploadingIntroVideo ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Upload className="mr-2 h-4 w-4" />}
|
||||
{uploadingIntroVideo ? "Uploading..." : "Upload intro video"}
|
||||
{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 URL via /files/upload"
|
||||
)}
|
||||
</Button>
|
||||
{introVideoUrl.trim() ? (
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => setIntroVideoUrl("")}>
|
||||
Clear URL
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
{introVideoPreview ? (
|
||||
<div className="rounded-xl border border-grayScale-200 bg-grayScale-50/40 p-3">
|
||||
<p className="mb-2 text-xs font-medium text-grayScale-500">Preview</p>
|
||||
{introVideoPreview.kind === "vimeo" ? (
|
||||
<div className="overflow-hidden rounded-lg border border-grayScale-200 bg-black">
|
||||
<iframe
|
||||
src={introVideoPreview.url}
|
||||
title="Intro video preview"
|
||||
className="aspect-video w-full"
|
||||
allow="autoplay; fullscreen; picture-in-picture"
|
||||
allowFullScreen
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<video
|
||||
controls
|
||||
src={introVideoPreview.url}
|
||||
className="aspect-video w-full rounded-lg border border-grayScale-200 bg-black"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
<p className="text-xs leading-relaxed text-grayScale-500">
|
||||
File uploads and URL imports both go through `/files/upload`.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-4 rounded-xl border border-grayScale-200 bg-grayScale-50/40 p-4">
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user