1259 lines
61 KiB
TypeScript
1259 lines
61 KiB
TypeScript
import React, { useState, useEffect, useRef } from 'react';
|
|
import {
|
|
Plus,
|
|
Settings,
|
|
Users,
|
|
BarChart,
|
|
Trash2,
|
|
Edit2,
|
|
X,
|
|
MessageSquare,
|
|
Send,
|
|
Image as ImageIcon,
|
|
Minus,
|
|
PlusCircle,
|
|
Video,
|
|
Loader2,
|
|
FileText,
|
|
FileSpreadsheet,
|
|
FileArchive,
|
|
Link as LinkIcon,
|
|
Paperclip,
|
|
Lightbulb,
|
|
ListVideo,
|
|
Check,
|
|
Lock,
|
|
Unlock,
|
|
Globe,
|
|
Download,
|
|
Copy,
|
|
ChevronUp,
|
|
ChevronDown,
|
|
ChevronLeft,
|
|
ChevronRight,
|
|
Move,
|
|
RotateCcw,
|
|
} from 'lucide-react';
|
|
import { Course, Comment, Attachment, Lesson, AttachmentType, LessonMedia } from '../types';
|
|
import { useToast } from '../context/ToastContext';
|
|
import { getManagedCourses, saveCourse, deleteCourse, duplicateCourse, getComments, replyToComment, moderateComment } from '../services/db';
|
|
import { getAttachmentIcon, getAttachmentBadge } from '../components/MaterialCard';
|
|
import { CourseCoverImage } from '../components/CourseCoverImage';
|
|
import { BunnyVideo, courseApi, instructorApi, InstructorAnalytics } from '../services/api';
|
|
import { useSearchParams } from 'react-router-dom';
|
|
|
|
const formatVideoDuration = (seconds: number | null | undefined) => {
|
|
if (!seconds || seconds < 1) return 'Processando';
|
|
const totalSeconds = Math.round(seconds);
|
|
const hours = Math.floor(totalSeconds / 3600);
|
|
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
|
const remainingSeconds = totalSeconds % 60;
|
|
if (hours) return `${hours}h ${String(minutes).padStart(2, '0')}min`;
|
|
if (minutes) return `${minutes}min`;
|
|
return `${remainingSeconds}s`;
|
|
};
|
|
|
|
const totalVideoDuration = (lessons: Lesson[]) => {
|
|
const seconds = lessons.reduce((total, lesson) => total + (lesson.media?.[0]?.durationSeconds || 0), 0);
|
|
return seconds ? formatVideoDuration(seconds) : 'Em processamento';
|
|
};
|
|
|
|
// 1. Create/Edit Course Modal
|
|
const CourseModal: React.FC<{
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
onSave: (course: Course) => Promise<void>;
|
|
initialData?: Course;
|
|
}> = ({ isOpen, onClose, onSave, initialData }) => {
|
|
const [title, setTitle] = useState('');
|
|
const [description, setDescription] = useState('');
|
|
const [category, setCategory] = useState('');
|
|
const [status, setStatus] = useState<Course['status']>('draft');
|
|
const [thumbnailPreview, setThumbnailPreview] = useState<string>('');
|
|
const [coverImageUrl, setCoverImageUrl] = useState<string>('');
|
|
const [coverImageZoom, setCoverImageZoom] = useState(1);
|
|
const [coverImagePositionX, setCoverImagePositionX] = useState(50);
|
|
const [coverImagePositionY, setCoverImagePositionY] = useState(50);
|
|
const [isAdjustingCoverPosition, setIsAdjustingCoverPosition] = useState(false);
|
|
const [coverPreviewError, setCoverPreviewError] = useState(false);
|
|
const coverFileRef = useRef<HTMLInputElement>(null);
|
|
const localCoverPreviewRef = useRef<string | null>(null);
|
|
const coverDragStartRef = useRef<{ pointerX: number; pointerY: number; positionX: number; positionY: number } | null>(null);
|
|
const [categories, setCategories] = useState<string[]>([]);
|
|
const [coverConfigured, setCoverConfigured] = useState<boolean | null>(null);
|
|
const [coverUploadMessage, setCoverUploadMessage] = useState('');
|
|
const [isCoverUploading, setIsCoverUploading] = useState(false);
|
|
const [editorStep, setEditorStep] = useState<1 | 2 | 3>(1);
|
|
|
|
// Lesson Management
|
|
const [lessons, setLessons] = useState<Lesson[]>([]);
|
|
|
|
// New Lesson State
|
|
const [newLessonTitle, setNewLessonTitle] = useState('');
|
|
const [newLessonMedia, setNewLessonMedia] = useState<LessonMedia | null>(null);
|
|
const [newLessonIsFree, setNewLessonIsFree] = useState(false);
|
|
const [bunnyConfigured, setBunnyConfigured] = useState<boolean | null>(null);
|
|
const [bunnyUploadState, setBunnyUploadState] = useState<'idle' | 'uploading' | 'uploaded' | 'error'>('idle');
|
|
const [bunnyUploadMessage, setBunnyUploadMessage] = useState('');
|
|
const [deletingVideoId, setDeletingVideoId] = useState<string | null>(null);
|
|
|
|
// Tips & Global Attachments
|
|
const [tips, setTips] = useState<string[]>([]);
|
|
const [newTip, setNewTip] = useState('');
|
|
const [attachments, setAttachments] = useState<Attachment[]>([]);
|
|
|
|
// New Attachment State
|
|
const [newAttName, setNewAttName] = useState('');
|
|
const [newAttType, setNewAttType] = useState<AttachmentType>('pdf');
|
|
const [newAttUrl, setNewAttUrl] = useState('');
|
|
const [newAttSize, setNewAttSize] = useState('');
|
|
const [newAttDesc, setNewAttDesc] = useState('');
|
|
|
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
|
|
useEffect(() => {
|
|
if (isOpen) {
|
|
setEditorStep(1);
|
|
if (initialData) {
|
|
setTitle(initialData.title);
|
|
setDescription(initialData.description);
|
|
setCategory(initialData.category);
|
|
setStatus(initialData.status || 'published');
|
|
setThumbnailPreview(initialData.thumbnail);
|
|
setCoverImageUrl(initialData.thumbnail);
|
|
setCoverImageZoom(initialData.coverImageZoom || 1);
|
|
setCoverImagePositionX(initialData.coverImagePositionX ?? 50);
|
|
setCoverImagePositionY(initialData.coverImagePositionY ?? 50);
|
|
setTips(initialData.tips || []);
|
|
setAttachments(initialData.attachments || []);
|
|
setLessons(initialData.lessons || []);
|
|
} else {
|
|
setTitle('');
|
|
setDescription('');
|
|
setCategory('');
|
|
setStatus('draft');
|
|
setThumbnailPreview('');
|
|
setCoverImageUrl('');
|
|
setCoverImageZoom(1);
|
|
setCoverImagePositionX(50);
|
|
setCoverImagePositionY(50);
|
|
setTips([]);
|
|
setAttachments([]);
|
|
setLessons([]);
|
|
}
|
|
if (localCoverPreviewRef.current) {
|
|
URL.revokeObjectURL(localCoverPreviewRef.current);
|
|
localCoverPreviewRef.current = null;
|
|
}
|
|
setCoverPreviewError(false);
|
|
setIsAdjustingCoverPosition(false);
|
|
setNewLessonMedia(null);
|
|
setBunnyUploadState('idle');
|
|
setBunnyUploadMessage('');
|
|
setCoverUploadMessage('');
|
|
instructorApi.bunnyConfiguration()
|
|
.then((response) => setBunnyConfigured(response.data.configured))
|
|
.catch(() => setBunnyConfigured(false));
|
|
instructorApi.coverConfiguration()
|
|
.then((response) => setCoverConfigured(response.data.configured))
|
|
.catch(() => setCoverConfigured(false));
|
|
courseApi.categories()
|
|
.then((response) => {
|
|
setCategories(response.data);
|
|
if (!initialData) setCategory((current) => current || response.data[0] || '');
|
|
})
|
|
.catch(() => setCategories([]));
|
|
}
|
|
}, [isOpen, initialData]);
|
|
|
|
useEffect(() => () => {
|
|
if (localCoverPreviewRef.current) URL.revokeObjectURL(localCoverPreviewRef.current);
|
|
}, []);
|
|
|
|
const toBunnyMedia = (video: BunnyVideo): LessonMedia => ({
|
|
provider: 'bunny',
|
|
externalId: video.id,
|
|
status: video.status,
|
|
durationSeconds: video.durationSeconds,
|
|
});
|
|
|
|
const handleBunnyUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
const file = event.target.files?.[0];
|
|
event.target.value = '';
|
|
if (!file) return;
|
|
if (!newLessonTitle.trim()) {
|
|
alert('Informe o título da aula antes de enviar o vídeo.');
|
|
return;
|
|
}
|
|
if (!bunnyConfigured) {
|
|
alert('O envio de vídeos ainda não está disponível. Tente novamente mais tarde ou entre em contato com o administrador.');
|
|
return;
|
|
}
|
|
|
|
setBunnyUploadState('uploading');
|
|
setBunnyUploadMessage(`Enviando ${file.name}...`);
|
|
try {
|
|
const created = await instructorApi.createBunnyVideo(newLessonTitle.trim());
|
|
const uploaded = await instructorApi.uploadBunnyVideo(created.data.id, file);
|
|
const media = toBunnyMedia(uploaded.data);
|
|
setNewLessonMedia(media);
|
|
setBunnyUploadState('uploaded');
|
|
setBunnyUploadMessage(uploaded.data.status === 'ready'
|
|
? 'Vídeo pronto para ser adicionado à aula.'
|
|
: 'Upload concluído. O vídeo está sendo processado; você já pode salvar a aula.');
|
|
} catch (error) {
|
|
setBunnyUploadState('error');
|
|
setBunnyUploadMessage('Não foi possível enviar o vídeo. Tente novamente.');
|
|
}
|
|
};
|
|
|
|
const handleCoverUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
const file = event.target.files?.[0];
|
|
event.target.value = '';
|
|
if (!file) return;
|
|
if (!coverConfigured) {
|
|
setCoverUploadMessage('O armazenamento de imagens ainda não foi configurado pelo administrador.');
|
|
return;
|
|
}
|
|
setIsCoverUploading(true);
|
|
setCoverUploadMessage(`Enviando ${file.name}...`);
|
|
try {
|
|
const response = await instructorApi.uploadCover(file);
|
|
if (localCoverPreviewRef.current) URL.revokeObjectURL(localCoverPreviewRef.current);
|
|
const localPreviewUrl = URL.createObjectURL(file);
|
|
localCoverPreviewRef.current = localPreviewUrl;
|
|
setThumbnailPreview(localPreviewUrl);
|
|
setCoverImageUrl(response.data.coverImageUrl);
|
|
setCoverImageZoom(1);
|
|
setCoverImagePositionX(50);
|
|
setCoverImagePositionY(50);
|
|
setCoverPreviewError(false);
|
|
setCoverUploadMessage('Imagem de capa enviada com sucesso.');
|
|
} catch (error) {
|
|
setCoverUploadMessage(error instanceof Error ? error.message : 'Não foi possível enviar a imagem de capa.');
|
|
} finally {
|
|
setIsCoverUploading(false);
|
|
}
|
|
};
|
|
|
|
const startCoverPositioning = (event: React.PointerEvent<HTMLDivElement>) => {
|
|
event.preventDefault();
|
|
event.currentTarget.setPointerCapture(event.pointerId);
|
|
coverDragStartRef.current = {
|
|
pointerX: event.clientX,
|
|
pointerY: event.clientY,
|
|
positionX: coverImagePositionX,
|
|
positionY: coverImagePositionY,
|
|
};
|
|
};
|
|
|
|
const moveCoverPosition = (event: React.PointerEvent<HTMLDivElement>) => {
|
|
const start = coverDragStartRef.current;
|
|
if (!start) return;
|
|
const bounds = event.currentTarget.getBoundingClientRect();
|
|
const nextX = Math.min(100, Math.max(0, start.positionX - ((event.clientX - start.pointerX) / bounds.width) * 100));
|
|
const nextY = Math.min(100, Math.max(0, start.positionY - ((event.clientY - start.pointerY) / bounds.height) * 100));
|
|
setCoverImagePositionX(Number(nextX.toFixed(1)));
|
|
setCoverImagePositionY(Number(nextY.toFixed(1)));
|
|
};
|
|
|
|
const endCoverPositioning = (event: React.PointerEvent<HTMLDivElement>) => {
|
|
coverDragStartRef.current = null;
|
|
if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);
|
|
};
|
|
|
|
const refreshLessonMedia = async (lessonId: string) => {
|
|
const lesson = lessons.find((item) => item.id === lessonId);
|
|
const media = lesson?.media?.[0];
|
|
if (!lesson || !media || media.provider !== 'bunny') return;
|
|
try {
|
|
const response = await instructorApi.bunnyVideoStatus(media.externalId);
|
|
const refreshed = toBunnyMedia(response.data);
|
|
setLessons((current) => current.map((item) => item.id === lessonId ? {
|
|
...item,
|
|
duration: formatVideoDuration(refreshed.durationSeconds),
|
|
media: [refreshed],
|
|
} : item));
|
|
} catch (error) {
|
|
alert(error instanceof Error ? error.message : 'Não foi possível atualizar o status do vídeo.');
|
|
}
|
|
};
|
|
|
|
// Bunny encodes asynchronously. While the course editor stays open, keep
|
|
// pending lessons up to date and persist the latest state on the API.
|
|
useEffect(() => {
|
|
if (!isOpen) return;
|
|
const pending = lessons
|
|
.map((lesson) => ({ lesson, media: lesson.media?.[0] }))
|
|
.filter((item): item is { lesson: Lesson; media: LessonMedia } => item.media?.provider === 'bunny' && item.media.status === 'processing');
|
|
if (!pending.length) return;
|
|
|
|
const timer = window.setTimeout(() => {
|
|
Promise.all(pending.map(async ({ lesson, media }) => {
|
|
try {
|
|
const response = await instructorApi.bunnyVideoStatus(media.externalId);
|
|
return { lessonId: lesson.id, media: toBunnyMedia(response.data) };
|
|
} catch {
|
|
return null;
|
|
}
|
|
})).then((updates) => {
|
|
const available = updates.filter((update): update is { lessonId: string; media: LessonMedia } => Boolean(update));
|
|
if (!available.length) return;
|
|
setLessons((current) => current.map((lesson) => {
|
|
const update = available.find((item) => item.lessonId === lesson.id);
|
|
if (!update) return lesson;
|
|
return {
|
|
...lesson,
|
|
duration: formatVideoDuration(update.media.durationSeconds),
|
|
media: [update.media],
|
|
};
|
|
}));
|
|
});
|
|
}, 10_000);
|
|
return () => window.clearTimeout(timer);
|
|
}, [isOpen, lessons]);
|
|
|
|
const handleAddLesson = () => {
|
|
if (!newLessonTitle.trim()) {
|
|
alert("Digite um título para a aula.");
|
|
return;
|
|
}
|
|
if (!newLessonMedia) {
|
|
alert('Envie o vídeo da aula antes de adicioná-la.');
|
|
return;
|
|
}
|
|
|
|
const newLesson: Lesson = {
|
|
id: `lesson-${Date.now()}`,
|
|
title: newLessonTitle,
|
|
duration: formatVideoDuration(newLessonMedia.durationSeconds),
|
|
videoUrl: newLessonMedia.playbackUrl || '',
|
|
mediaProvider: newLessonMedia.provider,
|
|
media: [newLessonMedia],
|
|
isFree: newLessonIsFree,
|
|
description: ''
|
|
};
|
|
|
|
setLessons([...lessons, newLesson]);
|
|
|
|
setNewLessonTitle('');
|
|
setNewLessonMedia(null);
|
|
setNewLessonIsFree(false);
|
|
setBunnyUploadState('idle');
|
|
setBunnyUploadMessage('');
|
|
};
|
|
|
|
const removeLesson = async (lesson: Lesson) => {
|
|
const video = lesson.media?.find((media) => media.provider === 'bunny');
|
|
const message = video
|
|
? 'Excluir esta aula e o vídeo enviado? O vídeo será removido permanentemente e esta ação não poderá ser desfeita.'
|
|
: 'Excluir esta aula? Esta ação não poderá ser desfeita após salvar o curso.';
|
|
if (!window.confirm(message)) return;
|
|
|
|
if (video) {
|
|
setDeletingVideoId(video.externalId);
|
|
try {
|
|
await instructorApi.deleteBunnyVideo(video.externalId);
|
|
} catch (error) {
|
|
alert(error instanceof Error ? error.message : 'Não foi possível excluir o vídeo. Tente novamente.');
|
|
return;
|
|
} finally {
|
|
setDeletingVideoId(null);
|
|
}
|
|
}
|
|
|
|
setLessons((current) => current.filter((item) => item.id !== lesson.id));
|
|
};
|
|
|
|
const toggleLessonAccess = (id: string) => {
|
|
setLessons(lessons.map(l => l.id === id ? { ...l, isFree: !l.isFree } : l));
|
|
};
|
|
|
|
const moveLesson = (id: string, direction: -1 | 1) => {
|
|
setLessons((current) => {
|
|
const index = current.findIndex((lesson) => lesson.id === id);
|
|
const nextIndex = index + direction;
|
|
if (index < 0 || nextIndex < 0 || nextIndex >= current.length) return current;
|
|
const next = [...current];
|
|
[next[index], next[nextIndex]] = [next[nextIndex], next[index]];
|
|
return next;
|
|
});
|
|
};
|
|
|
|
const handleAddTip = () => {
|
|
if (newTip.trim()) {
|
|
setTips([...tips, newTip.trim()]);
|
|
setNewTip('');
|
|
}
|
|
};
|
|
|
|
const removeTip = (idx: number) => {
|
|
setTips(tips.filter((_, i) => i !== idx));
|
|
};
|
|
|
|
const handleAddAttachment = () => {
|
|
if (!newAttName.trim()) {
|
|
alert("Informe o nome do arquivo para download.");
|
|
return;
|
|
}
|
|
if (!newAttUrl.trim()) {
|
|
alert('Informe a URL do material.');
|
|
return;
|
|
}
|
|
try {
|
|
new URL(newAttUrl.trim());
|
|
} catch {
|
|
alert('Informe uma URL válida para o material.');
|
|
return;
|
|
}
|
|
|
|
const att: Attachment = {
|
|
id: `att-${Date.now()}`,
|
|
name: newAttName.trim(),
|
|
type: newAttType,
|
|
url: newAttUrl.trim(),
|
|
size: newAttSize.trim() || undefined,
|
|
description: newAttDesc.trim()
|
|
};
|
|
|
|
setAttachments([...attachments, att]);
|
|
setNewAttName('');
|
|
setNewAttSize('');
|
|
setNewAttUrl('');
|
|
setNewAttDesc('');
|
|
};
|
|
|
|
const removeAttachment = (id: string) => {
|
|
setAttachments(attachments.filter(a => a.id !== id));
|
|
};
|
|
|
|
const goToEditorStep = (nextStep: 1 | 2 | 3) => {
|
|
if (nextStep > 1 && (!title.trim() || !category)) {
|
|
alert('Informe o título e a categoria do curso antes de continuar.');
|
|
return;
|
|
}
|
|
if (nextStep > 2 && lessons.length === 0) {
|
|
alert('Adicione pelo menos uma aula antes de continuar.');
|
|
return;
|
|
}
|
|
setEditorStep(nextStep);
|
|
};
|
|
|
|
const handleSubmit = async () => {
|
|
if (lessons.length === 0) {
|
|
alert("Adicione pelo menos uma aula à trilha.");
|
|
return;
|
|
}
|
|
|
|
setIsSubmitting(true);
|
|
|
|
const durationStr = totalVideoDuration(lessons);
|
|
|
|
try {
|
|
await onSave({
|
|
id: initialData?.id || `course-${Date.now()}`,
|
|
title,
|
|
description,
|
|
category: category || categories[0],
|
|
status,
|
|
thumbnail: coverImageUrl || '/course-placeholder.svg',
|
|
coverImageZoom,
|
|
coverImagePositionX,
|
|
coverImagePositionY,
|
|
duration: durationStr,
|
|
instructor: initialData?.instructor || '',
|
|
views: initialData?.views || 0,
|
|
progress: initialData?.progress || 0,
|
|
lessons: lessons,
|
|
tips: tips,
|
|
attachments: attachments
|
|
});
|
|
onClose();
|
|
} catch (error) {
|
|
alert(error instanceof Error ? error.message : 'Não foi possível salvar este curso.');
|
|
} finally {
|
|
setIsSubmitting(false);
|
|
}
|
|
};
|
|
|
|
if (!isOpen) return null;
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4">
|
|
<div className="absolute inset-0 bg-black/80 backdrop-blur-xl" onClick={onClose} />
|
|
<div className="relative w-full max-w-5xl bg-[#141416] rounded-3xl border border-white/10 p-5 md:p-8 shadow-2xl animate-fade-in-up max-h-[92vh] overflow-y-auto">
|
|
<div className="flex items-start justify-between gap-4 mb-7">
|
|
<div>
|
|
<p className="text-xs font-bold uppercase tracking-wider text-orange-400">{initialData ? 'Editar curso' : 'Novo curso'}</p>
|
|
<h2 className="mt-1 text-2xl font-extrabold text-white">{editorStep === 1 ? 'Informações do curso' : editorStep === 2 ? 'Estruture as aulas' : 'Materiais complementares'}</h2>
|
|
<p className="mt-1 text-sm text-gray-500">{editorStep === 1 ? 'Defina como o curso será apresentado.' : editorStep === 2 ? 'Adicione e organize a trilha de aprendizado.' : 'Inclua os arquivos e links de apoio.'}</p>
|
|
</div>
|
|
<button type="button" onClick={onClose} className="shrink-0 rounded-full bg-white/5 p-2.5 text-gray-300 hover:bg-white/10 hover:text-white" aria-label="Fechar editor"><X className="w-5 h-5" /></button>
|
|
</div>
|
|
|
|
<div className="mb-8 grid grid-cols-3 gap-2" aria-label="Etapas de criação do curso">
|
|
{[
|
|
{ step: 1, label: 'Curso' },
|
|
{ step: 2, label: 'Aulas' },
|
|
{ step: 3, label: 'Materiais' },
|
|
].map(({ step, label }) => (
|
|
<button key={step} type="button" onClick={() => goToEditorStep(step as 1 | 2 | 3)} className={`flex items-center gap-2 rounded-xl border px-3 py-3 text-left transition-colors ${editorStep === step ? 'border-orange-500/50 bg-orange-500/10 text-white' : editorStep > step ? 'border-emerald-500/20 bg-emerald-500/[0.04] text-emerald-300' : 'border-white/10 bg-black/20 text-gray-500 hover:text-gray-300'}`}>
|
|
<span className={`flex h-6 w-6 items-center justify-center rounded-full text-xs font-bold ${editorStep === step ? 'bg-orange-500 text-white' : editorStep > step ? 'bg-emerald-500/20 text-emerald-300' : 'bg-white/5'}`}>{editorStep > step ? <Check className="w-3.5 h-3.5" /> : step}</span>
|
|
<span className="text-xs font-semibold uppercase tracking-wide">{label}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
<div className={`w-full ${editorStep === 1 || editorStep === 3 ? 'block' : 'hidden'}`}>
|
|
<div className={editorStep === 1 ? 'space-y-6' : 'hidden'}>
|
|
|
|
{/* Thumbnail */}
|
|
<div>
|
|
<label className="text-xs text-gray-400 uppercase font-bold tracking-wider mb-2 block">Imagem de Capa (16:9)</label>
|
|
<div
|
|
onClick={() => { if (!isCoverUploading && coverConfigured) coverFileRef.current?.click(); }}
|
|
onKeyDown={(event) => { if ((event.key === 'Enter' || event.key === ' ') && !isCoverUploading && coverConfigured) coverFileRef.current?.click(); }}
|
|
role="button"
|
|
tabIndex={0}
|
|
aria-label="Enviar imagem de capa"
|
|
className={`w-full aspect-video rounded-2xl border-2 border-dashed border-white/10 bg-black/40 flex flex-col items-center justify-center transition-colors relative overflow-hidden group ${coverConfigured && !isCoverUploading ? 'cursor-pointer hover:border-orange-500/50' : 'cursor-not-allowed opacity-70'}`}
|
|
>
|
|
{thumbnailPreview ? (
|
|
<>
|
|
{coverPreviewError ? (
|
|
<div className="p-6 text-center">
|
|
<ImageIcon className="mx-auto mb-2 h-9 w-9 text-amber-400" />
|
|
<p className="text-sm font-semibold text-white">Não foi possível carregar a imagem</p>
|
|
<p className="mt-1 text-xs text-gray-400">Tente enviá-la novamente. Se o problema continuar, verifique a configuração de entrega de imagens.</p>
|
|
</div>
|
|
) : (
|
|
<CourseCoverImage
|
|
source={thumbnailPreview}
|
|
alt="Prévia da imagem de capa"
|
|
onFinalError={() => setCoverPreviewError(true)}
|
|
className="h-full w-full object-cover opacity-80 transition-transform duration-200"
|
|
style={{
|
|
transform: `scale(${coverImageZoom})`,
|
|
objectPosition: `${coverImagePositionX}% ${coverImagePositionY}%`,
|
|
}}
|
|
/>
|
|
)}
|
|
{isAdjustingCoverPosition && !coverPreviewError && (
|
|
<div
|
|
className="absolute inset-0 z-10 cursor-grab touch-none active:cursor-grabbing"
|
|
onClick={(event) => event.stopPropagation()}
|
|
onPointerDown={startCoverPositioning}
|
|
onPointerMove={moveCoverPosition}
|
|
onPointerUp={endCoverPositioning}
|
|
onPointerCancel={endCoverPositioning}
|
|
>
|
|
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 rounded-lg bg-black/75 px-4 py-2 text-center text-xs font-semibold text-white backdrop-blur-sm">
|
|
Arraste a imagem para ajustar o enquadramento
|
|
</div>
|
|
</div>
|
|
)}
|
|
<div className="absolute inset-x-3 bottom-3 z-20 flex items-center justify-between gap-3">
|
|
<span className="rounded-lg bg-black/70 px-3 py-2 text-xs font-semibold text-white backdrop-blur-sm">Clique na imagem para alterar</span>
|
|
<div className="flex items-center rounded-lg border border-white/10 bg-black/70 p-1 backdrop-blur-sm">
|
|
<button
|
|
type="button"
|
|
aria-label="Reduzir zoom da imagem"
|
|
disabled={coverImageZoom <= 1}
|
|
onClick={(event) => { event.stopPropagation(); setCoverImageZoom((current) => Math.max(1, Number((current - 0.1).toFixed(1)))); }}
|
|
className="rounded-md p-1.5 text-white transition-colors hover:bg-white/10 disabled:cursor-not-allowed disabled:opacity-30"
|
|
><Minus className="h-4 w-4" /></button>
|
|
<span className="min-w-12 text-center text-[11px] font-bold text-white">{Math.round(coverImageZoom * 100)}%</span>
|
|
<button
|
|
type="button"
|
|
aria-label="Aumentar zoom da imagem"
|
|
disabled={coverImageZoom >= 2.5}
|
|
onClick={(event) => { event.stopPropagation(); setCoverImageZoom((current) => Math.min(2.5, Number((current + 0.1).toFixed(1)))); }}
|
|
className="rounded-md p-1.5 text-white transition-colors hover:bg-white/10 disabled:cursor-not-allowed disabled:opacity-30"
|
|
><PlusCircle className="h-4 w-4" /></button>
|
|
<span className="mx-1 h-5 w-px bg-white/10" />
|
|
<button
|
|
type="button"
|
|
aria-label="Ajustar posição da imagem"
|
|
aria-pressed={isAdjustingCoverPosition}
|
|
onClick={(event) => { event.stopPropagation(); setIsAdjustingCoverPosition((current) => !current); }}
|
|
className={`rounded-md p-1.5 transition-colors ${isAdjustingCoverPosition ? 'bg-orange-500 text-white' : 'text-white hover:bg-white/10'}`}
|
|
><Move className="h-4 w-4" /></button>
|
|
<button
|
|
type="button"
|
|
aria-label="Redefinir zoom e posição da imagem"
|
|
onClick={(event) => { event.stopPropagation(); setCoverImageZoom(1); setCoverImagePositionX(50); setCoverImagePositionY(50); setIsAdjustingCoverPosition(false); }}
|
|
className="rounded-md p-1.5 text-white transition-colors hover:bg-white/10"
|
|
><RotateCcw className="h-4 w-4" /></button>
|
|
</div>
|
|
</div>
|
|
</>
|
|
) : (
|
|
<div className="text-center p-6">
|
|
<ImageIcon className="w-10 h-10 text-orange-400/60 mx-auto mb-2" />
|
|
<p className="text-gray-300 text-xs font-medium">{isCoverUploading ? 'Enviando imagem...' : 'Clique para enviar uma imagem de capa'}</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<input
|
|
ref={coverFileRef}
|
|
type="file"
|
|
accept="image/jpeg,image/png,image/webp"
|
|
className="hidden"
|
|
disabled={!coverConfigured || isCoverUploading}
|
|
onChange={handleCoverUpload}
|
|
/>
|
|
<p className={`mt-2 text-[11px] ${coverUploadMessage.includes('sucesso') ? 'text-emerald-300' : coverUploadMessage ? 'text-amber-300' : 'text-gray-500'}`}>{coverUploadMessage || (coverConfigured ? 'JPG, PNG ou WebP. Tamanho máximo: 10 MB.' : 'O envio de imagens estará disponível assim que o armazenamento de mídia for configurado.')}</p>
|
|
</div>
|
|
|
|
<div className="space-y-4">
|
|
<div>
|
|
<label className="text-xs text-gray-400 uppercase font-bold tracking-wider mb-1 block">Título do Curso</label>
|
|
<input
|
|
type="text"
|
|
value={title}
|
|
onChange={(e) => setTitle(e.target.value)}
|
|
placeholder="Ex.: Estratégias de tráfego pago para e-commerce"
|
|
className="w-full bg-black/50 text-white text-base font-bold border border-white/10 rounded-xl px-4 py-3 focus:outline-none focus:border-orange-500/50"
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div>
|
|
<label className="text-xs text-gray-400 uppercase font-bold tracking-wider mb-1 block">Categoria</label>
|
|
<select
|
|
value={category}
|
|
onChange={(e) => setCategory(e.target.value)}
|
|
className="w-full bg-black/50 text-white border border-white/10 rounded-xl px-3 py-2.5 text-xs focus:outline-none focus:border-orange-500/50"
|
|
>
|
|
{categories.map(cat => (
|
|
<option key={cat} value={cat} className="bg-zinc-900">{cat}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="text-xs text-gray-400 uppercase font-bold tracking-wider mb-1 block">Publicação</label>
|
|
<select value={status} onChange={(e) => setStatus(e.target.value as Course['status'])} className="w-full bg-black/50 text-white border border-white/10 rounded-xl px-3 py-2.5 text-xs focus:outline-none focus:border-orange-500/50">
|
|
<option value="draft" className="bg-zinc-900">Salvar como rascunho</option>
|
|
<option value="published" className="bg-zinc-900">Publicar agora</option>
|
|
</select>
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<div>
|
|
<label className="text-xs text-gray-400 uppercase font-bold tracking-wider mb-1 block">Descrição do Curso</label>
|
|
<textarea
|
|
value={description}
|
|
onChange={(e) => setDescription(e.target.value)}
|
|
rows={3}
|
|
placeholder="Descreva os tópicos, as competências e os resultados esperados para os alunos."
|
|
className="w-full bg-black/50 text-white border border-white/10 rounded-xl px-4 py-2.5 text-xs focus:outline-none focus:border-orange-500/50 resize-none leading-relaxed"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex justify-end border-t border-white/5 pt-5">
|
|
<button type="button" onClick={() => goToEditorStep(2)} disabled={!title.trim() || !category} className="inline-flex items-center gap-2 rounded-xl bg-orange-500 px-5 py-3 text-sm font-bold text-white transition-colors hover:bg-orange-600 disabled:cursor-not-allowed disabled:opacity-40">Continuar para aulas <ChevronRight className="w-4 h-4" /></button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Downloadable Materials Section */}
|
|
<div className={editorStep === 3 ? 'bg-black/40 p-5 rounded-2xl border border-white/10 space-y-3' : 'hidden'}>
|
|
<div className="flex items-center justify-between">
|
|
<h3 className="text-xs font-bold text-orange-400 uppercase tracking-wider flex items-center gap-1.5">
|
|
<Download className="w-4 h-4" />
|
|
Materiais Complementares ({attachments.length})
|
|
</h3>
|
|
</div>
|
|
|
|
{/* Add attachment inputs */}
|
|
<div className="space-y-2 pt-1">
|
|
<div className="grid grid-cols-3 gap-2">
|
|
<input
|
|
type="text"
|
|
value={newAttName}
|
|
onChange={(e) => setNewAttName(e.target.value)}
|
|
placeholder="Ex.: Planilha de acompanhamento de campanhas"
|
|
className="col-span-2 bg-white/5 border border-white/10 rounded-lg px-2.5 py-1.5 text-xs text-white focus:outline-none focus:border-orange-500/50"
|
|
/>
|
|
<select
|
|
value={newAttType}
|
|
onChange={(e) => setNewAttType(e.target.value as AttachmentType)}
|
|
className="bg-zinc-900 border border-white/10 rounded-lg px-2 py-1.5 text-xs text-white focus:outline-none"
|
|
>
|
|
<option value="pdf">PDF</option>
|
|
<option value="spreadsheet">Planilha</option>
|
|
<option value="zip">Pacote ZIP</option>
|
|
<option value="doc">Documento</option>
|
|
<option value="link">Link</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div className="flex gap-2">
|
|
<input
|
|
type="url"
|
|
value={newAttUrl}
|
|
onChange={(e) => setNewAttUrl(e.target.value)}
|
|
placeholder="URL do material (https://...)"
|
|
className="min-w-0 flex-1 bg-white/5 border border-white/10 rounded-lg px-2.5 py-1.5 text-xs text-white focus:outline-none"
|
|
/>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<input
|
|
type="text"
|
|
value={newAttSize}
|
|
onChange={(e) => setNewAttSize(e.target.value)}
|
|
placeholder="Tamanho (opcional)"
|
|
className="w-28 bg-white/5 border border-white/10 rounded-lg px-2.5 py-1.5 text-xs text-white focus:outline-none"
|
|
/>
|
|
<input
|
|
type="text"
|
|
value={newAttDesc}
|
|
onChange={(e) => setNewAttDesc(e.target.value)}
|
|
placeholder="Explique como este material apoia o aprendizado."
|
|
className="flex-1 bg-white/5 border border-white/10 rounded-lg px-2.5 py-1.5 text-xs text-white focus:outline-none"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={handleAddAttachment}
|
|
className="bg-orange-500 hover:bg-orange-600 px-3 py-1.5 rounded-lg text-white text-xs font-bold shrink-0 transition-colors"
|
|
>
|
|
Adicionar
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* List of Attachments */}
|
|
<div className="space-y-1.5 max-h-36 overflow-y-auto pr-1 pt-1">
|
|
{attachments.map((att) => (
|
|
<div key={att.id} className="flex items-center justify-between p-2 rounded-lg bg-white/5 border border-white/5 text-xs">
|
|
<div className="flex items-center gap-2 min-w-0 pr-2">
|
|
{getAttachmentIcon(att.type)}
|
|
<span className="text-gray-200 font-medium truncate">{att.name}</span>
|
|
<span className="text-[10px] text-gray-500 font-mono">({att.size || '1 MB'})</span>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => removeAttachment(att.id)}
|
|
className="text-gray-500 hover:text-red-400 p-1"
|
|
>
|
|
<Trash2 className="w-3.5 h-3.5" />
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<div className="mt-5 flex flex-col-reverse gap-3 border-t border-white/5 pt-5 sm:flex-row sm:justify-between">
|
|
<button type="button" onClick={() => setEditorStep(2)} className="inline-flex items-center justify-center gap-2 rounded-xl px-4 py-3 text-sm font-semibold text-gray-300 hover:bg-white/5 hover:text-white"><ChevronLeft className="w-4 h-4" /> Voltar para aulas</button>
|
|
<button type="button" onClick={() => void handleSubmit()} disabled={isSubmitting} className="inline-flex items-center justify-center gap-2 rounded-xl bg-orange-500 px-6 py-3 text-sm font-extrabold text-white shadow-lg shadow-orange-500/20 transition-colors hover:bg-orange-600 disabled:opacity-60">{isSubmitting ? 'Salvando...' : status === 'draft' ? 'Salvar como rascunho' : 'Salvar e publicar'}</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Right Column: Lessons Builder */}
|
|
<div className={editorStep === 2 ? 'w-full flex flex-col justify-between' : 'hidden'}>
|
|
<div>
|
|
<div className="bg-black/50 rounded-2xl border border-white/10 overflow-hidden mb-6">
|
|
<div className="p-4 border-b border-white/5 bg-zinc-900/50 flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<ListVideo className="w-4 h-4 text-orange-400" />
|
|
<h3 className="font-bold text-white text-sm">Grade do Curso</h3>
|
|
</div>
|
|
<span className="bg-orange-500/10 text-orange-400 border border-orange-500/20 px-3 py-0.5 rounded-full text-xs font-bold">
|
|
{lessons.length} Aulas
|
|
</span>
|
|
</div>
|
|
|
|
{/* Lesson List */}
|
|
<div className="overflow-y-auto p-4 space-y-2.5 max-h-[300px]">
|
|
{lessons.length === 0 && (
|
|
<div className="flex flex-col items-center justify-center py-10 text-gray-500 space-y-2">
|
|
<ListVideo className="w-8 h-8 opacity-20 text-orange-400" />
|
|
<p className="text-xs">Nenhuma aula cadastrada ainda. Adicione abaixo.</p>
|
|
</div>
|
|
)}
|
|
{lessons.map((lesson, index) => (
|
|
<div key={lesson.id} className="flex items-center gap-3 bg-white/[0.04] p-3 rounded-xl border border-white/5 group hover:border-orange-500/30 transition-all">
|
|
<div className="flex items-center justify-center w-6 h-6 rounded-lg bg-white/10 text-gray-400 font-mono text-xs font-bold">
|
|
{index + 1}
|
|
</div>
|
|
|
|
<div className="flex-1 min-w-0">
|
|
<h4 className="text-white font-medium text-xs md:text-sm truncate">{lesson.title}</h4>
|
|
<div className="flex items-center gap-2 text-[11px] text-gray-500 mt-0.5">
|
|
<span className="flex items-center gap-1"><Video className="w-3 h-3 text-orange-400/70"/> {lesson.duration}</span>
|
|
{lesson.media?.[0]?.provider === 'bunny' && (
|
|
<span className={`font-bold px-1.5 py-0.2 rounded text-[9px] uppercase ${lesson.media[0].status === 'ready' ? 'text-emerald-400 bg-emerald-500/10' : lesson.media[0].status === 'failed' ? 'text-red-400 bg-red-500/10' : 'text-amber-400 bg-amber-500/10'}`}>
|
|
Vídeo {lesson.media[0].status === 'ready' ? 'pronto' : lesson.media[0].status === 'failed' ? 'com erro' : 'processando'}
|
|
</span>
|
|
)}
|
|
{lesson.isFree ? (
|
|
<span className="text-emerald-400 font-bold bg-emerald-500/10 px-1.5 py-0.2 rounded text-[9px] uppercase">GRÁTIS</span>
|
|
) : (
|
|
<span className="text-purple-400 font-bold bg-purple-500/10 px-1.5 py-0.2 rounded text-[9px] uppercase">EXCLUSIVA</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-1">
|
|
<button type="button" disabled={index === 0} onClick={() => moveLesson(lesson.id, -1)} className="p-1 text-gray-400 hover:text-white disabled:opacity-30" title="Mover aula para cima"><ChevronUp className="w-4 h-4" /></button>
|
|
<button type="button" disabled={index === lessons.length - 1} onClick={() => moveLesson(lesson.id, 1)} className="p-1 text-gray-400 hover:text-white disabled:opacity-30" title="Mover aula para baixo"><ChevronDown className="w-4 h-4" /></button>
|
|
{lesson.media?.[0]?.provider === 'bunny' && lesson.media[0].status !== 'ready' && (
|
|
<button
|
|
type="button"
|
|
onClick={() => refreshLessonMedia(lesson.id)}
|
|
className="px-2 py-1 text-[10px] text-orange-300 hover:bg-orange-500/10 rounded-lg transition-colors"
|
|
title="Atualizar status do vídeo"
|
|
>
|
|
Atualizar
|
|
</button>
|
|
)}
|
|
<button
|
|
type="button"
|
|
onClick={() => toggleLessonAccess(lesson.id)}
|
|
className={`p-1.5 rounded-lg transition-colors ${lesson.isFree ? 'text-emerald-400 hover:bg-emerald-500/10' : 'text-purple-400 hover:bg-purple-500/10'}`}
|
|
title={lesson.isFree ? "Tornar Exclusiva para Membros" : "Tornar Grátis"}
|
|
>
|
|
{lesson.isFree ? <Unlock className="w-4 h-4" /> : <Lock className="w-4 h-4" />}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
disabled={Boolean(lesson.media?.[0]?.provider === 'bunny' && deletingVideoId === lesson.media[0].externalId)}
|
|
onClick={() => void removeLesson(lesson)}
|
|
className="p-1.5 text-gray-400 hover:text-red-400 hover:bg-red-500/10 rounded-lg transition-colors disabled:cursor-not-allowed disabled:opacity-40"
|
|
title="Excluir aula e vídeo"
|
|
>
|
|
{lesson.media?.[0]?.provider === 'bunny' && deletingVideoId === lesson.media[0].externalId ? <Loader2 className="w-4 h-4 animate-spin" /> : <Trash2 className="w-4 h-4" />}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Add Lesson Form Box */}
|
|
<div className="p-4 bg-zinc-950 border-t border-white/5 space-y-3">
|
|
<h4 className="text-xs font-bold text-gray-400 uppercase tracking-wider flex items-center gap-1.5">
|
|
<Plus className="w-3.5 h-3.5 text-orange-400" />
|
|
Adicionar aula
|
|
</h4>
|
|
|
|
<div>
|
|
<input
|
|
type="text"
|
|
value={newLessonTitle}
|
|
onChange={(e) => setNewLessonTitle(e.target.value)}
|
|
placeholder="Título da aula"
|
|
className="w-full bg-white/5 border border-white/10 rounded-xl px-3 py-2 text-xs text-white focus:outline-none focus:border-orange-500/50"
|
|
/>
|
|
</div>
|
|
|
|
<div className="rounded-xl border border-orange-500/20 bg-orange-500/[0.04] p-3 space-y-2">
|
|
<div className="flex items-center justify-between gap-3">
|
|
<div>
|
|
<p className="text-xs font-bold text-orange-200">Vídeo da aula</p>
|
|
<p className="text-[11px] text-gray-500">Envie o arquivo para adicioná-lo à aula. O processamento ocorre automaticamente.</p>
|
|
</div>
|
|
<label className={`cursor-pointer rounded-lg px-3 py-2 text-xs font-bold transition-colors ${bunnyConfigured && bunnyUploadState !== 'uploading' ? 'bg-orange-500 hover:bg-orange-600 text-white' : 'bg-white/10 text-gray-500 cursor-not-allowed'}`}>
|
|
{bunnyUploadState === 'uploading' ? 'Enviando...' : 'Enviar vídeo'}
|
|
<input
|
|
type="file"
|
|
accept="video/*"
|
|
className="hidden"
|
|
disabled={!bunnyConfigured || bunnyUploadState === 'uploading'}
|
|
onChange={handleBunnyUpload}
|
|
/>
|
|
</label>
|
|
</div>
|
|
{bunnyConfigured === false && <p className="text-[11px] text-amber-300">O envio de vídeos está indisponível no momento. Entre em contato com o administrador.</p>}
|
|
{bunnyUploadMessage && <p className={`text-[11px] ${bunnyUploadState === 'error' ? 'text-red-300' : bunnyUploadState === 'uploaded' ? 'text-emerald-300' : 'text-gray-400'}`}>{bunnyUploadMessage}</p>}
|
|
{newLessonMedia && (
|
|
<div className="flex items-center justify-between rounded-lg bg-black/30 px-3 py-2 text-[11px] text-gray-300">
|
|
<span>Vídeo enviado ({newLessonMedia.status === 'ready' ? 'pronto' : 'processando'})</span>
|
|
<button type="button" onClick={() => setNewLessonMedia(null)} className="text-gray-400 hover:text-red-300">Remover</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex justify-end pt-1">
|
|
<button
|
|
type="button"
|
|
onClick={handleAddLesson}
|
|
className="bg-white hover:bg-orange-500 hover:text-white text-black font-bold px-4 py-2 rounded-xl text-xs transition-all shrink-0"
|
|
>
|
|
Adicionar Aula
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex flex-col-reverse gap-3 border-t border-white/5 pt-5 sm:flex-row sm:justify-between">
|
|
<button type="button" onClick={() => setEditorStep(1)} className="inline-flex items-center justify-center gap-2 rounded-xl px-4 py-3 text-sm font-semibold text-gray-300 hover:bg-white/5 hover:text-white"><ChevronLeft className="w-4 h-4" /> Voltar para curso</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => goToEditorStep(3)}
|
|
className="inline-flex items-center justify-center gap-2 rounded-xl bg-orange-500 px-6 py-3 text-sm font-extrabold text-white shadow-lg shadow-orange-500/20 transition-colors hover:bg-orange-600"
|
|
>
|
|
Continuar para materiais <ChevronRight className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export const ManageCourses: React.FC = () => {
|
|
const [searchParams, setSearchParams] = useSearchParams();
|
|
const [courses, setCourses] = useState<Course[]>([]);
|
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
|
const [editingCourse, setEditingCourse] = useState<Course | undefined>(undefined);
|
|
const [loading, setLoading] = useState(true);
|
|
const [activeTab, setActiveTab] = useState<'courses' | 'comments'>('courses');
|
|
const [allComments, setAllComments] = useState<{comment: Comment, courseTitle: string}[]>([]);
|
|
const [replyingTo, setReplyingTo] = useState<string | null>(null);
|
|
const [replyText, setReplyText] = useState('');
|
|
const [analytics, setAnalytics] = useState<InstructorAnalytics | null>(null);
|
|
|
|
const { showToast } = useToast();
|
|
|
|
const loadData = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const data = await getManagedCourses();
|
|
setCourses(data);
|
|
const analyticsResponse = await instructorApi.analytics();
|
|
setAnalytics(analyticsResponse.data);
|
|
const commentLists = await Promise.all(data.map(async (course) => ({ course, comments: await getComments(course.id) })));
|
|
setAllComments(commentLists.flatMap(({ course, comments }) => comments.map((comment) => ({ comment, courseTitle: course.title }))));
|
|
} catch {
|
|
showToast('Não foi possível carregar o painel do instrutor.', 'error');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
loadData();
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const courseId = searchParams.get('editar');
|
|
if (!courseId || courses.length === 0) return;
|
|
const course = courses.find((item) => item.id === courseId);
|
|
if (course) {
|
|
setEditingCourse(course);
|
|
setIsModalOpen(true);
|
|
}
|
|
setSearchParams({}, { replace: true });
|
|
}, [courses, searchParams, setSearchParams]);
|
|
|
|
const handleCreate = () => {
|
|
setEditingCourse(undefined);
|
|
setIsModalOpen(true);
|
|
};
|
|
|
|
const handleEdit = (course: Course) => {
|
|
setEditingCourse(course);
|
|
setIsModalOpen(true);
|
|
};
|
|
|
|
const handleDelete = async (id: string) => {
|
|
if (window.confirm('Tem certeza que deseja excluir este curso?')) {
|
|
await deleteCourse(id);
|
|
showToast('Curso removido com sucesso!', 'success');
|
|
loadData();
|
|
}
|
|
};
|
|
|
|
const handleDuplicate = async (id: string) => {
|
|
try {
|
|
await duplicateCourse(id);
|
|
showToast('Cópia criada como rascunho.', 'success');
|
|
loadData();
|
|
} catch (error) {
|
|
showToast(error instanceof Error ? error.message : 'Não foi possível duplicar este curso.', 'error');
|
|
}
|
|
};
|
|
|
|
const handleSave = async (course: Course) => {
|
|
await saveCourse(course);
|
|
showToast('Curso e materiais salvos com sucesso!', 'success');
|
|
loadData();
|
|
};
|
|
|
|
const handleSendReply = async (commentId: string) => {
|
|
if (!replyText.trim()) return;
|
|
|
|
const target = allComments.find(c => c.comment.id === commentId);
|
|
if (target) {
|
|
await replyToComment(commentId, replyText);
|
|
showToast('Resposta enviada com sucesso!', 'success');
|
|
setReplyingTo(null);
|
|
setReplyText('');
|
|
loadData();
|
|
}
|
|
};
|
|
|
|
const handleModerateComment = async (commentId: string) => {
|
|
if (!window.confirm('Remover este comentário permanentemente?')) return;
|
|
await moderateComment(commentId);
|
|
showToast('Comentário removido.', 'success');
|
|
loadData();
|
|
};
|
|
|
|
// Metrics
|
|
const totalLearners = analytics?.learners ?? 0;
|
|
const completedLessons = analytics?.completedLessons ?? 0;
|
|
const totalQuestions = analytics?.comments ?? allComments.length;
|
|
const totalLessons = courses.reduce((acc, c) => acc + c.lessons.length, 0);
|
|
const totalMaterials = courses.reduce((acc, c) => {
|
|
const globalCount = c.attachments?.length || 0;
|
|
const lessonCount = c.lessons.reduce((lAcc, l) => lAcc + (l.attachments?.length || 0), 0);
|
|
return acc + globalCount + lessonCount;
|
|
}, 0);
|
|
|
|
return (
|
|
<div className="min-h-screen bg-[#09090b] text-white pt-24 pb-20 px-6 md:px-12 max-w-[1440px] mx-auto">
|
|
|
|
{/* Header */}
|
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-6 pb-8 border-b border-white/10">
|
|
<div>
|
|
<div className="flex items-center gap-2 text-caption font-semibold uppercase tracking-caption text-orange-400 mb-2">
|
|
<span>Compor HUB</span>
|
|
<span>•</span>
|
|
<span>Painel do Instrutor</span>
|
|
</div>
|
|
<h1 className="font-display text-subheading sm:text-heading-sm md:text-heading font-bold tracking-heading leading-heading">
|
|
Gestão de Cursos & Materiais
|
|
</h1>
|
|
<p className="text-body-sm font-normal text-gray-400 mt-1 leading-body-sm tracking-body-sm">
|
|
Cadastre videoaulas, organize trilhas e disponibilize arquivos de apoio para download.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-3">
|
|
<button
|
|
onClick={handleCreate}
|
|
className="flex items-center gap-2 bg-orange-500 hover:bg-orange-600 text-white text-body-sm font-semibold tracking-body-sm px-6 py-3 rounded-[980px] shadow-xl shadow-orange-500/20 transition-all active:scale-95"
|
|
>
|
|
<Plus className="w-4 h-4" />
|
|
<span>Novo Curso</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Metrics Row */}
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-6 gap-4 my-8">
|
|
<div className="bg-zinc-900/60 border border-white/10 p-5 rounded-[8px] shadow-xl">
|
|
<span className="text-caption text-gray-400 font-semibold uppercase tracking-caption">Total de Cursos</span>
|
|
<p className="font-display text-subheading md:text-heading-sm font-bold text-white mt-1">{courses.length}</p>
|
|
</div>
|
|
<div className="bg-zinc-900/60 border border-white/10 p-5 rounded-[8px] shadow-xl">
|
|
<span className="text-caption text-gray-400 font-semibold uppercase tracking-caption">Total de Aulas</span>
|
|
<p className="font-display text-subheading md:text-heading-sm font-bold text-orange-400 mt-1">{totalLessons}</p>
|
|
</div>
|
|
<div className="bg-zinc-900/60 border border-white/10 p-5 rounded-[8px] shadow-xl">
|
|
<span className="text-caption text-gray-400 font-semibold uppercase tracking-caption">Materiais para Download</span>
|
|
<p className="font-display text-subheading md:text-heading-sm font-bold text-emerald-400 mt-1">{totalMaterials}</p>
|
|
</div>
|
|
<div className="bg-zinc-900/60 border border-white/10 p-5 rounded-[8px] shadow-xl">
|
|
<span className="text-caption text-gray-400 font-semibold uppercase tracking-caption">Alunos com Progresso</span>
|
|
<p className="font-display text-subheading md:text-heading-sm font-bold text-white mt-1">{totalLearners.toLocaleString('pt-BR')}</p>
|
|
</div>
|
|
<div className="bg-zinc-900/60 border border-white/10 p-5 rounded-[8px] shadow-xl">
|
|
<span className="text-caption text-gray-400 font-semibold uppercase tracking-caption">Aulas Concluídas</span>
|
|
<p className="font-display text-subheading md:text-heading-sm font-bold text-orange-400 mt-1">{completedLessons.toLocaleString('pt-BR')}</p>
|
|
</div>
|
|
<div className="bg-zinc-900/60 border border-white/10 p-5 rounded-[8px] shadow-xl">
|
|
<span className="text-caption text-gray-400 font-semibold uppercase tracking-caption">Dúvidas Recebidas</span>
|
|
<p className="font-display text-subheading md:text-heading-sm font-bold text-white mt-1">{totalQuestions.toLocaleString('pt-BR')}</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Navigation Tabs */}
|
|
<div className="flex gap-4 border-b border-white/10 mb-8">
|
|
<button
|
|
onClick={() => setActiveTab('courses')}
|
|
className={`pb-3 text-body-sm font-semibold tracking-body-sm transition-all border-b-2 ${
|
|
activeTab === 'courses'
|
|
? 'border-orange-500 text-white'
|
|
: 'border-transparent text-gray-500 hover:text-gray-300'
|
|
}`}
|
|
>
|
|
Cursos Cadastrados ({courses.length})
|
|
</button>
|
|
<button
|
|
onClick={() => setActiveTab('comments')}
|
|
className={`pb-3 text-body-sm font-semibold tracking-body-sm transition-all border-b-2 flex items-center gap-2 ${
|
|
activeTab === 'comments'
|
|
? 'border-orange-500 text-white'
|
|
: 'border-transparent text-gray-500 hover:text-gray-300'
|
|
}`}
|
|
>
|
|
<MessageSquare className="w-4 h-4" />
|
|
Dúvidas dos Alunos ({allComments.length})
|
|
</button>
|
|
</div>
|
|
|
|
{/* Tab: Courses List */}
|
|
{activeTab === 'courses' && (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
|
{courses.map(course => {
|
|
const courseAttachments = (course.attachments?.length || 0) + course.lessons.reduce((acc, l) => acc + (l.attachments?.length || 0), 0);
|
|
|
|
return (
|
|
<div
|
|
key={course.id}
|
|
className="bg-zinc-900/80 border border-white/10 rounded-[8px] overflow-hidden flex flex-col justify-between hover:border-orange-500/40 transition-all duration-300 group shadow-xl"
|
|
>
|
|
<div>
|
|
{/* Thumbnail */}
|
|
<div className="aspect-video relative overflow-hidden bg-black rounded-t-[8px]">
|
|
<CourseCoverImage
|
|
source={course.thumbnail}
|
|
alt={course.title}
|
|
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
|
|
style={{
|
|
transform: `scale(${course.coverImageZoom || 1})`,
|
|
objectPosition: `${course.coverImagePositionX ?? 50}% ${course.coverImagePositionY ?? 50}%`,
|
|
}}
|
|
/>
|
|
<div className="absolute top-3 left-3 bg-black/70 backdrop-blur-md px-2.5 py-1 rounded-[980px] text-caption font-semibold uppercase tracking-caption text-white border border-white/10">
|
|
{course.category}
|
|
</div>
|
|
{course.status === 'draft' && <div className="absolute top-3 right-3 bg-amber-500/20 backdrop-blur-md px-2.5 py-1 rounded-[980px] text-caption font-semibold uppercase tracking-caption text-amber-300 border border-amber-500/30">Rascunho</div>}
|
|
</div>
|
|
|
|
{/* Body */}
|
|
<div className="p-5">
|
|
<h3 className="font-display font-bold text-subheading text-white mb-2 line-clamp-1 group-hover:text-orange-400 transition-colors tracking-subheading leading-subheading">
|
|
{course.title}
|
|
</h3>
|
|
<p className="text-caption text-gray-400 line-clamp-2 leading-caption tracking-caption font-normal mb-4">
|
|
{course.description}
|
|
</p>
|
|
|
|
<div className="flex items-center gap-3 text-caption text-gray-400 pt-3 border-t border-white/5 font-semibold tracking-caption">
|
|
<span className="flex items-center gap-1 text-gray-300 font-semibold">
|
|
<Video className="w-3.5 h-3.5 text-orange-400" />
|
|
{course.lessons.length} Aulas
|
|
</span>
|
|
<span>•</span>
|
|
<span className="flex items-center gap-1 text-gray-300 font-semibold">
|
|
<Download className="w-3.5 h-3.5 text-emerald-400" />
|
|
{courseAttachments} Materiais
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Actions */}
|
|
<div className="p-4 bg-black/40 border-t border-white/5 flex items-center justify-between gap-2">
|
|
<button
|
|
onClick={() => handleEdit(course)}
|
|
className="flex-1 flex items-center justify-center gap-1.5 py-2.5 rounded-[980px] bg-white/5 hover:bg-orange-500 hover:text-white text-body-sm font-semibold tracking-body-sm transition-colors text-gray-200"
|
|
>
|
|
<Edit2 className="w-3.5 h-3.5" />
|
|
Editar Conteúdo
|
|
</button>
|
|
|
|
<button onClick={() => void handleDuplicate(course.id)} className="p-2.5 rounded-[980px] bg-white/5 hover:bg-white/10 text-gray-400 hover:text-white transition-colors" title="Duplicar curso"><Copy className="w-4 h-4" /></button>
|
|
|
|
<button
|
|
onClick={() => handleDelete(course.id)}
|
|
className="p-2.5 rounded-[980px] bg-white/5 hover:bg-red-500/20 text-gray-400 hover:text-red-400 transition-colors"
|
|
title="Excluir Curso"
|
|
>
|
|
<Trash2 className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{/* Tab: Comments / Questions */}
|
|
{activeTab === 'comments' && (
|
|
<div className="space-y-4 max-w-4xl">
|
|
{allComments.length === 0 ? (
|
|
<div className="text-center py-20 text-gray-500">
|
|
<MessageSquare className="w-12 h-12 mx-auto mb-3 opacity-30 text-orange-400" />
|
|
<p className="text-body-sm font-normal text-gray-400">Nenhuma dúvida enviada por alunos até o momento.</p>
|
|
</div>
|
|
) : (
|
|
allComments.map(({ comment, courseTitle }) => (
|
|
<div key={comment.id} className="p-6 rounded-[8px] bg-zinc-900/60 border border-white/10 space-y-4 shadow-xl">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<span className="text-caption text-orange-400 font-semibold uppercase tracking-caption">{courseTitle}</span>
|
|
<h4 className="font-semibold text-white text-body-sm tracking-body-sm">{comment.user}</h4>
|
|
</div>
|
|
<span className="text-caption text-gray-500 font-mono">{comment.date}</span>
|
|
</div>
|
|
|
|
<p className="text-body-sm text-gray-300 bg-black/30 p-3.5 rounded-[8px] border border-white/5 leading-body-sm tracking-body-sm font-normal">
|
|
{comment.text}
|
|
</p>
|
|
|
|
{comment.reply ? (
|
|
<div className="pl-4 border-l-2 border-orange-500 bg-white/[0.02] p-3 rounded-r-[8px]">
|
|
<span className="text-caption font-semibold text-orange-400 uppercase tracking-caption">Sua Resposta ({comment.replyDate}):</span>
|
|
<p className="text-body-sm text-gray-200 mt-1 leading-body-sm">{comment.reply}</p>
|
|
</div>
|
|
) : (
|
|
<div>
|
|
{replyingTo === comment.id ? (
|
|
<div className="space-y-3 pt-2">
|
|
<textarea
|
|
value={replyText}
|
|
onChange={(e) => setReplyText(e.target.value)}
|
|
placeholder="Escreva uma resposta clara para o aluno..."
|
|
className="w-full bg-black/60 border border-white/10 rounded-[8px] p-3 text-body-sm text-white focus:outline-none focus:border-orange-500/50 resize-none"
|
|
rows={3}
|
|
/>
|
|
<div className="flex justify-end gap-2">
|
|
<button
|
|
onClick={() => { setReplyingTo(null); setReplyText(''); }}
|
|
className="px-3.5 py-2 rounded-[980px] text-body-sm text-gray-400 hover:text-white font-semibold"
|
|
>
|
|
Cancelar
|
|
</button>
|
|
<button
|
|
onClick={() => handleSendReply(comment.id)}
|
|
className="px-5 py-2 rounded-[980px] bg-orange-500 hover:bg-orange-600 text-white text-body-sm font-semibold tracking-body-sm shadow-xl"
|
|
>
|
|
Enviar Resposta
|
|
</button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<button
|
|
onClick={() => setReplyingTo(comment.id)}
|
|
className="text-body-sm font-semibold text-orange-400 hover:text-orange-300 tracking-body-sm"
|
|
>
|
|
Responder Dúvida →
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
<button onClick={() => handleModerateComment(comment.id)} className="text-xs text-red-400 hover:text-red-300 font-semibold">Remover comentário</button>
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Create/Edit Modal */}
|
|
<CourseModal
|
|
isOpen={isModalOpen}
|
|
onClose={() => setIsModalOpen(false)}
|
|
onSave={handleSave}
|
|
initialData={editingCourse}
|
|
/>
|
|
</div>
|
|
);
|
|
};
|