Compare commits
10 Commits
d1842579e9
...
26e1b0a7d5
| Author | SHA1 | Date | |
|---|---|---|---|
| 26e1b0a7d5 | |||
| 189077e946 | |||
| d4b1bf0813 | |||
| 42bb74c15c | |||
| cb2a6843a3 | |||
| 06af3a97f2 | |||
| 0dc7aa81ba | |||
| 265d94999a | |||
| 3c864fe8ec | |||
| 79da9014de |
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,76 @@ 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 escapeHtml(raw: string): string {
|
||||||
|
return raw
|
||||||
|
.replaceAll("&", "&")
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">")
|
||||||
|
.replaceAll('"', """)
|
||||||
|
.replaceAll("'", "'")
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeAdminRichTextHtml(input: string): string {
|
||||||
|
if (!input.trim()) return ""
|
||||||
|
try {
|
||||||
|
const parser = new DOMParser()
|
||||||
|
const doc = parser.parseFromString(input, "text/html")
|
||||||
|
const blockedTags = new Set(["script", "style", "iframe", "object", "embed", "link", "meta"])
|
||||||
|
doc.body.querySelectorAll("*").forEach((el) => {
|
||||||
|
const tagName = el.tagName.toLowerCase()
|
||||||
|
if (blockedTags.has(tagName)) {
|
||||||
|
el.remove()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const attrs = [...el.attributes]
|
||||||
|
attrs.forEach((attr) => {
|
||||||
|
const name = attr.name.toLowerCase()
|
||||||
|
const value = attr.value.trim().toLowerCase()
|
||||||
|
if (name.startsWith("on")) {
|
||||||
|
el.removeAttribute(attr.name)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if ((name === "href" || name === "src") && value.startsWith("javascript:")) {
|
||||||
|
el.removeAttribute(attr.name)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
return doc.body.innerHTML
|
||||||
|
} catch {
|
||||||
|
return escapeHtml(input).replace(/\r?\n/g, "<br />")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDescriptionForPreview(raw: string): string {
|
||||||
|
if (!raw.trim()) return ""
|
||||||
|
const hasHtml = /<\/?[a-z][\s\S]*>/i.test(raw)
|
||||||
|
if (hasHtml) return sanitizeAdminRichTextHtml(raw)
|
||||||
|
return escapeHtml(raw).replace(/\r?\n/g, "<br />")
|
||||||
|
}
|
||||||
|
|
||||||
function createEmptyQuestion(id: string): Question {
|
function createEmptyQuestion(id: string): Question {
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
|
|
@ -120,6 +190,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 +246,49 @@ export function AddNewPracticePage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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: 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 descriptionPreviewHtml = useMemo(
|
||||||
|
() => formatDescriptionForPreview(practiceDescription),
|
||||||
|
[practiceDescription],
|
||||||
|
)
|
||||||
|
|
||||||
const addQuestion = () => {
|
const addQuestion = () => {
|
||||||
setQuestions([...questions, createEmptyQuestion(String(Date.now()))])
|
setQuestions([...questions, createEmptyQuestion(String(Date.now()))])
|
||||||
}
|
}
|
||||||
|
|
@ -375,6 +489,9 @@ export function AddNewPracticePage() {
|
||||||
className="min-h-[88px] w-full rounded-lg border border-grayScale-200 px-3 py-2.5 text-sm transition-colors focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-100"
|
className="min-h-[88px] w-full rounded-lg border border-grayScale-200 px-3 py-2.5 text-sm transition-colors focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-100"
|
||||||
rows={3}
|
rows={3}
|
||||||
/>
|
/>
|
||||||
|
<p className="text-xs text-grayScale-500">
|
||||||
|
Supports plain text and formatted HTML (for headings, lists, italics, and emphasis).
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|
@ -384,6 +501,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 +514,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 +532,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>
|
||||||
|
|
@ -683,9 +839,16 @@ export function AddNewPracticePage() {
|
||||||
<span className="text-sm text-grayScale-500">Title</span>
|
<span className="text-sm text-grayScale-500">Title</span>
|
||||||
<span className="text-sm font-medium text-grayScale-900">{practiceTitle || "Untitled Practice"}</span>
|
<span className="text-sm font-medium text-grayScale-900">{practiceTitle || "Untitled Practice"}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between bg-grayScale-50/50 px-6 py-3.5">
|
<div className="bg-grayScale-50/50 px-6 py-4">
|
||||||
<span className="text-sm text-grayScale-500">Description</span>
|
<span className="text-sm text-grayScale-500">Description</span>
|
||||||
<span className="max-w-[min(28rem,55%)] text-right text-sm leading-relaxed text-grayScale-700">{practiceDescription || "—"}</span>
|
{descriptionPreviewHtml ? (
|
||||||
|
<div
|
||||||
|
className="mt-2 rounded-lg border border-grayScale-200 bg-white px-4 py-3 text-sm leading-relaxed text-grayScale-800 [&_h1]:text-xl [&_h1]:font-semibold [&_h2]:text-lg [&_h2]:font-semibold [&_ol]:list-decimal [&_ol]:pl-6 [&_p]:mb-2 [&_strong]:font-semibold [&_ul]:list-disc [&_ul]:pl-6"
|
||||||
|
dangerouslySetInnerHTML={{ __html: descriptionPreviewHtml }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<p className="mt-2 text-sm text-grayScale-400">—</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between px-6 py-3.5">
|
<div className="flex justify-between px-6 py-3.5">
|
||||||
<span className="text-sm text-grayScale-500">Intro video URL</span>
|
<span className="text-sm text-grayScale-500">Intro video URL</span>
|
||||||
|
|
@ -693,6 +856,30 @@ export function AddNewPracticePage() {
|
||||||
{introVideoUrl.trim() || "—"}
|
{introVideoUrl.trim() || "—"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
{introVideoPreview ? (
|
||||||
|
<div className="bg-grayScale-50/50 px-6 py-4">
|
||||||
|
<span className="text-sm text-grayScale-500">Intro video preview</span>
|
||||||
|
<div className="mt-2 rounded-lg border border-grayScale-200 bg-white p-3">
|
||||||
|
{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>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<div className="flex justify-between bg-grayScale-50/50 px-6 py-3.5">
|
<div className="flex justify-between bg-grayScale-50/50 px-6 py-3.5">
|
||||||
<span className="text-sm text-grayScale-500">Passing Score</span>
|
<span className="text-sm text-grayScale-500">Passing Score</span>
|
||||||
<span className="text-sm font-medium text-grayScale-900">{passingScore}%</span>
|
<span className="text-sm font-medium text-grayScale-900">{passingScore}%</span>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useEffect, useMemo, useState } from "react"
|
import { useEffect, useMemo, useState, type ChangeEvent } from "react"
|
||||||
import { Link } from "react-router-dom"
|
import { Link, useNavigate } from "react-router-dom"
|
||||||
import {
|
import {
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
|
|
@ -35,14 +35,15 @@ import {
|
||||||
createCourse,
|
createCourse,
|
||||||
createCourseCategory,
|
createCourseCategory,
|
||||||
createHumanLanguageLesson,
|
createHumanLanguageLesson,
|
||||||
deletePractice,
|
deleteQuestionSet,
|
||||||
deleteQuestion,
|
deleteQuestion,
|
||||||
deleteSubCourse,
|
deleteSubCourse,
|
||||||
getHumanLanguageHierarchy,
|
getHumanLanguageHierarchy,
|
||||||
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,8 @@ 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 {
|
import {
|
||||||
createEmptyPracticeQuestionDraft,
|
createEmptyPracticeQuestionDraft,
|
||||||
PracticeQuestionEditorFields,
|
PracticeQuestionEditorFields,
|
||||||
|
|
@ -184,7 +187,130 @@ type PendingRemove = {
|
||||||
description: string
|
description: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function MediaPreviewCard({
|
||||||
|
urlRaw,
|
||||||
|
hint,
|
||||||
|
className = "mt-2",
|
||||||
|
label,
|
||||||
|
}: {
|
||||||
|
urlRaw: string
|
||||||
|
hint?: "audio" | "video" | "image"
|
||||||
|
className?: string
|
||||||
|
label?: string
|
||||||
|
}) {
|
||||||
|
const normalized = normalizeUrl(urlRaw)
|
||||||
|
const [resolvedUrl, setResolvedUrl] = useState(normalized)
|
||||||
|
const [resolving, setResolving] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
const run = async () => {
|
||||||
|
if (!normalized) {
|
||||||
|
setResolvedUrl("")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (/^https?:\/\//i.test(normalized)) {
|
||||||
|
setResolvedUrl(normalized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setResolving(true)
|
||||||
|
try {
|
||||||
|
const url = await resolveMediaPreviewUrl(normalized)
|
||||||
|
if (!cancelled) setResolvedUrl(url || normalized)
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) setResolvedUrl(normalized)
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setResolving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void run()
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [normalized])
|
||||||
|
|
||||||
|
if (!normalized) return null
|
||||||
|
const previewUrl = resolvedUrl || normalized
|
||||||
|
const mediaType = detectMediaType(previewUrl, hint)
|
||||||
|
const vimeoEmbed = getVimeoEmbedUrl(previewUrl)
|
||||||
|
const showPlayer = mediaType === "image" || mediaType === "video" || mediaType === "audio"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"rounded-lg border border-grayScale-100 bg-white p-3 shadow-sm",
|
||||||
|
!showPlayer && "border-dashed bg-grayScale-50/50",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{label ? (
|
||||||
|
<p className="mb-2 flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-wider text-grayScale-500">
|
||||||
|
{hint === "image" ? (
|
||||||
|
<ImageIcon className="h-3 w-3" aria-hidden />
|
||||||
|
) : hint === "audio" ? (
|
||||||
|
<Mic className="h-3 w-3" aria-hidden />
|
||||||
|
) : hint === "video" ? (
|
||||||
|
<Video className="h-3 w-3" aria-hidden />
|
||||||
|
) : (
|
||||||
|
<Link2 className="h-3 w-3" aria-hidden />
|
||||||
|
)}
|
||||||
|
{label}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{resolving ? (
|
||||||
|
<div className="flex items-center gap-2 rounded-md border border-grayScale-100 bg-grayScale-50 px-3 py-2 text-xs text-grayScale-500">
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
Resolving media URL...
|
||||||
|
</div>
|
||||||
|
) : mediaType === "image" ? (
|
||||||
|
<img
|
||||||
|
src={previewUrl}
|
||||||
|
alt=""
|
||||||
|
className="max-h-52 w-full rounded-md border border-grayScale-200/90 bg-grayScale-50 object-contain"
|
||||||
|
/>
|
||||||
|
) : mediaType === "video" ? (
|
||||||
|
vimeoEmbed ? (
|
||||||
|
<iframe
|
||||||
|
src={vimeoEmbed}
|
||||||
|
title="Vimeo preview"
|
||||||
|
className="aspect-video h-auto min-h-[200px] w-full rounded-md border border-grayScale-200/90 bg-black/5"
|
||||||
|
allow="autoplay; fullscreen; picture-in-picture"
|
||||||
|
allowFullScreen
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<video
|
||||||
|
controls
|
||||||
|
className="max-h-60 w-full rounded-md border border-grayScale-200/90 bg-black/5"
|
||||||
|
src={previewUrl}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
) : mediaType === "audio" ? (
|
||||||
|
<audio controls className="h-9 w-full" src={previewUrl} />
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-grayScale-500">Preview not available for this URL type.</p>
|
||||||
|
)}
|
||||||
|
<a
|
||||||
|
href={previewUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="mt-2 inline-flex items-center gap-1 text-xs font-medium text-brand-600 hover:text-brand-700 hover:underline"
|
||||||
|
>
|
||||||
|
<Link2 className="h-3 w-3 shrink-0" aria-hidden />
|
||||||
|
Open link
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextMissingPositive(values: number[]): number {
|
||||||
|
const existing = new Set(values.filter((n) => Number.isFinite(n) && n > 0))
|
||||||
|
let candidate = 1
|
||||||
|
while (existing.has(candidate)) candidate += 1
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
|
||||||
export function HumanLanguagePage() {
|
export function HumanLanguagePage() {
|
||||||
|
const navigate = useNavigate()
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [categoryId, setCategoryId] = useState<number | null>(null)
|
const [categoryId, setCategoryId] = useState<number | null>(null)
|
||||||
const [subCategories, setSubCategories] = useState<HumanLanguageSubCategoryTree[]>([])
|
const [subCategories, setSubCategories] = useState<HumanLanguageSubCategoryTree[]>([])
|
||||||
|
|
@ -192,6 +318,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("")
|
||||||
|
|
@ -208,7 +336,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)
|
||||||
|
|
@ -224,80 +360,15 @@ 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,
|
||||||
hint?: "audio" | "video" | "image",
|
hint?: "audio" | "video" | "image",
|
||||||
className = "mt-2",
|
className = "mt-2",
|
||||||
label?: string,
|
label?: string,
|
||||||
) => {
|
) => <MediaPreviewCard urlRaw={urlRaw} hint={hint} className={className} label={label} />
|
||||||
const url = normalizeUrl(urlRaw)
|
|
||||||
if (!url) return null
|
|
||||||
const mediaType = detectMediaType(url, hint)
|
|
||||||
const vimeoEmbed = getVimeoEmbedUrl(url)
|
|
||||||
const showPlayer =
|
|
||||||
mediaType === "image" || mediaType === "video" || mediaType === "audio"
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"rounded-lg border border-grayScale-100 bg-white p-3 shadow-sm",
|
|
||||||
!showPlayer && "border-dashed bg-grayScale-50/50",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{label ? (
|
|
||||||
<p className="mb-2 flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-wider text-grayScale-500">
|
|
||||||
{hint === "image" ? (
|
|
||||||
<ImageIcon className="h-3 w-3" aria-hidden />
|
|
||||||
) : hint === "audio" ? (
|
|
||||||
<Mic className="h-3 w-3" aria-hidden />
|
|
||||||
) : hint === "video" ? (
|
|
||||||
<Video className="h-3 w-3" aria-hidden />
|
|
||||||
) : (
|
|
||||||
<Link2 className="h-3 w-3" aria-hidden />
|
|
||||||
)}
|
|
||||||
{label}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
{mediaType === "image" ? (
|
|
||||||
<img
|
|
||||||
src={url}
|
|
||||||
alt=""
|
|
||||||
className="max-h-52 w-full rounded-md border border-grayScale-200/90 bg-grayScale-50 object-contain"
|
|
||||||
/>
|
|
||||||
) : mediaType === "video" ? (
|
|
||||||
vimeoEmbed ? (
|
|
||||||
<iframe
|
|
||||||
src={vimeoEmbed}
|
|
||||||
title="Vimeo preview"
|
|
||||||
className="aspect-video h-auto min-h-[200px] w-full rounded-md border border-grayScale-200/90 bg-black/5"
|
|
||||||
allow="autoplay; fullscreen; picture-in-picture"
|
|
||||||
allowFullScreen
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<video
|
|
||||||
controls
|
|
||||||
className="max-h-60 w-full rounded-md border border-grayScale-200/90 bg-black/5"
|
|
||||||
src={url}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
) : mediaType === "audio" ? (
|
|
||||||
<audio controls className="h-9 w-full" src={url} />
|
|
||||||
) : (
|
|
||||||
<p className="text-xs text-grayScale-500">Preview not available for this URL type.</p>
|
|
||||||
)}
|
|
||||||
<a
|
|
||||||
href={url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="mt-2 inline-flex items-center gap-1 text-xs font-medium text-brand-600 hover:text-brand-700 hover:underline"
|
|
||||||
>
|
|
||||||
<Link2 className="h-3 w-3 shrink-0" aria-hidden />
|
|
||||||
Open link
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const loadHierarchy = async () => {
|
const loadHierarchy = async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
|
|
@ -305,7 +376,23 @@ export function HumanLanguagePage() {
|
||||||
const res = await getHumanLanguageHierarchy()
|
const res = await getHumanLanguageHierarchy()
|
||||||
const data = res.data?.data
|
const data = res.data?.data
|
||||||
setCategoryId(data?.category_id ?? null)
|
setCategoryId(data?.category_id ?? null)
|
||||||
setSubCategories(data?.sub_categories ?? [])
|
const nextSubCategories = data?.sub_categories ?? []
|
||||||
|
setSubCategories(nextSubCategories)
|
||||||
|
// Default UI behavior: modules and sub-modules start collapsed.
|
||||||
|
const moduleIds = nextSubCategories.flatMap((subCategory) =>
|
||||||
|
subCategory.courses.flatMap((course) =>
|
||||||
|
course.levels.flatMap((levelNode) => levelNode.modules.map((module) => module.id)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const subModuleIds = nextSubCategories.flatMap((subCategory) =>
|
||||||
|
subCategory.courses.flatMap((course) =>
|
||||||
|
course.levels.flatMap((levelNode) =>
|
||||||
|
levelNode.modules.flatMap((module) => module.sub_modules.map((subModule) => subModule.id)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
setCollapsedModuleIds(moduleIds)
|
||||||
|
setCollapsedSubModuleIds(subModuleIds)
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
|
|
@ -378,6 +465,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())
|
||||||
|
|
||||||
|
|
@ -401,11 +500,10 @@ export function HumanLanguagePage() {
|
||||||
const key = `module-${courseId}-${level}`
|
const key = `module-${courseId}-${level}`
|
||||||
setCreatingKey(key)
|
setCreatingKey(key)
|
||||||
try {
|
try {
|
||||||
const maxExisting = modules
|
const usedNumbers = modules
|
||||||
.map((m) => parseModuleNumber(m.title))
|
.map((m) => parseModuleNumber(m.title))
|
||||||
.filter((v): v is number => v !== null)
|
.filter((v): v is number => v !== null && v > 0)
|
||||||
.reduce((acc, n) => Math.max(acc, n), 0)
|
const next = nextMissingPositive(usedNumbers)
|
||||||
const next = maxExisting + 1
|
|
||||||
const title = `Module-${next}`
|
const title = `Module-${next}`
|
||||||
await createHumanLanguageLesson({
|
await createHumanLanguageLesson({
|
||||||
course_id: courseId,
|
course_id: courseId,
|
||||||
|
|
@ -437,11 +535,11 @@ export function HumanLanguagePage() {
|
||||||
const key = `submodule-${courseId}-${level}-${moduleNo}`
|
const key = `submodule-${courseId}-${level}-${moduleNo}`
|
||||||
setCreatingKey(key)
|
setCreatingKey(key)
|
||||||
try {
|
try {
|
||||||
const maxExisting = existingSubModules
|
const usedNumbers = existingSubModules
|
||||||
.map((s) => parseSubModuleNumber(s.title))
|
.map((s) => parseSubModuleNumber(s.title))
|
||||||
.filter((v): v is { module: number; sub: number } => v !== null && v.module === moduleNo)
|
.filter((v): v is { module: number; sub: number } => v !== null && v.module === moduleNo)
|
||||||
.reduce((acc, item) => Math.max(acc, item.sub), 0)
|
.map((item) => item.sub)
|
||||||
const next = maxExisting + 1
|
const next = nextMissingPositive(usedNumbers)
|
||||||
const title = `Module-${moduleNo}.${next}`
|
const title = `Module-${moduleNo}.${next}`
|
||||||
await createHumanLanguageLesson({
|
await createHumanLanguageLesson({
|
||||||
course_id: courseId,
|
course_id: courseId,
|
||||||
|
|
@ -593,23 +691,59 @@ 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())
|
||||||
}
|
}
|
||||||
|
|
||||||
const openCreatePracticeDialog = (subModuleId: number) => {
|
const openCreatePracticeDialog = (courseId: number, subModuleId: number) => {
|
||||||
setPracticeSubmitAttempted(false)
|
if (!categoryId) {
|
||||||
setPracticeFormTouched(false)
|
toast.error("Category is not ready yet. Please try again.")
|
||||||
resetPracticeForm()
|
return
|
||||||
setPracticeDialog({ open: true, mode: "create", subModuleId })
|
}
|
||||||
|
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(() => {
|
||||||
|
|
@ -638,10 +772,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")
|
||||||
}
|
}
|
||||||
|
|
@ -658,6 +796,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)
|
||||||
|
|
@ -857,7 +1019,7 @@ export function HumanLanguagePage() {
|
||||||
if (!practiceTargetDelete) return
|
if (!practiceTargetDelete) return
|
||||||
setDeletingPractice(true)
|
setDeletingPractice(true)
|
||||||
try {
|
try {
|
||||||
await deletePractice(practiceTargetDelete.id)
|
await deleteQuestionSet(practiceTargetDelete.id)
|
||||||
toast.success("Practice deleted")
|
toast.success("Practice deleted")
|
||||||
setPracticeTargetDelete(null)
|
setPracticeTargetDelete(null)
|
||||||
await loadHierarchy()
|
await loadHierarchy()
|
||||||
|
|
@ -907,30 +1069,43 @@ export function HumanLanguagePage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="mx-auto w-full max-w-[1600px] space-y-6 pb-10">
|
||||||
<div className="rounded-2xl border border-grayScale-200 bg-gradient-to-r from-white to-brand-50/30 p-5 shadow-sm">
|
<div className="overflow-hidden rounded-3xl border border-brand-100/70 bg-gradient-to-r from-white via-brand-50/20 to-violet-50/40 p-6 shadow-sm sm:p-7">
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||||
<div className="rounded-xl bg-brand-100 p-2 text-brand-700">
|
<div className="flex items-start gap-3">
|
||||||
<Languages className="h-5 w-5" />
|
<div className="rounded-2xl bg-brand-100 p-2.5 text-brand-700 shadow-sm ring-1 ring-brand-200/70">
|
||||||
|
<Languages className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold tracking-tight text-grayScale-900">Human Language Content</h2>
|
||||||
|
<p className="mt-1.5 text-sm leading-relaxed text-grayScale-600">
|
||||||
|
Manage CEFR learning paths from A1 to C3 with quick lesson and practice oversight.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<h2 className="text-lg font-semibold text-grayScale-900">Human Language Content</h2>
|
<span className="rounded-full bg-white/90 px-3 py-1 text-xs font-semibold text-grayScale-700 ring-1 ring-grayScale-200">
|
||||||
<p className="mt-1 text-sm text-grayScale-500">
|
{selectedCourses.length} path{selectedCourses.length === 1 ? "" : "s"}
|
||||||
Dedicated management view for CEFR levels A1 to C3 with no sub-levels.
|
</span>
|
||||||
</p>
|
<span className="rounded-full bg-white/90 px-3 py-1 text-xs font-semibold text-grayScale-700 ring-1 ring-grayScale-200">
|
||||||
|
{subCategories.length} sub-categor{subCategories.length === 1 ? "y" : "ies"}
|
||||||
|
</span>
|
||||||
|
<span className="rounded-full bg-white/90 px-3 py-1 text-xs font-semibold text-grayScale-700 ring-1 ring-grayScale-200">
|
||||||
|
{visibleCefrLevels.length} level{visibleCefrLevels.length === 1 ? "" : "s"}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card className="border-grayScale-200/80 shadow-sm">
|
<Card className="sticky top-3 z-20 border-grayScale-200/80 bg-white/95 shadow-sm backdrop-blur supports-[backdrop-filter]:bg-white/80">
|
||||||
<CardHeader>
|
<CardHeader className="pb-3">
|
||||||
<CardTitle className="text-base">Filters</CardTitle>
|
<CardTitle className="text-base font-semibold text-grayScale-900">Filters</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="text-xs font-medium uppercase tracking-wide text-grayScale-500">Subcategory</label>
|
<label className="text-xs font-medium uppercase tracking-wide text-grayScale-500">Subcategory</label>
|
||||||
<select
|
<select
|
||||||
className="h-10 w-full rounded-md border border-grayScale-200 bg-white px-3 text-sm"
|
className="h-11 w-full rounded-xl border border-grayScale-200 bg-white px-3 text-sm shadow-sm transition focus:border-brand-400 focus:outline-none focus:ring-2 focus:ring-brand-100"
|
||||||
value={selectedSubCategoryId}
|
value={selectedSubCategoryId}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setSelectedSubCategoryId(e.target.value === "ALL" ? "ALL" : Number(e.target.value))
|
setSelectedSubCategoryId(e.target.value === "ALL" ? "ALL" : Number(e.target.value))
|
||||||
|
|
@ -947,7 +1122,7 @@ export function HumanLanguagePage() {
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="text-xs font-medium uppercase tracking-wide text-grayScale-500">Course</label>
|
<label className="text-xs font-medium uppercase tracking-wide text-grayScale-500">Course</label>
|
||||||
<select
|
<select
|
||||||
className="h-10 w-full rounded-md border border-grayScale-200 bg-white px-3 text-sm"
|
className="h-11 w-full rounded-xl border border-grayScale-200 bg-white px-3 text-sm shadow-sm transition focus:border-brand-400 focus:outline-none focus:ring-2 focus:ring-brand-100"
|
||||||
value={selectedCourseId}
|
value={selectedCourseId}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setSelectedCourseId(e.target.value === "ALL" ? "ALL" : Number(e.target.value))
|
setSelectedCourseId(e.target.value === "ALL" ? "ALL" : Number(e.target.value))
|
||||||
|
|
@ -964,7 +1139,7 @@ export function HumanLanguagePage() {
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="text-xs font-medium uppercase tracking-wide text-grayScale-500">Fetch lessons by level</label>
|
<label className="text-xs font-medium uppercase tracking-wide text-grayScale-500">Fetch lessons by level</label>
|
||||||
<select
|
<select
|
||||||
className="h-10 w-full rounded-md border border-grayScale-200 bg-white px-3 text-sm"
|
className="h-11 w-full rounded-xl border border-grayScale-200 bg-white px-3 text-sm shadow-sm transition focus:border-brand-400 focus:outline-none focus:ring-2 focus:ring-brand-100"
|
||||||
value={selectedLevel}
|
value={selectedLevel}
|
||||||
onChange={(e) => setSelectedLevel(e.target.value as CefrLevel | "ALL")}
|
onChange={(e) => setSelectedLevel(e.target.value as CefrLevel | "ALL")}
|
||||||
>
|
>
|
||||||
|
|
@ -985,7 +1160,7 @@ export function HumanLanguagePage() {
|
||||||
Loading human language lessons...
|
Loading human language lessons...
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-4">
|
||||||
{availableCourses.length === 0 ? (
|
{availableCourses.length === 0 ? (
|
||||||
<Card className="overflow-hidden border-grayScale-200/80">
|
<Card className="overflow-hidden border-grayScale-200/80">
|
||||||
<div className="flex items-center justify-between border-b border-grayScale-100 bg-white px-5 py-4">
|
<div className="flex items-center justify-between border-b border-grayScale-100 bg-white px-5 py-4">
|
||||||
|
|
@ -993,7 +1168,7 @@ export function HumanLanguagePage() {
|
||||||
<div className="relative w-full max-w-sm">
|
<div className="relative w-full max-w-sm">
|
||||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-grayScale-400" />
|
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-grayScale-400" />
|
||||||
<input
|
<input
|
||||||
className="h-11 w-full rounded-xl border border-grayScale-200 bg-white pl-9 pr-3 text-sm"
|
className="h-11 w-full rounded-xl border border-grayScale-200 bg-white pl-9 pr-3 text-sm shadow-sm transition focus:border-brand-400 focus:outline-none focus:ring-2 focus:ring-brand-100"
|
||||||
placeholder="Search sub-categories..."
|
placeholder="Search sub-categories..."
|
||||||
value={quickSearch}
|
value={quickSearch}
|
||||||
onChange={(e) => setQuickSearch(e.target.value)}
|
onChange={(e) => setQuickSearch(e.target.value)}
|
||||||
|
|
@ -1011,13 +1186,13 @@ export function HumanLanguagePage() {
|
||||||
</p>
|
</p>
|
||||||
<div className="mx-auto mt-5 grid max-w-3xl grid-cols-1 gap-2 md:grid-cols-3">
|
<div className="mx-auto mt-5 grid max-w-3xl grid-cols-1 gap-2 md:grid-cols-3">
|
||||||
<input
|
<input
|
||||||
className="h-10 rounded-md border border-grayScale-200 bg-white px-3 text-sm"
|
className="h-11 rounded-xl border border-grayScale-200 bg-white px-3 text-sm shadow-sm transition focus:border-brand-400 focus:outline-none focus:ring-2 focus:ring-brand-100"
|
||||||
placeholder="Subcategory (e.g., English)"
|
placeholder="Subcategory (e.g., English)"
|
||||||
value={quickSubCategoryName}
|
value={quickSubCategoryName}
|
||||||
onChange={(e) => setQuickSubCategoryName(e.target.value)}
|
onChange={(e) => setQuickSubCategoryName(e.target.value)}
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
className="h-10 rounded-md border border-grayScale-200 bg-white px-3 text-sm"
|
className="h-11 rounded-xl border border-grayScale-200 bg-white px-3 text-sm shadow-sm transition focus:border-brand-400 focus:outline-none focus:ring-2 focus:ring-brand-100"
|
||||||
placeholder="Course (e.g., Speaking)"
|
placeholder="Course (e.g., Speaking)"
|
||||||
value={quickCourseName}
|
value={quickCourseName}
|
||||||
onChange={(e) => setQuickCourseName(e.target.value)}
|
onChange={(e) => setQuickCourseName(e.target.value)}
|
||||||
|
|
@ -1046,8 +1221,8 @@ export function HumanLanguagePage() {
|
||||||
const pathLevelsFull = levelsDone.length >= CEFR_LEVELS.length
|
const pathLevelsFull = levelsDone.length >= CEFR_LEVELS.length
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card key={course.course_id} className="overflow-hidden border-grayScale-200/80 shadow-sm">
|
<Card key={course.course_id} className="overflow-hidden border-grayScale-200/80 shadow-sm transition-shadow hover:shadow-md">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-grayScale-100 bg-white px-4 py-3">
|
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-grayScale-100 bg-gradient-to-r from-white to-grayScale-50/40 px-4 py-3.5">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex min-w-0 flex-1 items-center gap-2 text-left"
|
className="flex min-w-0 flex-1 items-center gap-2 text-left"
|
||||||
|
|
@ -1059,6 +1234,9 @@ export function HumanLanguagePage() {
|
||||||
<ChevronDown className="h-5 w-5 shrink-0 text-grayScale-500" aria-hidden />
|
<ChevronDown className="h-5 w-5 shrink-0 text-grayScale-500" aria-hidden />
|
||||||
)}
|
)}
|
||||||
<span className="text-base font-semibold text-brand-700">{course.course_name}</span>
|
<span className="text-base font-semibold text-brand-700">{course.course_name}</span>
|
||||||
|
<span className="rounded-full bg-brand-50 px-2 py-0.5 text-[11px] font-semibold text-brand-700 ring-1 ring-brand-100">
|
||||||
|
{levelsDone.length}/{CEFR_LEVELS.length} levels
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -1080,7 +1258,7 @@ export function HumanLanguagePage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{!pathCollapsed ? (
|
{!pathCollapsed ? (
|
||||||
<CardContent className="space-y-3 p-4">
|
<CardContent className="space-y-3 p-4 sm:p-5">
|
||||||
{courseLevels.length === 0 ? (
|
{courseLevels.length === 0 ? (
|
||||||
<p className="text-sm text-grayScale-500">No levels match the current level filter.</p>
|
<p className="text-sm text-grayScale-500">No levels match the current level filter.</p>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -1091,8 +1269,8 @@ export function HumanLanguagePage() {
|
||||||
const levelRemoveIds = modules.flatMap((m) => m.sub_modules.map((s) => s.id))
|
const levelRemoveIds = modules.flatMap((m) => m.sub_modules.map((s) => s.id))
|
||||||
const canRemoveLevel = levelRemoveIds.length > 0
|
const canRemoveLevel = levelRemoveIds.length > 0
|
||||||
return (
|
return (
|
||||||
<div key={levelKey} className="overflow-hidden rounded-lg border border-grayScale-200/90">
|
<div key={levelKey} className="overflow-hidden rounded-xl border border-grayScale-200/90 bg-white shadow-sm">
|
||||||
<div className="flex w-full flex-wrap items-center justify-between gap-2 border-b border-grayScale-100 bg-grayScale-50/60 px-4 py-3">
|
<div className="flex w-full flex-wrap items-center justify-between gap-2 border-b border-grayScale-100 bg-gradient-to-r from-grayScale-50/80 to-white px-4 py-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex min-w-0 flex-1 items-center gap-2 text-left"
|
className="flex min-w-0 flex-1 items-center gap-2 text-left"
|
||||||
|
|
@ -1126,11 +1304,12 @@ export function HumanLanguagePage() {
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{!collapsedLevels.includes(levelKey) ? (
|
{!collapsedLevels.includes(levelKey) ? (
|
||||||
<div className="space-y-2 p-3">
|
<div className="space-y-2.5 p-3.5">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
className="h-8 border-grayScale-200 bg-white text-xs hover:border-brand-200 hover:bg-brand-50/40"
|
||||||
onClick={() => handleCreateModule(course.course_id, level, modules)}
|
onClick={() => handleCreateModule(course.course_id, level, modules)}
|
||||||
disabled={creatingKey === `module-${course.course_id}-${level}`}
|
disabled={creatingKey === `module-${course.course_id}-${level}`}
|
||||||
>
|
>
|
||||||
|
|
@ -1146,13 +1325,32 @@ export function HumanLanguagePage() {
|
||||||
<p className="text-xs text-grayScale-500">No modules yet. Use “Add Module” to start.</p>
|
<p className="text-xs text-grayScale-500">No modules yet. Use “Add Module” to start.</p>
|
||||||
) : (
|
) : (
|
||||||
modules.map((module) => (
|
modules.map((module) => (
|
||||||
<div key={module.id} className="rounded-lg border border-grayScale-100 bg-grayScale-50/60 p-3">
|
<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"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
className="h-8 border-grayScale-200 bg-white text-xs hover:border-brand-200 hover:bg-brand-50/40"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
handleCreateSubModule(course.course_id, level, module.title, module.sub_modules)
|
handleCreateSubModule(course.course_id, level, module.title, module.sub_modules)
|
||||||
}
|
}
|
||||||
|
|
@ -1169,7 +1367,7 @@ export function HumanLanguagePage() {
|
||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="h-8 gap-1 border-red-200/90 px-2.5 text-xs font-medium text-red-600 hover:bg-red-50"
|
className="h-8 gap-1 border-red-200/90 bg-white px-2.5 text-xs font-medium text-red-600 hover:bg-red-50"
|
||||||
disabled={deletingKey === `module-${module.id}`}
|
disabled={deletingKey === `module-${module.id}`}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
requestRemove({
|
requestRemove({
|
||||||
|
|
@ -1187,7 +1385,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)
|
||||||
|
|
@ -1210,18 +1409,29 @@ export function HumanLanguagePage() {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={subModule.id}
|
key={subModule.id}
|
||||||
className="mt-2 overflow-hidden rounded-lg 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-grayScale-50/90 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
|
||||||
Sub-module: {subModule.title}
|
type="button"
|
||||||
</p>
|
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}
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
{categoryId ? (
|
{categoryId ? (
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<Link
|
<Link
|
||||||
to={`/content/human-language/${categoryId}/${course.course_id}/sub-module/${subModule.id}`}
|
to={`/content/human-language/${categoryId}/${course.course_id}/sub-module/${subModule.id}`}
|
||||||
>
|
>
|
||||||
<Button type="button" variant="outline" size="sm" className="h-8 text-xs">
|
<Button type="button" variant="outline" size="sm" className="h-8 border-grayScale-200 bg-white text-xs hover:border-brand-200 hover:bg-brand-50/40">
|
||||||
Open editor
|
Open editor
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
@ -1229,7 +1439,7 @@ export function HumanLanguagePage() {
|
||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="h-8 gap-1 border-red-200/90 px-2.5 text-xs font-medium text-red-600 hover:bg-red-50"
|
className="h-8 gap-1 border-red-200/90 bg-white px-2.5 text-xs font-medium text-red-600 hover:bg-red-50"
|
||||||
disabled={deletingKey === `submodule-${subModule.id}`}
|
disabled={deletingKey === `submodule-${subModule.id}`}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
requestRemove({
|
requestRemove({
|
||||||
|
|
@ -1248,7 +1458,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">
|
||||||
|
|
@ -1290,8 +1501,8 @@ export function HumanLanguagePage() {
|
||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="h-7 px-2 text-[11px]"
|
className="h-8 border-grayScale-200 bg-white px-2 text-[11px] hover:border-brand-200 hover:bg-brand-50/40"
|
||||||
onClick={() => openCreatePracticeDialog(subModule.id)}
|
onClick={() => openCreatePracticeDialog(course.course_id, subModule.id)}
|
||||||
>
|
>
|
||||||
<Plus className="h-3.5 w-3.5" />
|
<Plus className="h-3.5 w-3.5" />
|
||||||
New practice
|
New practice
|
||||||
|
|
@ -1300,7 +1511,7 @@ export function HumanLanguagePage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-3">
|
<div className="p-3.5">
|
||||||
{panelTab === "lessons" ? (
|
{panelTab === "lessons" ? (
|
||||||
lessonRows.length === 0 ? (
|
lessonRows.length === 0 ? (
|
||||||
<div className="rounded-lg border border-dashed border-grayScale-200 bg-grayScale-50/40 px-4 py-8 text-center text-sm text-grayScale-500">
|
<div className="rounded-lg border border-dashed border-grayScale-200 bg-grayScale-50/40 px-4 py-8 text-center text-sm text-grayScale-500">
|
||||||
|
|
@ -1511,15 +1722,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>
|
||||||
|
|
@ -1554,8 +1756,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>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -1695,6 +1896,16 @@ export function HumanLanguagePage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
{q.audio_correct_answer_text ? (
|
||||||
|
<div className="rounded-lg border border-blue-100 bg-blue-50/40 px-3 py-2.5">
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-wide text-blue-700">
|
||||||
|
Sample answer text
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-sm leading-relaxed text-blue-900/90">
|
||||||
|
{q.audio_correct_answer_text}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
|
|
@ -1706,9 +1917,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>
|
||||||
|
|
@ -1721,9 +1930,14 @@ export function HumanLanguagePage() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
}) : null}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|
@ -1769,17 +1983,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
|
||||||
|
|
@ -1827,12 +2047,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>
|
||||||
|
{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>
|
</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