lesson integration
This commit is contained in:
parent
3634d2eb79
commit
b4ab66b4a6
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
13
src/lib/sessionRole.ts
Normal 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
124
src/lib/videoPreview.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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() ?? "",
|
||||||
|
},
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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">
|
||||||
|
|
|
||||||
481
src/pages/content-management/components/CreatePracticeWizard.tsx
Normal file
481
src/pages/content-management/components/CreatePracticeWizard.tsx
Normal 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 "Look up
|
||||||
|
practice" 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 & 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 & 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/{id}/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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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,84 +45,330 @@ 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",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Duration Badge */}
|
{hoverModuleActions && (onEdit || onDelete) ? (
|
||||||
<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">
|
<div
|
||||||
{duration}
|
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"
|
||||||
</div>
|
>
|
||||||
{/* Play Overlay */}
|
{onEdit ? (
|
||||||
<div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity bg-black/10">
|
<Button
|
||||||
<div className="h-12 w-12 rounded-full bg-white/20 backdrop-blur-md flex items-center justify-center border border-white/30">
|
type="button"
|
||||||
<Play className="h-6 w-6 text-white fill-current" />
|
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>
|
</div>
|
||||||
</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 ? (
|
||||||
|
<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}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{/* 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">
|
||||||
|
<Play className="h-6 w-6 text-white fill-current" />
|
||||||
|
</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 can’t 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 can’t 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">
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2",
|
||||||
|
hoverModuleActions ? "justify-start" : "justify-between",
|
||||||
|
)}
|
||||||
|
>
|
||||||
{/* Status Badge */}
|
{/* Status Badge */}
|
||||||
<div
|
{status ? (
|
||||||
className={cn(
|
|
||||||
"flex items-center gap-1.5 px-3 py-1 rounded-full text-[11px] font-bold uppercase tracking-wider border",
|
|
||||||
status === "Published"
|
|
||||||
? "bg-[#ECFDF5] text-[#059669] border-[#D1FAE5]"
|
|
||||||
: "bg-[#F3F4F6] text-[#6B7280] border-[#E5E7EB]",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-1.5 w-1.5 rounded-full",
|
"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" ? "bg-[#10B981]" : "bg-[#9CA3AF]",
|
status === "Published"
|
||||||
|
? "bg-[#ECFDF5] text-[#059669] border-[#D1FAE5]"
|
||||||
|
: "bg-[#F3F4F6] text-[#6B7280] border-[#E5E7EB]",
|
||||||
)}
|
)}
|
||||||
/>
|
>
|
||||||
{status}
|
<div
|
||||||
</div>
|
className={cn(
|
||||||
{/* Menu */}
|
"h-1.5 w-1.5 rounded-full flex-shrink-0",
|
||||||
<button className="h-8 w-8 flex items-center justify-center rounded-full hover:bg-grayScale-50 transition-colors text-grayScale-400">
|
status === "Published" ? "bg-[#10B981]" : "bg-[#9CA3AF]",
|
||||||
<MoreVertical className="h-5 w-5" />
|
)}
|
||||||
</button>
|
/>
|
||||||
|
{status}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<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" />
|
||||||
|
</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 */}
|
||||||
<div className="pt-2 space-y-3 mt-auto">
|
{!hoverModuleActions ? (
|
||||||
<Button
|
<div className="pt-2 space-y-3 mt-auto">
|
||||||
variant="outline"
|
<Button
|
||||||
onClick={onEdit}
|
variant="outline"
|
||||||
className="w-full h-10 rounded-xl border-grayScale-200 text-grayScale-600 font-bold hover:bg-grayScale-50 transition-all flex items-center justify-center gap-2"
|
onClick={onEdit}
|
||||||
>
|
className="w-full h-10 rounded-xl border-grayScale-200 text-grayScale-600 font-bold hover:bg-grayScale-50 transition-all flex items-center justify-center gap-2"
|
||||||
<Edit2 className="h-4 w-4" />
|
>
|
||||||
Edit
|
<Edit2 className="h-4 w-4" />
|
||||||
</Button>
|
Edit
|
||||||
<Button
|
</Button>
|
||||||
disabled={status === "Published"}
|
{status ? (
|
||||||
onClick={onPublish}
|
<Button
|
||||||
className={cn(
|
disabled={status === "Published"}
|
||||||
"w-full h-10 rounded-xl font-bold transition-all shadow-sm",
|
onClick={onPublish}
|
||||||
status === "Published"
|
className={cn(
|
||||||
? "bg-[#E9D5E5] text-white opacity-100 cursor-default"
|
"w-full h-10 rounded-xl font-bold transition-all shadow-sm",
|
||||||
: "bg-brand-500 text-white hover:bg-brand-600 shadow-brand-500/10",
|
status === "Published"
|
||||||
)}
|
? "bg-[#E9D5E5] text-white opacity-100 cursor-default"
|
||||||
>
|
: "bg-brand-500 text-white hover:bg-brand-600 shadow-brand-500/10",
|
||||||
{status === "Published" ? "Published" : "Publish"}
|
)}
|
||||||
</Button>
|
>
|
||||||
</div>
|
{status === "Published" ? "Published" : "Publish"}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
</span>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-10 flex items-center justify-center bg-[#F8FAFC]/30">
|
<div className="p-8">
|
||||||
<div className="relative w-full max-w-4xl aspect-video rounded-[12px] overflow-hidden bg-black shadow-2xl group border-4 border-white">
|
<div className="flex flex-col gap-10 xl:flex-row xl:items-start xl:gap-10">
|
||||||
{/* Mock Player Control Overlays */}
|
{/* Video preview */}
|
||||||
<div className="absolute inset-0 flex items-center justify-center">
|
<div className="min-w-0 flex-1 space-y-3">
|
||||||
<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">
|
<span className="text-[11px] font-bold text-grayScale-500 uppercase tracking-widest block">
|
||||||
<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" />
|
Video
|
||||||
</div>
|
</span>
|
||||||
</div>
|
{formData.videoUrl ? (
|
||||||
|
<div className="space-y-3">
|
||||||
{/* Bottom Controls — Matching Image 1884 */}
|
{videoPreview.kind === "iframe" && limitedEmbedSrc ? (
|
||||||
<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 */}
|
<div className="relative aspect-video w-full max-w-4xl">
|
||||||
<div className="flex items-center gap-4 text-white">
|
<iframe
|
||||||
<span className="text-[13px] font-medium opacity-90">0:00</span>
|
key={limitedEmbedSrc}
|
||||||
<div className="flex-1 h-1 bg-white/20 rounded-full relative cursor-pointer overflow-hidden group/seeker">
|
src={limitedEmbedSrc}
|
||||||
<div
|
title={`${videoPreview.label} lesson preview`}
|
||||||
className="absolute left-0 top-0 bottom-0 bg-brand-500 rounded-full"
|
className="absolute inset-0 h-full w-full"
|
||||||
style={{ width: "40%" }}
|
allow="autoplay; fullscreen; picture-in-picture"
|
||||||
/>
|
allowFullScreen
|
||||||
</div>
|
/>
|
||||||
<span className="text-[13px] font-medium opacity-90">
|
<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">
|
||||||
12:30
|
Short clip · max {previewLengthLabel}
|
||||||
</span>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{/* Row 2: Icons */}
|
) : videoPreview.kind === "video" ? (
|
||||||
<div className="flex items-center justify-between text-white">
|
<div className="overflow-hidden rounded-xl border border-grayScale-200 bg-black shadow-sm">
|
||||||
<div className="flex items-center gap-6">
|
<PreviewLimitedFileVideo
|
||||||
<Volume2 className="h-[22px] w-[22px] opacity-90 cursor-pointer hover:opacity-100 transition-opacity" />
|
src={videoPreview.src}
|
||||||
<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">
|
maxSeconds={DEFAULT_PREVIEW_MAX_SECONDS}
|
||||||
CC
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-6">
|
) : (
|
||||||
<Settings className="h-5 w-5 opacity-90 cursor-pointer hover:opacity-100 transition-opacity" />
|
<p className="text-grayScale-400 text-sm">—</p>
|
||||||
<Maximize2 className="h-5 w-5 opacity-90 cursor-pointer hover:opacity-100 transition-opacity" />
|
)}
|
||||||
|
</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>
|
||||||
|
{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>
|
||||||
|
</div>
|
||||||
|
<p className="text-[12px] text-grayScale-500 break-all">
|
||||||
|
{truncate(formData.thumbnailUrl, 160)}
|
||||||
|
</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="space-y-2">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-8">
|
<span className="text-[11px] font-bold text-grayScale-500 uppercase tracking-widest block">
|
||||||
<div className="space-y-2">
|
Title
|
||||||
<span className="text-[11px] font-bold text-grayScale-500 uppercase tracking-widest block">
|
</span>
|
||||||
TITLE
|
<p className="text-[15px] font-medium text-grayScale-900">
|
||||||
</span>
|
{formData.title || "—"}
|
||||||
<p className="text-[15px] font-medium text-grayScale-900">
|
</p>
|
||||||
{formData.title || "Introduction to Past Tense"}
|
|
||||||
</p>
|
|
||||||
</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>
|
</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>
|
||||||
|
|
|
||||||
|
|
@ -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(/ /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>
|
</p>
|
||||||
</div>
|
<LessonMediaUploadField
|
||||||
</div>
|
kind="video"
|
||||||
<h4 className="text-[17px] text-grayScale-900 mb-2">
|
value={formData.videoUrl}
|
||||||
Drag and drop video files here
|
onChange={(v) =>
|
||||||
</h4>
|
setFormData((prev) => ({ ...prev, videoUrl: v }))
|
||||||
<p className="text-grayScale-400 font-medium text-[13px] mb-8">
|
}
|
||||||
MP4, MOV, WebM. Max size 2GB.
|
/>
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-4 w-full max-w-[200px] mb-8">
|
|
||||||
<div className="flex-1 h-[1px] bg-grayScale-200" />
|
|
||||||
<span className="text-[12px] font-bold text-grayScale-300 uppercase tracking-widest">
|
|
||||||
OR
|
|
||||||
</span>
|
|
||||||
<div className="flex-1 h-[1px] bg-grayScale-200" />
|
|
||||||
</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>
|
</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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user