improve intro video upload and preview in practice creation
Import intro video URLs through /files/upload, keep Vimeo-friendly URL handling, and render inline video preview for uploaded/imported links in Step 1 context. Made-with: Cursor
This commit is contained in:
parent
3c864fe8ec
commit
265d94999a
|
|
@ -73,6 +73,29 @@ function introVideoUrlFromUploadResponse(data: { url?: string; embed_url?: strin
|
||||||
return pageUrl || null
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
function createEmptyQuestion(id: string): Question {
|
function createEmptyQuestion(id: string): Question {
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
|
|
@ -120,6 +143,7 @@ export function AddNewPracticePage() {
|
||||||
const [practiceDescription, setPracticeDescription] = useState("")
|
const [practiceDescription, setPracticeDescription] = 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 introVideoFileInputRef = useRef<HTMLInputElement>(null)
|
const introVideoFileInputRef = useRef<HTMLInputElement>(null)
|
||||||
const [shuffleQuestions, setShuffleQuestions] = useState(false)
|
const [shuffleQuestions, setShuffleQuestions] = useState(false)
|
||||||
const [passingScore, setPassingScore] = useState(50)
|
const [passingScore, setPassingScore] = useState(50)
|
||||||
|
|
@ -175,6 +199,37 @@ export function AddNewPracticePage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleImportIntroVideoFromUrl = async () => {
|
||||||
|
const source = introVideoUrl.trim()
|
||||||
|
if (!source || !/^https?:\/\//i.test(source)) return
|
||||||
|
|
||||||
|
setImportingIntroVideoUrl(true)
|
||||||
|
try {
|
||||||
|
const uploadRes = await uploadVideoFile(source, {
|
||||||
|
title: practiceTitle.trim() || "Practice intro",
|
||||||
|
description: practiceDescription.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 = () => {
|
const addQuestion = () => {
|
||||||
setQuestions([...questions, createEmptyQuestion(String(Date.now()))])
|
setQuestions([...questions, createEmptyQuestion(String(Date.now()))])
|
||||||
}
|
}
|
||||||
|
|
@ -384,6 +439,7 @@ export function AddNewPracticePage() {
|
||||||
<Input
|
<Input
|
||||||
value={introVideoUrl}
|
value={introVideoUrl}
|
||||||
onChange={(e) => setIntroVideoUrl(e.target.value)}
|
onChange={(e) => setIntroVideoUrl(e.target.value)}
|
||||||
|
onBlur={() => void handleImportIntroVideoFromUrl()}
|
||||||
placeholder="https://…"
|
placeholder="https://…"
|
||||||
type="url"
|
type="url"
|
||||||
inputMode="url"
|
inputMode="url"
|
||||||
|
|
@ -396,14 +452,14 @@ export function AddNewPracticePage() {
|
||||||
accept="video/*"
|
accept="video/*"
|
||||||
className="hidden"
|
className="hidden"
|
||||||
onChange={handleIntroVideoFileChange}
|
onChange={handleIntroVideoFileChange}
|
||||||
disabled={uploadingIntroVideo}
|
disabled={uploadingIntroVideo || importingIntroVideoUrl}
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={uploadingIntroVideo}
|
disabled={uploadingIntroVideo || importingIntroVideoUrl}
|
||||||
onClick={() => introVideoFileInputRef.current?.click()}
|
onClick={() => introVideoFileInputRef.current?.click()}
|
||||||
className="gap-1.5"
|
className="gap-1.5"
|
||||||
>
|
>
|
||||||
|
|
@ -414,12 +470,50 @@ export function AddNewPracticePage() {
|
||||||
)}
|
)}
|
||||||
{uploadingIntroVideo ? "Uploading…" : "Upload video from computer"}
|
{uploadingIntroVideo ? "Uploading…" : "Upload video from computer"}
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={uploadingIntroVideo || importingIntroVideoUrl || !introVideoUrl.trim()}
|
||||||
|
onClick={() => void handleImportIntroVideoFromUrl()}
|
||||||
|
>
|
||||||
|
{importingIntroVideoUrl ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
|
||||||
|
Importing URL…
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Import URL via /files/upload"
|
||||||
|
)}
|
||||||
|
</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
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</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">
|
<p className="text-xs leading-relaxed text-grayScale-500">
|
||||||
Paste a link or upload from your computer; uploads go through the file service (optional, not tied to sub-course video rows).
|
Paste a link or upload from your computer; uploads go through the file service (optional, not tied to sub-course video rows).
|
||||||
</p>
|
</p>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user