feat: add create lesson UI
This commit is contained in:
Generated
+78
@@ -8,9 +8,11 @@
|
|||||||
"name": "aplp.frontend.lms",
|
"name": "aplp.frontend.lms",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@radix-ui/react-collapsible": "^1.1.20",
|
||||||
"@radix-ui/react-label": "^2.1.15",
|
"@radix-ui/react-label": "^2.1.15",
|
||||||
"@radix-ui/react-separator": "^1.1.15",
|
"@radix-ui/react-separator": "^1.1.15",
|
||||||
"@radix-ui/react-slot": "^1.3.3",
|
"@radix-ui/react-slot": "^1.3.3",
|
||||||
|
"@radix-ui/react-switch": "^1.3.7",
|
||||||
"@radix-ui/react-tabs": "^1.1.21",
|
"@radix-ui/react-tabs": "^1.1.21",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
@@ -590,6 +592,36 @@
|
|||||||
"integrity": "sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==",
|
"integrity": "sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@radix-ui/react-collection": {
|
||||||
"version": "1.1.15",
|
"version": "1.1.15",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.15.tgz",
|
"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": {
|
"node_modules/@radix-ui/react-tabs": {
|
||||||
"version": "1.1.21",
|
"version": "1.1.21",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.21.tgz",
|
"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": {
|
"node_modules/@rolldown/binding-android-arm64": {
|
||||||
"version": "1.2.4",
|
"version": "1.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.4.tgz",
|
||||||
|
|||||||
@@ -10,9 +10,11 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@radix-ui/react-collapsible": "^1.1.20",
|
||||||
"@radix-ui/react-label": "^2.1.15",
|
"@radix-ui/react-label": "^2.1.15",
|
||||||
"@radix-ui/react-separator": "^1.1.15",
|
"@radix-ui/react-separator": "^1.1.15",
|
||||||
"@radix-ui/react-slot": "^1.3.3",
|
"@radix-ui/react-slot": "^1.3.3",
|
||||||
|
"@radix-ui/react-switch": "^1.3.7",
|
||||||
"@radix-ui/react-tabs": "^1.1.21",
|
"@radix-ui/react-tabs": "^1.1.21",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import MainLayout from './layouts/MainLayout.jsx';
|
|||||||
import CoursesPage from './pages/Course/CoursesPage.jsx';
|
import CoursesPage from './pages/Course/CoursesPage.jsx';
|
||||||
import CourseDetail from './pages/Course/CourseDetails.jsx';
|
import CourseDetail from './pages/Course/CourseDetails.jsx';
|
||||||
import LessonPage from './pages/lesson/LessonPage.jsx';
|
import LessonPage from './pages/lesson/LessonPage.jsx';
|
||||||
|
import LessonFormPage from './pages/lesson/LessonFormPage.jsx';
|
||||||
import MCQPage from './pages/question-bank/MCQPage.jsx';
|
import MCQPage from './pages/question-bank/MCQPage.jsx';
|
||||||
import ParagraphPage from './pages/question-bank/ParagraphPage.jsx';
|
import ParagraphPage from './pages/question-bank/ParagraphPage.jsx';
|
||||||
import UsersPage from './pages/user/UsersPage.jsx';
|
import UsersPage from './pages/user/UsersPage.jsx';
|
||||||
@@ -20,6 +21,8 @@ function App() {
|
|||||||
<Route index element={<Navigate to="/courses" replace />} />
|
<Route index element={<Navigate to="/courses" replace />} />
|
||||||
<Route path="/courses" element={<CoursesPage />} />
|
<Route path="/courses" element={<CoursesPage />} />
|
||||||
<Route path="/courses/:courseId" element={<CourseDetail />} />
|
<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="/lessons" element={<LessonPage />} />
|
||||||
<Route path="/question-bank/mcq" element={<MCQPage />} />
|
<Route path="/question-bank/mcq" element={<MCQPage />} />
|
||||||
<Route path="/question-bank/paragraph" element={<ParagraphPage />} />
|
<Route path="/question-bank/paragraph" element={<ParagraphPage />} />
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -1,13 +1,30 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
import { Card } from '../../components/ui/card';
|
import { Card } from '../../components/ui/card';
|
||||||
import { Button } from '../../components/ui/button';
|
import { Button } from '../../components/ui/button';
|
||||||
import { Badge } from '../../components/ui/badge';
|
import { Badge } from '../../components/ui/badge';
|
||||||
import { Label } from '../../components/ui/label';
|
import { Label } from '../../components/ui/label';
|
||||||
import { Input } from '../../components/ui/input';
|
import { Input } from '../../components/ui/input';
|
||||||
import { Textarea } from '../../components/ui/textarea';
|
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 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',
|
code: 'LDR-101',
|
||||||
title: 'Foundations of Modern Leadership',
|
title: 'Foundations of Modern Leadership',
|
||||||
@@ -16,7 +33,7 @@ const data = [
|
|||||||
status: 'Active',
|
status: 'Active',
|
||||||
lastUpdate: '2026-08-19',
|
lastUpdate: '2026-08-19',
|
||||||
image:
|
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',
|
code: 'TEC-204',
|
||||||
@@ -26,7 +43,7 @@ const data = [
|
|||||||
status: 'Inactive',
|
status: 'Inactive',
|
||||||
lastUpdate: '2026-08-19',
|
lastUpdate: '2026-08-19',
|
||||||
image:
|
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',
|
code: 'CMP-099',
|
||||||
@@ -35,27 +52,166 @@ const data = [
|
|||||||
status: 'Inactive',
|
status: 'Inactive',
|
||||||
lastUpdate: '2026-08-19',
|
lastUpdate: '2026-08-19',
|
||||||
image:
|
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() {
|
function CourseDetail() {
|
||||||
const { courseId } = useParams();
|
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) {
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHeader />
|
<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">
|
<div className="flex flex-row items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
color="gray"
|
variant="outline"
|
||||||
className="rounded-xl bg-app-surface-muted text-app-text hover:bg-app-interactive-hover"
|
className="rounded-xl bg-app-surface-muted text-app-text hover:bg-app-interactive-hover"
|
||||||
>
|
>
|
||||||
|
<HiOutlineArrowUpTray className="w-4 h-4 mr-2" />
|
||||||
Inactivate
|
Inactivate
|
||||||
</Button>
|
</Button>
|
||||||
<Button className="rounded-xl bg-app-primary text-white hover:bg-app-primary-dark">
|
<Button className="rounded-xl bg-app-primary text-white hover:bg-app-primary-dark">
|
||||||
@@ -63,45 +219,228 @@ function CourseDetail() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<Card
|
<Separator className="mb-6" />
|
||||||
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' }}
|
{/* Tabs */}
|
||||||
>
|
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||||
<div className="p-5">
|
<TabsList className="mb-6">
|
||||||
<h4 className="font-medium text-app-text">Course Details</h4>
|
<TabsTrigger value="overview" className="gap-2">
|
||||||
</div>
|
<HiOutlineBookOpen className="w-4 h-4" />
|
||||||
<div className="px-5 pb-5">
|
Overview
|
||||||
<div className="my-2 w-full max-w-64">
|
</TabsTrigger>
|
||||||
<Label className="mb-1 block text-app-text">Course Code</Label>
|
<TabsTrigger value="lessons" className="gap-2">
|
||||||
<Input
|
<HiOutlineDocumentText className="w-4 h-4" />
|
||||||
value={course?.code}
|
Lessons
|
||||||
readOnly
|
<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>
|
</div>
|
||||||
<Label className="mb-1 block text-app-text">Status</Label>
|
<div className="px-5 pb-5 pt-4">
|
||||||
{course?.status === 'Active' ? (
|
<div className="mb-4 w-full max-w-sm">
|
||||||
<Badge className="rounded-[6px] bg-app-success text-app-success-text">
|
<Label className="mb-1.5 block text-app-text text-xs font-semibold uppercase tracking-wider">
|
||||||
Active
|
Course Code
|
||||||
</Badge>
|
</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">
|
<Card
|
||||||
Inactive
|
className="rounded-xl border border-app-border bg-white overflow-hidden"
|
||||||
</Badge>
|
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>
|
</div>
|
||||||
</Card>
|
</TabsContent>
|
||||||
</div>
|
</Tabs>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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 > courses > {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 • 1080p • 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;
|
||||||
Reference in New Issue
Block a user