lesson integration
This commit is contained in:
parent
3634d2eb79
commit
b4ab66b4a6
|
|
@ -78,6 +78,15 @@ import type {
|
|||
CreateTopLevelCourseModuleResponse,
|
||||
CreateProgramCourseRequest,
|
||||
CreateProgramCourseResponse,
|
||||
GetTopLevelModuleLessonsResponse,
|
||||
GetPracticesByParentContextResponse,
|
||||
CreateParentLinkedPracticeRequest,
|
||||
CreateParentLinkedPracticeResponse,
|
||||
UpdateParentLinkedPracticeRequest,
|
||||
UpdateParentLinkedPracticeResponse,
|
||||
UpdateTopLevelModuleLessonRequest,
|
||||
CreateTopLevelModuleLessonRequest,
|
||||
CreateTopLevelModuleLessonResponse,
|
||||
} from "../types/course.types"
|
||||
|
||||
type UnifiedHierarchyRow = {
|
||||
|
|
@ -473,6 +482,69 @@ export const updateTopLevelCourseModule = (
|
|||
export const deleteTopLevelCourseModule = (moduleId: number) =>
|
||||
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) =>
|
||||
http.put(`/programs/${programId}`, data)
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import { AppLayout } from "../layouts/AppLayout";
|
|||
import { DashboardPage } from "../pages/DashboardPage";
|
||||
import { AnalyticsPage } from "../pages/analytics/AnalyticsPage";
|
||||
import { ContentManagementLayout } from "../pages/content-management/ContentManagementLayout";
|
||||
import { CourseCategoryPage } from "../pages/content-management/CourseCategoryPage";
|
||||
import { AllCoursesPage } from "../pages/content-management/AllCoursesPage";
|
||||
import { CourseFlowBuilderPage } from "../pages/content-management/CourseFlowBuilderPage";
|
||||
import { ContentOverviewPage } from "../pages/content-management/ContentOverviewPage";
|
||||
|
|
@ -89,7 +88,7 @@ export function AppRoutes() {
|
|||
</Route>
|
||||
|
||||
<Route path="/content" element={<ContentManagementLayout />}>
|
||||
<Route index element={<CourseCategoryPage />} />
|
||||
<Route index element={<Navigate to="practices" replace />} />
|
||||
<Route path="courses" element={<AllCoursesPage />} />
|
||||
<Route path="flows" element={<CourseFlowBuilderPage />} />
|
||||
<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 { 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 { Stepper } from "../../components/ui/stepper";
|
||||
import { createModuleLesson } from "../../api/courses.api";
|
||||
|
||||
import { VideoDetailStep } from "./components/video-steps/VideoDetailStep";
|
||||
import { ReviewPublishStep } from "./components/video-steps/ReviewPublishStep";
|
||||
|
|
@ -13,6 +15,33 @@ const STEPS = [
|
|||
{ 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() {
|
||||
const navigate = useNavigate();
|
||||
const { level, courseId, moduleId } = useParams<{
|
||||
|
|
@ -22,24 +51,65 @@ export function AddVideoFlow() {
|
|||
}>();
|
||||
const [currentStep, setCurrentStep] = useState(1);
|
||||
const [isPublished, setIsPublished] = useState(false);
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
title: "",
|
||||
order: "1",
|
||||
description: "",
|
||||
thumbnail: null,
|
||||
videoFile: null,
|
||||
});
|
||||
const [formData, setFormData] = useState<AddLessonFormData>(emptyForm);
|
||||
const [publishing, setPublishing] = useState(false);
|
||||
const [formResetKey, setFormResetKey] = useState(0);
|
||||
|
||||
const nextStep = () => setCurrentStep((prev) => Math.min(prev + 1, 2));
|
||||
const prevStep = () => setCurrentStep((prev) => Math.max(prev - 1, 1));
|
||||
|
||||
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) {
|
||||
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 ">
|
||||
{/* Success Icon Wrapper (Jagged Circle Style) */}
|
||||
<div className="mb-12 relative scale-110">
|
||||
<div className="absolute inset-0 bg-brand-500/5 blur-3xl rounded-full" />
|
||||
<div className="relative">
|
||||
|
|
@ -53,35 +123,37 @@ export function AddVideoFlow() {
|
|||
</div>
|
||||
|
||||
<h1 className="text-[26px] font-bold text-grayScale-900 mb-4">
|
||||
Video Published Successfully!
|
||||
Lesson created successfully
|
||||
</h1>
|
||||
<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>
|
||||
|
||||
<div className="flex flex-col gap-4 w-full max-w-[400px]">
|
||||
<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"
|
||||
>
|
||||
Go back to Learn English
|
||||
View module
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setFormData({
|
||||
title: "",
|
||||
order: "1",
|
||||
description: "",
|
||||
thumbnail: null,
|
||||
videoFile: null,
|
||||
});
|
||||
setFormData(emptyForm());
|
||||
setFormResetKey((k) => k + 1);
|
||||
setIsPublished(false);
|
||||
setCurrentStep(1);
|
||||
}}
|
||||
variant="outline"
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -90,7 +162,6 @@ export function AddVideoFlow() {
|
|||
|
||||
return (
|
||||
<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="flex items-center justify-between mb-8">
|
||||
<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"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back to Modules
|
||||
Back to module
|
||||
</Link>
|
||||
<Button
|
||||
variant="outline"
|
||||
|
|
@ -110,7 +181,7 @@ export function AddVideoFlow() {
|
|||
</div>
|
||||
|
||||
<h1 className="text-2xl font-bold text-[#0F172A] mb-10">
|
||||
Add New Video
|
||||
Add new lesson
|
||||
</h1>
|
||||
|
||||
<div className="mx-auto max-w-4xl mb-12">
|
||||
|
|
@ -120,13 +191,13 @@ export function AddVideoFlow() {
|
|||
/>
|
||||
</div>
|
||||
|
||||
{/* Step Content */}
|
||||
<div className="mx-auto max-w-7xl">
|
||||
{currentStep === 1 && (
|
||||
<VideoDetailStep
|
||||
key={formResetKey}
|
||||
formData={formData}
|
||||
setFormData={setFormData}
|
||||
nextStep={nextStep}
|
||||
onContinue={nextStep}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
@ -134,7 +205,8 @@ export function AddVideoFlow() {
|
|||
<ReviewPublishStep
|
||||
formData={formData}
|
||||
prevStep={prevStep}
|
||||
setIsPublished={setIsPublished}
|
||||
onPublish={() => void handlePublish()}
|
||||
publishing={publishing}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,19 +1,8 @@
|
|||
import { NavLink, 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" },
|
||||
]
|
||||
import { Outlet } from "react-router-dom"
|
||||
|
||||
export function ContentManagementLayout() {
|
||||
return (
|
||||
<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="flex items-center gap-3">
|
||||
<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
|
||||
</h1>
|
||||
<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>
|
||||
</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 />
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -515,6 +515,12 @@ export function CourseDetailPage() {
|
|||
onClick={() =>
|
||||
navigate(
|
||||
`/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 {
|
||||
ArrowLeft,
|
||||
Video,
|
||||
|
|
@ -7,42 +7,39 @@ import {
|
|||
Layers,
|
||||
Edit2,
|
||||
Trash2,
|
||||
X,
|
||||
} 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 {
|
||||
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 { LessonMediaUploadField } from "./components/LessonMediaUploadField";
|
||||
import { VideoCard } from "./components/VideoCard";
|
||||
|
||||
const MOCK_VIDEOS = [
|
||||
{
|
||||
id: "v1",
|
||||
title: "1.1 Introduction to Formal Greetings",
|
||||
duration: "08:45",
|
||||
status: "Draft",
|
||||
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 LESSON_THUMB_GRADIENTS = [
|
||||
"from-[#CBD5E1] to-[#94A3B8]",
|
||||
"from-[#DBEAFE] to-[#93C5FD]",
|
||||
"from-[#FEF3C7] to-[#FCD34D]",
|
||||
"from-[#FCE7F3] to-[#F9A8D4]",
|
||||
] as const;
|
||||
|
||||
const MOCK_PRACTICES = [
|
||||
{
|
||||
|
|
@ -75,8 +72,15 @@ const MOCK_PRACTICES = [
|
|||
},
|
||||
];
|
||||
|
||||
type ModuleDetailState = {
|
||||
moduleName?: string;
|
||||
moduleDescription?: string;
|
||||
};
|
||||
|
||||
export function ModuleDetailPage() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const navState = location.state as ModuleDetailState | null;
|
||||
const { level, courseId, moduleId } = useParams<{
|
||||
level: string;
|
||||
courseId: string;
|
||||
|
|
@ -84,14 +88,211 @@ export function ModuleDetailPage() {
|
|||
}>();
|
||||
const [activeTab, setActiveTab] = useState<"video" | "practice">("video");
|
||||
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 [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
|
||||
?.split("-")
|
||||
.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 (
|
||||
<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="">
|
||||
<h1 className="text-2xl font-medium text-grayScale-900 tracking-tight">
|
||||
Module 3: {moduleTitle}
|
||||
{displayModuleName}
|
||||
</h1>
|
||||
<p className="text-grayScale-500 text-[14px] max-w-2xl">
|
||||
This module covers essential vocabulary and phrases used in modern
|
||||
business environments, including email etiquette and meeting
|
||||
protocols.
|
||||
{displayModuleDescription}
|
||||
</p>
|
||||
</div>
|
||||
<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">
|
||||
<span className="text-xl leading-none font-light">+</span>
|
||||
</div>
|
||||
Add Video
|
||||
Add Lesson
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -159,7 +358,7 @@ export function ModuleDetailPage() {
|
|||
: "text-grayScale-400 hover:text-grayScale-600",
|
||||
)}
|
||||
>
|
||||
Video
|
||||
Lesson
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("practice")}
|
||||
|
|
@ -178,14 +377,27 @@ export function ModuleDetailPage() {
|
|||
{/* Content */}
|
||||
<div className="mt-8">
|
||||
{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">
|
||||
{videos.map((video) => (
|
||||
{lessons.map((lesson, i) => (
|
||||
<VideoCard
|
||||
key={video.id}
|
||||
{...(video as any)}
|
||||
onEdit={() => console.log("Edit", video.id)}
|
||||
onPublish={() => console.log("Publish", video.id)}
|
||||
key={lesson.id}
|
||||
id={lesson.id}
|
||||
title={lesson.title}
|
||||
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>
|
||||
|
|
@ -197,11 +409,11 @@ export function ModuleDetailPage() {
|
|||
</div>
|
||||
</div>
|
||||
<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>
|
||||
<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
|
||||
module by adding your first video lesson now.
|
||||
Lessons are a great way to engage students. Add your first
|
||||
lesson to get started.
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
|
|
@ -213,7 +425,7 @@ export function ModuleDetailPage() {
|
|||
}
|
||||
>
|
||||
<Video className="h-5 w-5" />
|
||||
Add Video
|
||||
Add Lesson
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
|
|
@ -251,6 +463,149 @@ export function ModuleDetailPage() {
|
|||
</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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -568,9 +568,11 @@ export function ProgramCoursesPage() {
|
|||
) : (
|
||||
<div className="flex flex-wrap gap-10">
|
||||
{courses.map((course) => {
|
||||
const modules = course.modules_count ?? 0;
|
||||
const videos = course.videos_count ?? 0;
|
||||
const practices = course.practices_count ?? 0;
|
||||
const modules =
|
||||
course.module_count ?? course.modules_count ?? 0;
|
||||
const lessons = course.lesson_count ?? course.videos_count ?? 0;
|
||||
const practices =
|
||||
course.practice_count ?? course.practices_count ?? 0;
|
||||
const thumbnailSrc =
|
||||
course.thumbnail?.trim() || course.thumbnail_url?.trim() || "";
|
||||
return (
|
||||
|
|
@ -634,10 +636,10 @@ export function ProgramCoursesPage() {
|
|||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-base font-bold text-grayScale-700">
|
||||
{videos}
|
||||
{lessons}
|
||||
</p>
|
||||
<p className="text-[10px] font-medium uppercase tracking-wider text-grayScale-400">
|
||||
Videos
|
||||
Lessons
|
||||
</p>
|
||||
</div>
|
||||
<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 {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "../../../components/ui/dialog";
|
||||
import { isAdminOrSuperAdminRole } from "../../../lib/sessionRole";
|
||||
import { cn } from "../../../lib/utils";
|
||||
import {
|
||||
applyShortPreviewToEmbedUrl,
|
||||
DEFAULT_PREVIEW_MAX_SECONDS,
|
||||
formatPreviewLength,
|
||||
getVideoPreview,
|
||||
} from "../../../lib/videoPreview";
|
||||
import { PreviewLimitedFileVideo } from "./PreviewLimitedFileVideo";
|
||||
|
||||
interface VideoCardProps {
|
||||
id: string;
|
||||
id?: string | number;
|
||||
title: string;
|
||||
duration: string;
|
||||
status: "Draft" | "Published";
|
||||
thumbnailGradient: string;
|
||||
/** Omits the duration chip when not provided (e.g. API has no length yet). */
|
||||
duration?: 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;
|
||||
onDelete?: () => void;
|
||||
onPublish?: () => void;
|
||||
}
|
||||
|
||||
|
|
@ -16,84 +45,330 @@ export function VideoCard({
|
|||
title,
|
||||
duration,
|
||||
status,
|
||||
thumbnailGradient,
|
||||
thumbnailGradient = "from-[#CBD5E1] to-[#94A3B8]",
|
||||
thumbnailUrl,
|
||||
videoUrl,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onPublish,
|
||||
hoverModuleActions = false,
|
||||
}: 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 (
|
||||
<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 */}
|
||||
<div
|
||||
className={cn(
|
||||
"relative h-44 w-full bg-gradient-to-br",
|
||||
thumbnailGradient,
|
||||
"relative h-44 w-full overflow-hidden",
|
||||
useGradient && "bg-gradient-to-br",
|
||||
useGradient && thumbnailGradient,
|
||||
!useGradient && "bg-grayScale-100",
|
||||
)}
|
||||
>
|
||||
{/* Duration Badge */}
|
||||
<div className="absolute bottom-3 right-3 bg-black/70 text-white text-[11px] font-bold px-2 py-1 rounded-md backdrop-blur-sm">
|
||||
{duration}
|
||||
</div>
|
||||
{/* Play Overlay */}
|
||||
<div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity bg-black/10">
|
||||
<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" />
|
||||
{hoverModuleActions && (onEdit || onDelete) ? (
|
||||
<div
|
||||
className="absolute right-2 top-2 z-20 flex translate-y-1 gap-1 opacity-0 pointer-events-none transition-all duration-300 ease-out group-hover:translate-y-0 group-hover:opacity-100 group-hover:pointer-events-auto"
|
||||
>
|
||||
{onEdit ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded-md bg-white/95 text-grayScale-600 shadow-sm transition-colors hover:bg-white"
|
||||
aria-label={`Edit ${title}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEdit();
|
||||
}}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
) : null}
|
||||
{onDelete ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded-md bg-white/95 text-red-600 shadow-sm transition-colors hover:bg-red-50"
|
||||
aria-label={`Delete ${title}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</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>
|
||||
|
||||
<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 */}
|
||||
<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 */}
|
||||
<div
|
||||
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]",
|
||||
)}
|
||||
>
|
||||
{status ? (
|
||||
<div
|
||||
className={cn(
|
||||
"h-1.5 w-1.5 rounded-full",
|
||||
status === "Published" ? "bg-[#10B981]" : "bg-[#9CA3AF]",
|
||||
"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-[#ECFDF5] text-[#059669] border-[#D1FAE5]"
|
||||
: "bg-[#F3F4F6] text-[#6B7280] border-[#E5E7EB]",
|
||||
)}
|
||||
/>
|
||||
{status}
|
||||
</div>
|
||||
{/* Menu */}
|
||||
<button className="h-8 w-8 flex items-center justify-center rounded-full hover:bg-grayScale-50 transition-colors text-grayScale-400">
|
||||
<MoreVertical className="h-5 w-5" />
|
||||
</button>
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"h-1.5 w-1.5 rounded-full flex-shrink-0",
|
||||
status === "Published" ? "bg-[#10B981]" : "bg-[#9CA3AF]",
|
||||
)}
|
||||
/>
|
||||
{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>
|
||||
|
||||
<h3 className="text-[16px] font-medium text-grayScale-900 line-clamp-2 leading-snug">
|
||||
{title}
|
||||
</h3>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="pt-2 space-y-3 mt-auto">
|
||||
<Button
|
||||
variant="outline"
|
||||
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
|
||||
</Button>
|
||||
<Button
|
||||
disabled={status === "Published"}
|
||||
onClick={onPublish}
|
||||
className={cn(
|
||||
"w-full h-10 rounded-xl font-bold transition-all shadow-sm",
|
||||
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>
|
||||
{/* Actions (footer) — not used for API lesson cards with hover tools */}
|
||||
{!hoverModuleActions ? (
|
||||
<div className="pt-2 space-y-3 mt-auto">
|
||||
<Button
|
||||
variant="outline"
|
||||
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
|
||||
</Button>
|
||||
{status ? (
|
||||
<Button
|
||||
disabled={status === "Published"}
|
||||
onClick={onPublish}
|
||||
className={cn(
|
||||
"w-full h-10 rounded-xl font-bold transition-all shadow-sm",
|
||||
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>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,86 +1,167 @@
|
|||
import {
|
||||
Rocket,
|
||||
Edit2,
|
||||
Layout,
|
||||
Volume2,
|
||||
Settings,
|
||||
Maximize2,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Rocket, Edit2, Link2, Video } from "lucide-react";
|
||||
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 {
|
||||
formData: any;
|
||||
formData: AddLessonFormData;
|
||||
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({
|
||||
formData,
|
||||
prevStep,
|
||||
setIsPublished,
|
||||
onPublish,
|
||||
publishing,
|
||||
}: 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 (
|
||||
<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="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">
|
||||
Video Preview
|
||||
Media preview
|
||||
</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">
|
||||
PROCESSED
|
||||
</span>
|
||||
<p className="text-xs font-medium text-grayScale-500">
|
||||
Video: short clip (first {previewLengthLabel} only)
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-10 flex items-center justify-center bg-[#F8FAFC]/30">
|
||||
<div className="relative w-full max-w-4xl aspect-video rounded-[12px] overflow-hidden bg-black shadow-2xl group border-4 border-white">
|
||||
{/* Mock Player Control Overlays */}
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<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">
|
||||
<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" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom Controls — Matching Image 1884 */}
|
||||
<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">
|
||||
{/* Row 1: Seeker and Timestamps */}
|
||||
<div className="flex items-center gap-4 text-white">
|
||||
<span className="text-[13px] font-medium opacity-90">0:00</span>
|
||||
<div className="flex-1 h-1 bg-white/20 rounded-full relative cursor-pointer overflow-hidden group/seeker">
|
||||
<div
|
||||
className="absolute left-0 top-0 bottom-0 bg-brand-500 rounded-full"
|
||||
style={{ width: "40%" }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-[13px] font-medium opacity-90">
|
||||
12:30
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Row 2: Icons */}
|
||||
<div className="flex items-center justify-between text-white">
|
||||
<div className="flex items-center gap-6">
|
||||
<Volume2 className="h-[22px] w-[22px] opacity-90 cursor-pointer hover:opacity-100 transition-opacity" />
|
||||
<div className="h-5 w-6 border-2 border-white rounded-[3px] flex items-center justify-center text-[9px] font-bold opacity-90 cursor-pointer hover:opacity-100 transition-opacity">
|
||||
CC
|
||||
<div className="p-8">
|
||||
<div className="flex flex-col gap-10 xl:flex-row xl:items-start xl:gap-10">
|
||||
{/* Video preview */}
|
||||
<div className="min-w-0 flex-1 space-y-3">
|
||||
<span className="text-[11px] font-bold text-grayScale-500 uppercase tracking-widest block">
|
||||
Video
|
||||
</span>
|
||||
{formData.videoUrl ? (
|
||||
<div className="space-y-3">
|
||||
{videoPreview.kind === "iframe" && limitedEmbedSrc ? (
|
||||
<div className="overflow-hidden rounded-xl border border-grayScale-200 bg-black shadow-sm">
|
||||
<div className="relative aspect-video w-full max-w-4xl">
|
||||
<iframe
|
||||
key={limitedEmbedSrc}
|
||||
src={limitedEmbedSrc}
|
||||
title={`${videoPreview.label} lesson preview`}
|
||||
className="absolute inset-0 h-full w-full"
|
||||
allow="autoplay; fullscreen; picture-in-picture"
|
||||
allowFullScreen
|
||||
/>
|
||||
<div className="pointer-events-none absolute bottom-0 left-0 right-0 z-10 bg-gradient-to-t from-black/90 via-black/50 to-transparent px-3 py-2.5 text-center text-[11px] font-semibold text-white/95">
|
||||
Short clip · max {previewLengthLabel}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : videoPreview.kind === "video" ? (
|
||||
<div className="overflow-hidden rounded-xl border border-grayScale-200 bg-black shadow-sm">
|
||||
<PreviewLimitedFileVideo
|
||||
src={videoPreview.src}
|
||||
maxSeconds={DEFAULT_PREVIEW_MAX_SECONDS}
|
||||
/>
|
||||
</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 className="flex items-center gap-6">
|
||||
<Settings className="h-5 w-5 opacity-90 cursor-pointer hover:opacity-100 transition-opacity" />
|
||||
<Maximize2 className="h-5 w-5 opacity-90 cursor-pointer hover:opacity-100 transition-opacity" />
|
||||
) : (
|
||||
<p className="text-grayScale-400 text-sm">—</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Thumbnail preview */}
|
||||
<div className="w-full shrink-0 space-y-3 xl:max-w-[360px]">
|
||||
<span className="text-[11px] font-bold text-grayScale-500 uppercase tracking-widest block">
|
||||
Thumbnail
|
||||
</span>
|
||||
{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>
|
||||
) : (
|
||||
<p className="text-grayScale-400 text-sm">—</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 2. Content Details Card */}
|
||||
<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">
|
||||
<h3 className="text-[16px] font-bold text-grayScale-900">
|
||||
Content Details
|
||||
Content details
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={prevStep}
|
||||
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 className="p-8 space-y-10">
|
||||
{/* Metadata Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-8">
|
||||
<div className="space-y-2">
|
||||
<span className="text-[11px] font-bold text-grayScale-500 uppercase tracking-widest block">
|
||||
TITLE
|
||||
</span>
|
||||
<p className="text-[15px] font-medium text-grayScale-900">
|
||||
{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 className="space-y-2">
|
||||
<span className="text-[11px] font-bold text-grayScale-500 uppercase tracking-widest block">
|
||||
Title
|
||||
</span>
|
||||
<p className="text-[15px] font-medium text-grayScale-900">
|
||||
{formData.title || "—"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Description Section */}
|
||||
<div className="space-y-3">
|
||||
<span className="text-[11px] font-bold text-grayScale-500 uppercase tracking-widest block">
|
||||
DESCRIPTION
|
||||
Description
|
||||
</span>
|
||||
<div
|
||||
className="text-[14px] text-grayScale-600 leading-relaxed max-w-4xl"
|
||||
className="text-[14px] text-grayScale-600 leading-relaxed max-w-4xl"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html:
|
||||
formData.description ||
|
||||
"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.",
|
||||
formData.description || "<p class='text-grayScale-400'>—</p>",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Gradient Divider */}
|
||||
<div className="relative">
|
||||
<div
|
||||
className="absolute inset-0 flex items-center"
|
||||
|
|
@ -164,18 +204,17 @@ export function ReviewPublishStep({
|
|||
<div className="relative flex justify-center">
|
||||
<div
|
||||
className="h-[0.5px] w-full opacity-20 rounded-full"
|
||||
style={{
|
||||
background: "gray",
|
||||
}}
|
||||
style={{ background: "gray" }}
|
||||
/>
|
||||
</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">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
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"
|
||||
>
|
||||
Back
|
||||
|
|
@ -183,17 +222,24 @@ export function ReviewPublishStep({
|
|||
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
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"
|
||||
disabled={publishing}
|
||||
onClick={() =>
|
||||
toast.info("Drafts are not supported yet. Use Create lesson.")
|
||||
}
|
||||
>
|
||||
Save as Draft
|
||||
Save as draft
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setIsPublished(true)}
|
||||
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"
|
||||
type="button"
|
||||
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" />
|
||||
Publish Now
|
||||
{publishing ? "Creating…" : "Create lesson"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,32 +1,36 @@
|
|||
import { useRef, useEffect } from "react";
|
||||
import {
|
||||
Video,
|
||||
List,
|
||||
Link as LinkIcon,
|
||||
Lightbulb,
|
||||
ChevronRight,
|
||||
ImageIcon,
|
||||
ArrowRight,
|
||||
} from "lucide-react";
|
||||
useRef,
|
||||
useEffect,
|
||||
type Dispatch,
|
||||
type SetStateAction,
|
||||
} from "react";
|
||||
import { List, Link as LinkIcon, Lightbulb, ArrowRight } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "../../../../components/ui/button";
|
||||
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 {
|
||||
formData: any;
|
||||
setFormData: (data: any) => void;
|
||||
nextStep: () => void;
|
||||
formData: AddLessonFormData;
|
||||
setFormData: Dispatch<SetStateAction<AddLessonFormData>>;
|
||||
onContinue: () => void;
|
||||
}
|
||||
|
||||
export function VideoDetailStep({
|
||||
formData,
|
||||
setFormData,
|
||||
nextStep,
|
||||
onContinue,
|
||||
}: VideoDetailStepProps) {
|
||||
const editorRef = useRef<HTMLDivElement>(null);
|
||||
const isInternalChange = useRef(false);
|
||||
|
||||
// Initialize editor content only once or when needed from outside
|
||||
useEffect(() => {
|
||||
if (editorRef.current && !isInternalChange.current) {
|
||||
editorRef.current.innerHTML = formData.description || "";
|
||||
|
|
@ -41,8 +45,10 @@ export function VideoDetailStep({
|
|||
const syncState = () => {
|
||||
if (editorRef.current) {
|
||||
isInternalChange.current = true;
|
||||
setFormData({ ...formData, description: editorRef.current.innerHTML });
|
||||
// Reset after a short delay to allow exterior updates if any (e.g., from step change)
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
description: editorRef.current!.innerHTML,
|
||||
}));
|
||||
setTimeout(() => {
|
||||
isInternalChange.current = false;
|
||||
}, 0);
|
||||
|
|
@ -53,50 +59,57 @@ export function VideoDetailStep({
|
|||
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 (
|
||||
<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">
|
||||
{/* 1. Upload Video Section */}
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-[20px] font-bold text-grayScale-900 ml-1">
|
||||
Upload Video
|
||||
Video
|
||||
</h3>
|
||||
<div className="relative group cursor-pointer">
|
||||
<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">
|
||||
<div className="h-16 w-16 rounded-full bg-white shadow-sm flex items-center justify-center mb-6">
|
||||
<div className="h-10 w-10 rounded-full bg-[#FAF5FF] flex items-center justify-center">
|
||||
<div className="h-6 w-6 relative flex items-center justify-center">
|
||||
<div className="absolute inset-0 bg-brand-500/10 rounded-full blur-sm" />
|
||||
<Video className="h-5 w-5 text-brand-500 relative" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<h4 className="text-[17px] text-grayScale-900 mb-2">
|
||||
Drag and drop video files here
|
||||
</h4>
|
||||
<p className="text-grayScale-400 font-medium text-[13px] mb-8">
|
||||
MP4, MOV, WebM. Max size 2GB.
|
||||
</p>
|
||||
|
||||
<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>
|
||||
<p className="text-sm text-grayScale-500 ml-1 max-w-2xl">
|
||||
Upload a file or paste a link (Vimeo, hosted file, etc.). Files are
|
||||
sent to your storage via{" "}
|
||||
<code className="rounded bg-grayScale-100 px-1 text-[11px]">
|
||||
POST /files/upload
|
||||
</code>
|
||||
.
|
||||
</p>
|
||||
<LessonMediaUploadField
|
||||
kind="video"
|
||||
value={formData.videoUrl}
|
||||
onChange={(v) =>
|
||||
setFormData((prev) => ({ ...prev, videoUrl: v }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{/* Gradient Divider */}
|
||||
|
||||
<div className="relative">
|
||||
<div
|
||||
className="absolute inset-0 flex items-center"
|
||||
|
|
@ -107,75 +120,57 @@ export function VideoDetailStep({
|
|||
<div className="relative flex justify-center">
|
||||
<div
|
||||
className="h-[0.5px] w-full opacity-20 rounded-full"
|
||||
style={{
|
||||
background: "gray",
|
||||
}}
|
||||
style={{ background: "gray" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 2. Form & Side Panel Grid */}
|
||||
<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="space-y-3">
|
||||
<label className="text-[14px] font-medium text-grayScale-900 ml-1">
|
||||
Video Title
|
||||
Lesson title
|
||||
</label>
|
||||
<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"
|
||||
value={formData.title}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, title: e.target.value })
|
||||
setFormData((prev) => ({ ...prev, title: e.target.value }))
|
||||
}
|
||||
/>
|
||||
</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">
|
||||
<label className="text-[14px] font-medium text-grayScale-900 ml-1">
|
||||
Description
|
||||
</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">
|
||||
{/* Toolbar */}
|
||||
<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">
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
>
|
||||
B
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
>
|
||||
I
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
>
|
||||
<List className="h-5 w-5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const url = prompt("Enter URL:");
|
||||
if (url) handleCommand("createLink", url);
|
||||
|
|
@ -188,12 +183,9 @@ export function VideoDetailStep({
|
|||
</div>
|
||||
|
||||
<div className="relative p-6 flex-1">
|
||||
{(!formData.description ||
|
||||
formData.description === "<br>" ||
|
||||
formData.description === "" ||
|
||||
formData.description === "<div><br></div>") && (
|
||||
{isDescriptionEmpty(formData.description) && (
|
||||
<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
|
||||
|
|
@ -207,59 +199,44 @@ export function VideoDetailStep({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Column: Thumbnail, Pro Tip */}
|
||||
<div className="w-full lg:w-[320px] space-y-5">
|
||||
{/* Thumbnail Section */}
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1 ml-1">
|
||||
<h3 className="text-[14px] font-medium text-grayScale-900">
|
||||
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="w-full lg:w-[360px] space-y-5">
|
||||
<LessonMediaUploadField
|
||||
kind="thumbnail"
|
||||
value={formData.thumbnailUrl}
|
||||
onChange={(v) =>
|
||||
setFormData((prev) => ({ ...prev, thumbnailUrl: v }))
|
||||
}
|
||||
/>
|
||||
<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="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 className="relative top-[-10px]">
|
||||
<h3 className="text-[14px] font-bold text-grayScale-900">
|
||||
Pro Tip
|
||||
Pro tip
|
||||
</h3>
|
||||
<p className="text-[12px] text-grayScale-700 font-medium leading-relaxed">
|
||||
Short, descriptive titles work best. Include keywords like
|
||||
"Grammar" or "Vocabulary" to help students find your content.
|
||||
Use clear titles and a thumbnail that matches the lesson. The
|
||||
lesson is created with{" "}
|
||||
<code className="rounded bg-white/80 px-1 text-[10px]">
|
||||
POST /modules/:moduleId/lessons
|
||||
</code>{" "}
|
||||
when you publish.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer (Inside Card Container) */}
|
||||
<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>
|
||||
<div className="pt-5 border-t border-grayScale-200 flex items-center justify-end">
|
||||
<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"
|
||||
>
|
||||
Continue
|
||||
|
|
|
|||
|
|
@ -111,7 +111,11 @@ export interface ProgramCourseListItem {
|
|||
thumbnail?: string | null
|
||||
/** Some list endpoints may expose the image as `thumbnail_url` instead. */
|
||||
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
|
||||
videos_count?: number
|
||||
practices_count?: number
|
||||
|
|
@ -199,6 +203,122 @@ export interface CreateTopLevelCourseModuleResponse {
|
|||
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)
|
||||
// Keeping for backward compatibility with existing API endpoints
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user