From 45c385e5faf6e2ce6ffcbf3abe8b019a3e3bb35e Mon Sep 17 00:00:00 2001 From: Yared Yemane Date: Tue, 7 Apr 2026 09:01:59 -0700 Subject: [PATCH] Add Human Language sub-module page with Lesson/Practice tabs. Dedicated routes under /content/human-language/.../sub-module/:id for lesson videos and practice cards (aligned with existing sub-course styling); confirm removals via Dialog on hierarchy page; wire add-practice and questions back navigation for HL paths. Made-with: Cursor --- src/app/AppRoutes.tsx | 13 + .../content-management/AddNewPracticePage.tsx | 5 +- .../content-management/HumanLanguagePage.tsx | 119 +- .../HumanLanguageSubModulePage.tsx | 1076 +++++++++++++++++ .../PracticeQuestionsPage.tsx | 10 +- 5 files changed, 1178 insertions(+), 45 deletions(-) create mode 100644 src/pages/content-management/HumanLanguageSubModulePage.tsx diff --git a/src/app/AppRoutes.tsx b/src/app/AppRoutes.tsx index 830bf51..093dde9 100644 --- a/src/app/AppRoutes.tsx +++ b/src/app/AppRoutes.tsx @@ -32,6 +32,7 @@ import { PracticeMembersPage } from "../pages/content-management/PracticeMembers import { QuestionsPage } from "../pages/content-management/QuestionsPage" import { AddQuestionPage } from "../pages/content-management/AddQuestionPage" import { HumanLanguagePage } from "../pages/content-management/HumanLanguagePage" +import { HumanLanguageSubModulePage } from "../pages/content-management/HumanLanguageSubModulePage" import { UserLogPage } from "../pages/user-log/UserLogPage" import { IssuesPage } from "../pages/issues/IssuesPage" import { ProfilePage } from "../pages/ProfilePage" @@ -78,6 +79,18 @@ export function AppRoutes() { } /> } /> } /> + } + /> + } + /> + } + /> } /> } /> {/* Course → Sub-course → Video/Practice */} diff --git a/src/pages/content-management/AddNewPracticePage.tsx b/src/pages/content-management/AddNewPracticePage.tsx index c8e6306..b7605b8 100644 --- a/src/pages/content-management/AddNewPracticePage.tsx +++ b/src/pages/content-management/AddNewPracticePage.tsx @@ -100,9 +100,12 @@ export function AddNewPracticePage() { const searchParams = new URLSearchParams(location.search) const source = searchParams.get("source") const backTo = useMemo(() => { + if (location.pathname.includes("/content/human-language/") && location.pathname.includes("/sub-module/")) { + return `/content/human-language/${categoryId}/${courseId}/sub-module/${subCourseId}` + } if (source === "human-language") return "/content/human-language" return `/content/category/${categoryId}/courses/${courseId}/sub-courses/${subCourseId}` - }, [source, categoryId, courseId, subCourseId]) + }, [location.pathname, source, categoryId, courseId, subCourseId]) const [currentStep, setCurrentStep] = useState(1) const [saving, setSaving] = useState(false) diff --git a/src/pages/content-management/HumanLanguagePage.tsx b/src/pages/content-management/HumanLanguagePage.tsx index cb3267b..a8f0f78 100644 --- a/src/pages/content-management/HumanLanguagePage.tsx +++ b/src/pages/content-management/HumanLanguagePage.tsx @@ -1,8 +1,16 @@ import { useEffect, useMemo, useState } from "react" import { Link } from "react-router-dom" -import { BookOpen, ChevronDown, ChevronRight, Languages, Loader2, Plus, Search, Trash2 } from "lucide-react" +import { ChevronDown, ChevronRight, Languages, Loader2, Plus, Search, Trash2 } from "lucide-react" import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card" import { Button } from "../../components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "../../components/ui/dialog" import { SpinnerIcon } from "../../components/ui/spinner-icon" import { createCourse, createCourseCategory, createHumanLanguageLesson, deleteSubCourse, getHumanLanguageHierarchy } from "../../api/courses.api" import type { HumanLanguageCourseTree, HumanLanguageSubCategoryTree } from "../../types/course.types" @@ -11,6 +19,14 @@ import { toast } from "sonner" const CEFR_LEVELS = ["A1", "A2", "A3", "B1", "B2", "B3", "C1", "C2", "C3"] as const type CefrLevel = (typeof CEFR_LEVELS)[number] +type PendingRemove = { + ids: number[] + key: string + successMessage: string + title: string + description: string +} + export function HumanLanguagePage() { const [loading, setLoading] = useState(false) const [categoryId, setCategoryId] = useState(null) @@ -27,6 +43,7 @@ export function HumanLanguagePage() { const [deletingKey, setDeletingKey] = useState(null) /** Course IDs whose path body is collapsed (headers stay visible). */ const [collapsedPathIds, setCollapsedPathIds] = useState([]) + const [pendingRemove, setPendingRemove] = useState(null) const loadHierarchy = async () => { setLoading(true) @@ -188,10 +205,15 @@ export function HumanLanguagePage() { } } - const handleDeleteSubModules = async (ids: number[], key: string, successMessage: string) => { - if (ids.length === 0) return - const proceed = window.confirm("This action will permanently delete selected item(s). Continue?") - if (!proceed) return + const requestRemove = (payload: PendingRemove) => { + if (payload.ids.length === 0) return + setPendingRemove(payload) + } + + const executePendingRemove = async () => { + if (!pendingRemove) return + const { ids, key, successMessage } = pendingRemove + setPendingRemove(null) setDeletingKey(key) try { for (const id of ids) { @@ -427,13 +449,6 @@ export function HumanLanguagePage() { {course.course_name}
- {categoryId ? ( - - - - ) : null} - - - +
+ +
) : null}
-
- {subModule.videos.map((video) => ( -
- - {video.title} -
- ))} - {subModule.practices.map((practice) => ( -
- Practice: {practice.title} ({practice.question_count} audio question(s)) -
- ))} -
+

+ {subModule.videos.length} lesson video(s) · {subModule.practices.length} practice(s) +

))} @@ -615,6 +633,23 @@ export function HumanLanguagePage() { : null} )} + + !open && setPendingRemove(null)}> + + + {pendingRemove?.title ?? "Confirm removal"} + {pendingRemove?.description} + + + + + + + ) } diff --git a/src/pages/content-management/HumanLanguageSubModulePage.tsx b/src/pages/content-management/HumanLanguageSubModulePage.tsx new file mode 100644 index 0000000..43b21b8 --- /dev/null +++ b/src/pages/content-management/HumanLanguageSubModulePage.tsx @@ -0,0 +1,1076 @@ +import { useEffect, useState } from "react" +import { Link, useParams, useNavigate } from "react-router-dom" +import { ArrowLeft, Plus, FileText, Layers, Edit, Trash2, X, Video, MoreVertical, Play } from "lucide-react" +import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg" +import { Card } from "../../components/ui/card" +import alertSrc from "../../assets/Alert.svg" +import { Badge } from "../../components/ui/badge" +import { Button } from "../../components/ui/button" +import { Input } from "../../components/ui/input" +import { + getSubCoursesByCourse, + getQuestionSetsByOwner, + getVideosBySubCourse, + updatePractice, + deleteQuestionSet, + createCourseVideo, + updateSubCourseVideo, + deleteSubCourseVideo, + getVimeoSample, +} from "../../api/courses.api" +import { uploadVideoFile } from "../../api/files.api" +import type { + SubCourse, + QuestionSet, + SubCourseVideo, + VimeoSampleVideo, + VideoStatus, + VideoVisibility, +} from "../../types/course.types" +import { SpinnerIcon } from "../../components/ui/spinner-icon" + +type TabType = "lesson" | "practice" +type StatusFilter = "all" | "published" | "draft" | "archived" + +/** Human Language–only sub-module editor: lesson (videos) + practice tabs; not used by general course flows. */ +export function HumanLanguageSubModulePage() { + const { categoryId, courseId, subCourseId } = useParams<{ + categoryId: string + courseId: string + subCourseId: string + }>() + const navigate = useNavigate() + + const [subCourse, setSubCourse] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + const [activeTab, setActiveTab] = useState("lesson") + const [statusFilter] = useState("all") + + const [practices, setPractices] = useState([]) + const [videos, setVideos] = useState([]) + const [practicesLoading, setPracticesLoading] = useState(false) + const [videosLoading, setVideosLoading] = useState(false) + + const [showEditPracticeModal, setShowEditPracticeModal] = useState(false) + const [practiceToEdit, setPracticeToEdit] = useState(null) + const [showDeleteModal, setShowDeleteModal] = useState(false) + const [practiceToDelete, setPracticeToDelete] = useState(null) + const [deleting, setDeleting] = useState(false) + + const [title, setTitle] = useState("") + const [description, setDescription] = useState("") + const [persona, setPersona] = useState("") + const [saving, setSaving] = useState(false) + const [saveError, setSaveError] = useState(null) + + const [showAddVideoModal, setShowAddVideoModal] = useState(false) + const [showEditVideoModal, setShowEditVideoModal] = useState(false) + const [videoToEdit, setVideoToEdit] = useState(null) + const [showDeleteVideoModal, setShowDeleteVideoModal] = useState(false) + const [videoToDelete, setVideoToDelete] = useState(null) + const [deletingVideo, setDeletingVideo] = useState(false) + const [openVideoMenuId, setOpenVideoMenuId] = useState(null) + + const [videoTitle, setVideoTitle] = useState("") + const [videoDescription, setVideoDescription] = useState("") + const [videoUrl, setVideoUrl] = useState("") + const [videoFile, setVideoFile] = useState(null) + const [videoFileSize, setVideoFileSize] = useState(0) + const [videoDuration, setVideoDuration] = useState(0) + const [videoResolution, setVideoResolution] = useState("1080p") + const [videoVisibility, setVideoVisibility] = useState("PUBLISHED") + const [videoStatus, setVideoStatus] = useState("PUBLISHED") + const [videoDisplayOrder, setVideoDisplayOrder] = useState(1) + + // Vimeo preview state + const [showPreviewModal, setShowPreviewModal] = useState(false) + const [previewIframe, setPreviewIframe] = useState("") + const [previewVideo, setPreviewVideo] = useState(null) + const [previewLoading, setPreviewLoading] = useState(false) + + useEffect(() => { + const fetchData = async () => { + if (!subCourseId || !courseId) return + + try { + const subCoursesRes = await getSubCoursesByCourse(Number(courseId)) + const foundSubCourse = subCoursesRes.data.data.sub_courses?.find( + (sc) => sc.id === Number(subCourseId) + ) + setSubCourse(foundSubCourse ?? null) + } catch (err) { + console.error("Failed to fetch course data:", err) + setError("Failed to load course") + } finally { + setLoading(false) + } + } + + fetchData() + }, [subCourseId, courseId]) + + const fetchPractices = async () => { + if (!subCourseId) return + setPracticesLoading(true) + try { + const res = await getQuestionSetsByOwner("SUB_COURSE", Number(subCourseId)) + const raw = res.data.data + const list = Array.isArray(raw) ? raw : raw?.question_sets ?? [] + setPractices(list) + } catch (err) { + console.error("Failed to fetch practices:", err) + } finally { + setPracticesLoading(false) + } + } + + const fetchVideos = async () => { + if (!subCourseId) return + setVideosLoading(true) + try { + const res = await getVideosBySubCourse(Number(subCourseId)) + setVideos(res.data.data.videos ?? []) + } catch (err) { + console.error("Failed to fetch videos:", err) + } finally { + setVideosLoading(false) + } + } + + useEffect(() => { + if (activeTab === "practice") { + fetchPractices() + } else if (activeTab === "lesson") { + fetchVideos() + } + }, [activeTab, subCourseId]) + + const handleAddPractice = () => { + navigate(`/content/human-language/${categoryId}/${courseId}/sub-module/${subCourseId}/add-practice`) + } + + + + const handleEditClick = (practice: QuestionSet) => { + setPracticeToEdit(practice) + setTitle(practice.title) + setDescription(practice.description) + setPersona(practice.persona || "") + setSaveError(null) + setShowEditPracticeModal(true) + } + + const handleSaveEditPractice = async () => { + if (!practiceToEdit) return + setSaving(true) + setSaveError(null) + try { + await updatePractice(practiceToEdit.id, { + title, + description, + persona, + }) + setShowEditPracticeModal(false) + setPracticeToEdit(null) + setTitle("") + setDescription("") + setPersona("") + await fetchPractices() + } catch (err) { + console.error("Failed to update practice:", err) + setSaveError("Failed to update practice") + } finally { + setSaving(false) + } + } + + const handleDeleteClick = (practice: QuestionSet) => { + setPracticeToDelete(practice) + setShowDeleteModal(true) + } + + const handleConfirmDelete = async () => { + if (!practiceToDelete) return + setDeleting(true) + try { + await deleteQuestionSet(practiceToDelete.id) + setShowDeleteModal(false) + setPracticeToDelete(null) + await fetchPractices() + } catch (err) { + console.error("Failed to delete practice:", err) + } finally { + setDeleting(false) + } + } + + const handlePracticeClick = (practiceId: number) => { + navigate(`/content/human-language/${categoryId}/${courseId}/sub-module/${subCourseId}/practices/${practiceId}/questions`) + } + + const handleAddVideo = () => { + setVideoTitle("") + setVideoDescription("") + setVideoUrl("") + setVideoFile(null) + setVideoFileSize(0) + setVideoDuration(0) + setVideoResolution("1080p") + setVideoVisibility("PUBLISHED") + setVideoStatus("PUBLISHED") + setVideoDisplayOrder(1) + setSaveError(null) + setShowAddVideoModal(true) + } + + const handleVideoFileSelect = (file: File | null) => { + setVideoFile(file) + if (!file) { + setVideoFileSize(0) + setVideoDuration(0) + return + } + + setVideoFileSize(file.size) + const video = document.createElement("video") + const objectUrl = URL.createObjectURL(file) + video.preload = "metadata" + video.src = objectUrl + video.onloadedmetadata = () => { + setVideoDuration(Math.max(0, Math.round(video.duration || 0))) + URL.revokeObjectURL(objectUrl) + } + video.onerror = () => { + URL.revokeObjectURL(objectUrl) + } + } + + const handleSaveNewVideo = async () => { + if (!subCourseId || !videoFile) return + setSaving(true) + setSaveError(null) + try { + const uploadRes = await uploadVideoFile(videoFile, { + title: videoTitle.trim(), + description: videoDescription.trim(), + }) + + // Per backend guide, use embed_url as the video_url reference. + const embedUrl = uploadRes.data?.data?.embed_url?.trim() + const vimeoUrl = uploadRes.data?.data?.url?.trim() + if (!embedUrl) throw new Error("Missing uploaded video embed_url") + + // Backend requires: https://player.vimeo.com/video/?h= + // where is the last path segment from `url` (e.g. https://vimeo.com//) + const hashFromUrl = vimeoUrl ? vimeoUrl.split("/").filter(Boolean).at(-1) : undefined + const finalVideoUrl = hashFromUrl ? `${embedUrl}?h=${hashFromUrl}` : embedUrl + + const finalTitle = videoTitle.trim() || videoFile.name + + await createCourseVideo({ + sub_course_id: Number(subCourseId), + title: finalTitle, + description: videoDescription.trim(), + video_url: finalVideoUrl, + duration: videoDuration, + resolution: videoResolution.trim() || undefined, + visibility: videoVisibility, + display_order: Number.isFinite(videoDisplayOrder) ? videoDisplayOrder : undefined, + status: videoStatus, + }) + setShowAddVideoModal(false) + setVideoTitle("") + setVideoDescription("") + setVideoUrl("") + setVideoFile(null) + setVideoFileSize(0) + setVideoDuration(0) + setVideoResolution("1080p") + setVideoVisibility("PUBLISHED") + setVideoStatus("PUBLISHED") + setVideoDisplayOrder(1) + await fetchVideos() + } catch (err) { + console.error("Failed to create video:", err) + setSaveError("Failed to create video") + } finally { + setSaving(false) + } + } + + const handleEditVideoClick = (video: SubCourseVideo) => { + setVideoToEdit(video) + setVideoTitle(video.title) + setVideoDescription(video.description || "") + setVideoUrl(video.video_url || "") + setSaveError(null) + setShowEditVideoModal(true) + } + + const handleSaveEditVideo = async () => { + if (!videoToEdit) return + setSaving(true) + setSaveError(null) + try { + await updateSubCourseVideo(videoToEdit.id, { + title: videoTitle, + description: videoDescription, + video_url: videoUrl, + }) + setShowEditVideoModal(false) + setVideoToEdit(null) + setVideoTitle("") + setVideoDescription("") + setVideoUrl("") + await fetchVideos() + } catch (err) { + console.error("Failed to update video:", err) + setSaveError("Failed to update video") + } finally { + setSaving(false) + } + } + + const handleDeleteVideoClick = (video: SubCourseVideo) => { + setVideoToDelete(video) + setShowDeleteVideoModal(true) + } + + const handleConfirmDeleteVideo = async () => { + if (!videoToDelete) return + setDeletingVideo(true) + try { + await deleteSubCourseVideo(videoToDelete.id) + setShowDeleteVideoModal(false) + setVideoToDelete(null) + await fetchVideos() + } catch (err) { + console.error("Failed to delete video:", err) + } finally { + setDeletingVideo(false) + } + } + + // Preview a video card. + // We prefer embedding directly from `video_url` because Vimeo embeds may require the `h=` hash. + const handlePreviewVideo = async (video: SubCourseVideo) => { + setShowPreviewModal(true) + setPreviewLoading(true) + setPreviewIframe("") + setPreviewVideo(null) + try { + const directUrl = video.video_url?.trim() + if (directUrl) { + setPreviewIframe( + ``, + ) + setPreviewVideo(null) + return + } + + // Fallback to sample API when a direct URL is unavailable. + const idMatch = video.video_url?.match(/(\d{5,})/) + const vimeoId = idMatch?.[1] ?? "76979871" // fallback to Big Buck Bunny + const res = await getVimeoSample(vimeoId) + setPreviewIframe(res.data.data.iframe) + setPreviewVideo(res.data.data.video) + } catch { + setPreviewIframe("") + } finally { + setPreviewLoading(false) + } + } + + const filteredPractices = practices.filter((practice) => { + if (statusFilter === "all") return true + if (statusFilter === "published") return practice.status === "PUBLISHED" + if (statusFilter === "draft") return practice.status === "DRAFT" + if (statusFilter === "archived") return practice.status === "ARCHIVED" + return true + }) + + if (loading) { + return ( +
+ +

Loading course…

+
+ ) + } + + if (error) { + return ( +
+ +

{error}

+
+ ) + } + + return ( +
+ {/* Back Button */} + + + Back to Human Language + + + {/* SubCourse Header */} +
+
+
+

+ {subCourse?.title} +

+ {subCourse?.level && ( + {subCourse.level} + )} +
+

+ {subCourse?.description || "No description available"} +

+
+
+ + +
+
+ +

+ Use Lesson for videos/audio and{" "} + Practice for question sets tied to this sub-module. +

+ + {/* Tabs */} +
+
+ + +
+
+ + + + {/* Content */} + {activeTab === "practice" && ( + <> + {practicesLoading ? ( +
+ +

Loading practices…

+
+ ) : filteredPractices.length === 0 ? ( +
+
+ +
+

No practices yet

+

Create your first practice to get started

+ +
+ ) : ( +
+ {filteredPractices.map((practice) => { + const statusConfig: Record = { + PUBLISHED: { bg: "bg-green-50 text-green-700 ring-1 ring-inset ring-green-200", dot: "bg-green-500", text: "Published" }, + DRAFT: { bg: "bg-grayScale-50 text-grayScale-600 ring-1 ring-inset ring-grayScale-200", dot: "bg-grayScale-400", text: "Draft" }, + ARCHIVED: { bg: "bg-amber-50 text-amber-700 ring-1 ring-inset ring-amber-200", dot: "bg-amber-500", text: "Archived" }, + } + const status = statusConfig[practice.status] ?? statusConfig.DRAFT + + return ( + handlePracticeClick(practice.id)} + > +
+
+

{practice.title}

+ + + {status.text} + +
+ +

{practice.description}

+ +
+ + {practice.set_type} + + {practice.persona && ( + + {practice.persona} + + )} +
+ +
+
+ + {practice.owner_type.replace("_", " ")} +
+ {practice.shuffle_questions && ( + Shuffle ON + )} +
+ +
+ + {new Date(practice.created_at).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + })} + +
e.stopPropagation()}> + + +
+
+
+
+ ) + })} +
+ )} + + )} + + {activeTab === "lesson" && ( + <> + {videosLoading ? ( +
+ +

Loading videos…

+
+ ) : videos.length === 0 ? ( +
+
+
+

No videos yet

+

Upload your first video to get started

+ +
+ ) : ( +
+ {videos.map((video, index) => { + const gradients = [ + "bg-gradient-to-br from-blue-100 via-blue-50 to-indigo-100", + "bg-gradient-to-br from-amber-100 via-yellow-50 to-orange-100", + "bg-gradient-to-br from-purple-100 via-fuchsia-50 to-pink-100", + "bg-gradient-to-br from-emerald-100 via-green-50 to-teal-100", + ] + const formatDuration = (seconds: number) => { + const mins = Math.floor(seconds / 60) + const secs = seconds % 60 + return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}` + } + return ( + + {/* Thumbnail with duration */} +
+ {video.thumbnail ? ( + {video.title} + ) : ( +
+
+ )} +
+ {formatDuration(video.duration || 0)} +
+
+ + {/* Content */} +
+ {/* Status and menu */} +
+ + + {video.is_published ? "PUBLISHED" : "DRAFT"} + +
+ + {openVideoMenuId === video.id && ( +
+ +
+ )} +
+
+ + {/* Title */} +

{video.title}

+ + {/* Edit / Preview buttons */} +
+ + +
+ + {/* Publish button */} + +
+
+ ) + })} +
+ )} + + )} + + {/* Delete Modal */} + {showDeleteModal && practiceToDelete && ( +
+
+
+

Delete Practice

+ +
+
+

+ Are you sure you want to delete{" "} + {practiceToDelete.title}? This action cannot be undone. +

+
+
+ + +
+
+
+ )} + + {/* Edit Practice Modal */} + {showEditPracticeModal && practiceToEdit && ( +
+
+
+

Edit Practice

+ +
+
+
+ + setTitle(e.target.value)} + placeholder="Enter practice title" + /> +
+
+ +