feat: add create lesson UI
This commit is contained in:
Generated
+78
@@ -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",
|
||||
|
||||
@@ -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,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 />} />
|
||||
|
||||
@@ -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 { 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="mb-4 flex flex-row items-center justify-between">
|
||||
<h2 className="text-xl font-bold text-app-text">{course?.title}</h2>
|
||||
<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 />
|
||||
|
||||
{/* 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>
|
||||
|
||||
<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-100 overflow-hidden rounded-xl border border-app-border bg-white"
|
||||
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">
|
||||
<h4 className="font-medium text-app-text">Course Details</h4>
|
||||
<div className="p-5 border-b border-app-border">
|
||||
<h4 className="font-semibold 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
|
||||
/>
|
||||
<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>
|
||||
<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
|
||||
<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>
|
||||
) : (
|
||||
<Badge className="rounded-[8px] bg-app-neutral-badge text-app-neutral-badge-text">
|
||||
Inactive
|
||||
</Badge>
|
||||
)}
|
||||
<div className="my-2 w-full max-w-64">
|
||||
<Label className="mb-1 block text-app-text">Description</Label>
|
||||
</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-50"
|
||||
placeholder="..."
|
||||
className="max-h-40 bg-app-surface-muted"
|
||||
placeholder="Course description..."
|
||||
rows={4}
|
||||
value={course?.description}
|
||||
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>
|
||||
) : (
|
||||
<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>
|
||||
</TabsContent>
|
||||
</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