267 lines
9.6 KiB
React
267 lines
9.6 KiB
React
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;
|