feat: add create lesson UI

This commit is contained in:
2026-09-08 20:39:30 +07:00
parent b55e7f97c9
commit 7bc3cf4e73
14 changed files with 1534 additions and 46 deletions
+78
View File
@@ -8,9 +8,11 @@
"name": "aplp.frontend.lms",
"version": "0.0.0",
"dependencies": {
"@radix-ui/react-collapsible": "^1.1.20",
"@radix-ui/react-label": "^2.1.15",
"@radix-ui/react-separator": "^1.1.15",
"@radix-ui/react-slot": "^1.3.3",
"@radix-ui/react-switch": "^1.3.7",
"@radix-ui/react-tabs": "^1.1.21",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -590,6 +592,36 @@
"integrity": "sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==",
"license": "MIT"
},
"node_modules/@radix-ui/react-collapsible": {
"version": "1.1.20",
"resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.20.tgz",
"integrity": "sha512-mcGesGplBnzN2sbvJETzpCNfSMyPnb29q1GRLU+Ib7bJrpIG2ywmRoh2V5VbA2uNvKikKUlVbAPks7JDjz4A8Q==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.7",
"@radix-ui/react-compose-refs": "1.1.5",
"@radix-ui/react-context": "1.2.2",
"@radix-ui/react-id": "1.1.4",
"@radix-ui/react-presence": "1.1.10",
"@radix-ui/react-primitive": "2.1.10",
"@radix-ui/react-use-controllable-state": "1.2.6",
"@radix-ui/react-use-layout-effect": "1.1.4"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-collection": {
"version": "1.1.15",
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.15.tgz",
@@ -822,6 +854,34 @@
}
}
},
"node_modules/@radix-ui/react-switch": {
"version": "1.3.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.3.7.tgz",
"integrity": "sha512-48tB/4dn2UVLBCYhTu9AuR63IHl73l/qLbLgxd86noTUor4/K4LFDAcYjK+isP5313qxaFpjPVogE7+Y0/V3Kw==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.7",
"@radix-ui/react-compose-refs": "1.1.5",
"@radix-ui/react-context": "1.2.2",
"@radix-ui/react-primitive": "2.1.10",
"@radix-ui/react-use-controllable-state": "1.2.6",
"@radix-ui/react-use-size": "1.1.4"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-tabs": {
"version": "1.1.21",
"resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.21.tgz",
@@ -935,6 +995,24 @@
}
}
},
"node_modules/@radix-ui/react-use-size": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.4.tgz",
"integrity": "sha512-D3anSY15EJoxrihpsXI6SMrmmonnQtR2ni7arO+Lfdg3O95b9hNXxONk8jA5C8ANdF/h5HMAxejgs8PWJ6rlhw==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.4"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.4.tgz",
+2
View File
@@ -10,9 +10,11 @@
"preview": "vite preview"
},
"dependencies": {
"@radix-ui/react-collapsible": "^1.1.20",
"@radix-ui/react-label": "^2.1.15",
"@radix-ui/react-separator": "^1.1.15",
"@radix-ui/react-slot": "^1.3.3",
"@radix-ui/react-switch": "^1.3.7",
"@radix-ui/react-tabs": "^1.1.21",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
+3
View File
@@ -3,6 +3,7 @@ import MainLayout from './layouts/MainLayout.jsx';
import CoursesPage from './pages/Course/CoursesPage.jsx';
import CourseDetail from './pages/Course/CourseDetails.jsx';
import LessonPage from './pages/lesson/LessonPage.jsx';
import LessonFormPage from './pages/lesson/LessonFormPage.jsx';
import MCQPage from './pages/question-bank/MCQPage.jsx';
import ParagraphPage from './pages/question-bank/ParagraphPage.jsx';
import UsersPage from './pages/user/UsersPage.jsx';
@@ -20,6 +21,8 @@ function App() {
<Route index element={<Navigate to="/courses" replace />} />
<Route path="/courses" element={<CoursesPage />} />
<Route path="/courses/:courseId" element={<CourseDetail />} />
<Route path="/courses/:courseId/lessons/new" element={<LessonFormPage />} />
<Route path="/courses/:courseId/lessons/:lessonId/edit" element={<LessonFormPage />} />
<Route path="/lessons" element={<LessonPage />} />
<Route path="/question-bank/mcq" element={<MCQPage />} />
<Route path="/question-bank/paragraph" element={<ParagraphPage />} />
+23
View File
@@ -0,0 +1,23 @@
import * as React from 'react';
import * as SwitchPrimitives from '@radix-ui/react-switch';
import { cn } from '../../lib/utils';
const Switch = React.forwardRef(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
'peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input',
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
'pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0'
)}
/>
</SwitchPrimitives.Root>
));
Switch.displayName = SwitchPrimitives.Root.displayName;
export { Switch };
+385 -46
View File
@@ -1,13 +1,30 @@
import { useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Card } from '../../components/ui/card';
import { Button } from '../../components/ui/button';
import { Badge } from '../../components/ui/badge';
import { Label } from '../../components/ui/label';
import { Input } from '../../components/ui/input';
import { Textarea } from '../../components/ui/textarea';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '../../components/ui/tabs';
import { Separator } from '../../components/ui/separator';
import PageHeader from '../../components/PageHeader';
import { useParams } from 'react-router-dom';
import {
HiOutlinePlus,
HiOutlinePencil,
HiOutlineTrash,
HiOutlineEye,
HiOutlineChevronRight,
HiOutlineBookOpen,
HiOutlineVideoCamera,
HiOutlineDocumentText,
HiOutlineQuestionMarkCircle,
HiOutlineCodeBracket,
HiOutlineArrowUpTray,
HiOutlineFunnel,
} from 'react-icons/hi2';
const data = [
const coursesData = [
{
code: 'LDR-101',
title: 'Foundations of Modern Leadership',
@@ -16,7 +33,7 @@ const data = [
status: 'Active',
lastUpdate: '2026-08-19',
image:
'https://images.unsplash.com/photo-1556761175-b413da4baf72?auto=format&fit=crop&w=800&q=80'
'https://images.unsplash.com/photo-1556761175-b413da4baf72?auto=format&fit=crop&w=800&q=80',
},
{
code: 'TEC-204',
@@ -26,7 +43,7 @@ const data = [
status: 'Inactive',
lastUpdate: '2026-08-19',
image:
'https://images.unsplash.com/photo-1533090161767-e6ffed986c88?auto=format&fit=crop&w=800&q=80'
'https://images.unsplash.com/photo-1533090161767-e6ffed986c88?auto=format&fit=crop&w=800&q=80',
},
{
code: 'CMP-099',
@@ -35,27 +52,166 @@ const data = [
status: 'Inactive',
lastUpdate: '2026-08-19',
image:
'https://images.unsplash.com/photo-1497366754035-f200968a6e72?auto=format&fit=crop&w=800&q=80'
}
'https://images.unsplash.com/photo-1497366754035-f200968a6e72?auto=format&fit=crop&w=800&q=80',
},
];
const lessonsByCourse = {
'LDR-101': [
{
id: 'les_001',
title: '1.1 What is Leadership?',
position: 1,
type: 'video',
duration: '12:30',
status: 'Published',
isFreePreview: true,
updatedAt: '2026-08-15',
},
{
id: 'les_002',
title: '1.2 Leadership Styles Overview',
position: 2,
type: 'text',
duration: '~8 min read',
status: 'Published',
isFreePreview: false,
updatedAt: '2026-08-16',
},
{
id: 'les_003',
title: '1.3 Self-Assessment Quiz',
position: 3,
type: 'quiz',
duration: '10:00',
status: 'Draft',
isFreePreview: false,
updatedAt: '2026-08-17',
},
{
id: 'les_004',
title: '2.1 Communication Fundamentals',
position: 4,
type: 'video',
duration: '15:20',
status: 'Published',
isFreePreview: false,
updatedAt: '2026-08-18',
},
{
id: 'les_005',
title: '2.2 Active Listening Techniques',
position: 5,
type: 'text',
duration: '~6 min read',
status: 'Draft',
isFreePreview: false,
updatedAt: '2026-08-19',
},
],
'TEC-204': [
{
id: 'les_006',
title: '1.1 Cloud Fundamentals Recap',
position: 1,
type: 'video',
duration: '18:45',
status: 'Published',
isFreePreview: true,
updatedAt: '2026-08-10',
},
{
id: 'les_007',
title: '1.2 Microservices Architecture',
position: 2,
type: 'embed',
duration: 'Figma iFrame',
status: 'Draft',
isFreePreview: false,
updatedAt: '2026-08-12',
},
],
'CMP-099': [],
};
const typeConfig = {
video: { label: 'Video', icon: HiOutlineVideoCamera, color: 'bg-blue-100 text-blue-700' },
text: { label: 'Text', icon: HiOutlineDocumentText, color: 'bg-purple-100 text-purple-700' },
quiz: { label: 'Quiz', icon: HiOutlineQuestionMarkCircle, color: 'bg-orange-100 text-orange-700' },
embed: { label: 'Embed', icon: HiOutlineCodeBracket, color: 'bg-gray-100 text-gray-700' },
};
const statusStyles = {
Published: 'bg-app-success text-app-success-text',
Draft: 'bg-app-neutral-badge text-app-neutral-badge-text',
};
function CourseDetail() {
const { courseId } = useParams();
const course = data.find((e) => e.code === courseId);
const navigate = useNavigate();
const course = coursesData.find((e) => e.code === courseId);
const lessons = lessonsByCourse[courseId] || [];
const [activeTab, setActiveTab] = useState('overview');
if (!course) {
return <div>Course not found</div>;
return (
<>
<PageHeader />
<div className="flex flex-col items-center justify-center py-20 text-app-text-muted">
<HiOutlineBookOpen className="w-16 h-16 mb-4 opacity-30" />
<p className="text-lg font-medium">Course not found</p>
<Button
variant="link"
className="mt-2 text-app-primary"
onClick={() => navigate('/courses')}
>
Back to Courses
</Button>
</div>
</>
);
}
const publishedCount = lessons.filter((l) => l.status === 'Published').length;
const draftCount = lessons.filter((l) => l.status === 'Draft').length;
return (
<>
<PageHeader />
<div className="mb-4 flex flex-row items-center justify-between">
<h2 className="text-xl font-bold text-app-text">{course?.title}</h2>
{/* Breadcrumb */}
<div className="flex items-center gap-2 text-sm text-app-text-muted mb-4">
<button
onClick={() => navigate('/courses')}
className="hover:text-app-primary transition-colors flex items-center gap-1"
>
<HiOutlineBookOpen className="w-4 h-4" />
Courses
</button>
<HiOutlineChevronRight className="w-3.5 h-3.5" />
<span className="font-medium text-app-text">{course.title}</span>
</div>
{/* Header */}
<div className="flex flex-row items-center justify-between mb-6">
<div className="flex items-center gap-3">
<h2 className="text-xl font-bold text-app-text">{course.title}</h2>
<Badge
className={`rounded-[6px] text-xs font-medium ${
course.status === 'Active'
? 'bg-app-success text-app-success-text'
: 'bg-app-neutral-badge text-app-neutral-badge-text'
}`}
>
{course.status}
</Badge>
</div>
<div className="flex flex-row items-center gap-2">
<Button
color="gray"
variant="outline"
className="rounded-xl bg-app-surface-muted text-app-text hover:bg-app-interactive-hover"
>
<HiOutlineArrowUpTray className="w-4 h-4 mr-2" />
Inactivate
</Button>
<Button className="rounded-xl bg-app-primary text-white hover:bg-app-primary-dark">
@@ -63,45 +219,228 @@ function CourseDetail() {
</Button>
</div>
</div>
<div>
<Card
className="w-full max-w-100 overflow-hidden rounded-xl border border-app-border bg-white"
style={{ boxShadow: 'rgba(0,0,0,0.03) 0px 4px 24px' }}
>
<div className="p-5">
<h4 className="font-medium text-app-text">Course Details</h4>
</div>
<div className="px-5 pb-5">
<div className="my-2 w-full max-w-64">
<Label className="mb-1 block text-app-text">Course Code</Label>
<Input
value={course?.code}
readOnly
/>
<Separator className="mb-6" />
{/* Tabs */}
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="mb-6">
<TabsTrigger value="overview" className="gap-2">
<HiOutlineBookOpen className="w-4 h-4" />
Overview
</TabsTrigger>
<TabsTrigger value="lessons" className="gap-2">
<HiOutlineDocumentText className="w-4 h-4" />
Lessons
<Badge className="ml-1 rounded-full bg-app-primary-soft text-app-primary text-[10px] px-1.5 py-0">
{lessons.length}
</Badge>
</TabsTrigger>
</TabsList>
{/* Overview Tab */}
<TabsContent value="overview">
<Card
className="w-full max-w-2xl overflow-hidden rounded-xl border border-app-border bg-white"
style={{ boxShadow: 'rgba(0,0,0,0.03) 0px 4px 24px' }}
>
<div className="p-5 border-b border-app-border">
<h4 className="font-semibold text-app-text">Course Details</h4>
</div>
<Label className="mb-1 block text-app-text">Status</Label>
{course?.status === 'Active' ? (
<Badge className="rounded-[6px] bg-app-success text-app-success-text">
Active
</Badge>
<div className="px-5 pb-5 pt-4">
<div className="mb-4 w-full max-w-sm">
<Label className="mb-1.5 block text-app-text text-xs font-semibold uppercase tracking-wider">
Course Code
</Label>
<Input value={course.code} readOnly className="bg-app-surface-muted" />
</div>
<div className="mb-4">
<Label className="mb-1.5 block text-app-text text-xs font-semibold uppercase tracking-wider">
Status
</Label>
<Badge
className={`rounded-[6px] text-xs font-medium ${
course.status === 'Active'
? 'bg-app-success text-app-success-text'
: 'bg-app-neutral-badge text-app-neutral-badge-text'
}`}
>
{course.status}
</Badge>
</div>
<div className="mb-4 w-full max-w-sm">
<Label className="mb-1.5 block text-app-text text-xs font-semibold uppercase tracking-wider">
Description
</Label>
<Textarea
className="max-h-40 bg-app-surface-muted"
placeholder="Course description..."
rows={4}
value={course.description}
readOnly
/>
</div>
<div className="flex items-center gap-6 text-sm text-app-text-muted">
<span>
Last updated:{' '}
<span className="font-medium text-app-text">{course.lastUpdate}</span>
</span>
<span>
Total lessons:{' '}
<span className="font-medium text-app-text">{lessons.length}</span>
</span>
</div>
</div>
</Card>
</TabsContent>
{/* Lessons Tab */}
<TabsContent value="lessons">
<div className="flex flex-col gap-4">
{/* Toolbar */}
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<div className="flex items-center gap-2">
<h3 className="text-lg font-semibold text-app-text">Lessons</h3>
<Badge className="rounded-full bg-app-surface-muted text-app-text-muted text-xs">
{lessons.length} items
</Badge>
</div>
<p className="text-sm text-app-text-muted mt-0.5">
{publishedCount} published, {draftCount} drafts
</p>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
className="rounded-xl bg-app-surface-muted text-app-text hover:bg-app-interactive-hover"
>
<HiOutlineFunnel className="w-4 h-4 mr-1.5" />
Filter
</Button>
<Button
className="rounded-xl bg-app-primary text-white hover:bg-app-primary-dark shadow-sm"
onClick={() => navigate(`/courses/${courseId}/lessons/new`)}
>
<HiOutlinePlus className="w-4 h-4 mr-1.5" />
Add Lesson
</Button>
</div>
</div>
{/* Lessons Table */}
{lessons.length === 0 ? (
<Card
className="rounded-xl border border-app-border bg-white p-12 flex flex-col items-center justify-center text-center"
style={{ boxShadow: 'rgba(0,0,0,0.03) 0px 4px 24px' }}
>
<div className="w-16 h-16 rounded-2xl bg-app-surface-muted flex items-center justify-center mb-4">
<HiOutlineBookOpen className="w-8 h-8 text-app-text-muted/40" />
</div>
<p className="text-lg font-medium text-app-text mb-1">No lessons yet</p>
<p className="text-sm text-app-text-muted mb-4">
Start building your course by adding the first lesson.
</p>
<Button
className="rounded-xl bg-app-primary text-white hover:bg-app-primary-dark"
onClick={() => navigate(`/courses/${courseId}/lessons/new`)}
>
<HiOutlinePlus className="w-4 h-4 mr-2" />
Add First Lesson
</Button>
</Card>
) : (
<Badge className="rounded-[8px] bg-app-neutral-badge text-app-neutral-badge-text">
Inactive
</Badge>
<Card
className="rounded-xl border border-app-border bg-white overflow-hidden"
style={{ boxShadow: 'rgba(0,0,0,0.03) 0px 4px 24px' }}
>
{/* Table Header */}
<div className="grid grid-cols-12 gap-4 px-5 py-3 bg-app-surface-muted/50 border-b border-app-border text-xs font-semibold text-app-text-muted uppercase tracking-wider">
<div className="col-span-1">#</div>
<div className="col-span-1">Type</div>
<div className="col-span-4">Title</div>
<div className="col-span-2">Duration</div>
<div className="col-span-2">Status</div>
<div className="col-span-1">Preview</div>
<div className="col-span-1 text-right">Actions</div>
</div>
{/* Table Rows */}
{lessons.map((lesson) => {
const tc = typeConfig[lesson.type];
const TypeIcon = tc.icon;
return (
<div
key={lesson.id}
className="grid grid-cols-12 gap-4 px-5 py-3.5 border-b border-app-border/50 last:border-b-0 items-center hover:bg-app-surface-muted/30 transition-colors"
>
<div className="col-span-1 text-sm text-app-text-muted font-mono">
{lesson.position}
</div>
<div className="col-span-1">
<span
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-lg text-[11px] font-semibold uppercase ${tc.color}`}
>
<TypeIcon className="w-3 h-3" />
{tc.label}
</span>
</div>
<div className="col-span-4">
<span className="text-sm font-medium text-app-text truncate block">
{lesson.title}
</span>
</div>
<div className="col-span-2 text-sm text-app-text-muted font-mono">
{lesson.duration}
</div>
<div className="col-span-2">
<Badge
className={`rounded-[6px] text-[11px] font-medium ${
statusStyles[lesson.status]
}`}
>
{lesson.status}
</Badge>
</div>
<div className="col-span-1">
{lesson.isFreePreview && (
<Badge className="rounded-full bg-blue-50 text-blue-600 text-[10px]">
Free
</Badge>
)}
</div>
<div className="col-span-1 flex items-center justify-end gap-1">
<button
onClick={() =>
navigate(`/courses/${courseId}/lessons/new`)
}
className="w-8 h-8 rounded-lg flex items-center justify-center text-app-text-muted hover:text-app-primary hover:bg-app-primary-soft transition-colors"
title="Edit"
>
<HiOutlinePencil className="w-4 h-4" />
</button>
<button
className="w-8 h-8 rounded-lg flex items-center justify-center text-app-text-muted hover:text-app-primary hover:bg-app-primary-soft transition-colors"
title="Preview"
>
<HiOutlineEye className="w-4 h-4" />
</button>
<button
className="w-8 h-8 rounded-lg flex items-center justify-center text-app-text-muted hover:text-red-500 hover:bg-red-50 transition-colors"
title="Delete"
>
<HiOutlineTrash className="w-4 h-4" />
</button>
</div>
</div>
);
})}
</Card>
)}
<div className="my-2 w-full max-w-64">
<Label className="mb-1 block text-app-text">Description</Label>
<Textarea
className="max-h-50"
placeholder="..."
rows={4}
value={course?.description}
readOnly
/>
</div>
</div>
</Card>
</div>
</TabsContent>
</Tabs>
</>
);
}
+91
View File
@@ -0,0 +1,91 @@
import { useState } from 'react';
import { useParams } from 'react-router-dom';
import PageHeader from '../../components/PageHeader';
import ActionBar from './components/ActionBar';
import LessonMetadataForm from './components/LessonMetadataForm';
import SectionManager from './components/SectionManager';
import LessonSidebar from './components/LessonSidebar';
const initialMetadata = {
title: '',
position: 1,
slug: '',
description: '',
};
const initialSections = [
{
id: 'section_1',
type: 'video',
title: 'Introduction to Grid 8pt System',
url: 'https://player.vimeo.com/video/847291039',
duration: '08:45',
thumbnail: '',
isFreePreview: true,
},
{
id: 'section_2',
type: 'text',
title: 'Breakpoint Standards & Responsive Guide',
content:
'## 1. Standard Breakpoints\n\nIn modern responsive design, the grid must follow the soft 8-pt grid structure.\n\n```\n$screens: ( sm: 640px, md: 768px, lg: 1024px, xl: 1280px, 2xl: 1536px );\n```\n\nAll spacing (gutter, margin) must be a multiple of 8: 8px, 16px, 24px, 32px...',
isFreePreview: false,
},
{
id: 'section_3',
type: 'quiz',
title: 'Knowledge Check: Breakpoints & Fluid Grids',
questionCount: 5,
timeLimit: 10,
passScore: 80,
maxAttempts: 3,
isFreePreview: false,
},
{
id: 'section_4',
type: 'embed',
title: 'Figma Prototype Interaction',
embedUrl: 'https://www.figma.com/embed?embed_host=share&url=https://www.figma.com/file/rN7X3d/Responsive-Design-Library',
isFreePreview: false,
},
];
function LessonFormPage() {
const { courseId } = useParams();
const [metadata, setMetadata] = useState(initialMetadata);
const [sections, setSections] = useState(initialSections);
const [isPublished, setIsPublished] = useState(false);
return (
<>
<PageHeader />
<div className="max-w-7xl mx-auto px-6 py-8">
<div className="flex flex-col w-full">
<ActionBar
courseId={courseId}
isPublished={isPublished}
onTogglePublish={() => setIsPublished(!isPublished)}
/>
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 items-start">
<div className="lg:col-span-8 flex flex-col gap-8">
<LessonMetadataForm metadata={metadata} onChange={setMetadata} />
<SectionManager sections={sections} onChange={setSections} />
</div>
<div className="lg:col-span-4">
<LessonSidebar
isPublished={isPublished}
onTogglePublish={() => setIsPublished(!isPublished)}
sections={sections}
metadata={metadata}
/>
</div>
</div>
</div>
</div>
</>
);
}
export default LessonFormPage;
+52
View File
@@ -0,0 +1,52 @@
import { Button } from '../../../components/ui/button';
import { HiOutlineArrowLeft, HiOutlineBookmark, HiOutlineRocketLaunch } from 'react-icons/hi2';
import { Link } from 'react-router-dom';
function ActionBar({ courseId, isPublished, onTogglePublish }) {
return (
<div className="flex flex-wrap items-center justify-between gap-4 mb-6">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Link to="/courses" className="hover:text-primary transition-colors flex items-center gap-1">
<HiOutlineArrowLeft className="w-4 h-4" />
<span>Courses</span>
</Link>
<span>/</span>
<Link to={`/courses/${courseId}`} className="hover:text-primary transition-colors truncate max-w-[200px]">
{courseId}
</Link>
<span>/</span>
<span className="font-medium text-foreground">New Lesson</span>
<span className="ml-2 px-2.5 py-0.5 rounded-full bg-app-primary-soft text-app-primary text-xs font-medium flex items-center gap-1">
<span className="w-1.5 h-1.5 rounded-full bg-app-primary animate-pulse" />
Editing
</span>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
className="rounded-xl bg-app-surface-muted text-app-text hover:bg-app-interactive-hover"
>
<HiOutlineArrowLeft className="w-4 h-4 mr-2" />
Back
</Button>
<Button
variant="outline"
className="rounded-xl bg-app-surface-muted text-app-text hover:bg-app-interactive-hover"
>
<HiOutlineBookmark className="w-4 h-4 mr-2" />
Save Draft
</Button>
<Button
className="rounded-xl bg-app-primary text-white hover:bg-app-primary-dark shadow-sm"
onClick={onTogglePublish}
>
<HiOutlineRocketLaunch className="w-4 h-4 mr-2" />
{isPublished ? 'Update & Publish' : 'Save & Publish'}
</Button>
</div>
</div>
);
}
export default ActionBar;
@@ -0,0 +1,53 @@
import { Input } from '../../../components/ui/input';
import { Label } from '../../../components/ui/label';
import { HiOutlineCodeBracket, HiOutlineEye, HiOutlineArrowTopRightOnSquare } from 'react-icons/hi2';
function EmbedSection({ section, onChange }) {
const handleChange = (field, value) => {
onChange({ ...section, [field]: value });
};
return (
<div className="p-5 flex flex-col gap-3 bg-white">
<div>
<Label className="mb-1.5 block text-app-text text-xs font-semibold">
Embed Code or Share URL (Figma / CodePen / Miro)
</Label>
<div className="flex rounded-xl overflow-hidden bg-app-surface-muted">
<span className="px-4 py-2 text-app-text-muted bg-app-border/30 flex items-center">
<HiOutlineCodeBracket className="w-4 h-4" />
</span>
<Input
value={section.embedUrl || ''}
onChange={(e) => handleChange('embedUrl', e.target.value)}
placeholder="https://..."
className="border-0 bg-transparent font-mono text-sm focus:ring-0 focus:bg-transparent"
/>
</div>
</div>
<div className="mt-2 rounded-xl bg-app-surface-muted p-4 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-app-border/30 flex items-center justify-center text-app-primary">
<HiOutlineEye className="w-5 h-5" />
</div>
<div>
<span className="font-medium text-sm text-app-text block">Embed Preview</span>
<p className="text-xs text-app-text-muted">Default size: 100% width, 540px height</p>
</div>
</div>
<a
href={section.embedUrl || '#'}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-app-primary hover:underline flex items-center gap-1 font-medium"
>
Check original
<HiOutlineArrowTopRightOnSquare className="w-3.5 h-3.5" />
</a>
</div>
</div>
);
}
export default EmbedSection;
@@ -0,0 +1,110 @@
import { Card } from '../../../components/ui/card';
import { Input } from '../../../components/ui/input';
import { Label } from '../../../components/ui/label';
import { Textarea } from '../../../components/ui/textarea';
import { HiOutlineDocumentText, HiOutlineHashtag, HiOutlineArrowPath } from 'react-icons/hi2';
function LessonMetadataForm({ metadata, onChange }) {
const handleChange = (field, value) => {
onChange({ ...metadata, [field]: value });
};
const generateSlug = () => {
const slug = metadata.title
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/đ/g, 'd')
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.trim();
handleChange('slug', slug);
};
return (
<Card className="rounded-xl border border-app-border bg-white overflow-hidden" style={{ boxShadow: 'rgba(0,0,0,0.03) 0px 4px 24px' }}>
<div className="p-5">
<div className="flex items-center justify-between pb-4 mb-4 border-b border-app-border">
<div className="flex items-center gap-2">
<HiOutlineDocumentText className="w-5 h-5 text-app-primary" />
<h2 className="text-lg font-semibold text-app-text">Lesson Metadata</h2>
</div>
<span className="text-xs font-mono text-app-text-muted uppercase tracking-wider bg-app-surface-muted px-2 py-0.5 rounded">
schema: lessons
</span>
</div>
<div className="grid grid-cols-1 md:grid-cols-6 gap-4">
<div className="md:col-span-4">
<Label className="mb-1.5 block text-app-text text-xs font-semibold uppercase tracking-wider">
Lesson Title <span className="text-red-500">*</span>
</Label>
<Input
placeholder="VD: 1.1 Khái niệm cơ bản..."
value={metadata.title}
onChange={(e) => handleChange('title', e.target.value)}
className="rounded-xl bg-app-surface-muted border-0 focus:bg-white focus:ring-2 focus:ring-app-primary"
/>
</div>
<div className="md:col-span-2">
<Label className="mb-1.5 block text-app-text text-xs font-semibold uppercase tracking-wider">
Position
</Label>
<div className="relative">
<HiOutlineHashtag className="absolute left-3 top-2.5 w-4 h-4 text-app-text-muted" />
<Input
type="number"
value={metadata.position}
onChange={(e) => handleChange('position', e.target.value)}
className="pl-9 rounded-xl bg-app-surface-muted border-0 focus:bg-white focus:ring-2 focus:ring-app-primary"
/>
</div>
</div>
<div className="md:col-span-6">
<div className="flex items-center justify-between mb-1.5">
<Label className="block text-app-text text-xs font-semibold uppercase tracking-wider">
Slug
</Label>
<button
type="button"
onClick={generateSlug}
className="text-app-primary hover:underline text-xs font-medium flex items-center gap-1"
>
<HiOutlineArrowPath className="w-3.5 h-3.5" />
Auto-generate from title
</button>
</div>
<div className="flex rounded-xl overflow-hidden bg-app-surface-muted">
<span className="px-4 py-2 text-app-text-muted font-mono text-sm bg-app-border/30 flex items-center select-none">
/lessons/
</span>
<Input
value={metadata.slug}
onChange={(e) => handleChange('slug', e.target.value)}
className="border-0 bg-transparent font-mono focus:ring-0 focus:bg-transparent"
/>
</div>
</div>
<div className="md:col-span-6">
<Label className="mb-1.5 block text-app-text text-xs font-semibold uppercase tracking-wider">
Description & Learning Objectives
</Label>
<Textarea
placeholder="Mô tả tóm tắt nội dung bài học, kết quả mong đợi của học viên..."
rows={3}
value={metadata.description}
onChange={(e) => handleChange('description', e.target.value)}
className="rounded-xl bg-app-surface-muted border-0 focus:bg-white focus:ring-2 focus:ring-app-primary resize-none"
/>
</div>
</div>
</div>
</Card>
);
}
export default LessonMetadataForm;
@@ -0,0 +1,225 @@
import { Card } from '../../../components/ui/card';
import { Switch } from '../../../components/ui/switch';
import { Badge } from '../../../components/ui/badge';
import { HiOutlineLockOpen } from 'react-icons/hi2';
function PublishCard({ isPublished, onTogglePublish, courseTitle, moduleTitle }) {
return (
<Card className="rounded-xl border border-app-border bg-white overflow-hidden" style={{ boxShadow: 'rgba(0,0,0,0.03) 0px 4px 24px' }}>
<div className="p-5">
<div className="flex items-center justify-between pb-3 border-b border-app-border">
<h3 className="font-semibold text-app-text">Publish Status</h3>
<Badge className="rounded-full bg-app-surface-muted text-app-text-muted text-xs flex items-center gap-1">
<span className="w-1.5 h-1.5 rounded-full bg-app-primary" />
Draft
</Badge>
</div>
<div className="mt-4 p-4 rounded-xl bg-app-surface-muted flex items-center justify-between">
<div className="flex flex-col">
<span className="font-medium text-sm text-app-text">Publish Lesson</span>
<span className="text-xs text-app-text-muted">Allow students to access</span>
</div>
<Switch checked={isPublished} onCheckedChange={onTogglePublish} />
</div>
<div className="mt-3 flex items-center justify-between text-sm text-app-text-muted px-1">
<span>Linked Course:</span>
<span className="font-medium text-app-text truncate max-w-[170px]">{courseTitle}</span>
</div>
<div className="mt-1 flex items-center justify-between text-sm text-app-text-muted px-1">
<span>Module:</span>
<span className="font-medium text-app-text">{moduleTitle}</span>
</div>
</div>
</Card>
);
}
function ContentStatsCard({ sections }) {
const typeCounts = sections.reduce((acc, s) => {
acc[s.type] = (acc[s.type] || 0) + 1;
return acc;
}, {});
const totalDuration = sections
.filter((s) => s.type === 'video')
.reduce((acc, s) => {
if (!s.duration) return acc;
const parts = s.duration.split(':').map(Number);
return acc + (parts[0] || 0) * 60 + (parts[1] || 0);
}, 0);
const formatDuration = (secs) => {
const m = Math.floor(secs / 60);
const s = secs % 60;
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
};
const totalItems = sections.length;
const freePreviewCount = sections.filter((s) => s.isFreePreview).length;
const typeColors = {
video: 'bg-app-primary',
text: 'bg-purple-400',
quiz: 'bg-orange-400',
embed: 'bg-gray-400',
};
const typeLabels = {
video: 'Video',
text: 'Text',
quiz: 'Quiz',
embed: 'Embed',
};
return (
<Card className="rounded-xl border border-app-border bg-white overflow-hidden" style={{ boxShadow: 'rgba(0,0,0,0.03) 0px 4px 24px' }}>
<div className="p-5">
<h3 className="font-semibold text-app-text pb-3 border-b border-app-border">Content Overview</h3>
<div className="grid grid-cols-2 gap-3 mt-4">
<div className="p-3 rounded-xl bg-app-surface-muted flex flex-col">
<span className="text-xs font-semibold text-app-text-muted uppercase">Total Duration</span>
<span className="text-xl font-bold text-app-primary mt-1">{formatDuration(totalDuration)}</span>
<span className="text-[11px] text-app-text-muted mt-0.5">~{Math.ceil(totalDuration / 60)} min learning</span>
</div>
<div className="p-3 rounded-xl bg-app-surface-muted flex flex-col">
<span className="text-xs font-semibold text-app-text-muted uppercase">Sections</span>
<span className="text-xl font-bold text-app-text mt-1">{String(totalItems).padStart(2, '0')}</span>
<span className="text-[11px] text-app-text-muted mt-0.5">
{Object.entries(typeCounts)
.map(([t, c]) => `${c} ${typeLabels[t]}`)
.join(', ')}
</span>
</div>
</div>
{totalItems > 0 && (
<div className="flex flex-col gap-1.5 pt-4">
<div className="flex items-center justify-between text-xs text-app-text-muted">
<span>Format distribution</span>
<span>100%</span>
</div>
<div className="w-full h-2.5 rounded-full overflow-hidden flex bg-app-border/30">
{Object.entries(typeCounts).map(([type, count]) => (
<div
key={type}
className={`${typeColors[type]} h-full`}
style={{ width: `${(count / totalItems) * 100}%` }}
title={`${typeLabels[type]} ${Math.round((count / totalItems) * 100)}%`}
/>
))}
</div>
<div className="flex items-center justify-between text-[11px] text-app-text-muted pt-1">
{Object.entries(typeCounts).map(([type]) => (
<span key={type} className="flex items-center gap-1">
<span className={`w-2 h-2 rounded-full ${typeColors[type]}`} />
{typeLabels[type]}
</span>
))}
</div>
</div>
)}
{freePreviewCount > 0 && (
<div className="mt-4 p-3 rounded-xl bg-app-primary-soft flex items-start gap-3">
<HiOutlineLockOpen className="w-5 h-5 text-app-primary mt-0.5" />
<div>
<span className="font-medium text-sm text-app-text block">{freePreviewCount} free preview items</span>
<p className="text-xs text-app-text-muted mt-0.5">
Non-enrolled students can view these to encourage sign-ups.
</p>
</div>
</div>
)}
</div>
</Card>
);
}
function SeoCard({ title, slug, description }) {
return (
<Card className="rounded-xl border border-app-border bg-white overflow-hidden" style={{ boxShadow: 'rgba(0,0,0,0.03) 0px 4px 24px' }}>
<div className="p-5">
<h3 className="font-semibold text-app-text pb-3 border-b border-app-border">SEO & Search</h3>
<div className="mt-4 p-3 rounded-xl bg-app-surface-muted flex flex-col gap-1">
<span className="text-xs font-mono text-app-primary truncate">
academy.edu.vn &gt; courses &gt; {slug || 'lesson-slug'}
</span>
<span className="font-medium text-sm text-app-primary hover:underline cursor-pointer">
{title || 'Lesson Title'} - LMS Enterprise
</span>
<p className="text-xs text-app-text-muted line-clamp-2">
{description || 'Lesson description will appear here...'}
</p>
</div>
</div>
</Card>
);
}
function AuditCard({ lessonId, courseId, createdAt, updatedAt, authorId }) {
return (
<Card className="rounded-xl border border-app-border bg-white overflow-hidden" style={{ boxShadow: 'rgba(0,0,0,0.03) 0px 4px 24px' }}>
<div className="p-5">
<h3 className="font-semibold text-app-text pb-3 border-b border-app-border">System Info (Audit)</h3>
<div className="mt-4 flex flex-col gap-2 font-mono text-xs text-app-text-muted">
<div className="flex items-center justify-between">
<span>lesson_id:</span>
<span className="text-app-text">{lessonId}</span>
</div>
<div className="flex items-center justify-between">
<span>course_id:</span>
<span className="text-app-text">{courseId}</span>
</div>
<div className="flex items-center justify-between">
<span>created_at:</span>
<span className="text-app-text">{createdAt}</span>
</div>
<div className="flex items-center justify-between">
<span>updated_at:</span>
<span className="text-app-text">{updatedAt}</span>
</div>
<div className="flex items-center justify-between">
<span>author_id:</span>
<span className="text-app-text">{authorId}</span>
</div>
</div>
</div>
</Card>
);
}
function LessonSidebar({
isPublished,
onTogglePublish,
sections,
metadata,
}) {
return (
<div className="flex flex-col gap-6">
<PublishCard
isPublished={isPublished}
onTogglePublish={onTogglePublish}
courseTitle="Advanced UI/UX 2024"
moduleTitle="Module 1: Grid Fundamentals"
/>
<ContentStatsCard sections={sections} />
<SeoCard
title={metadata.title}
slug={metadata.slug}
description={metadata.description}
/>
<AuditCard
lessonId="lsn_new"
courseId="crs_7109cb44"
createdAt="Just now"
updatedAt="Just now"
authorId="usr_admin_01"
/>
</div>
);
}
export default LessonSidebar;
@@ -0,0 +1,50 @@
import { HiOutlineQuestionMarkCircle, HiOutlineClock, HiOutlineAcademicCap, HiOutlinePencilSquare } from 'react-icons/hi2';
function QuizSection({ section }) {
return (
<div className="p-5 flex flex-col gap-4 bg-white">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="bg-app-surface-muted p-4 rounded-xl flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-white flex items-center justify-center text-app-primary shadow-sm">
<HiOutlineQuestionMarkCircle className="w-5 h-5" />
</div>
<div>
<span className="font-medium text-sm text-app-text block">{section.questionCount || 5} Questions</span>
<span className="text-xs text-app-text-muted">Multiple choice & True/False</span>
</div>
</div>
<div className="bg-app-surface-muted p-4 rounded-xl flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-white flex items-center justify-center text-app-primary shadow-sm">
<HiOutlineClock className="w-5 h-5" />
</div>
<div>
<span className="font-medium text-sm text-app-text block">Time: {section.timeLimit || 10} min</span>
<span className="text-xs text-app-text-muted">{(section.timeLimit || 10) * 60} seconds countdown</span>
</div>
</div>
<div className="bg-app-surface-muted p-4 rounded-xl flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-white flex items-center justify-center text-app-primary shadow-sm">
<HiOutlineAcademicCap className="w-5 h-5" />
</div>
<div>
<span className="font-medium text-sm text-app-text block">Pass: {section.passScore || 80}%</span>
<span className="text-xs text-app-text-muted">Max {section.maxAttempts || 3} attempts</span>
</div>
</div>
</div>
<div className="flex items-center justify-between bg-app-surface-muted p-3 rounded-xl">
<div className="flex items-center gap-2 text-sm text-app-text-muted">
<span className="w-2 h-2 rounded-full bg-app-primary" />
<span>1. What problem does the 8-point grid system solve?</span>
</div>
<button className="px-4 py-1.5 rounded-xl bg-white hover:bg-app-surface-muted text-app-primary text-xs font-medium flex items-center gap-1 transition-colors border border-app-border">
<HiOutlinePencilSquare className="w-3.5 h-3.5" />
Edit Questions
</button>
</div>
</div>
);
}
export default QuizSection;
@@ -0,0 +1,266 @@
import { useState, useRef } from 'react';
import { Button } from '../../../components/ui/button';
import { Badge } from '../../../components/ui/badge';
import {
HiOutlineBars3,
HiOutlineVideoCamera,
HiOutlineDocumentText,
HiOutlineQuestionMarkCircle,
HiOutlineCodeBracket,
HiOutlineChevronUp,
HiOutlineChevronDown,
HiOutlineDocumentDuplicate,
HiOutlineTrash,
HiOutlinePlus,
HiOutlineChevronDown as HiOutlineExpand,
} from 'react-icons/hi2';
import VideoSection from './VideoSection';
import TextSection from './TextSection';
import QuizSection from './QuizSection';
import EmbedSection from './EmbedSection';
const SECTION_TYPES = {
video: {
label: 'Video',
icon: HiOutlineVideoCamera,
color: 'bg-blue-100 text-blue-700',
},
text: {
label: 'Text',
icon: HiOutlineDocumentText,
color: 'bg-purple-100 text-purple-700',
},
quiz: {
label: 'Quiz',
icon: HiOutlineQuestionMarkCircle,
color: 'bg-orange-100 text-orange-700',
},
embed: {
label: 'Embed',
icon: HiOutlineCodeBracket,
color: 'bg-gray-100 text-gray-700',
},
};
const sectionEditors = {
video: VideoSection,
text: TextSection,
quiz: QuizSection,
embed: EmbedSection,
};
function SectionItem({ section, index, total, onUpdate, onRemove, onDuplicate, onMoveUp, onMoveDown }) {
const [isExpanded, setIsExpanded] = useState(true);
const typeConfig = SECTION_TYPES[section.type];
const Icon = typeConfig.icon;
const Editor = sectionEditors[section.type];
const getMetaText = () => {
switch (section.type) {
case 'video':
return section.duration || '00:00';
case 'text':
return `~${Math.max(1, Math.ceil((section.content || '').split(/\s+/).length / 200))} min read`;
case 'quiz':
return `${section.timeLimit || 10}:00 \u2022 ${section.questionCount || 5} questions`;
case 'embed':
return 'iFrame';
default:
return '';
}
};
return (
<div className="group relative bg-white rounded-xl shadow-sm hover:shadow-md transition-all overflow-hidden border border-app-border/50">
<div className="p-4 flex items-center justify-between gap-3 bg-app-surface-muted/50">
<div className="flex items-center gap-3 flex-1 min-w-0">
<HiOutlineBars3 className="w-5 h-5 text-app-text-muted cursor-grab active:cursor-grabbing hover:text-app-primary" />
<span className={`px-2 py-0.5 rounded-lg text-xs font-semibold uppercase tracking-wider flex items-center gap-1 ${typeConfig.color}`}>
<Icon className="w-3.5 h-3.5" />
{typeConfig.label}
</span>
<span className="font-medium text-sm text-app-text truncate">{section.title || `Section ${index + 1}`}</span>
{section.isFreePreview && (
<Badge className="rounded-full bg-app-surface-muted text-app-text-muted text-[11px]">Free Preview</Badge>
)}
</div>
<div className="flex items-center gap-1">
<span className="text-xs text-app-text-muted font-mono mr-2">{getMetaText()}</span>
<button
type="button"
onClick={() => setIsExpanded(!isExpanded)}
className="w-8 h-8 rounded-lg flex items-center justify-center text-app-text-muted hover:text-app-text hover:bg-app-border/30 transition-colors"
>
{isExpanded ? <HiOutlineChevronUp className="w-4 h-4" /> : <HiOutlineChevronDown className="w-4 h-4" />}
</button>
<button
type="button"
onClick={onMoveUp}
disabled={index === 0}
className="w-8 h-8 rounded-lg flex items-center justify-center text-app-text-muted hover:text-app-text hover:bg-app-border/30 transition-colors disabled:opacity-30"
>
<HiOutlineChevronUp className="w-4 h-4" />
</button>
<button
type="button"
onClick={onMoveDown}
disabled={index === total - 1}
className="w-8 h-8 rounded-lg flex items-center justify-center text-app-text-muted hover:text-app-text hover:bg-app-border/30 transition-colors disabled:opacity-30"
>
<HiOutlineChevronDown className="w-4 h-4" />
</button>
<button
type="button"
onClick={onDuplicate}
className="w-8 h-8 rounded-lg flex items-center justify-center text-app-text-muted hover:text-app-text hover:bg-app-border/30 transition-colors"
>
<HiOutlineDocumentDuplicate className="w-4 h-4" />
</button>
<button
type="button"
onClick={onRemove}
className="w-8 h-8 rounded-lg flex items-center justify-center text-app-text-muted hover:text-red-500 hover:bg-red-50 transition-colors"
>
<HiOutlineTrash className="w-4 h-4" />
</button>
</div>
</div>
{isExpanded && Editor && (
<Editor
section={section}
onChange={(updated) => onUpdate(index, updated)}
/>
)}
</div>
);
}
function SectionManager({ sections, onChange }) {
const [showAddMenu, setShowAddMenu] = useState(false);
const counterRef = useRef(0);
const addSection = (type) => {
counterRef.current += 1;
const newSection = {
id: `section_${counterRef.current}`,
type,
title: '',
isFreePreview: false,
...(type === 'video' && { url: '', duration: '', thumbnail: '' }),
...(type === 'text' && { content: '' }),
...(type === 'quiz' && { questionCount: 5, timeLimit: 10, passScore: 80, maxAttempts: 3 }),
...(type === 'embed' && { embedUrl: '' }),
};
onChange([...sections, newSection]);
setShowAddMenu(false);
};
const updateSection = (index, updated) => {
const newSections = [...sections];
newSections[index] = updated;
onChange(newSections);
};
const removeSection = (index) => {
onChange(sections.filter((_, i) => i !== index));
};
const duplicateSection = (index) => {
counterRef.current += 1;
const duplicate = { ...sections[index], id: `section_${counterRef.current}` };
const newSections = [...sections];
newSections.splice(index + 1, 0, duplicate);
onChange(newSections);
};
const moveSection = (from, to) => {
if (to < 0 || to >= sections.length) return;
const newSections = [...sections];
const [moved] = newSections.splice(from, 1);
newSections.splice(to, 0, moved);
onChange(newSections);
};
return (
<div className="flex flex-col gap-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<div className="flex items-center gap-2">
<h3 className="text-lg font-semibold text-app-text">Sections</h3>
<Badge className="rounded-full bg-app-surface-muted text-app-text-muted">{sections.length} items</Badge>
</div>
<p className="text-sm text-app-text-muted mt-0.5">
Drag to reorder. Each section supports different multimedia content types.
</p>
</div>
<div className="relative">
<Button
className="rounded-xl bg-app-primary text-white hover:bg-app-primary-dark shadow-sm"
onClick={() => setShowAddMenu(!showAddMenu)}
>
<HiOutlinePlus className="w-4 h-4 mr-2" />
Add Section
<HiOutlineExpand className="w-4 h-4 ml-1" />
</Button>
{showAddMenu && (
<div className="absolute right-0 mt-2 w-48 bg-white rounded-xl shadow-lg border border-app-border z-10 py-1">
{Object.entries(SECTION_TYPES).map(([type, config]) => {
const Icon = config.icon;
return (
<button
key={type}
type="button"
onClick={() => addSection(type)}
className="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-app-text hover:bg-app-surface-muted transition-colors"
>
<Icon className={`w-4 h-4 ${config.color.split(' ')[1]}`} />
Add {config.label}
</button>
);
})}
</div>
)}
</div>
</div>
<div className="flex flex-col gap-3">
{sections.map((section, index) => (
<SectionItem
key={section.id}
section={section}
index={index}
total={sections.length}
onUpdate={updateSection}
onRemove={() => removeSection(index)}
onDuplicate={() => duplicateSection(index)}
onMoveUp={() => moveSection(index, index - 1)}
onMoveDown={() => moveSection(index, index + 1)}
/>
))}
</div>
<div className="p-3 rounded-xl bg-white shadow-sm border border-app-border/50 flex flex-wrap items-center justify-between gap-3">
<span className="text-xs text-app-text-muted uppercase tracking-wider pl-2">Quick add:</span>
<div className="flex flex-wrap items-center gap-2">
{Object.entries(SECTION_TYPES).map(([type, config]) => {
const Icon = config.icon;
return (
<button
key={type}
type="button"
onClick={() => addSection(type)}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-app-surface-muted hover:bg-app-border/40 text-app-text text-sm font-medium transition-colors"
>
<Icon className={`w-4 h-4 ${config.color.split(' ')[1]}`} />
+ {config.label}
</button>
);
})}
</div>
</div>
</div>
);
}
export default SectionManager;
@@ -0,0 +1,87 @@
import { Textarea } from '../../../components/ui/textarea';
import {
HiOutlineBold,
HiOutlineItalic,
HiOutlineListBullet,
HiOutlineListBullet as HiOutlineNumberedList,
HiOutlineChatBubbleLeftRight,
HiOutlineCodeBracket,
HiOutlinePhoto,
HiOutlineTableCells,
HiOutlineExclamationCircle,
} from 'react-icons/hi2';
function TextSection({ section, onChange }) {
const handleChange = (field, value) => {
onChange({ ...section, [field]: value });
};
return (
<div className="p-5 flex flex-col gap-3 bg-white">
<div className="flex flex-wrap items-center gap-1 p-1.5 rounded-xl bg-app-surface-muted text-app-text-muted">
<button type="button" className="p-1.5 rounded hover:bg-app-border/40 text-app-text">
<HiOutlineBold className="w-4 h-4" />
</button>
<button type="button" className="p-1.5 rounded hover:bg-app-border/40">
<HiOutlineItalic className="w-4 h-4" />
</button>
<button type="button" className="p-1.5 rounded hover:bg-app-border/40">
<svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path d="M4 6h16M4 12h10M4 18h14" />
</svg>
</button>
<div className="w-px h-5 bg-app-border mx-1" />
<button type="button" className="p-1.5 rounded hover:bg-app-border/40">
<HiOutlineListBullet className="w-4 h-4" />
</button>
<button type="button" className="p-1.5 rounded hover:bg-app-border/40">
<HiOutlineNumberedList className="w-4 h-4" />
</button>
<button type="button" className="p-1.5 rounded hover:bg-app-border/40">
<HiOutlineChatBubbleLeftRight className="w-4 h-4" />
</button>
<button type="button" className="p-1.5 rounded hover:bg-app-border/40">
<HiOutlineCodeBracket className="w-4 h-4" />
</button>
<div className="w-px h-5 bg-app-border mx-1" />
<button type="button" className="p-1.5 rounded hover:bg-app-border/40 flex items-center gap-1 text-xs">
<HiOutlinePhoto className="w-4 h-4" />
Image
</button>
<button type="button" className="p-1.5 rounded hover:bg-app-border/40 flex items-center gap-1 text-xs">
<HiOutlineTableCells className="w-4 h-4" />
Table
</button>
<button type="button" className="p-1.5 rounded hover:bg-app-border/40 flex items-center gap-1 text-xs text-app-primary">
<HiOutlineExclamationCircle className="w-4 h-4" />
Callout
</button>
</div>
<div className="rounded-xl bg-white min-h-[160px] p-4">
<Textarea
value={section.content || ''}
onChange={(e) => handleChange('content', e.target.value)}
placeholder="Write your lesson content here..."
rows={8}
className="border-0 bg-transparent focus:ring-0 focus:bg-transparent resize-none text-sm leading-relaxed"
/>
</div>
<div className="flex items-center justify-between pt-2 border-t border-app-border">
<span className="text-xs text-app-text-muted">Last saved at 14:32 (Auto)</span>
<label className="flex items-center gap-2 cursor-pointer text-xs text-app-text-muted">
<input
type="checkbox"
checked={section.isFreePreview || false}
onChange={(e) => handleChange('isFreePreview', e.target.checked)}
className="w-3.5 h-3.5 rounded text-app-primary focus:ring-0"
/>
<span>Mark as free preview</span>
</label>
</div>
</div>
);
}
export default TextSection;
@@ -0,0 +1,109 @@
import { Input } from '../../../components/ui/input';
import { Label } from '../../../components/ui/label';
import { HiOutlineVideoCamera, HiOutlineLink, HiOutlineClock, HiOutlineCheckBadge } from 'react-icons/hi2';
function VideoSection({ section, onChange }) {
const handleChange = (field, value) => {
onChange({ ...section, [field]: value });
};
return (
<div className="p-5 flex flex-col gap-4 bg-white">
<div className="grid grid-cols-1 md:grid-cols-12 gap-4">
<div className="md:col-span-5 flex flex-col gap-2">
<span className="text-xs font-semibold text-app-text-muted uppercase">Video Preview</span>
<div className="relative rounded-xl overflow-hidden bg-app-surface-muted aspect-video flex items-center justify-center group shadow-inner">
{section.thumbnail ? (
<div
className="bg-cover bg-center w-full h-full absolute inset-0"
style={{ backgroundImage: `url('${section.thumbnail}')` }}
/>
) : (
<HiOutlineVideoCamera className="w-12 h-12 text-app-text-muted/40" />
)}
<div className="absolute inset-0 bg-black/30 flex items-center justify-center">
<button className="w-12 h-12 rounded-full bg-white/90 text-app-primary flex items-center justify-center shadow-lg hover:scale-110 transition-transform">
<svg className="w-6 h-6 ml-0.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z" />
</svg>
</button>
</div>
{section.duration && (
<span className="absolute bottom-2 right-2 bg-black/70 text-white px-2 py-0.5 rounded text-xs font-mono">
{section.duration}
</span>
)}
</div>
<div className="flex items-center justify-between text-xs text-app-text-muted">
<span>H.264 &bull; 1080p &bull; 60fps</span>
<button className="text-app-primary hover:underline flex items-center gap-1">
<HiOutlineLink className="w-3.5 h-3.5" />
Change video
</button>
</div>
</div>
<div className="md:col-span-7 flex flex-col justify-between gap-3">
<div className="flex flex-col gap-3">
<div>
<Label className="mb-1 block text-app-text text-xs font-semibold">Video Title</Label>
<Input
value={section.title || ''}
onChange={(e) => handleChange('title', e.target.value)}
placeholder="Enter video title..."
className="rounded-xl bg-app-surface-muted border-0 focus:bg-white focus:ring-2 focus:ring-app-primary"
/>
</div>
<div>
<Label className="mb-1 block text-app-text text-xs font-semibold">Video Source (Vimeo, YouTube or S3)</Label>
<div className="relative">
<HiOutlineLink className="absolute left-3 top-2.5 w-4 h-4 text-app-text-muted" />
<Input
value={section.url || ''}
onChange={(e) => handleChange('url', e.target.value)}
placeholder="https://..."
className="pl-9 rounded-xl bg-app-surface-muted border-0 font-mono text-sm focus:bg-white focus:ring-2 focus:ring-app-primary"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="mb-1 block text-app-text text-xs font-semibold">Duration</Label>
<div className="relative">
<HiOutlineClock className="absolute left-3 top-2.5 w-4 h-4 text-app-text-muted" />
<Input
value={section.duration || ''}
onChange={(e) => handleChange('duration', e.target.value)}
placeholder="08:45"
className="pl-9 rounded-xl bg-app-surface-muted border-0 font-mono text-sm focus:bg-white focus:ring-2 focus:ring-app-primary"
/>
</div>
</div>
<div>
<Label className="mb-1 block text-app-text text-xs font-semibold">Free Preview</Label>
<label className="flex items-center gap-2 cursor-pointer mt-2">
<input
type="checkbox"
checked={section.isFreePreview || false}
onChange={(e) => handleChange('isFreePreview', e.target.checked)}
className="w-4 h-4 rounded text-app-primary focus:ring-0"
/>
<span className="text-sm text-app-text">Allow preview</span>
</label>
</div>
</div>
</div>
<div className="bg-app-surface-muted p-2.5 rounded-xl flex items-center justify-between text-xs text-app-text-muted">
<div className="flex items-center gap-1.5">
<HiOutlineCheckBadge className="w-4 h-4 text-app-primary" />
<span>Video encoded and ready for streaming.</span>
</div>
<span className="font-mono">JSONB: OK</span>
</div>
</div>
</div>
</div>
);
}
export default VideoSection;