lesson integration

This commit is contained in:
Yared Yemane 2026-04-25 02:48:52 -07:00
parent 3634d2eb79
commit b4ab66b4a6
17 changed files with 3295 additions and 760 deletions

View File

@ -78,6 +78,15 @@ import type {
CreateTopLevelCourseModuleResponse, CreateTopLevelCourseModuleResponse,
CreateProgramCourseRequest, CreateProgramCourseRequest,
CreateProgramCourseResponse, CreateProgramCourseResponse,
GetTopLevelModuleLessonsResponse,
GetPracticesByParentContextResponse,
CreateParentLinkedPracticeRequest,
CreateParentLinkedPracticeResponse,
UpdateParentLinkedPracticeRequest,
UpdateParentLinkedPracticeResponse,
UpdateTopLevelModuleLessonRequest,
CreateTopLevelModuleLessonRequest,
CreateTopLevelModuleLessonResponse,
} from "../types/course.types" } from "../types/course.types"
type UnifiedHierarchyRow = { type UnifiedHierarchyRow = {
@ -473,6 +482,69 @@ export const updateTopLevelCourseModule = (
export const deleteTopLevelCourseModule = (moduleId: number) => export const deleteTopLevelCourseModule = (moduleId: number) =>
http.delete(`/modules/${moduleId}`) http.delete(`/modules/${moduleId}`)
/** Learn English top-level module lessons — GET /modules/:moduleId/lessons */
export const getModuleLessons = (
moduleId: number,
params?: { limit?: number; offset?: number },
) =>
http.get<GetTopLevelModuleLessonsResponse>(`/modules/${moduleId}/lessons`, {
params,
})
/** Learn English top-level module lesson — POST /modules/:moduleId/lessons */
export const createModuleLesson = (
moduleId: number,
data: CreateTopLevelModuleLessonRequest,
) =>
http.post<CreateTopLevelModuleLessonResponse>(`/modules/${moduleId}/lessons`, data)
/** Learn English top-level module lesson — PUT /lessons/:id */
export const updateTopLevelModuleLesson = (
lessonId: number,
data: UpdateTopLevelModuleLessonRequest,
) => http.put(`/lessons/${lessonId}`, data)
/** Learn English top-level module lesson — DELETE /lessons/:id */
export const deleteTopLevelModuleLesson = (lessonId: number) =>
http.delete(`/lessons/${lessonId}`)
/** GET /courses/:courseId/practices — practices linked to a top-level course (at most one in normal use). */
export const getPracticesByParentCourse = (
courseId: number,
params?: { limit?: number; offset?: number },
) =>
http.get<GetPracticesByParentContextResponse>(`/courses/${courseId}/practices`, { params })
/** GET /modules/:moduleId/practices */
export const getPracticesByParentModule = (
moduleId: number,
params?: { limit?: number; offset?: number },
) =>
http.get<GetPracticesByParentContextResponse>(`/modules/${moduleId}/practices`, { params })
/** GET /lessons/:lessonId/practices */
export const getPracticesByParentLesson = (
lessonId: number,
params?: { limit?: number; offset?: number },
) =>
http.get<GetPracticesByParentContextResponse>(`/lessons/${lessonId}/practices`, { params })
/** POST /practices — create a practice (story + question set) for course / module / lesson. */
export const createParentLinkedPractice = (data: CreateParentLinkedPracticeRequest) =>
http.post<CreateParentLinkedPracticeResponse>("/practices", data)
/** PUT /practices/:id */
export const updateParentLinkedPractice = (
practiceId: number,
data: UpdateParentLinkedPracticeRequest,
) => http.put<UpdateParentLinkedPracticeResponse>(`/practices/${practiceId}`, data)
/** DELETE /practices/:id */
export const deleteParentLinkedPractice = (practiceId: number) =>
http.delete<{ message: string; success: boolean; status_code: number; metadata: unknown }>(
`/practices/${practiceId}`,
)
export const updateLearningProgram = (programId: number, data: UpdateLearningProgramRequest) => export const updateLearningProgram = (programId: number, data: UpdateLearningProgramRequest) =>
http.put(`/programs/${programId}`, data) http.put(`/programs/${programId}`, data)

View File

@ -3,7 +3,6 @@ import { AppLayout } from "../layouts/AppLayout";
import { DashboardPage } from "../pages/DashboardPage"; import { DashboardPage } from "../pages/DashboardPage";
import { AnalyticsPage } from "../pages/analytics/AnalyticsPage"; import { AnalyticsPage } from "../pages/analytics/AnalyticsPage";
import { ContentManagementLayout } from "../pages/content-management/ContentManagementLayout"; import { ContentManagementLayout } from "../pages/content-management/ContentManagementLayout";
import { CourseCategoryPage } from "../pages/content-management/CourseCategoryPage";
import { AllCoursesPage } from "../pages/content-management/AllCoursesPage"; import { AllCoursesPage } from "../pages/content-management/AllCoursesPage";
import { CourseFlowBuilderPage } from "../pages/content-management/CourseFlowBuilderPage"; import { CourseFlowBuilderPage } from "../pages/content-management/CourseFlowBuilderPage";
import { ContentOverviewPage } from "../pages/content-management/ContentOverviewPage"; import { ContentOverviewPage } from "../pages/content-management/ContentOverviewPage";
@ -89,7 +88,7 @@ export function AppRoutes() {
</Route> </Route>
<Route path="/content" element={<ContentManagementLayout />}> <Route path="/content" element={<ContentManagementLayout />}>
<Route index element={<CourseCategoryPage />} /> <Route index element={<Navigate to="practices" replace />} />
<Route path="courses" element={<AllCoursesPage />} /> <Route path="courses" element={<AllCoursesPage />} />
<Route path="flows" element={<CourseFlowBuilderPage />} /> <Route path="flows" element={<CourseFlowBuilderPage />} />
<Route path="human-language" element={<HumanLanguageHierarchyPage />} /> <Route path="human-language" element={<HumanLanguageHierarchyPage />} />

13
src/lib/sessionRole.ts Normal file
View File

@ -0,0 +1,13 @@
const ADMIN_OR_SUPER: ReadonlySet<string> = new Set([
"admin",
"super_admin",
]);
/**
* True when the stored session role is admin or super_admin (login stores `role` in localStorage).
*/
export function isAdminOrSuperAdminRole(): boolean {
const raw = localStorage.getItem("role");
if (!raw) return false;
return ADMIN_OR_SUPER.has(raw.trim().toLowerCase());
}

124
src/lib/videoPreview.ts Normal file
View File

@ -0,0 +1,124 @@
/**
* Resolves a user-facing video URL into something we can preview (iframe or <video>).
*/
export 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;
}
}
export function toYoutubeEmbedUrl(rawUrl: string): string | null {
try {
const u = new URL(rawUrl.trim());
const host = u.hostname.replace(/^www\./, "").toLowerCase();
if (host === "youtu.be") {
const id = u.pathname.split("/").filter(Boolean)[0];
if (id) return `https://www.youtube.com/embed/${id}`;
}
if (host === "youtube.com" || host === "m.youtube.com") {
const v = u.searchParams.get("v");
if (v) return `https://www.youtube.com/embed/${v}`;
let m = u.pathname.match(/\/embed\/([^/]+)/);
if (m) return `https://www.youtube.com/embed/${m[1]}`;
m = u.pathname.match(/\/shorts\/([^/]+)/);
if (m) return `https://www.youtube.com/embed/${m[1]}`;
}
} catch {
return null;
}
return null;
}
export function isDirectVideoFileUrl(url: string): boolean {
const clean = url.split("?")[0].toLowerCase();
return /^https?:\/\//.test(url.trim()) && /\.(mp4|webm|ogg|mov|m4v)$/.test(clean);
}
export type VideoPreviewKind =
| { kind: "iframe"; src: string; label: "Vimeo" | "YouTube" }
| { kind: "video"; src: string }
| { kind: "none" };
export function getVideoPreview(url: string): VideoPreviewKind {
const t = url.trim();
if (!t) return { kind: "none" };
const vimeo = toVimeoEmbedUrl(t);
if (vimeo) return { kind: "iframe", src: vimeo, label: "Vimeo" };
const yt = toYoutubeEmbedUrl(t);
if (yt) return { kind: "iframe", src: yt, label: "YouTube" };
if (isDirectVideoFileUrl(t)) return { kind: "video", src: t };
return { kind: "none" };
}
/**
* First N seconds only embed short preview in admin cards / review, not the full file.
* @see https://developers.google.com/youtube/player_parameters (end, start)
*/
export const DEFAULT_PREVIEW_MAX_SECONDS = 60;
export function formatPreviewLength(totalSeconds: number): string {
if (totalSeconds < 60) return `${totalSeconds} seconds`;
if (totalSeconds % 60 === 0) {
const m = totalSeconds / 60;
return m === 1 ? "1 minute" : `${m} minutes`;
}
return `${totalSeconds} seconds`;
}
/**
* YouTube: `end` = stop after this many seconds from the start of the video.
* Vimeo: time range in URL fragment (supported on many vimeo.com player links).
*/
export function applyShortPreviewToEmbedUrl(
embedUrl: string,
label: "Vimeo" | "YouTube",
maxSeconds: number = DEFAULT_PREVIEW_MAX_SECONDS,
): string {
try {
if (label === "YouTube") {
const u = new URL(embedUrl);
u.searchParams.set("start", "0");
u.searchParams.set("end", String(maxSeconds));
u.searchParams.set("rel", u.searchParams.get("rel") ?? "0");
return u.toString();
}
if (label === "Vimeo") {
const u = new URL(embedUrl);
u.searchParams.set("start", "0");
u.searchParams.set("end", String(maxSeconds));
u.hash = `t=0,${maxSeconds}`;
return u.toString();
}
} catch {
// fall through
}
return embedUrl;
}
/** Google Drive "view" links are not direct image URLs; use the thumbnail API for preview. */
export function resolveThumbnailForPreview(
url: string | null | undefined,
): string | null {
if (!url?.trim()) return null;
const t = url.trim();
const m = t.match(/\/file\/d\/([a-zA-Z0-9_-]+)/);
if (m) {
return `https://drive.google.com/thumbnail?id=${m[1]}&sz=w800`;
}
return t;
}

View File

@ -1,8 +1,10 @@
import { useState } from "react"; import { useState } from "react";
import { Link, useNavigate, useParams } from "react-router-dom"; import { Link, useNavigate, useParams } from "react-router-dom";
import { ArrowLeft, Check } from "lucide-react"; import { toast } from "sonner";
import { ArrowLeft } from "lucide-react";
import { Button } from "../../components/ui/button"; import { Button } from "../../components/ui/button";
import { Stepper } from "../../components/ui/stepper"; import { Stepper } from "../../components/ui/stepper";
import { createModuleLesson } from "../../api/courses.api";
import { VideoDetailStep } from "./components/video-steps/VideoDetailStep"; import { VideoDetailStep } from "./components/video-steps/VideoDetailStep";
import { ReviewPublishStep } from "./components/video-steps/ReviewPublishStep"; import { ReviewPublishStep } from "./components/video-steps/ReviewPublishStep";
@ -13,6 +15,33 @@ const STEPS = [
{ id: 2, label: "Review & Publish" }, { id: 2, label: "Review & Publish" },
]; ];
export type AddLessonFormData = {
title: string;
order: string;
description: string;
videoUrl: string;
thumbnailUrl: string;
};
const emptyForm = (): AddLessonFormData => ({
title: "",
order: "1",
description: "",
videoUrl: "",
thumbnailUrl: "",
});
function descriptionToApiPlain(html: string): string {
if (!html?.trim()) return "";
const t = html.trim();
if (!t.includes("<")) return t;
if (typeof document === "undefined") {
return t.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim();
}
const doc = new DOMParser().parseFromString(html, "text/html");
return doc.body.textContent?.replace(/\s+/g, " ").trim() ?? "";
}
export function AddVideoFlow() { export function AddVideoFlow() {
const navigate = useNavigate(); const navigate = useNavigate();
const { level, courseId, moduleId } = useParams<{ const { level, courseId, moduleId } = useParams<{
@ -22,24 +51,65 @@ export function AddVideoFlow() {
}>(); }>();
const [currentStep, setCurrentStep] = useState(1); const [currentStep, setCurrentStep] = useState(1);
const [isPublished, setIsPublished] = useState(false); const [isPublished, setIsPublished] = useState(false);
const [formData, setFormData] = useState<AddLessonFormData>(emptyForm);
const [formData, setFormData] = useState({ const [publishing, setPublishing] = useState(false);
title: "", const [formResetKey, setFormResetKey] = useState(0);
order: "1",
description: "",
thumbnail: null,
videoFile: null,
});
const nextStep = () => setCurrentStep((prev) => Math.min(prev + 1, 2)); const nextStep = () => setCurrentStep((prev) => Math.min(prev + 1, 2));
const prevStep = () => setCurrentStep((prev) => Math.max(prev - 1, 1)); const prevStep = () => setCurrentStep((prev) => Math.max(prev - 1, 1));
const backPath = `/new-content/learn-english/${level}/courses/${courseId}/modules/${moduleId}`; const backPath = `/new-content/learn-english/${level}/courses/${courseId}/modules/${moduleId}`;
const handlePublish = async () => {
const mid = Number(moduleId);
if (!Number.isFinite(mid) || mid < 1) {
toast.error("Invalid module");
return;
}
const title = formData.title.trim();
const videoUrl = formData.videoUrl.trim();
const thumbnail = formData.thumbnailUrl.trim();
if (!title) {
toast.error("Title is required");
return;
}
if (!videoUrl) {
toast.error("Video URL is required");
return;
}
if (!thumbnail) {
toast.error("Thumbnail is required");
return;
}
const description = descriptionToApiPlain(formData.description);
if (!description) {
toast.error("Description is required");
return;
}
setPublishing(true);
try {
await createModuleLesson(mid, {
title,
video_url: videoUrl,
thumbnail,
description,
});
toast.success("Lesson created");
setIsPublished(true);
} catch (e: unknown) {
console.error(e);
const msg =
(e as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? "Failed to create lesson";
toast.error(msg);
} finally {
setPublishing(false);
}
};
if (isPublished) { if (isPublished) {
return ( return (
<div className="flex flex-col items-center justify-center min-h-screen px-4 text-center pb-20 animate-in fade-in zoom-in duration-500 "> <div className="flex flex-col items-center justify-center min-h-screen px-4 text-center pb-20 animate-in fade-in zoom-in duration-500 ">
{/* Success Icon Wrapper (Jagged Circle Style) */}
<div className="mb-12 relative scale-110"> <div className="mb-12 relative scale-110">
<div className="absolute inset-0 bg-brand-500/5 blur-3xl rounded-full" /> <div className="absolute inset-0 bg-brand-500/5 blur-3xl rounded-full" />
<div className="relative"> <div className="relative">
@ -53,35 +123,37 @@ export function AddVideoFlow() {
</div> </div>
<h1 className="text-[26px] font-bold text-grayScale-900 mb-4"> <h1 className="text-[26px] font-bold text-grayScale-900 mb-4">
Video Published Successfully! Lesson created successfully
</h1> </h1>
<p className="text-grayScale-600 text-base mb-14 max-w-lg font-medium leading-relaxed"> <p className="text-grayScale-600 text-base mb-14 max-w-lg font-medium leading-relaxed">
Your video is now live and available inside the selected module. Your lesson is now available in this module.
</p> </p>
<div className="flex flex-col gap-4 w-full max-w-[400px]"> <div className="flex flex-col gap-4 w-full max-w-[400px]">
<Button <Button
onClick={() => navigate(`/new-content/learn-english/${level}`)} onClick={() => navigate(backPath)}
className="h-12 rounded-[6px] bg-brand-500 font-bold text-[17px] text-white transition-all active:scale-95" className="h-12 rounded-[6px] bg-brand-500 font-bold text-[17px] text-white transition-all active:scale-95"
> >
Go back to Learn English View module
</Button> </Button>
<Button <Button
onClick={() => { onClick={() => {
setFormData({ setFormData(emptyForm());
title: "", setFormResetKey((k) => k + 1);
order: "1",
description: "",
thumbnail: null,
videoFile: null,
});
setIsPublished(false); setIsPublished(false);
setCurrentStep(1); setCurrentStep(1);
}} }}
variant="outline" variant="outline"
className="h-12 rounded-[6px] border-brand-200 text-brand-500 font-bold text-[17px] active:scale-95 bg-white" className="h-12 rounded-[6px] border-brand-200 text-brand-500 font-bold text-[17px] active:scale-95 bg-white"
> >
Add Another Video Add another lesson
</Button>
<Button
onClick={() => navigate(`/new-content/learn-english/${level}/courses`)}
variant="ghost"
className="h-10 text-grayScale-600 font-medium"
>
All courses
</Button> </Button>
</div> </div>
</div> </div>
@ -90,7 +162,6 @@ export function AddVideoFlow() {
return ( return (
<div className="space-y-8 pb-32 px-6 pt-6 min-h-screen "> <div className="space-y-8 pb-32 px-6 pt-6 min-h-screen ">
{/* Header */}
<div className="mx-auto max-w-7xl w-full"> <div className="mx-auto max-w-7xl w-full">
<div className="flex items-center justify-between mb-8"> <div className="flex items-center justify-between mb-8">
<Link <Link
@ -98,7 +169,7 @@ export function AddVideoFlow() {
className="flex items-center gap-2 text-[15px] font-medium text-grayScale-500 transition-colors hover:text-brand-500 decoration-none" className="flex items-center gap-2 text-[15px] font-medium text-grayScale-500 transition-colors hover:text-brand-500 decoration-none"
> >
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" />
Back to Modules Back to module
</Link> </Link>
<Button <Button
variant="outline" variant="outline"
@ -110,7 +181,7 @@ export function AddVideoFlow() {
</div> </div>
<h1 className="text-2xl font-bold text-[#0F172A] mb-10"> <h1 className="text-2xl font-bold text-[#0F172A] mb-10">
Add New Video Add new lesson
</h1> </h1>
<div className="mx-auto max-w-4xl mb-12"> <div className="mx-auto max-w-4xl mb-12">
@ -120,13 +191,13 @@ export function AddVideoFlow() {
/> />
</div> </div>
{/* Step Content */}
<div className="mx-auto max-w-7xl"> <div className="mx-auto max-w-7xl">
{currentStep === 1 && ( {currentStep === 1 && (
<VideoDetailStep <VideoDetailStep
key={formResetKey}
formData={formData} formData={formData}
setFormData={setFormData} setFormData={setFormData}
nextStep={nextStep} onContinue={nextStep}
/> />
)} )}
@ -134,7 +205,8 @@ export function AddVideoFlow() {
<ReviewPublishStep <ReviewPublishStep
formData={formData} formData={formData}
prevStep={prevStep} prevStep={prevStep}
setIsPublished={setIsPublished} onPublish={() => void handlePublish()}
publishing={publishing}
/> />
)} )}
</div> </div>

View File

@ -1,19 +1,8 @@
import { NavLink, Outlet } from "react-router-dom" import { Outlet } from "react-router-dom"
import { cn } from "../../lib/utils"
const tabs = [
{ label: "Overview", to: "/content" },
{ label: "Courses", to: "/content/courses" },
{ label: "Human Language", to: "/content/human-language" },
{ label: "Flows", to: "/content/flows" },
{ label: "Practice", to: "/content/practices" },
{ label: "Questions", to: "/content/questions" },
]
export function ContentManagementLayout() { export function ContentManagementLayout() {
return ( return (
<div className="mx-auto w-full max-w-7xl px-4 py-6 sm:px-6 lg:px-8"> <div className="mx-auto w-full max-w-7xl px-4 py-6 sm:px-6 lg:px-8">
{/* Header */}
<div className="mb-8"> <div className="mb-8">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="h-9 w-1 rounded-full bg-gradient-to-b from-brand-500 to-brand-600" /> <div className="h-9 w-1 rounded-full bg-gradient-to-b from-brand-500 to-brand-600" />
@ -22,38 +11,12 @@ export function ContentManagementLayout() {
Content Management Content Management
</h1> </h1>
<p className="mt-1 max-w-2xl text-sm leading-relaxed text-grayScale-500"> <p className="mt-1 max-w-2xl text-sm leading-relaxed text-grayScale-500">
Manage courses, speaking exercises, practices, and questions View and manage practice content for courses, modules, and lessons
</p> </p>
</div> </div>
</div> </div>
</div> </div>
{/* Tab bar */}
<div
className="scroll-hide mb-8 flex items-center gap-1 overflow-x-auto rounded-2xl border border-grayScale-100 bg-grayScale-50/60 p-1.5 shadow-sm backdrop-blur"
style={{ scrollbarWidth: "none", msOverflowStyle: "none" }}
>
<style>{`.scroll-hide::-webkit-scrollbar { display: none; }`}</style>
{tabs.map((t) => (
<NavLink
key={t.to}
to={t.to}
end={t.to === "/content"}
className={({ isActive }) =>
cn(
"relative whitespace-nowrap rounded-xl px-5 py-2 text-sm font-semibold transition-all duration-200 ease-in-out",
"text-grayScale-500 hover:bg-white/80 hover:text-brand-600 hover:shadow-sm",
isActive &&
"bg-brand-500 text-white shadow-md shadow-brand-500/25 hover:bg-brand-600 hover:text-white",
)
}
>
{t.label}
</NavLink>
))}
</div>
{/* Page content */}
<Outlet /> <Outlet />
</div> </div>
) )

View File

@ -515,6 +515,12 @@ export function CourseDetailPage() {
onClick={() => onClick={() =>
navigate( navigate(
`/new-content/learn-english/${programIdParam}/courses/${courseIdParam}/modules/${module.id}`, `/new-content/learn-english/${programIdParam}/courses/${courseIdParam}/modules/${module.id}`,
{
state: {
moduleName: module.name,
moduleDescription: module.description?.trim() ?? "",
},
},
) )
} }
> >

View File

@ -1,4 +1,4 @@
import { useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { import {
ArrowLeft, ArrowLeft,
Video, Video,
@ -7,42 +7,39 @@ import {
Layers, Layers,
Edit2, Edit2,
Trash2, Trash2,
X,
} from "lucide-react"; } from "lucide-react";
import { Link, useNavigate, useParams } from "react-router-dom"; import { Link, useLocation, useNavigate, useParams } from "react-router-dom";
import { toast } from "sonner";
import {
deleteTopLevelModuleLesson,
getModuleLessons,
getTopLevelCourseModules,
updateTopLevelModuleLesson,
} from "../../api/courses.api";
import type { TopLevelModuleLessonItem } from "../../types/course.types";
import { Button } from "../../components/ui/button"; import { Button } from "../../components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "../../components/ui/dialog";
import { Input } from "../../components/ui/input";
import { Textarea } from "../../components/ui/textarea";
import { resolveThumbnailForPreview } from "../../lib/videoPreview";
import { cn } from "../../lib/utils"; import { cn } from "../../lib/utils";
import { LessonMediaUploadField } from "./components/LessonMediaUploadField";
import { VideoCard } from "./components/VideoCard"; import { VideoCard } from "./components/VideoCard";
const MOCK_VIDEOS = [ const LESSON_THUMB_GRADIENTS = [
{ "from-[#CBD5E1] to-[#94A3B8]",
id: "v1", "from-[#DBEAFE] to-[#93C5FD]",
title: "1.1 Introduction to Formal Greetings", "from-[#FEF3C7] to-[#FCD34D]",
duration: "08:45", "from-[#FCE7F3] to-[#F9A8D4]",
status: "Draft", ] as const;
thumbnailGradient: "from-[#CBD5E1] to-[#94A3B8]",
},
{
id: "v2",
title: "1.2 Understanding Email Structure",
duration: "08:45",
status: "Published",
thumbnailGradient: "from-[#DBEAFE] to-[#93C5FD]",
},
{
id: "v3",
title: "1.3 Common Business Idioms",
duration: "08:45",
status: "Published",
thumbnailGradient: "from-[#FEF3C7] to-[#FCD34D]",
},
{
id: "v4",
title: "1.4 Video Conference Etiquette",
duration: "08:45",
status: "Published",
thumbnailGradient: "from-[#FCE7F3] to-[#F9A8D4]",
},
];
const MOCK_PRACTICES = [ const MOCK_PRACTICES = [
{ {
@ -75,8 +72,15 @@ const MOCK_PRACTICES = [
}, },
]; ];
type ModuleDetailState = {
moduleName?: string;
moduleDescription?: string;
};
export function ModuleDetailPage() { export function ModuleDetailPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation();
const navState = location.state as ModuleDetailState | null;
const { level, courseId, moduleId } = useParams<{ const { level, courseId, moduleId } = useParams<{
level: string; level: string;
courseId: string; courseId: string;
@ -84,14 +88,211 @@ export function ModuleDetailPage() {
}>(); }>();
const [activeTab, setActiveTab] = useState<"video" | "practice">("video"); const [activeTab, setActiveTab] = useState<"video" | "practice">("video");
const [activeFilter, setActiveFilter] = useState("Draft"); const [activeFilter, setActiveFilter] = useState("Draft");
const [videos] = useState(MOCK_VIDEOS); const [lessons, setLessons] = useState<TopLevelModuleLessonItem[]>([]);
const [lessonsLoading, setLessonsLoading] = useState(true);
const [lessonsLoadError, setLessonsLoadError] = useState<string | null>(null);
const [editingLesson, setEditingLesson] =
useState<TopLevelModuleLessonItem | null>(null);
const [editLessonTitle, setEditLessonTitle] = useState("");
const [editLessonVideoUrl, setEditLessonVideoUrl] = useState("");
const [editLessonThumbnail, setEditLessonThumbnail] = useState("");
const [editLessonDescription, setEditLessonDescription] = useState("");
const [savingLessonEdit, setSavingLessonEdit] = useState(false);
const [thumbUploadBusy, setThumbUploadBusy] = useState(false);
const [videoUploadBusy, setVideoUploadBusy] = useState(false);
const lessonMediaUploadBusy = thumbUploadBusy || videoUploadBusy;
const [deletingLesson, setDeletingLesson] =
useState<TopLevelModuleLessonItem | null>(null);
const [deletingLessonInFlight, setDeletingLessonInFlight] = useState(false);
const [practices] = useState(MOCK_PRACTICES); const [practices] = useState(MOCK_PRACTICES);
const [loadedModuleName, setLoadedModuleName] = useState<string | null>(null);
const [loadedModuleDescription, setLoadedModuleDescription] = useState<
string | null
>(null);
const [moduleListResolved, setModuleListResolved] = useState(
Boolean(navState?.moduleName?.trim()),
);
const moduleTitle = const moduleTitleFallback =
moduleId moduleId
?.split("-") ?.split("-")
.map((word) => word.charAt(0).toUpperCase() + word.slice(1)) .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(" ") || "Business English Fundamentals"; .join(" ") || "Module";
const displayModuleName =
navState?.moduleName?.trim() ||
loadedModuleName ||
moduleTitleFallback;
const hasNavName = Boolean(navState?.moduleName?.trim());
const displayModuleDescription = (() => {
if (hasNavName) {
return navState?.moduleDescription?.trim() || "—";
}
if (!moduleListResolved) {
return "Loading…";
}
if (loadedModuleDescription !== null) {
return loadedModuleDescription.trim() || "—";
}
return "—";
})();
useEffect(() => {
if (navState?.moduleName?.trim()) {
return;
}
const id = Number(moduleId);
const cid = Number(courseId);
if (!Number.isFinite(id) || id < 1 || !Number.isFinite(cid) || cid < 1) {
setModuleListResolved(true);
return;
}
let cancelled = false;
(async () => {
try {
const res = await getTopLevelCourseModules(cid, { limit: 100, offset: 0 });
if (cancelled) return;
const list = res.data?.data?.modules;
if (Array.isArray(list)) {
const m = list.find((mod) => mod.id === id);
if (m) {
setLoadedModuleName(m.name);
setLoadedModuleDescription(m.description ?? "");
} else {
setLoadedModuleName(null);
setLoadedModuleDescription("");
}
} else {
setLoadedModuleName(null);
setLoadedModuleDescription(null);
}
} catch {
if (!cancelled) {
setLoadedModuleName(null);
setLoadedModuleDescription(null);
}
} finally {
if (!cancelled) {
setModuleListResolved(true);
}
}
})();
return () => {
cancelled = true;
};
}, [navState?.moduleName, courseId, moduleId]);
const loadModuleLessons = useCallback(
async (options?: { showPageLoading?: boolean }) => {
const showPageLoading = options?.showPageLoading ?? true;
const mid = Number(moduleId);
if (!Number.isFinite(mid) || mid < 1) {
setLessons([]);
setLessonsLoadError(null);
setLessonsLoading(false);
return;
}
if (showPageLoading) {
setLessonsLoading(true);
setLessonsLoadError(null);
}
try {
const res = await getModuleLessons(mid, { limit: 100, offset: 0 });
const list = res.data?.data?.lessons;
if (Array.isArray(list)) {
setLessons(
[...list].sort(
(a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0),
),
);
} else {
setLessons([]);
}
if (showPageLoading) {
setLessonsLoadError(null);
}
} catch {
if (showPageLoading) {
setLessons([]);
setLessonsLoadError("Failed to load lessons. Please try again.");
} else {
toast.error("Failed to refresh lessons");
}
} finally {
if (showPageLoading) {
setLessonsLoading(false);
}
}
},
[moduleId],
);
useEffect(() => {
void loadModuleLessons({ showPageLoading: true });
}, [loadModuleLessons]);
const openEditLesson = (lesson: TopLevelModuleLessonItem) => {
setEditingLesson(lesson);
setEditLessonTitle(lesson.title ?? "");
setEditLessonVideoUrl(lesson.video_url ?? "");
setEditLessonThumbnail(lesson.thumbnail ?? "");
setEditLessonDescription(lesson.description ?? "");
};
const closeEditLesson = () => {
if (savingLessonEdit || lessonMediaUploadBusy) return;
setEditingLesson(null);
};
const handleSaveLessonEdit = async () => {
if (!editingLesson) return;
const title = editLessonTitle.trim();
if (!title) {
toast.error("Title is required");
return;
}
setSavingLessonEdit(true);
try {
await updateTopLevelModuleLesson(editingLesson.id, {
title,
video_url: editLessonVideoUrl.trim(),
thumbnail: editLessonThumbnail.trim(),
description: editLessonDescription.trim(),
});
toast.success("Lesson updated");
setEditingLesson(null);
await loadModuleLessons({ showPageLoading: false });
} catch (e: unknown) {
console.error(e);
const msg =
(e as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? "Failed to update lesson";
toast.error(msg);
} finally {
setSavingLessonEdit(false);
}
};
const handleConfirmDeleteLesson = async () => {
if (!deletingLesson) return;
setDeletingLessonInFlight(true);
try {
await deleteTopLevelModuleLesson(deletingLesson.id);
toast.success("Lesson deleted");
setDeletingLesson(null);
await loadModuleLessons({ showPageLoading: false });
} catch (e: unknown) {
console.error(e);
const msg =
(e as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? "Failed to delete lesson";
toast.error(msg);
} finally {
setDeletingLessonInFlight(false);
}
};
return ( return (
<div className="space-y-10 pt-10 pb-20 animate-in fade-in duration-500"> <div className="space-y-10 pt-10 pb-20 animate-in fade-in duration-500">
@ -110,12 +311,10 @@ export function ModuleDetailPage() {
<div className="flex flex-col md:flex-row md:items-start justify-between gap-6"> <div className="flex flex-col md:flex-row md:items-start justify-between gap-6">
<div className=""> <div className="">
<h1 className="text-2xl font-medium text-grayScale-900 tracking-tight"> <h1 className="text-2xl font-medium text-grayScale-900 tracking-tight">
Module 3: {moduleTitle} {displayModuleName}
</h1> </h1>
<p className="text-grayScale-500 text-[14px] max-w-2xl"> <p className="text-grayScale-500 text-[14px] max-w-2xl">
This module covers essential vocabulary and phrases used in modern {displayModuleDescription}
business environments, including email etiquette and meeting
protocols.
</p> </p>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@ -142,7 +341,7 @@ export function ModuleDetailPage() {
<div className="h-4 w-4 flex items-center justify-center"> <div className="h-4 w-4 flex items-center justify-center">
<span className="text-xl leading-none font-light">+</span> <span className="text-xl leading-none font-light">+</span>
</div> </div>
Add Video Add Lesson
</Button> </Button>
</div> </div>
</div> </div>
@ -159,7 +358,7 @@ export function ModuleDetailPage() {
: "text-grayScale-400 hover:text-grayScale-600", : "text-grayScale-400 hover:text-grayScale-600",
)} )}
> >
Video Lesson
</button> </button>
<button <button
onClick={() => setActiveTab("practice")} onClick={() => setActiveTab("practice")}
@ -178,14 +377,27 @@ export function ModuleDetailPage() {
{/* Content */} {/* Content */}
<div className="mt-8"> <div className="mt-8">
{activeTab === "video" ? ( {activeTab === "video" ? (
videos.length > 0 ? ( lessonsLoading ? (
<div className="flex flex-col items-center justify-center py-24 text-grayScale-500 text-[15px] font-medium">
Loading lessons
</div>
) : lessonsLoadError ? (
<div className="rounded-2xl border border-amber-100 bg-amber-50/80 px-6 py-8 text-center text-sm text-amber-900 max-w-lg mx-auto">
{lessonsLoadError}
</div>
) : lessons.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{videos.map((video) => ( {lessons.map((lesson, i) => (
<VideoCard <VideoCard
key={video.id} key={lesson.id}
{...(video as any)} id={lesson.id}
onEdit={() => console.log("Edit", video.id)} title={lesson.title}
onPublish={() => console.log("Publish", video.id)} videoUrl={lesson.video_url}
hoverModuleActions
thumbnailUrl={resolveThumbnailForPreview(lesson.thumbnail)}
thumbnailGradient={LESSON_THUMB_GRADIENTS[i % LESSON_THUMB_GRADIENTS.length]}
onEdit={() => openEditLesson(lesson)}
onDelete={() => setDeletingLesson(lesson)}
/> />
))} ))}
</div> </div>
@ -197,11 +409,11 @@ export function ModuleDetailPage() {
</div> </div>
</div> </div>
<h2 className="text-2xl font-extrabold text-grayScale-900 mb-3"> <h2 className="text-2xl font-extrabold text-grayScale-900 mb-3">
No videos added to this module yet No lessons in this module yet
</h2> </h2>
<p className="text-grayScale-400 font-medium text-[15px] text-center max-w-sm mb-10 leading-relaxed"> <p className="text-grayScale-400 font-medium text-[15px] text-center max-w-sm mb-10 leading-relaxed">
Videos are a great way to engage students. Start building your Lessons are a great way to engage students. Add your first
module by adding your first video lesson now. lesson to get started.
</p> </p>
<Button <Button
variant="outline" variant="outline"
@ -213,7 +425,7 @@ export function ModuleDetailPage() {
} }
> >
<Video className="h-5 w-5" /> <Video className="h-5 w-5" />
Add Video Add Lesson
</Button> </Button>
</div> </div>
) )
@ -251,6 +463,149 @@ export function ModuleDetailPage() {
</div> </div>
)} )}
</div> </div>
<Dialog
open={editingLesson !== null}
onOpenChange={(open) => {
if (!open && (savingLessonEdit || lessonMediaUploadBusy)) return;
if (!open) closeEditLesson();
}}
>
<DialogContent className="max-w-lg max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Edit lesson</DialogTitle>
<DialogDescription>
Update details. Video and thumbnail files use{" "}
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px]">
POST /files/upload
</code>
; the form is saved with{" "}
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px]">
PUT /lessons/:id
</code>
.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-2">
<div className="space-y-2">
<label
className="text-sm font-medium text-grayScale-700"
htmlFor="edit-lesson-title"
>
Title
</label>
<Input
id="edit-lesson-title"
value={editLessonTitle}
onChange={(e) => setEditLessonTitle(e.target.value)}
disabled={savingLessonEdit}
/>
</div>
<LessonMediaUploadField
kind="video"
value={editLessonVideoUrl}
onChange={setEditLessonVideoUrl}
disabled={savingLessonEdit}
onUploadBusyChange={setVideoUploadBusy}
/>
<LessonMediaUploadField
kind="thumbnail"
value={editLessonThumbnail}
onChange={setEditLessonThumbnail}
disabled={savingLessonEdit}
onUploadBusyChange={setThumbUploadBusy}
/>
<div className="space-y-2">
<label
className="text-sm font-medium text-grayScale-700"
htmlFor="edit-lesson-desc"
>
Description
</label>
<Textarea
id="edit-lesson-desc"
value={editLessonDescription}
onChange={(e) => setEditLessonDescription(e.target.value)}
rows={4}
disabled={savingLessonEdit}
className="min-h-[100px] resize-y"
/>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={closeEditLesson}
disabled={savingLessonEdit || lessonMediaUploadBusy}
>
Cancel
</Button>
<Button
type="button"
onClick={() => void handleSaveLessonEdit()}
disabled={savingLessonEdit || lessonMediaUploadBusy}
>
{savingLessonEdit ? "Saving…" : "Save changes"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{deletingLesson && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
<div className="mx-4 w-full max-w-sm animate-in fade-in zoom-in-95 rounded-2xl bg-white shadow-2xl">
<div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-5">
<h2 className="text-lg font-bold text-grayScale-700">
Delete lesson
</h2>
<button
type="button"
onClick={() =>
!deletingLessonInFlight && setDeletingLesson(null)
}
disabled={deletingLessonInFlight}
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-600 disabled:pointer-events-none disabled:opacity-50"
>
<X className="h-5 w-5" />
</button>
</div>
<div className="px-6 py-6">
<div className="mx-auto mb-4 grid h-12 w-12 place-items-center rounded-full bg-red-50">
<Trash2 className="h-5 w-5 text-red-500" />
</div>
<p className="text-center text-sm leading-relaxed text-grayScale-600">
Are you sure you want to delete{" "}
<span className="font-semibold text-grayScale-700">
{deletingLesson.title}
</span>
? This cannot be undone.
</p>
</div>
<div className="flex flex-col-reverse gap-3 border-t border-grayScale-100 px-6 py-4 sm:flex-row sm:justify-end">
<Button
type="button"
variant="outline"
onClick={() => setDeletingLesson(null)}
disabled={deletingLessonInFlight}
className="w-full sm:w-auto"
>
Cancel
</Button>
<Button
type="button"
className="w-full bg-red-500 shadow-sm transition-all hover:bg-red-600 hover:shadow-md sm:w-auto"
disabled={deletingLessonInFlight}
onClick={() => void handleConfirmDeleteLesson()}
>
{deletingLessonInFlight ? "Deleting…" : "Delete"}
</Button>
</div>
</div>
</div>
)}
</div> </div>
); );
} }

File diff suppressed because it is too large Load Diff

View File

@ -568,9 +568,11 @@ export function ProgramCoursesPage() {
) : ( ) : (
<div className="flex flex-wrap gap-10"> <div className="flex flex-wrap gap-10">
{courses.map((course) => { {courses.map((course) => {
const modules = course.modules_count ?? 0; const modules =
const videos = course.videos_count ?? 0; course.module_count ?? course.modules_count ?? 0;
const practices = course.practices_count ?? 0; const lessons = course.lesson_count ?? course.videos_count ?? 0;
const practices =
course.practice_count ?? course.practices_count ?? 0;
const thumbnailSrc = const thumbnailSrc =
course.thumbnail?.trim() || course.thumbnail_url?.trim() || ""; course.thumbnail?.trim() || course.thumbnail_url?.trim() || "";
return ( return (
@ -634,10 +636,10 @@ export function ProgramCoursesPage() {
</div> </div>
<div className="text-center"> <div className="text-center">
<p className="text-base font-bold text-grayScale-700"> <p className="text-base font-bold text-grayScale-700">
{videos} {lessons}
</p> </p>
<p className="text-[10px] font-medium uppercase tracking-wider text-grayScale-400"> <p className="text-[10px] font-medium uppercase tracking-wider text-grayScale-400">
Videos Lessons
</p> </p>
</div> </div>
<div className="text-center"> <div className="text-center">

View File

@ -0,0 +1,481 @@
import { useCallback, useEffect, useState } from "react"
import { Check, ChevronLeft, ChevronRight, ListOrdered, Plus, Trash2 } from "lucide-react"
import { toast } from "sonner"
import { Button } from "../../../components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "../../../components/ui/card"
import { Input } from "../../../components/ui/input"
import { Textarea } from "../../../components/ui/textarea"
import {
addQuestionToSet,
createParentLinkedPractice,
createQuestion,
createQuestionSet,
} from "../../../api/courses.api"
import type { CreateQuestionRequest, PracticeParentKind } from "../../../types/course.types"
import { cn } from "../../../lib/utils"
import { SpinnerIcon } from "../../../components/ui/spinner-icon"
export type CreatePracticeWizardParent = {
kind: PracticeParentKind
id: number
} | null
const STEPS = [
{ n: 1, label: "Question set" },
{ n: 2, label: "Questions" },
{ n: 3, label: "Attach" },
{ n: 4, label: "Practice" },
] as const
type QuestionDraft = {
question_text: string
voice_prompt: string
sample_answer_voice_prompt: string
audio_correct_answer_text: string
}
const emptyQuestion = (): QuestionDraft => ({
question_text: "",
voice_prompt: "",
sample_answer_voice_prompt: "",
audio_correct_answer_text: "",
})
type Props = {
parent: CreatePracticeWizardParent
onCreated?: () => void
}
export function CreatePracticeWizard({ parent, onCreated }: Props) {
const [step, setStep] = useState(1)
const [saving, setSaving] = useState(false)
const [setTitle, setSetTitle] = useState("")
const [questionSetId, setQuestionSetId] = useState<number | null>(null)
const [questionRows, setQuestionRows] = useState<QuestionDraft[]>([emptyQuestion()])
const [createdQuestionIds, setCreatedQuestionIds] = useState<number[]>([])
const [practiceTitle, setPracticeTitle] = useState("")
const [storyDescription, setStoryDescription] = useState("")
const [storyImage, setStoryImage] = useState("")
const [quickTips, setQuickTips] = useState("")
const canUseWizard = parent != null
useEffect(() => {
if (step === 4 && setTitle.trim() && !practiceTitle.trim()) {
setPracticeTitle(setTitle.trim())
}
}, [step, setTitle, practiceTitle])
const resetAll = useCallback(() => {
setStep(1)
setSetTitle("")
setQuestionSetId(null)
setQuestionRows([emptyQuestion()])
setCreatedQuestionIds([])
setPracticeTitle("")
setStoryDescription("")
setStoryImage("")
setQuickTips("")
}, [])
const handleStep1 = async () => {
if (!setTitle.trim()) {
toast.error("Enter a title for the question set")
return
}
setSaving(true)
try {
const res = await createQuestionSet({
title: setTitle.trim(),
set_type: "PRACTICE",
})
const id = res.data?.data?.id
if (id == null) {
throw new Error("No question set id in response")
}
setQuestionSetId(id)
toast.success("Question set created")
setStep(2)
} catch (e: unknown) {
const err = e as { response?: { data?: { message?: string } }; message?: string }
toast.error(err.response?.data?.message || err.message || "Failed to create question set")
} finally {
setSaving(false)
}
}
const handleStep2 = async () => {
for (let i = 0; i < questionRows.length; i++) {
const r = questionRows[i]
if (!r.question_text.trim()) {
toast.error(`Question ${i + 1}: enter question text`)
return
}
if (!r.voice_prompt.trim() || !r.sample_answer_voice_prompt.trim()) {
toast.error(`Question ${i + 1}: enter voice prompt URLs`)
return
}
if (!r.audio_correct_answer_text.trim()) {
toast.error(`Question ${i + 1}: enter the correct answer text`)
return
}
}
if (questionSetId == null) return
setSaving(true)
try {
const ids: number[] = []
for (const r of questionRows) {
const body: CreateQuestionRequest = {
question_text: r.question_text.trim(),
question_type: "AUDIO",
voice_prompt: r.voice_prompt.trim(),
sample_answer_voice_prompt: r.sample_answer_voice_prompt.trim(),
audio_correct_answer_text: r.audio_correct_answer_text.trim(),
}
const res = await createQuestion(body)
const qid = res.data?.data?.id
if (qid == null) {
throw new Error("A question was created but no id was returned")
}
ids.push(qid)
}
setCreatedQuestionIds(ids)
toast.success(`Created ${ids.length} question(s)`)
setStep(3)
} catch (e: unknown) {
const err = e as { response?: { data?: { message?: string } }; message?: string }
toast.error(err.response?.data?.message || err.message || "Failed to create questions")
} finally {
setSaving(false)
}
}
const handleStep3 = async () => {
if (questionSetId == null || createdQuestionIds.length === 0) return
setSaving(true)
try {
for (let i = 0; i < createdQuestionIds.length; i++) {
await addQuestionToSet(questionSetId, {
question_id: createdQuestionIds[i],
display_order: i + 1,
})
}
toast.success("Questions linked to the set")
setStep(4)
} catch (e: unknown) {
const err = e as { response?: { data?: { message?: string } }; message?: string }
toast.error(err.response?.data?.message || err.message || "Failed to attach questions")
} finally {
setSaving(false)
}
}
const handleStep4 = async () => {
if (!parent || questionSetId == null) return
if (!practiceTitle.trim() || !storyDescription.trim() || !storyImage.trim()) {
toast.error("Title, story description, and story image are required")
return
}
setSaving(true)
try {
await createParentLinkedPractice({
parent_kind: parent.kind,
parent_id: parent.id,
title: practiceTitle.trim(),
story_description: storyDescription.trim(),
story_image: storyImage.trim(),
question_set_id: questionSetId,
quick_tips: quickTips.trim(),
})
toast.success("Practice created successfully")
resetAll()
onCreated?.()
} catch (e: unknown) {
const err = e as { response?: { data?: { message?: string } }; message?: string }
toast.error(err.response?.data?.message || err.message || "Failed to create practice")
} finally {
setSaving(false)
}
}
return (
<Card className="border-brand-200/60 shadow-soft">
<CardHeader className="border-b border-grayScale-200 pb-4">
<CardTitle className="text-base font-semibold text-grayScale-800">Create a new practice</CardTitle>
<p className="text-sm font-normal text-grayScale-500">
Four steps: create a question set, add audio questions, attach them, then set the practice
story. Select the course, module, or lesson above first.
</p>
<ol className="mt-4 flex flex-wrap gap-2">
{STEPS.map((s) => {
const done = step > s.n
const active = step === s.n
return (
<li
key={s.n}
className={cn(
"flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs font-bold uppercase tracking-wider",
done && "border-mint-500/40 bg-mint-50 text-mint-800",
active && !done && "border-brand-500 bg-brand-500 text-white",
!active && !done && "border-grayScale-200 bg-white text-grayScale-500",
)}
>
{done ? <Check className="h-3.5 w-3.5" /> : <span className="font-mono tabular-nums">{s.n}</span>}
{s.label}
</li>
)
})}
</ol>
</CardHeader>
<CardContent className="pt-5">
{!canUseWizard && (
<p className="rounded-xl border border-amber-200 bg-amber-50/80 px-4 py-3 text-sm text-amber-900">
Choose a program, course, and the target (course / module / lesson) in the &quot;Look up
practice&quot; section, then return here. The practice is created for the same selection
(course id, module id, or lesson id).
</p>
)}
{canUseWizard && step === 1 && (
<div className="space-y-4">
<div>
<p className="mb-1.5 text-xs font-semibold uppercase tracking-wider text-grayScale-500">
Question set title
</p>
<Input
value={setTitle}
onChange={(e) => setSetTitle(e.target.value)}
placeholder='e.g. "Course-A1 practice"'
disabled={saving}
/>
</div>
<p className="text-xs text-grayScale-500">
This calls <span className="font-mono">POST /question-sets</span> with{" "}
<span className="font-mono">set_type: PRACTICE</span>.
</p>
<Button type="button" onClick={handleStep1} disabled={saving}>
{saving ? <SpinnerIcon className="h-4 w-4" /> : null}
Create question set &amp; continue
</Button>
</div>
)}
{canUseWizard && step === 2 && (
<div className="space-y-4">
<p className="text-sm text-grayScale-600">
Set id <span className="font-mono font-medium text-grayScale-800">#{questionSetId}</span> add
one or more <strong>AUDIO</strong> questions. Each is created via{" "}
<span className="font-mono">POST /questions</span>.
</p>
{questionRows.map((row, idx) => (
<div
key={idx}
className="space-y-3 rounded-2xl border border-grayScale-200 bg-grayScale-50/50 p-4"
>
<div className="flex items-center justify-between">
<span className="text-xs font-bold uppercase tracking-wider text-grayScale-500">
Question {idx + 1}
</span>
{questionRows.length > 1 && (
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 text-red-600 hover:text-red-700"
onClick={() => setQuestionRows((rows) => rows.filter((_, i) => i !== idx))}
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
<div>
<p className="mb-1 text-xs font-medium text-grayScale-500">Question text</p>
<Textarea
value={row.question_text}
onChange={(e) => {
const v = e.target.value
setQuestionRows((rows) =>
rows.map((r, i) => (i === idx ? { ...r, question_text: v } : r)),
)
}}
rows={2}
placeholder="Thank you for your help!"
disabled={saving}
/>
</div>
<div>
<p className="mb-1 text-xs font-medium text-grayScale-500">Voice prompt (URL)</p>
<Input
value={row.voice_prompt}
onChange={(e) => {
const v = e.target.value
setQuestionRows((rows) =>
rows.map((r, i) => (i === idx ? { ...r, voice_prompt: v } : r)),
)
}}
placeholder="https://…"
disabled={saving}
/>
</div>
<div>
<p className="mb-1 text-xs font-medium text-grayScale-500">Sample answer voice (URL)</p>
<Input
value={row.sample_answer_voice_prompt}
onChange={(e) => {
const v = e.target.value
setQuestionRows((rows) =>
rows.map((r, i) => (i === idx ? { ...r, sample_answer_voice_prompt: v } : r)),
)
}}
placeholder="https://…"
disabled={saving}
/>
</div>
<div>
<p className="mb-1 text-xs font-medium text-grayScale-500">Correct answer text</p>
<Textarea
value={row.audio_correct_answer_text}
onChange={(e) => {
const v = e.target.value
setQuestionRows((rows) =>
rows.map((r, i) => (i === idx ? { ...r, audio_correct_answer_text: v } : r)),
)
}}
rows={2}
placeholder="You're welcome! Have a nice day!"
disabled={saving}
/>
</div>
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setQuestionRows((rows) => [...rows, emptyQuestion()])}
disabled={saving}
>
<Plus className="mr-1.5 h-4 w-4" />
Add another question
</Button>
<div className="flex flex-wrap gap-2 pt-2">
<Button type="button" variant="outline" onClick={() => setStep(1)} disabled={saving}>
<ChevronLeft className="mr-1 h-4 w-4" />
Back
</Button>
<Button type="button" onClick={handleStep2} disabled={saving}>
{saving ? <SpinnerIcon className="h-4 w-4" /> : null}
Create questions &amp; continue
</Button>
</div>
</div>
)}
{canUseWizard && step === 3 && (
<div className="space-y-4">
<p className="text-sm text-grayScale-600">
Link each question to the set with a display order using{" "}
<span className="font-mono">POST /question-sets/&#123;id&#125;/questions</span>.
</p>
<ul className="space-y-2 rounded-xl border border-grayScale-200 bg-white p-3">
{createdQuestionIds.map((qid, i) => (
<li
key={qid}
className="flex items-center justify-between gap-2 text-sm text-grayScale-700"
>
<span className="font-mono">question #{qid}</span>
<span className="flex items-center gap-1 text-xs text-grayScale-500">
<ListOrdered className="h-3.5 w-3.5" />
order {i + 1}
</span>
</li>
))}
</ul>
<div className="flex flex-wrap gap-2">
<Button type="button" variant="outline" onClick={() => setStep(2)} disabled={saving}>
<ChevronLeft className="mr-1 h-4 w-4" />
Back
</Button>
<Button type="button" onClick={handleStep3} disabled={saving}>
{saving ? <SpinnerIcon className="h-4 w-4" /> : null}
Attach to question set
</Button>
</div>
</div>
)}
{canUseWizard && step === 4 && parent && (
<div className="space-y-4">
<p className="text-sm text-grayScale-600">
Parent:{" "}
<span className="font-mono text-xs">
{parent.kind} #{parent.id}
</span>{" "}
· question set <span className="font-mono">#{questionSetId}</span> ·{" "}
<span className="font-mono">POST /practices</span>
</p>
<div>
<p className="mb-1.5 text-xs font-semibold uppercase tracking-wider text-grayScale-500">
Practice title
</p>
<Input
value={practiceTitle}
onChange={(e) => setPracticeTitle(e.target.value)}
placeholder="Test title"
disabled={saving}
/>
</div>
<div>
<p className="mb-1.5 text-xs font-semibold uppercase tracking-wider text-grayScale-500">
Story description
</p>
<Textarea
value={storyDescription}
onChange={(e) => setStoryDescription(e.target.value)}
rows={4}
placeholder="Story for the learner…"
disabled={saving}
/>
</div>
<div>
<p className="mb-1.5 text-xs font-semibold uppercase tracking-wider text-grayScale-500">
Story image (URL)
</p>
<Input
value={storyImage}
onChange={(e) => setStoryImage(e.target.value)}
placeholder="https://…"
disabled={saving}
/>
</div>
<div>
<p className="mb-1.5 text-xs font-semibold uppercase tracking-wider text-grayScale-500">
Quick tips
</p>
<Textarea
value={quickTips}
onChange={(e) => setQuickTips(e.target.value)}
rows={2}
placeholder="Comma-separated tips (optional)"
disabled={saving}
/>
</div>
<div className="flex flex-wrap gap-2">
<Button type="button" variant="outline" onClick={() => setStep(3)} disabled={saving}>
<ChevronLeft className="mr-1 h-4 w-4" />
Back
</Button>
<Button type="button" onClick={handleStep4} disabled={saving}>
{saving ? <SpinnerIcon className="h-4 w-4" /> : <ChevronRight className="mr-1.5 h-4 w-4" />}
Create practice
</Button>
</div>
</div>
)}
</CardContent>
</Card>
)
}

View File

@ -0,0 +1,215 @@
import { useCallback, useRef, useState, type ChangeEvent, type DragEvent } from "react";
import { CloudUpload } from "lucide-react";
import { toast } from "sonner";
import { Input } from "../../../components/ui/input";
import { cn } from "../../../lib/utils";
import { uploadImageFile, uploadVideoFile } from "../../../api/files.api";
const MAX_THUMB_BYTES = 5 * 1024 * 1024;
const MAX_VIDEO_BYTES = 2 * 1024 * 1024 * 1024;
const THUMB_TYPES = new Set(["image/jpeg", "image/png"]);
const VIDEO_TYPES_PREFIX = "video/";
function isAllowedThumb(file: File): boolean {
if (THUMB_TYPES.has(file.type)) return true;
const n = file.name.toLowerCase();
return /\.(jpe?g|png)$/.test(n);
}
function isAllowedVideoFile(file: File): boolean {
if (file.type.startsWith(VIDEO_TYPES_PREFIX)) return true;
const n = file.name.toLowerCase();
return /\.(mp4|webm|mov|m4v|mkv)$/.test(n);
}
export type LessonMediaUploadKind = "thumbnail" | "video";
export interface LessonMediaUploadFieldProps {
kind: LessonMediaUploadKind;
value: string;
onChange: (url: string) => void;
disabled?: boolean;
onUploadBusyChange?: (busy: boolean) => void;
className?: string;
}
export function LessonMediaUploadField({
kind,
value,
onChange,
disabled = false,
onUploadBusyChange,
className,
}: LessonMediaUploadFieldProps) {
const fileInputRef = useRef<HTMLInputElement>(null);
const [uploading, setUploading] = useState(false);
const [dragActive, setDragActive] = useState(false);
const setBusy = useCallback(
(next: boolean) => {
setUploading(next);
onUploadBusyChange?.(next);
},
[onUploadBusyChange],
);
const processFile = useCallback(
async (file: File) => {
if (disabled || uploading) return;
if (kind === "thumbnail") {
if (!isAllowedThumb(file)) {
toast.error("Please use a JPG or PNG image.");
return;
}
if (file.size > MAX_THUMB_BYTES) {
toast.error("Image is too large", {
description: "Maximum size is 5 MB.",
});
return;
}
setBusy(true);
try {
const res = await uploadImageFile(file);
const url = res.data?.data?.url?.trim();
if (!url) throw new Error("Upload did not return a file URL");
onChange(url);
toast.success("Thumbnail uploaded");
} catch (e: unknown) {
console.error(e);
const msg =
(e as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? "Failed to upload thumbnail";
toast.error(msg);
} finally {
setBusy(false);
}
return;
}
if (!isAllowedVideoFile(file)) {
toast.error("Please use a video file (e.g. MP4, WebM, MOV).");
return;
}
if (file.size > MAX_VIDEO_BYTES) {
toast.error("Video is too large", {
description: "Maximum size is 2 GB.",
});
return;
}
setBusy(true);
try {
const res = await uploadVideoFile(file);
const url = res.data?.data?.url?.trim();
if (!url) throw new Error("Upload did not return a file URL");
onChange(url);
toast.success("Video uploaded");
} catch (e: unknown) {
console.error(e);
const msg =
(e as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? "Failed to upload video";
toast.error(msg);
} finally {
setBusy(false);
}
},
[disabled, uploading, kind, onChange, setBusy],
);
const handleFileInputChange = (e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
e.target.value = "";
if (file) void processFile(file);
};
const handleDragOver = (e: DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (!disabled && !uploading) setDragActive(true);
};
const handleDragLeave = (e: DragEvent) => {
e.preventDefault();
e.stopPropagation();
setDragActive(false);
};
const handleDrop = (e: DragEvent) => {
e.preventDefault();
e.stopPropagation();
setDragActive(false);
if (disabled || uploading) return;
const file = e.dataTransfer.files?.[0];
if (file) void processFile(file);
};
const zoneDisabled = disabled || uploading;
const isThumb = kind === "thumbnail";
const label = isThumb ? "Thumbnail" : "Video";
const hint = isThumb
? "JPG, PNG (MAX 5 MB)"
: "MP4, MOV, WebM (MAX 2 GB)";
return (
<div className={cn("space-y-3", className)}>
<label className="text-sm font-medium text-grayScale-700">
{label}
</label>
<input
ref={fileInputRef}
type="file"
accept={
isThumb
? "image/jpeg,image/png,.jpg,.jpeg,.png"
: "video/*,.mp4,.webm,.mov,.m4v,.mkv"
}
className="sr-only"
onChange={handleFileInputChange}
disabled={zoneDisabled}
/>
<button
type="button"
disabled={zoneDisabled}
onClick={() => fileInputRef.current?.click()}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={cn(
"flex w-full cursor-pointer flex-col items-center justify-center rounded-2xl border-2 border-dashed border-[#9E289133] bg-white p-10 text-center transition-colors",
"hover:border-[#9E289180] hover:bg-grayScale-50/30",
dragActive && "border-[#9E2891] bg-[#9E289108]",
zoneDisabled && "cursor-not-allowed opacity-60",
)}
>
{uploading ? (
<p className="text-sm font-medium text-grayScale-600">Uploading</p>
) : (
<>
<CloudUpload
className="mb-4 h-10 w-10 text-[#9E2891]"
strokeWidth={1.5}
aria-hidden
/>
<p className="text-sm">
<span className="font-bold text-[#9E2891]">Click to upload</span>{" "}
<span className="text-grayScale-500">or paste a URL below</span>
</p>
<p className="mt-1 text-xs font-medium uppercase tracking-wider text-grayScale-400">
{hint}
</p>
</>
)}
</button>
<Input
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder="https://…"
className="h-12 rounded-xl"
disabled={disabled || uploading}
autoComplete="off"
/>
</div>
);
}

View File

@ -0,0 +1,66 @@
import { useState } from "react";
import { cn } from "../../../lib/utils";
import {
DEFAULT_PREVIEW_MAX_SECONDS,
formatPreviewLength,
} from "../../../lib/videoPreview";
type Props = {
src: string;
maxSeconds?: number;
};
/**
* Stops direct file playback after the first N seconds (admin short preview).
*/
export function PreviewLimitedFileVideo({
src,
maxSeconds = DEFAULT_PREVIEW_MAX_SECONDS,
}: Props) {
const [capped, setCapped] = useState(false);
const previewLengthLabel = formatPreviewLength(maxSeconds);
const onTimeUpdate = (e: React.SyntheticEvent<HTMLVideoElement>) => {
const el = e.currentTarget;
if (el.currentTime >= maxSeconds) {
el.pause();
if (el.currentTime > maxSeconds) {
el.currentTime = maxSeconds;
}
setCapped(true);
} else {
setCapped(false);
}
};
const onSeeking = (e: React.SyntheticEvent<HTMLVideoElement>) => {
const el = e.currentTarget;
if (el.currentTime > maxSeconds) {
el.currentTime = maxSeconds;
}
};
return (
<div className="relative">
<video
controls
playsInline
className="aspect-video w-full object-contain"
src={src}
onTimeUpdate={onTimeUpdate}
onSeeking={onSeeking}
onPlay={() => setCapped(false)}
/>
<div
className={cn(
"pointer-events-none absolute bottom-0 left-0 right-0 z-10 bg-gradient-to-t from-black/90 via-black/50 to-transparent px-3 py-2.5 text-center text-[11px] font-semibold",
capped ? "text-amber-200" : "text-white/95",
)}
>
{capped
? `Preview stopped at ${previewLengthLabel} · rewind to rewatch the clip`
: `Short clip · playback stops at ${previewLengthLabel}`}
</div>
</div>
);
}

View File

@ -1,14 +1,43 @@
import { MoreVertical, Edit2, Play } from "lucide-react"; import { useEffect, useMemo, useState } from "react";
import { MoreVertical, Edit2, Play, Pencil, Trash2 } from "lucide-react";
import { Button } from "../../../components/ui/button"; import { Button } from "../../../components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "../../../components/ui/dialog";
import { isAdminOrSuperAdminRole } from "../../../lib/sessionRole";
import { cn } from "../../../lib/utils"; import { cn } from "../../../lib/utils";
import {
applyShortPreviewToEmbedUrl,
DEFAULT_PREVIEW_MAX_SECONDS,
formatPreviewLength,
getVideoPreview,
} from "../../../lib/videoPreview";
import { PreviewLimitedFileVideo } from "./PreviewLimitedFileVideo";
interface VideoCardProps { interface VideoCardProps {
id: string; id?: string | number;
title: string; title: string;
duration: string; /** Omits the duration chip when not provided (e.g. API has no length yet). */
status: "Draft" | "Published"; duration?: string;
thumbnailGradient: string; /** When omitted, shows a neutral "Lesson" chip and no Publish button. */
status?: "Draft" | "Published";
thumbnailGradient?: string;
thumbnailUrl?: string | null;
/**
* When set, the hover play control opens a preview (Vimeo, YouTube, or direct
* video file) in a dialog.
*/
videoUrl?: string;
/**
* When true, shows edit/delete in the top-right of the thumbnail (same
* hover pattern as module cards) and removes the footer + overflow menu.
*/
hoverModuleActions?: boolean;
onEdit?: () => void; onEdit?: () => void;
onDelete?: () => void;
onPublish?: () => void; onPublish?: () => void;
} }
@ -16,38 +45,270 @@ export function VideoCard({
title, title,
duration, duration,
status, status,
thumbnailGradient, thumbnailGradient = "from-[#CBD5E1] to-[#94A3B8]",
thumbnailUrl,
videoUrl,
onEdit, onEdit,
onDelete,
onPublish, onPublish,
hoverModuleActions = false,
}: VideoCardProps) { }: VideoCardProps) {
const [thumbFailed, setThumbFailed] = useState(false);
const [previewOpen, setPreviewOpen] = useState(false);
/** Iframe players ignore URL limits in many cases — unmount after real time. */
const [iframeSessionDone, setIframeSessionDone] = useState(false);
const [iframeSessionKey, setIframeSessionKey] = useState(0);
const useGradient = !thumbnailUrl?.trim() || thumbFailed;
const videoPreview = useMemo(
() => (videoUrl?.trim() ? getVideoPreview(videoUrl) : { kind: "none" as const }),
[videoUrl],
);
const limitedEmbedSrc = useMemo(() => {
if (videoPreview.kind !== "iframe") return null;
return applyShortPreviewToEmbedUrl(
videoPreview.src,
videoPreview.label,
DEFAULT_PREVIEW_MAX_SECONDS,
);
}, [videoPreview]);
const canPreview = Boolean(videoUrl?.trim());
const previewLengthLabel = formatPreviewLength(
DEFAULT_PREVIEW_MAX_SECONDS,
);
useEffect(() => {
if (!previewOpen) {
setIframeSessionDone(false);
return;
}
if (videoPreview.kind !== "iframe" || !limitedEmbedSrc) {
return;
}
if (iframeSessionDone) {
return;
}
const ms = DEFAULT_PREVIEW_MAX_SECONDS * 1000;
const id = window.setTimeout(() => {
setIframeSessionDone(true);
}, ms);
return () => window.clearTimeout(id);
}, [
previewOpen,
videoPreview.kind,
limitedEmbedSrc,
iframeSessionDone,
]);
const handlePreviewOpenChange = (open: boolean) => {
setPreviewOpen(open);
if (!open) {
setIframeSessionDone(false);
setIframeSessionKey((k) => k + 1);
}
};
return ( return (
<div className="group bg-white rounded-[24px] border border-grayScale-50 overflow-hidden shadow-sm hover:shadow-lg transition-all duration-300 flex flex-col"> <div
className={cn(
"group relative bg-white rounded-[24px] border border-grayScale-50 overflow-hidden shadow-sm hover:shadow-lg transition-all duration-300 flex flex-col",
)}
>
{/* Thumbnail */} {/* Thumbnail */}
<div <div
className={cn( className={cn(
"relative h-44 w-full bg-gradient-to-br", "relative h-44 w-full overflow-hidden",
thumbnailGradient, useGradient && "bg-gradient-to-br",
useGradient && thumbnailGradient,
!useGradient && "bg-grayScale-100",
)} )}
> >
{hoverModuleActions && (onEdit || onDelete) ? (
<div
className="absolute right-2 top-2 z-20 flex translate-y-1 gap-1 opacity-0 pointer-events-none transition-all duration-300 ease-out group-hover:translate-y-0 group-hover:opacity-100 group-hover:pointer-events-auto"
>
{onEdit ? (
<Button
type="button"
variant="secondary"
size="icon"
className="h-8 w-8 rounded-md bg-white/95 text-grayScale-600 shadow-sm transition-colors hover:bg-white"
aria-label={`Edit ${title}`}
onClick={(e) => {
e.stopPropagation();
onEdit();
}}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
) : null}
{onDelete ? (
<Button
type="button"
variant="secondary"
size="icon"
className="h-8 w-8 rounded-md bg-white/95 text-red-600 shadow-sm transition-colors hover:bg-red-50"
aria-label={`Delete ${title}`}
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
) : null}
</div>
) : null}
{!useGradient && thumbnailUrl ? (
<img
src={thumbnailUrl}
alt=""
className="absolute inset-0 h-full w-full object-cover"
onError={() => setThumbFailed(true)}
/>
) : null}
{/* Duration Badge */} {/* Duration Badge */}
<div className="absolute bottom-3 right-3 bg-black/70 text-white text-[11px] font-bold px-2 py-1 rounded-md backdrop-blur-sm"> {duration ? (
<div className="absolute bottom-3 right-3 z-10 bg-black/70 text-white text-[11px] font-bold px-2 py-1 rounded-md backdrop-blur-sm">
{duration} {duration}
</div> </div>
{/* Play Overlay */} ) : null}
<div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity bg-black/10"> {/* Play: opens preview dialog when videoUrl is set */}
{canPreview ? (
<button
type="button"
className="absolute inset-0 z-[8] flex cursor-pointer items-center justify-center bg-gradient-to-b from-black/0 via-black/20 to-black/30 opacity-0 transition-all duration-300 group-hover:opacity-100"
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
setPreviewOpen(true);
}}
aria-label={`Play preview: ${title}`}
>
<span className="flex h-12 w-12 items-center justify-center rounded-full border border-white/40 bg-white/20 shadow-lg backdrop-blur-md transition-transform duration-300 group-hover:scale-105 group-hover:border-white/50 group-hover:bg-white/30">
<Play className="h-6 w-6 text-white" fill="currentColor" />
</span>
</button>
) : (
<div className="pointer-events-none absolute inset-0 z-[5] flex items-center justify-center bg-black/10 opacity-0 transition-opacity group-hover:opacity-100">
<div className="h-12 w-12 rounded-full bg-white/20 backdrop-blur-md flex items-center justify-center border border-white/30"> <div className="h-12 w-12 rounded-full bg-white/20 backdrop-blur-md flex items-center justify-center border border-white/30">
<Play className="h-6 w-6 text-white fill-current" /> <Play className="h-6 w-6 text-white fill-current" />
</div> </div>
</div> </div>
)}
</div> </div>
<Dialog open={previewOpen} onOpenChange={handlePreviewOpenChange}>
<DialogContent
className="max-w-4xl w-[min(100vw-1.5rem,56rem)] gap-0 overflow-hidden rounded-2xl border border-grayScale-200 p-0 shadow-2xl"
onOpenAutoFocus={(e) => e.preventDefault()}
>
<div className="border-b border-grayScale-100 bg-gradient-to-r from-[#F8FAFC] to-white px-5 py-4 pr-12 sm:px-6 sm:pr-14">
<DialogHeader className="space-y-0.5 p-0 text-left">
<p className="text-[10px] font-bold uppercase tracking-[0.2em] text-brand-500">
Short preview
</p>
<DialogTitle className="line-clamp-2 text-left text-base font-bold leading-snug text-grayScale-900 sm:text-lg">
{title}
</DialogTitle>
<p className="pt-0.5 text-left text-xs font-medium text-grayScale-500">
The player closes automatically after {previewLengthLabel} in
this window (YouTube/Vimeo cant be trimmed reliably). For the
full lesson, use your LMS app.
</p>
</DialogHeader>
</div>
<div className="bg-black">
{videoPreview.kind === "iframe" && limitedEmbedSrc ? (
iframeSessionDone ? (
<div className="flex min-h-[220px] flex-col items-center justify-center gap-3 bg-gradient-to-b from-grayScale-900 to-grayScale-950 px-6 py-10 text-center">
<p className="text-sm font-semibold text-white">
Preview time in this window has ended
</p>
<p className="max-w-sm text-xs text-white/60">
The embed is removed after {previewLengthLabel} of real time
so the full video is not available here.
</p>
<Button
type="button"
size="sm"
variant="secondary"
className="mt-1 font-bold"
onClick={() => {
setIframeSessionDone(false);
setIframeSessionKey((k) => k + 1);
}}
>
Start preview again
</Button>
{videoUrl && isAdminOrSuperAdminRole() ? (
<a
href={videoUrl}
target="_blank"
rel="noopener noreferrer"
className="text-xs font-semibold text-brand-300 underline-offset-2 hover:underline"
>
Open full video in new tab
</a>
) : null}
</div>
) : (
<div className="relative aspect-video w-full">
<iframe
key={`${iframeSessionKey}-${limitedEmbedSrc}`}
src={limitedEmbedSrc}
title={`${videoPreview.label} preview: ${title}`}
className="absolute inset-0 h-full w-full"
allow="autoplay; fullscreen; picture-in-picture; encrypted-media"
allowFullScreen
/>
<div className="pointer-events-none absolute bottom-0 left-0 right-0 z-10 bg-gradient-to-t from-black/90 via-black/50 to-transparent px-3 py-2.5 text-center text-[11px] font-semibold text-white/95">
Stops in {previewLengthLabel} (hard limit)
</div>
</div>
)
) : videoPreview.kind === "video" ? (
<PreviewLimitedFileVideo
src={videoPreview.src}
maxSeconds={DEFAULT_PREVIEW_MAX_SECONDS}
/>
) : (
<div className="flex min-h-[200px] flex-col items-center justify-center gap-2 bg-grayScale-900 px-6 py-10 text-center">
<p className="text-sm font-medium text-white/90">
This link cant be played inline
</p>
<p className="max-w-sm text-xs text-white/50">
Use a Vimeo, YouTube, or direct URL to a video file (e.g. MP4)
for an embedded preview.
</p>
{videoUrl ? (
<a
href={videoUrl}
target="_blank"
rel="noopener noreferrer"
className="mt-1 text-sm font-semibold text-brand-300 underline-offset-2 hover:underline"
>
Open in new tab
</a>
) : null}
</div>
)}
</div>
</DialogContent>
</Dialog>
{/* Content */} {/* Content */}
<div className="p-5 space-y-4 flex-1 flex flex-col"> <div className="p-5 space-y-4 flex-1 flex flex-col">
<div className="flex items-center justify-between">
{/* Status Badge */}
<div <div
className={cn( className={cn(
"flex items-center gap-1.5 px-3 py-1 rounded-full text-[11px] font-bold uppercase tracking-wider border", "flex items-center gap-2",
hoverModuleActions ? "justify-start" : "justify-between",
)}
>
{/* Status Badge */}
{status ? (
<div
className={cn(
"flex items-center gap-1.5 px-3 py-1 rounded-full text-[11px] font-bold uppercase tracking-wider border min-w-0",
status === "Published" status === "Published"
? "bg-[#ECFDF5] text-[#059669] border-[#D1FAE5]" ? "bg-[#ECFDF5] text-[#059669] border-[#D1FAE5]"
: "bg-[#F3F4F6] text-[#6B7280] border-[#E5E7EB]", : "bg-[#F3F4F6] text-[#6B7280] border-[#E5E7EB]",
@ -55,23 +316,34 @@ export function VideoCard({
> >
<div <div
className={cn( className={cn(
"h-1.5 w-1.5 rounded-full", "h-1.5 w-1.5 rounded-full flex-shrink-0",
status === "Published" ? "bg-[#10B981]" : "bg-[#9CA3AF]", status === "Published" ? "bg-[#10B981]" : "bg-[#9CA3AF]",
)} )}
/> />
{status} {status}
</div> </div>
{/* Menu */} ) : (
<button className="h-8 w-8 flex items-center justify-center rounded-full hover:bg-grayScale-50 transition-colors text-grayScale-400"> <div className="flex min-w-0 items-center gap-1.5 px-3 py-1 rounded-full text-[11px] font-bold uppercase tracking-wider border border-[#E5E7EB] bg-grayScale-50 text-grayScale-500">
<div className="h-1.5 w-1.5 rounded-full flex-shrink-0 bg-[#9CA3AF]" />
Lesson
</div>
)}
{!hoverModuleActions ? (
<button
type="button"
className="h-8 w-8 flex flex-shrink-0 items-center justify-center rounded-full hover:bg-grayScale-50 transition-colors text-grayScale-400"
>
<MoreVertical className="h-5 w-5" /> <MoreVertical className="h-5 w-5" />
</button> </button>
) : null}
</div> </div>
<h3 className="text-[16px] font-medium text-grayScale-900 line-clamp-2 leading-snug"> <h3 className="text-[16px] font-medium text-grayScale-900 line-clamp-2 leading-snug">
{title} {title}
</h3> </h3>
{/* Actions */} {/* Actions (footer) — not used for API lesson cards with hover tools */}
{!hoverModuleActions ? (
<div className="pt-2 space-y-3 mt-auto"> <div className="pt-2 space-y-3 mt-auto">
<Button <Button
variant="outline" variant="outline"
@ -81,6 +353,7 @@ export function VideoCard({
<Edit2 className="h-4 w-4" /> <Edit2 className="h-4 w-4" />
Edit Edit
</Button> </Button>
{status ? (
<Button <Button
disabled={status === "Published"} disabled={status === "Published"}
onClick={onPublish} onClick={onPublish}
@ -93,7 +366,9 @@ export function VideoCard({
> >
{status === "Published" ? "Published" : "Publish"} {status === "Published" ? "Published" : "Publish"}
</Button> </Button>
) : null}
</div> </div>
) : null}
</div> </div>
</div> </div>
); );

View File

@ -1,86 +1,167 @@
import { import { useEffect, useMemo, useState } from "react";
Rocket, import { Rocket, Edit2, Link2, Video } from "lucide-react";
Edit2,
Layout,
Volume2,
Settings,
Maximize2,
} from "lucide-react";
import { Button } from "../../../../components/ui/button"; import { Button } from "../../../../components/ui/button";
import { toast } from "sonner";
import type { AddLessonFormData } from "../../AddVideoFlow";
import {
applyShortPreviewToEmbedUrl,
DEFAULT_PREVIEW_MAX_SECONDS,
formatPreviewLength,
getVideoPreview,
resolveThumbnailForPreview,
} from "../../../../lib/videoPreview";
import { PreviewLimitedFileVideo } from "../PreviewLimitedFileVideo";
interface ReviewPublishStepProps { interface ReviewPublishStepProps {
formData: any; formData: AddLessonFormData;
prevStep: () => void; prevStep: () => void;
setIsPublished: (val: boolean) => void; onPublish: () => void;
publishing: boolean;
}
function truncate(s: string, max: number): string {
if (s.length <= max) return s;
return `${s.slice(0, max)}`;
} }
export function ReviewPublishStep({ export function ReviewPublishStep({
formData, formData,
prevStep, prevStep,
setIsPublished, onPublish,
publishing,
}: ReviewPublishStepProps) { }: ReviewPublishStepProps) {
const [thumbBroken, setThumbBroken] = useState(false);
const videoPreview = useMemo(
() => getVideoPreview(formData.videoUrl),
[formData.videoUrl],
);
const limitedEmbedSrc = useMemo(() => {
if (videoPreview.kind !== "iframe") return null;
return applyShortPreviewToEmbedUrl(
videoPreview.src,
videoPreview.label,
DEFAULT_PREVIEW_MAX_SECONDS,
);
}, [videoPreview]);
const previewLengthLabel = formatPreviewLength(DEFAULT_PREVIEW_MAX_SECONDS);
const thumbSrc = useMemo(
() => resolveThumbnailForPreview(formData.thumbnailUrl),
[formData.thumbnailUrl],
);
useEffect(() => {
setThumbBroken(false);
}, [thumbSrc]);
return ( return (
<div className="space-y-6 animate-in fade-in slide-in-from-bottom-4 duration-500 pb-20"> <div className="space-y-6 animate-in fade-in slide-in-from-bottom-4 duration-500 pb-20">
{/* 1. Video Preview Card */}
<div className="bg-white rounded-[16px] border border-grayScale-50 shadow-sm overflow-hidden"> <div className="bg-white rounded-[16px] border border-grayScale-50 shadow-sm overflow-hidden">
<div className="px-8 py-5 border-b border-grayScale-50 flex items-center justify-between bg-white"> <div className="px-8 py-5 border-b border-grayScale-50 flex flex-col gap-1 sm:flex-row sm:items-end sm:justify-between bg-white">
<h3 className="text-[17px] font-bold text-grayScale-900"> <h3 className="text-[17px] font-bold text-grayScale-900">
Video Preview Media preview
</h3> </h3>
<span className="bg-[#FAF5FF] text-brand-500 text-[10px] font-bold px-3 py-1.5 rounded-[6px] tracking-wider uppercase border border-brand-100/50"> <p className="text-xs font-medium text-grayScale-500">
PROCESSED Video: short clip (first {previewLengthLabel} only)
</p>
</div>
<div className="p-8">
<div className="flex flex-col gap-10 xl:flex-row xl:items-start xl:gap-10">
{/* Video preview */}
<div className="min-w-0 flex-1 space-y-3">
<span className="text-[11px] font-bold text-grayScale-500 uppercase tracking-widest block">
Video
</span> </span>
</div> {formData.videoUrl ? (
<div className="p-10 flex items-center justify-center bg-[#F8FAFC]/30"> <div className="space-y-3">
<div className="relative w-full max-w-4xl aspect-video rounded-[12px] overflow-hidden bg-black shadow-2xl group border-4 border-white"> {videoPreview.kind === "iframe" && limitedEmbedSrc ? (
{/* Mock Player Control Overlays */} <div className="overflow-hidden rounded-xl border border-grayScale-200 bg-black shadow-sm">
<div className="absolute inset-0 flex items-center justify-center"> <div className="relative aspect-video w-full max-w-4xl">
<div className="h-16 w-16 rounded-full bg-white/20 backdrop-blur-md flex items-center justify-center border border-white/30 cursor-pointer hover:scale-110 transition-transform"> <iframe
<div className="w-0 h-0 border-t-[10px] border-t-transparent border-l-[18px] border-l-white border-b-[10px] border-b-transparent ml-1" /> key={limitedEmbedSrc}
src={limitedEmbedSrc}
title={`${videoPreview.label} lesson preview`}
className="absolute inset-0 h-full w-full"
allow="autoplay; fullscreen; picture-in-picture"
allowFullScreen
/>
<div className="pointer-events-none absolute bottom-0 left-0 right-0 z-10 bg-gradient-to-t from-black/90 via-black/50 to-transparent px-3 py-2.5 text-center text-[11px] font-semibold text-white/95">
Short clip · max {previewLengthLabel}
</div> </div>
</div> </div>
</div>
{/* Bottom Controls — Matching Image 1884 */} ) : videoPreview.kind === "video" ? (
<div className="absolute bottom-0 left-0 right-0 p-6 bg-gradient-to-t from-black/95 via-black/40 to-transparent space-y-4"> <div className="overflow-hidden rounded-xl border border-grayScale-200 bg-black shadow-sm">
{/* Row 1: Seeker and Timestamps */} <PreviewLimitedFileVideo
<div className="flex items-center gap-4 text-white"> src={videoPreview.src}
<span className="text-[13px] font-medium opacity-90">0:00</span> maxSeconds={DEFAULT_PREVIEW_MAX_SECONDS}
<div className="flex-1 h-1 bg-white/20 rounded-full relative cursor-pointer overflow-hidden group/seeker">
<div
className="absolute left-0 top-0 bottom-0 bg-brand-500 rounded-full"
style={{ width: "40%" }}
/> />
</div> </div>
<span className="text-[13px] font-medium opacity-90"> ) : (
12:30 <div className="flex min-h-[200px] flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed border-grayScale-200 bg-grayScale-50/80 px-6 py-10 text-center">
<Video className="h-10 w-10 text-grayScale-300" />
<p className="text-sm font-medium text-grayScale-600">
No inline preview for this URL
</p>
<p className="text-xs text-grayScale-500 max-w-md">
Use a Vimeo, YouTube, or direct link to a video file
(MP4, WebM, ) to see a player here. The URL below will
still be saved.
</p>
</div>
)}
<div className="flex items-start gap-2 text-[13px] text-grayScale-600 break-all">
<Link2 className="h-3.5 w-3.5 mt-0.5 flex-shrink-0 text-grayScale-400" />
{truncate(formData.videoUrl, 220)}
</div>
</div>
) : (
<p className="text-grayScale-400 text-sm"></p>
)}
</div>
{/* Thumbnail preview */}
<div className="w-full shrink-0 space-y-3 xl:max-w-[360px]">
<span className="text-[11px] font-bold text-grayScale-500 uppercase tracking-widest block">
Thumbnail
</span> </span>
{formData.thumbnailUrl && thumbSrc ? (
<div className="space-y-3">
<div className="overflow-hidden rounded-xl border border-grayScale-200 bg-grayScale-100 shadow-sm">
<div className="relative aspect-video w-full max-w-md">
{!thumbBroken ? (
<img
src={thumbSrc}
alt=""
className="absolute inset-0 h-full w-full object-cover"
onError={() => setThumbBroken(true)}
/>
) : (
<div className="flex aspect-video w-full max-w-md items-center justify-center bg-grayScale-200 px-4 text-center text-xs text-grayScale-500">
Thumbnail could not be loaded. URL will still be
saved.
</div> </div>
)}
{/* Row 2: Icons */}
<div className="flex items-center justify-between text-white">
<div className="flex items-center gap-6">
<Volume2 className="h-[22px] w-[22px] opacity-90 cursor-pointer hover:opacity-100 transition-opacity" />
<div className="h-5 w-6 border-2 border-white rounded-[3px] flex items-center justify-center text-[9px] font-bold opacity-90 cursor-pointer hover:opacity-100 transition-opacity">
CC
</div> </div>
</div> </div>
<div className="flex items-center gap-6"> <p className="text-[12px] text-grayScale-500 break-all">
<Settings className="h-5 w-5 opacity-90 cursor-pointer hover:opacity-100 transition-opacity" /> {truncate(formData.thumbnailUrl, 160)}
<Maximize2 className="h-5 w-5 opacity-90 cursor-pointer hover:opacity-100 transition-opacity" /> </p>
</div>
</div> </div>
) : (
<p className="text-grayScale-400 text-sm"></p>
)}
</div> </div>
</div> </div>
</div> </div>
</div> </div>
{/* 2. Content Details Card */}
<div className="bg-white rounded-[16px] border border-grayScale-50 shadow-sm overflow-hidden"> <div className="bg-white rounded-[16px] border border-grayScale-50 shadow-sm overflow-hidden">
<div className="px-8 py-5 border-b border-grayScale-50 flex items-center justify-between bg-white"> <div className="px-8 py-5 border-b border-grayScale-50 flex items-center justify-between bg-white">
<h3 className="text-[16px] font-bold text-grayScale-900"> <h3 className="text-[16px] font-bold text-grayScale-900">
Content Details Content details
</h3> </h3>
<button <button
type="button"
onClick={prevStep} onClick={prevStep}
className="flex items-center gap-2 text-brand-500 font-bold text-sm hover:opacity-80 transition-opacity" className="flex items-center gap-2 text-brand-500 font-bold text-sm hover:opacity-80 transition-opacity"
> >
@ -90,70 +171,29 @@ export function ReviewPublishStep({
</div> </div>
<div className="p-8 space-y-10"> <div className="p-8 space-y-10">
{/* Metadata Grid */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-8">
<div className="space-y-2"> <div className="space-y-2">
<span className="text-[11px] font-bold text-grayScale-500 uppercase tracking-widest block"> <span className="text-[11px] font-bold text-grayScale-500 uppercase tracking-widest block">
TITLE Title
</span> </span>
<p className="text-[15px] font-medium text-grayScale-900"> <p className="text-[15px] font-medium text-grayScale-900">
{formData.title || "Introduction to Past Tense"} {formData.title || ""}
</p> </p>
</div> </div>
<div className="space-y-2">
<span className="text-[11px] font-bold text-grayScale-500 uppercase tracking-widest block">
ASSIGNED MODULE
</span>
<div className="flex items-center gap-2">
<Layout className="h-4 w-4 text-grayScale-400" />
<p className="text-[14px] font-medium text-grayScale-700">
Grammar Basics - Level 1
</p>
</div>
</div>
<div className="space-y-2">
<span className="text-[11px] font-bold text-grayScale-500 uppercase tracking-widest block">
TEACHER NAME
</span>
<p className="text-[15px] font-medium text-grayScale-600">
Abebe Kebede
</p>
</div>
<div className="space-y-2">
<span className="text-[11px] font-bold text-grayScale-500 uppercase tracking-widest block">
FILE SIZE
</span>
<div className="flex items-baseline gap-1.5">
<span className="text-[15px] font-bold text-grayScale-900">
245 MB
</span>
<span className="text-[13px] text-grayScale-400 font-medium">
(1080p MP4)
</span>
</div>
</div>
</div>
{/* Description Section */}
<div className="space-y-3"> <div className="space-y-3">
<span className="text-[11px] font-bold text-grayScale-500 uppercase tracking-widest block"> <span className="text-[11px] font-bold text-grayScale-500 uppercase tracking-widest block">
DESCRIPTION Description
</span> </span>
<div <div
className="text-[14px] text-grayScale-600 leading-relaxed max-w-4xl" className="text-[14px] text-grayScale-600 leading-relaxed max-w-4xl"
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{
__html: __html:
formData.description || formData.description || "<p class='text-grayScale-400'>—</p>",
"This video covers the fundamental rules of forming the past tense in English, focusing on regular verbs ending in -ed. Suitable for beginners. Includes examples and common pitfalls.",
}} }}
/> />
</div> </div>
</div> </div>
{/* Gradient Divider */}
<div className="relative"> <div className="relative">
<div <div
className="absolute inset-0 flex items-center" className="absolute inset-0 flex items-center"
@ -164,18 +204,17 @@ export function ReviewPublishStep({
<div className="relative flex justify-center"> <div className="relative flex justify-center">
<div <div
className="h-[0.5px] w-full opacity-20 rounded-full" className="h-[0.5px] w-full opacity-20 rounded-full"
style={{ style={{ background: "gray" }}
background: "gray",
}}
/> />
</div> </div>
</div> </div>
{/* 3. Normal Footer (Inside Card) */}
<div className="px-8 py-6 border-t border-grayScale-50 flex items-center justify-between bg-white"> <div className="px-8 py-6 border-t border-grayScale-50 flex items-center justify-between bg-white">
<Button <Button
type="button"
variant="outline" variant="outline"
onClick={prevStep} onClick={prevStep}
disabled={publishing}
className="h-10 px-8 rounded-xl border-grayScale-200 font-bold text-grayScale-600 hover:bg-grayScale-50 transition-all shadow-sm" className="h-10 px-8 rounded-xl border-grayScale-200 font-bold text-grayScale-600 hover:bg-grayScale-50 transition-all shadow-sm"
> >
Back Back
@ -183,17 +222,24 @@ export function ReviewPublishStep({
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<Button <Button
type="button"
variant="outline" variant="outline"
className="h-12 px-8 rounded-[6px] border-grayScale-100 font-bold text-grayScale-600 hover:bg-grayScale-50 transition-all shadow-sm" className="h-12 px-8 rounded-[6px] border-grayScale-100 font-bold text-grayScale-600 hover:bg-grayScale-50 transition-all shadow-sm"
disabled={publishing}
onClick={() =>
toast.info("Drafts are not supported yet. Use Create lesson.")
}
> >
Save as Draft Save as draft
</Button> </Button>
<Button <Button
onClick={() => setIsPublished(true)} type="button"
className="h-10 px-10 rounded-[6px] bg-brand-500 font-bold text-white shadow-brand-500/20 transition-all flex items-center gap-2.5" onClick={onPublish}
disabled={publishing}
className="h-10 px-10 rounded-[6px] bg-brand-500 font-bold text-white shadow-brand-500/20 transition-all flex items-center gap-2.5 disabled:opacity-60"
> >
<Rocket className="h-4 w-4" /> <Rocket className="h-4 w-4" />
Publish Now {publishing ? "Creating…" : "Create lesson"}
</Button> </Button>
</div> </div>
</div> </div>

View File

@ -1,32 +1,36 @@
import { useRef, useEffect } from "react";
import { import {
Video, useRef,
List, useEffect,
Link as LinkIcon, type Dispatch,
Lightbulb, type SetStateAction,
ChevronRight, } from "react";
ImageIcon, import { List, Link as LinkIcon, Lightbulb, ArrowRight } from "lucide-react";
ArrowRight, import { toast } from "sonner";
} from "lucide-react";
import { Button } from "../../../../components/ui/button"; import { Button } from "../../../../components/ui/button";
import { Input } from "../../../../components/ui/input"; import { Input } from "../../../../components/ui/input";
import { Select } from "../../../../components/ui/select"; import type { AddLessonFormData } from "../../AddVideoFlow";
import { LessonMediaUploadField } from "../LessonMediaUploadField";
function isDescriptionEmpty(raw: string): boolean {
if (!raw?.trim()) return true;
const t = raw.replace(/<[^>]+>/g, "").replace(/&nbsp;/g, " ").trim();
return t.length === 0;
}
interface VideoDetailStepProps { interface VideoDetailStepProps {
formData: any; formData: AddLessonFormData;
setFormData: (data: any) => void; setFormData: Dispatch<SetStateAction<AddLessonFormData>>;
nextStep: () => void; onContinue: () => void;
} }
export function VideoDetailStep({ export function VideoDetailStep({
formData, formData,
setFormData, setFormData,
nextStep, onContinue,
}: VideoDetailStepProps) { }: VideoDetailStepProps) {
const editorRef = useRef<HTMLDivElement>(null); const editorRef = useRef<HTMLDivElement>(null);
const isInternalChange = useRef(false); const isInternalChange = useRef(false);
// Initialize editor content only once or when needed from outside
useEffect(() => { useEffect(() => {
if (editorRef.current && !isInternalChange.current) { if (editorRef.current && !isInternalChange.current) {
editorRef.current.innerHTML = formData.description || ""; editorRef.current.innerHTML = formData.description || "";
@ -41,8 +45,10 @@ export function VideoDetailStep({
const syncState = () => { const syncState = () => {
if (editorRef.current) { if (editorRef.current) {
isInternalChange.current = true; isInternalChange.current = true;
setFormData({ ...formData, description: editorRef.current.innerHTML }); setFormData((prev) => ({
// Reset after a short delay to allow exterior updates if any (e.g., from step change) ...prev,
description: editorRef.current!.innerHTML,
}));
setTimeout(() => { setTimeout(() => {
isInternalChange.current = false; isInternalChange.current = false;
}, 0); }, 0);
@ -53,50 +59,57 @@ export function VideoDetailStep({
syncState(); syncState();
}; };
const handleContinue = () => {
if (editorRef.current) {
setFormData((prev) => ({
...prev,
description: editorRef.current!.innerHTML,
}));
}
if (!formData.title.trim()) {
toast.error("Title is required");
return;
}
if (!formData.videoUrl.trim()) {
toast.error("Add a video URL or upload a video");
return;
}
if (!formData.thumbnailUrl.trim()) {
toast.error("Add a thumbnail or upload an image");
return;
}
const descHtml = editorRef.current?.innerHTML ?? formData.description;
if (isDescriptionEmpty(descHtml)) {
toast.error("Description is required");
return;
}
onContinue();
};
return ( return (
<div className="animate-in fade-in slide-in-from-bottom-4 duration-700 max-w-[1200px] mx-auto pb-20"> <div className="animate-in fade-in slide-in-from-bottom-4 duration-700 max-w-[1200px] mx-auto pb-20">
{/* Single Unified Card for Everything */}
<div className="bg-white rounded-[24px] border border-grayScale-50 p-10 shadow-sm space-y-8"> <div className="bg-white rounded-[24px] border border-grayScale-50 p-10 shadow-sm space-y-8">
{/* 1. Upload Video Section */} <div className="space-y-3">
<div className="space-y-6">
<h3 className="text-[20px] font-bold text-grayScale-900 ml-1"> <h3 className="text-[20px] font-bold text-grayScale-900 ml-1">
Upload Video Video
</h3> </h3>
<div className="relative group cursor-pointer"> <p className="text-sm text-grayScale-500 ml-1 max-w-2xl">
<div className="flex flex-col items-center justify-center rounded-[20px] border-2 border-dashed border-[#E2E8F0] bg-[#F8FAFC]/30 p-14 transition-all hover:border-brand-200 hover:bg-brand-50/5"> Upload a file or paste a link (Vimeo, hosted file, etc.). Files are
<div className="h-16 w-16 rounded-full bg-white shadow-sm flex items-center justify-center mb-6"> sent to your storage via{" "}
<div className="h-10 w-10 rounded-full bg-[#FAF5FF] flex items-center justify-center"> <code className="rounded bg-grayScale-100 px-1 text-[11px]">
<div className="h-6 w-6 relative flex items-center justify-center"> POST /files/upload
<div className="absolute inset-0 bg-brand-500/10 rounded-full blur-sm" /> </code>
<Video className="h-5 w-5 text-brand-500 relative" /> .
</div>
</div>
</div>
<h4 className="text-[17px] text-grayScale-900 mb-2">
Drag and drop video files here
</h4>
<p className="text-grayScale-400 font-medium text-[13px] mb-8">
MP4, MOV, WebM. Max size 2GB.
</p> </p>
<LessonMediaUploadField
<div className="flex items-center gap-4 w-full max-w-[200px] mb-8"> kind="video"
<div className="flex-1 h-[1px] bg-grayScale-200" /> value={formData.videoUrl}
<span className="text-[12px] font-bold text-grayScale-300 uppercase tracking-widest"> onChange={(v) =>
OR setFormData((prev) => ({ ...prev, videoUrl: v }))
</span> }
<div className="flex-1 h-[1px] bg-grayScale-200" /> />
</div> </div>
<Button
variant="outline"
className="h-11 px-8 rounded-xl border-grayScale-200 bg-white font-bold text-brand-500 hover:border-brand-500 hover:bg-brand-50 transition-all shadow-sm text-sm"
>
Browse Files
</Button>
</div>
</div>
</div>
{/* Gradient Divider */}
<div className="relative"> <div className="relative">
<div <div
className="absolute inset-0 flex items-center" className="absolute inset-0 flex items-center"
@ -107,75 +120,57 @@ export function VideoDetailStep({
<div className="relative flex justify-center"> <div className="relative flex justify-center">
<div <div
className="h-[0.5px] w-full opacity-20 rounded-full" className="h-[0.5px] w-full opacity-20 rounded-full"
style={{ style={{ background: "gray" }}
background: "gray",
}}
/> />
</div> </div>
</div> </div>
{/* 2. Form & Side Panel Grid */}
<div className="flex flex-col lg:flex-row gap-12 items-start"> <div className="flex flex-col lg:flex-row gap-12 items-start">
{/* Left Column: Title, Order, Description */}
<div className="flex-1 w-full space-y-10"> <div className="flex-1 w-full space-y-10">
<div className="space-y-3"> <div className="space-y-3">
<label className="text-[14px] font-medium text-grayScale-900 ml-1"> <label className="text-[14px] font-medium text-grayScale-900 ml-1">
Video Title Lesson title
</label> </label>
<Input <Input
placeholder="e.g., Introduction to Past Tense Verbs" placeholder="e.g. Introduction to Past Tense"
className="h-12 rounded-xl border-grayScale-200 bg-white px-6 text-[15px] text-grayScale-800 placeholder:text-grayScale-500 focus:border-brand-500 font-medium transition-all shadow-sm" className="h-12 rounded-xl border-grayScale-200 bg-white px-6 text-[15px] text-grayScale-800 placeholder:text-grayScale-500 focus:border-brand-500 font-medium transition-all shadow-sm"
value={formData.title} value={formData.title}
onChange={(e) => onChange={(e) =>
setFormData({ ...formData, title: e.target.value }) setFormData((prev) => ({ ...prev, title: e.target.value }))
} }
/> />
</div> </div>
<div className="space-y-3">
<label className="text-[14px] font-medium text-grayScale-900 ml-1">
Video Order
</label>
<Select
className="h-12 rounded-xl border-grayScale-200 bg-white px-6 text-[15px] text-grayScale-800 font-medium cursor-pointer focus:border-brand-500 shadow-sm"
value={formData.order}
onChange={(e) =>
setFormData({ ...formData, order: (e.target as any).value })
}
>
<option value="1">1</option>
<option value="2">2</option>
<option value="3">3</option>
</Select>
</div>
<div className="space-y-3"> <div className="space-y-3">
<label className="text-[14px] font-medium text-grayScale-900 ml-1"> <label className="text-[14px] font-medium text-grayScale-900 ml-1">
Description Description
</label> </label>
<div className="rounded-xl border border-grayScale-200 bg-white overflow-hidden flex flex-col min-h-[200px] shadow-sm focus-within:border-brand-200 transition-all"> <div className="rounded-xl border border-grayScale-200 bg-white overflow-hidden flex flex-col min-h-[200px] shadow-sm focus-within:border-brand-200 transition-all">
{/* Toolbar */}
<div className="flex items-center gap-1 bg-[#F8FAFC]"> <div className="flex items-center gap-1 bg-[#F8FAFC]">
<div className="flex items-center gap-1 w-fit bg-transparent px-2 py-1 rounded-lg"> <div className="flex items-center gap-1 w-fit bg-transparent px-2 py-1 rounded-lg">
<button <button
type="button"
onClick={() => handleCommand("bold")} onClick={() => handleCommand("bold")}
className="h-9 w-9 flex items-center justify-center rounded-lg hover:bg-white hover:shadow-sm text-grayScale-900 transition-all font-serif font-bold text-[17px] pb-0.5 active:bg-grayScale-50" className="h-9 w-9 flex items-center justify-center rounded-lg hover:bg-white hover:shadow-sm text-grayScale-900 transition-all font-serif font-bold text-[17px] pb-0.5 active:bg-grayScale-50"
> >
B B
</button> </button>
<button <button
type="button"
onClick={() => handleCommand("italic")} onClick={() => handleCommand("italic")}
className="h-9 w-9 flex items-center justify-center rounded-lg hover:bg-white hover:shadow-sm text-grayScale-900 transition-all font-serif italic text-[17px] pr-0.5 active:bg-grayScale-50" className="h-9 w-9 flex items-center justify-center rounded-lg hover:bg-white hover:shadow-sm text-grayScale-900 transition-all font-serif italic text-[17px] pr-0.5 active:bg-grayScale-50"
> >
I I
</button> </button>
<button <button
type="button"
onClick={() => handleCommand("insertUnorderedList")} onClick={() => handleCommand("insertUnorderedList")}
className="h-9 w-9 flex items-center justify-center rounded-lg hover:bg-white hover:shadow-sm text-grayScale-900 transition-all active:bg-grayScale-50" className="h-9 w-9 flex items-center justify-center rounded-lg hover:bg-white hover:shadow-sm text-grayScale-900 transition-all active:bg-grayScale-50"
> >
<List className="h-5 w-5" /> <List className="h-5 w-5" />
</button> </button>
<button <button
type="button"
onClick={() => { onClick={() => {
const url = prompt("Enter URL:"); const url = prompt("Enter URL:");
if (url) handleCommand("createLink", url); if (url) handleCommand("createLink", url);
@ -188,12 +183,9 @@ export function VideoDetailStep({
</div> </div>
<div className="relative p-6 flex-1"> <div className="relative p-6 flex-1">
{(!formData.description || {isDescriptionEmpty(formData.description) && (
formData.description === "<br>" ||
formData.description === "" ||
formData.description === "<div><br></div>") && (
<div className="absolute top-6 left-6 text-grayScale-300 font-medium text-[15px] pointer-events-none"> <div className="absolute top-6 left-6 text-grayScale-300 font-medium text-[15px] pointer-events-none">
Provide a brief summary of what the student will learn... What will students learn in this lesson?
</div> </div>
)} )}
<div <div
@ -207,59 +199,44 @@ export function VideoDetailStep({
</div> </div>
</div> </div>
{/* Right Column: Thumbnail, Pro Tip */} <div className="w-full lg:w-[360px] space-y-5">
<div className="w-full lg:w-[320px] space-y-5"> <LessonMediaUploadField
{/* Thumbnail Section */} kind="thumbnail"
<div className="space-y-4"> value={formData.thumbnailUrl}
<div className="space-y-1 ml-1"> onChange={(v) =>
<h3 className="text-[14px] font-medium text-grayScale-900"> setFormData((prev) => ({ ...prev, thumbnailUrl: v }))
Thumbnail }
</h3> />
<p className="text-[12px] text-grayScale-400 font-medium leading-relaxed">
Upload your video thumbnail. 1280×720px recommended.
</p>
</div>
<div className="relative group cursor-pointer aspect-video">
<div className="h-full w-full flex flex-col items-center justify-center rounded-xl border-2 border-dashed border-grayScale-200 bg-[#F8FAFC]/50 p-6 transition-all group-hover:border-brand-200">
<div className="h-10 w-10 flex items-center justify-center mb-3">
<ImageIcon className="h-7 w-7 text-grayScale-400" />
</div>
<p className="text-[13px] font-bold text-brand-400">
Click to upload
</p>
</div>
</div>
</div>
{/* Pro Tip Section */}
<div className="bg-brand-500/5 flex items-start gap-3 rounded-xl border border-[#F3E8FF] p-6 space-y-3"> <div className="bg-brand-500/5 flex items-start gap-3 rounded-xl border border-[#F3E8FF] p-6 space-y-3">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="h-8 w-8 flex-shrink-0 flex items-center justify-center"> <div className="h-8 w-8 flex-shrink-0 flex items-center justify-center">
<Lightbulb className="h-4 w-4 text-brand-50" fill="#A855F7" /> <Lightbulb
className="h-4 w-4 text-brand-50"
fill="#A855F7"
/>
</div> </div>
</div> </div>
<div className="relative top-[-10px]"> <div className="relative top-[-10px]">
<h3 className="text-[14px] font-bold text-grayScale-900"> <h3 className="text-[14px] font-bold text-grayScale-900">
Pro Tip Pro tip
</h3> </h3>
<p className="text-[12px] text-grayScale-700 font-medium leading-relaxed"> <p className="text-[12px] text-grayScale-700 font-medium leading-relaxed">
Short, descriptive titles work best. Include keywords like Use clear titles and a thumbnail that matches the lesson. The
"Grammar" or "Vocabulary" to help students find your content. lesson is created with{" "}
<code className="rounded bg-white/80 px-1 text-[10px]">
POST /modules/:moduleId/lessons
</code>{" "}
when you publish.
</p> </p>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
{/* Footer (Inside Card Container) */} <div className="pt-5 border-t border-grayScale-200 flex items-center justify-end">
<div className="pt-5 border-t border-grayScale-200 flex items-center justify-between">
<div className="flex items-center gap-3">
<span className="text-[14px] font-medium text-grayScale-600">
Last saved: Just now
</span>
</div>
<Button <Button
onClick={nextStep} type="button"
onClick={handleContinue}
className="h-10 px-10 rounded-[6px] bg-brand-500 font-bold text-white transition-all flex items-center gap-2 text-sm group active:scale-95" className="h-10 px-10 rounded-[6px] bg-brand-500 font-bold text-white transition-all flex items-center gap-2 text-sm group active:scale-95"
> >
Continue Continue

View File

@ -111,7 +111,11 @@ export interface ProgramCourseListItem {
thumbnail?: string | null thumbnail?: string | null
/** Some list endpoints may expose the image as `thumbnail_url` instead. */ /** Some list endpoints may expose the image as `thumbnail_url` instead. */
thumbnail_url?: string | null thumbnail_url?: string | null
/** When the API adds aggregates, map these for the course cards. */ /** GET /programs/:id/courses aggregates. */
module_count?: number
lesson_count?: number
practice_count?: number
/** Legacy aggregate field names; prefer module_count, lesson_count, practice_count. */
modules_count?: number modules_count?: number
videos_count?: number videos_count?: number
practices_count?: number practices_count?: number
@ -199,6 +203,122 @@ export interface CreateTopLevelCourseModuleResponse {
metadata: unknown | null metadata: unknown | null
} }
/** Row from GET /modules/:moduleId/lessons (Learn English top-level module lessons). */
export interface TopLevelModuleLessonItem {
id: number
module_id: number
title: string
video_url: string
thumbnail: string
description: string
sort_order: number
created_at: string
}
export interface GetTopLevelModuleLessonsResponse {
message: string
data: {
total_count: number
limit: number
offset: number
lessons: TopLevelModuleLessonItem[]
}
success: boolean
status_code: number
metadata: unknown | null
}
/** Practice returned by GET /courses|modules|lessons/.../practices (Learn English parent-linked practice). */
export interface ParentContextPractice {
id: number
parent_kind: string
parent_id: number
title: string
story_description: string
story_image: string
question_set_id: number
quick_tips: string
persona_id?: number | null
created_at: string
}
export interface GetPracticesByParentContextResponse {
message: string
data: {
offset: number
limit: number
practices: ParentContextPractice[]
total_count: number
}
success: boolean
status_code: number
metadata: unknown | null
}
export type PracticeParentKind = "COURSE" | "MODULE" | "LESSON"
/** POST /practices — create practice linked to a course, module, or lesson (Learn English). */
export interface CreateParentLinkedPracticeRequest {
parent_kind: PracticeParentKind
parent_id: number
title: string
story_description: string
story_image: string
question_set_id: number
quick_tips: string
persona_id?: number
}
export interface CreateParentLinkedPracticeResponse {
message: string
data: ParentContextPractice
success: boolean
status_code: number
metadata: unknown | null
}
/** Body for PUT /practices/:id (Learn English parent-linked practice). */
export interface UpdateParentLinkedPracticeRequest {
title: string
story_description: string
story_image: string
question_set_id: number
quick_tips: string
persona_id?: number | null
}
export interface UpdateParentLinkedPracticeResponse {
message: string
data: ParentContextPractice
success: boolean
status_code: number
metadata: unknown | null
}
/** Body for PUT /lessons/:id (Learn English top-level module lessons). */
export interface UpdateTopLevelModuleLessonRequest {
title: string
video_url: string
thumbnail: string
description: string
}
/** Body for POST /modules/:moduleId/lessons. */
export interface CreateTopLevelModuleLessonRequest {
title: string
video_url: string
thumbnail: string
description: string
}
export interface CreateTopLevelModuleLessonResponse {
message: string
data: TopLevelModuleLessonItem
success: boolean
status_code: number
metadata: unknown | null
}
// ============================================ // ============================================
// Legacy Types (deprecated - using SubCourse hierarchy now) // Legacy Types (deprecated - using SubCourse hierarchy now)
// Keeping for backward compatibility with existing API endpoints // Keeping for backward compatibility with existing API endpoints