feat: implement core course platform UI and logic
- Add project configuration and git ignore rules - Implement App router with protected routes - Develop course player, grid, and card components - Add state management for course progress and navigation - Initialize database services for course and comment data
This commit is contained in:
905
pages/ManageCourses.tsx
Normal file
905
pages/ManageCourses.tsx
Normal file
@@ -0,0 +1,905 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import {
|
||||
Plus,
|
||||
Settings,
|
||||
Users,
|
||||
BarChart,
|
||||
Trash2,
|
||||
Edit2,
|
||||
X,
|
||||
MessageSquare,
|
||||
Send,
|
||||
Image as ImageIcon,
|
||||
Video,
|
||||
Sparkles,
|
||||
Loader2,
|
||||
FileText,
|
||||
FileSpreadsheet,
|
||||
FileArchive,
|
||||
Link as LinkIcon,
|
||||
Paperclip,
|
||||
Lightbulb,
|
||||
ListVideo,
|
||||
Check,
|
||||
Lock,
|
||||
Unlock,
|
||||
UploadCloud,
|
||||
Globe,
|
||||
Download
|
||||
} from 'lucide-react';
|
||||
import { Course, Comment, Attachment, Lesson, AttachmentType } from '../types';
|
||||
import { useToast } from '../context/ToastContext';
|
||||
import { getCourses, saveCourse, deleteCourse, getComments, saveComment } from '../services/db';
|
||||
import { generateStudentReply } from '../services/ai';
|
||||
import { CATEGORIES } from '../constants';
|
||||
import { getAttachmentIcon, getAttachmentBadge } from '../components/MaterialCard';
|
||||
|
||||
// 1. Create/Edit Course Modal
|
||||
const CourseModal: React.FC<{
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (course: Course) => void;
|
||||
initialData?: Course;
|
||||
}> = ({ isOpen, onClose, onSave, initialData }) => {
|
||||
const [title, setTitle] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [category, setCategory] = useState('');
|
||||
const [thumbnailPreview, setThumbnailPreview] = useState<string>('');
|
||||
const [instructor, setInstructor] = useState('Mário Morgado');
|
||||
const [instructorRole, setInstructorRole] = useState('Especialista em Performance');
|
||||
|
||||
// Lesson Management
|
||||
const [lessons, setLessons] = useState<Lesson[]>([]);
|
||||
|
||||
// New Lesson State
|
||||
const [newLessonTitle, setNewLessonTitle] = useState('');
|
||||
const [newLessonDuration, setNewLessonDuration] = useState('');
|
||||
const [newLessonVideoUrl, setNewLessonVideoUrl] = useState('');
|
||||
const [newLessonIsFree, setNewLessonIsFree] = useState(false);
|
||||
const [videoSourceType, setVideoSourceType] = useState<'upload' | 'link'>('upload');
|
||||
const [selectedFileName, setSelectedFileName] = useState('');
|
||||
const lessonFileInputRef = useRef<HTMLInputElement>(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);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
if (initialData) {
|
||||
setTitle(initialData.title);
|
||||
setDescription(initialData.description);
|
||||
setCategory(initialData.category);
|
||||
setThumbnailPreview(initialData.thumbnail);
|
||||
setInstructor(initialData.instructor || 'Mário Morgado');
|
||||
setInstructorRole(initialData.instructorRole || 'Especialista em Performance');
|
||||
setTips(initialData.tips || []);
|
||||
setAttachments(initialData.attachments || []);
|
||||
setLessons(initialData.lessons || []);
|
||||
} else {
|
||||
setTitle('');
|
||||
setDescription('');
|
||||
setCategory(CATEGORIES[0]);
|
||||
setThumbnailPreview('');
|
||||
setInstructor('Mário Morgado');
|
||||
setInstructorRole('Especialista em Performance');
|
||||
setTips([]);
|
||||
setAttachments([]);
|
||||
setLessons([]);
|
||||
}
|
||||
}
|
||||
}, [isOpen, initialData]);
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
const objectUrl = URL.createObjectURL(file);
|
||||
setThumbnailPreview(objectUrl);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLessonVideoFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
const objectUrl = URL.createObjectURL(file);
|
||||
setNewLessonVideoUrl(objectUrl);
|
||||
setSelectedFileName(file.name);
|
||||
if (!newLessonDuration) setNewLessonDuration('15m');
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddLesson = () => {
|
||||
if (!newLessonTitle.trim()) {
|
||||
alert("Digite um título para a aula.");
|
||||
return;
|
||||
}
|
||||
if (!newLessonVideoUrl.trim()) {
|
||||
alert("Selecione um vídeo ou insira um link.");
|
||||
return;
|
||||
}
|
||||
|
||||
const newLesson: Lesson = {
|
||||
id: `lesson-${Date.now()}`,
|
||||
title: newLessonTitle,
|
||||
duration: newLessonDuration || '15m',
|
||||
videoUrl: newLessonVideoUrl,
|
||||
isFree: newLessonIsFree,
|
||||
description: ''
|
||||
};
|
||||
|
||||
setLessons([...lessons, newLesson]);
|
||||
|
||||
setNewLessonTitle('');
|
||||
setNewLessonDuration('');
|
||||
setNewLessonVideoUrl('');
|
||||
setSelectedFileName('');
|
||||
setNewLessonIsFree(false);
|
||||
if (lessonFileInputRef.current) lessonFileInputRef.current.value = '';
|
||||
};
|
||||
|
||||
const removeLesson = (id: string) => {
|
||||
setLessons(lessons.filter(l => l.id !== id));
|
||||
};
|
||||
|
||||
const toggleLessonAccess = (id: string) => {
|
||||
setLessons(lessons.map(l => l.id === id ? { ...l, isFree: !l.isFree } : l));
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const att: Attachment = {
|
||||
id: `att-${Date.now()}`,
|
||||
name: newAttName.trim(),
|
||||
type: newAttType,
|
||||
url: newAttUrl.trim() || '#',
|
||||
size: newAttSize.trim() || '1.5 MB',
|
||||
description: newAttDesc.trim()
|
||||
};
|
||||
|
||||
setAttachments([...attachments, att]);
|
||||
setNewAttName('');
|
||||
setNewAttSize('');
|
||||
setNewAttUrl('');
|
||||
setNewAttDesc('');
|
||||
};
|
||||
|
||||
const removeAttachment = (id: string) => {
|
||||
setAttachments(attachments.filter(a => a.id !== id));
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (lessons.length === 0) {
|
||||
alert("Adicione pelo menos uma aula à trilha.");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
|
||||
const durationStr = `${lessons.length} Aulas`;
|
||||
|
||||
onSave({
|
||||
id: initialData?.id || `course-${Date.now()}`,
|
||||
title,
|
||||
description,
|
||||
category: category || CATEGORIES[0],
|
||||
thumbnail: thumbnailPreview || 'https://images.unsplash.com/photo-1460925895917-afdab827c52f?auto=format&fit=crop&w=800&q=80',
|
||||
duration: durationStr,
|
||||
instructor,
|
||||
instructorRole,
|
||||
views: initialData?.views || 0,
|
||||
progress: initialData?.progress || 0,
|
||||
lessons: lessons,
|
||||
tips: tips,
|
||||
attachments: attachments
|
||||
});
|
||||
|
||||
setIsSubmitting(false);
|
||||
onClose();
|
||||
};
|
||||
|
||||
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-6xl bg-[#141416] rounded-3xl border border-white/10 p-6 md:p-8 shadow-2xl animate-fade-in-up max-h-[92vh] overflow-y-auto flex flex-col lg:flex-row gap-8">
|
||||
|
||||
{/* Left Column: Course Details, Tips & Downloads */}
|
||||
<div className="w-full lg:w-5/12 space-y-6">
|
||||
<div className="flex justify-between items-center lg:hidden mb-2">
|
||||
<h2 className="text-xl font-bold text-white">{initialData ? 'Editar Curso' : 'Criar Curso'}</h2>
|
||||
<button onClick={onClose}><X className="text-white" /></button>
|
||||
</div>
|
||||
|
||||
{/* Thumbnail */}
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 uppercase font-bold tracking-wider mb-2 block">Imagem de Capa (16:9)</label>
|
||||
<div
|
||||
className="w-full aspect-video rounded-2xl border-2 border-dashed border-white/10 bg-black/40 flex flex-col items-center justify-center cursor-pointer hover:border-orange-500/50 transition-colors relative overflow-hidden group"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
{thumbnailPreview ? (
|
||||
<>
|
||||
<img src={thumbnailPreview} alt="Preview" className="w-full h-full object-cover opacity-70 group-hover:opacity-40 transition-opacity" />
|
||||
<div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<span className="bg-orange-500 px-4 py-2 rounded-xl text-xs font-bold shadow-lg">Alterar Capa</span>
|
||||
</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">Clique para selecionar imagem</p>
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
</div>
|
||||
</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: Masterclass: Escala de Tráfego Pago"
|
||||
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">Instrutor</label>
|
||||
<input
|
||||
type="text"
|
||||
value={instructor}
|
||||
onChange={(e) => setInstructor(e.target.value)}
|
||||
placeholder="Nome do Instrutor"
|
||||
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"
|
||||
/>
|
||||
</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="O que os alunos vão aprender e dominar neste curso?"
|
||||
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>
|
||||
|
||||
{/* Downloadable Materials Section */}
|
||||
<div className="bg-black/40 p-4 rounded-2xl border border-white/10 space-y-3">
|
||||
<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="Nome do arquivo (ex: Planilha_ROAS.xlsx)"
|
||||
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="text"
|
||||
value={newAttSize}
|
||||
onChange={(e) => setNewAttSize(e.target.value)}
|
||||
placeholder="Tamanho (ex: 2.4 MB)"
|
||||
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="Breve descrição do material..."
|
||||
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>
|
||||
</div>
|
||||
|
||||
{/* Right Column: Lessons Builder */}
|
||||
<div className="flex-1 flex flex-col justify-between">
|
||||
<div>
|
||||
<div className="hidden lg:flex justify-between items-center mb-6">
|
||||
<h2 className="text-2xl font-extrabold text-white">
|
||||
{initialData ? 'Gerenciar Trilha & Aulas' : 'Estruturar Grade de Aulas'}
|
||||
</h2>
|
||||
<button onClick={onClose} className="p-2 bg-white/5 rounded-full hover:bg-white/20 transition-colors">
|
||||
<X className="w-5 h-5 text-white" />
|
||||
</button>
|
||||
</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.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"
|
||||
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"
|
||||
onClick={() => removeLesson(lesson.id)}
|
||||
className="p-1.5 text-gray-400 hover:text-red-400 hover:bg-red-500/10 rounded-lg transition-colors"
|
||||
>
|
||||
<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" />
|
||||
Cadastrar Nova Aula
|
||||
</h4>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newLessonTitle}
|
||||
onChange={(e) => setNewLessonTitle(e.target.value)}
|
||||
placeholder="Título da Aula (ex: 01. Configuração do Pixel)"
|
||||
className="flex-1 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"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={newLessonDuration}
|
||||
onChange={(e) => setNewLessonDuration(e.target.value)}
|
||||
placeholder="Duração (ex: 25m)"
|
||||
className="w-28 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>
|
||||
|
||||
{/* Video Source Tabs */}
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="flex bg-white/5 p-0.5 rounded-lg border border-white/5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setVideoSourceType('upload')}
|
||||
className={`px-3 py-1 rounded-md text-xs font-bold transition-all ${videoSourceType === 'upload' ? 'bg-orange-500 text-white' : 'text-gray-400'}`}
|
||||
>
|
||||
Vídeo MP4
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setVideoSourceType('link')}
|
||||
className={`px-3 py-1 rounded-md text-xs font-bold transition-all ${videoSourceType === 'link' ? 'bg-orange-500 text-white' : 'text-gray-400'}`}
|
||||
>
|
||||
URL Externa
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
{videoSourceType === 'upload' ? (
|
||||
<div
|
||||
onClick={() => lessonFileInputRef.current?.click()}
|
||||
className="border border-dashed border-white/20 hover:border-orange-500/50 rounded-xl py-2 px-3 text-center cursor-pointer transition-all bg-white/[0.02]"
|
||||
>
|
||||
<input
|
||||
ref={lessonFileInputRef}
|
||||
type="file"
|
||||
accept="video/*"
|
||||
className="hidden"
|
||||
onChange={handleLessonVideoFileSelect}
|
||||
/>
|
||||
<span className="text-xs text-gray-300 truncate block">
|
||||
{selectedFileName ? `Arquivo: ${selectedFileName}` : 'Selecionar arquivo de vídeo'}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
value={newLessonVideoUrl}
|
||||
onChange={(e) => setNewLessonVideoUrl(e.target.value)}
|
||||
placeholder="https://commondatastorage.googleapis.com/...mp4"
|
||||
className="w-full bg-white/5 border border-white/10 rounded-xl px-3 py-2 text-xs text-white focus:outline-none"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
{/* Submit Button */}
|
||||
<div className="pt-4 border-t border-white/5">
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting}
|
||||
className="w-full bg-orange-500 hover:bg-orange-600 text-white font-extrabold text-base py-4 rounded-2xl active:scale-95 transition-all shadow-lg shadow-orange-500/25"
|
||||
>
|
||||
{isSubmitting ? 'Salvando...' : (initialData ? 'Salvar Alterações no Curso' : 'Publicar Curso & Materiais')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const ManageCourses: React.FC = () => {
|
||||
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 [isGeneratingReply, setIsGeneratingReply] = useState(false);
|
||||
|
||||
const { showToast } = useToast();
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
const data = await getCourses();
|
||||
setCourses(data);
|
||||
|
||||
// Load all comments
|
||||
const commentsAccumulator: {comment: Comment, courseTitle: string}[] = [];
|
||||
for (const c of data) {
|
||||
const courseComments = await getComments(c.id);
|
||||
courseComments.forEach(comm => {
|
||||
commentsAccumulator.push({ comment: comm, courseTitle: c.title });
|
||||
});
|
||||
}
|
||||
setAllComments(commentsAccumulator);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
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 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) {
|
||||
const updatedComment: Comment = {
|
||||
...target.comment,
|
||||
reply: replyText,
|
||||
replyDate: new Date().toLocaleDateString('pt-BR')
|
||||
};
|
||||
await saveComment(updatedComment);
|
||||
showToast('Resposta enviada com sucesso!', 'success');
|
||||
setReplyingTo(null);
|
||||
setReplyText('');
|
||||
loadData();
|
||||
}
|
||||
};
|
||||
|
||||
const handleGenerateAIReply = async (commentText: string, courseTitle: string) => {
|
||||
setIsGeneratingReply(true);
|
||||
try {
|
||||
const aiReply = await generateStudentReply(commentText, courseTitle);
|
||||
setReplyText(aiReply);
|
||||
} catch (e) {
|
||||
showToast('Erro ao gerar resposta com IA.', 'error');
|
||||
} finally {
|
||||
setIsGeneratingReply(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Metrics
|
||||
const totalViews = courses.reduce((acc, c) => acc + (c.views || 0), 0);
|
||||
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-4 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">Visualizações Acumuladas</span>
|
||||
<p className="font-display text-subheading md:text-heading-sm font-bold text-white mt-1">{totalViews.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]">
|
||||
<img
|
||||
src={course.thumbnail}
|
||||
alt={course.title}
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
|
||||
/>
|
||||
<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>
|
||||
</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={() => 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 a resposta 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 items-center justify-between">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleGenerateAIReply(comment.text, courseTitle)}
|
||||
disabled={isGeneratingReply}
|
||||
className="inline-flex items-center gap-1.5 text-body-sm text-orange-400 hover:text-orange-300 font-semibold"
|
||||
>
|
||||
<Sparkles className="w-3.5 h-3.5" />
|
||||
{isGeneratingReply ? 'Gerando com IA...' : 'Sugerir Resposta com IA'}
|
||||
</button>
|
||||
|
||||
<div className="flex 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>
|
||||
</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>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create/Edit Modal */}
|
||||
<CourseModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
onSave={handleSave}
|
||||
initialData={editingCourse}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
948
pages/MaterialsPage.tsx
Normal file
948
pages/MaterialsPage.tsx
Normal file
@@ -0,0 +1,948 @@
|
||||
import React, { useState, useEffect, useMemo, useRef } from 'react';
|
||||
import {
|
||||
Folder,
|
||||
FileSpreadsheet,
|
||||
FileText,
|
||||
FileArchive,
|
||||
FileCode,
|
||||
Link as LinkIcon,
|
||||
Search,
|
||||
Download,
|
||||
ChevronRight,
|
||||
ArrowLeft,
|
||||
Sparkles,
|
||||
Copy,
|
||||
BookOpen,
|
||||
Undo2,
|
||||
Eye,
|
||||
LayoutGrid,
|
||||
List,
|
||||
HardDrive,
|
||||
X,
|
||||
Play
|
||||
} from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Course, Attachment, AttachmentType } from '../types';
|
||||
import { getCourses } from '../services/db';
|
||||
import { useToast } from '../context/ToastContext';
|
||||
|
||||
interface MaterialsPageProps {
|
||||
onPlayCourse: (course: Course) => void;
|
||||
}
|
||||
|
||||
interface MaterialFileItem {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
formatType: AttachmentType;
|
||||
size: string;
|
||||
modified: string;
|
||||
course: Course;
|
||||
lessonTitle?: string;
|
||||
description?: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export const MaterialsPage: React.FC<MaterialsPageProps> = ({ onPlayCourse }) => {
|
||||
const [courses, setCourses] = useState<Course[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
// Navigation & View Mode State
|
||||
const [activeFolder, setActiveFolder] = useState<string>('downloads');
|
||||
const [viewMode, setViewMode] = useState<'list' | 'grid'>('grid');
|
||||
const [selectedItem, setSelectedItem] = useState<MaterialFileItem | null>(null);
|
||||
const [previewItem, setPreviewItem] = useState<MaterialFileItem | null>(null);
|
||||
|
||||
// Filter Chip State (All, XLSX, PDF, ZIP, DOC)
|
||||
const [typeFilter, setTypeFilter] = useState<string>('all');
|
||||
|
||||
// Table Sort State
|
||||
const [sortBy, setSortBy] = useState<'name' | 'type' | 'size' | 'modified'>('name');
|
||||
const [sortAsc, setSortAsc] = useState<boolean>(true);
|
||||
|
||||
// Context Menu State
|
||||
const [contextMenu, setContextMenu] = useState<{
|
||||
visible: boolean;
|
||||
x: number;
|
||||
y: number;
|
||||
item: MaterialFileItem | null;
|
||||
}>({ visible: false, x: 0, y: 0, item: null });
|
||||
|
||||
const [downloadingId, setDownloadingId] = useState<string | null>(null);
|
||||
|
||||
const navigate = useNavigate();
|
||||
const { showToast, showDownloadToast } = useToast();
|
||||
const contextMenuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
window.scrollTo(0, 0);
|
||||
getCourses().then(data => {
|
||||
setCourses(data);
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (contextMenuRef.current && !contextMenuRef.current.contains(e.target as Node)) {
|
||||
setContextMenu(prev => ({ ...prev, visible: false }));
|
||||
}
|
||||
};
|
||||
window.addEventListener('click', handleClickOutside);
|
||||
return () => window.removeEventListener('click', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
// Format type helper
|
||||
const getFileExtension = (att: Attachment) => {
|
||||
if (att.type === 'spreadsheet') return 'XLSX';
|
||||
if (att.type === 'pdf') return 'PDF';
|
||||
if (att.type === 'zip') return 'ZIP';
|
||||
if (att.type === 'doc') return 'DOC';
|
||||
if (att.type === 'link') return 'JSON';
|
||||
return 'TXT';
|
||||
};
|
||||
|
||||
// Compile file list
|
||||
const allFiles: MaterialFileItem[] = useMemo(() => {
|
||||
const list: MaterialFileItem[] = [];
|
||||
courses.forEach((course, cIdx) => {
|
||||
if (course.attachments && course.attachments.length > 0) {
|
||||
course.attachments.forEach((att, aIdx) => {
|
||||
const fileId = `f-c-${course.id}-${att.id || aIdx}`;
|
||||
list.push({
|
||||
id: fileId,
|
||||
name: att.name,
|
||||
type: getFileExtension(att),
|
||||
formatType: att.type,
|
||||
size: att.size || `${(aIdx + 1) * 14 + 120} KB`,
|
||||
modified: `2026. 08. 1${(aIdx % 8) + 1}`,
|
||||
course,
|
||||
description: att.description,
|
||||
url: att.url || '#'
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (course.lessons) {
|
||||
course.lessons.forEach((lesson, lIdx) => {
|
||||
if (lesson.attachments && lesson.attachments.length > 0) {
|
||||
lesson.attachments.forEach((att, laIdx) => {
|
||||
const fileId = `f-l-${course.id}-${lesson.id}-${att.id || laIdx}`;
|
||||
list.push({
|
||||
id: fileId,
|
||||
name: att.name,
|
||||
type: getFileExtension(att),
|
||||
formatType: att.type,
|
||||
size: att.size || `${(lIdx + 2) * 22 + 210} KB`,
|
||||
modified: `2026. 08. 1${(lIdx % 9) + 1}`,
|
||||
course,
|
||||
lessonTitle: lesson.title,
|
||||
description: att.description,
|
||||
url: att.url || '#'
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
return list;
|
||||
}, [courses]);
|
||||
|
||||
// Set default selected item if none selected
|
||||
useEffect(() => {
|
||||
if (allFiles.length > 0 && !selectedItem) {
|
||||
setSelectedItem(allFiles[0]);
|
||||
}
|
||||
}, [allFiles, selectedItem]);
|
||||
|
||||
const categories = useMemo(() => {
|
||||
return Array.from(new Set(courses.map(c => c.category)));
|
||||
}, [courses]);
|
||||
|
||||
// Filtered and Sorted Files
|
||||
const displayedFiles = useMemo(() => {
|
||||
return allFiles.filter(file => {
|
||||
// Type Chip filter
|
||||
if (typeFilter !== 'all') {
|
||||
if (typeFilter === 'xlsx' && file.type !== 'XLSX') return false;
|
||||
if (typeFilter === 'pdf' && file.type !== 'PDF') return false;
|
||||
if (typeFilter === 'zip' && file.type !== 'ZIP') return false;
|
||||
if (typeFilter === 'doc' && file.type !== 'DOC' && file.type !== 'TXT') return false;
|
||||
}
|
||||
|
||||
// Search Query
|
||||
if (searchTerm.trim()) {
|
||||
const query = searchTerm.toLowerCase();
|
||||
const matchesName = file.name.toLowerCase().includes(query);
|
||||
const matchesType = file.type.toLowerCase().includes(query);
|
||||
const matchesCourse = file.course.title.toLowerCase().includes(query);
|
||||
const matchesDesc = file.description?.toLowerCase().includes(query);
|
||||
if (!matchesName && !matchesType && !matchesCourse && !matchesDesc) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Folder navigation filter
|
||||
if (activeFolder === 'downloads' || activeFolder === 'my-files' || activeFolder === 'recent') {
|
||||
return true;
|
||||
}
|
||||
if (activeFolder === 'zip') {
|
||||
return file.type === 'ZIP';
|
||||
}
|
||||
if (activeFolder === 'pdf') {
|
||||
return file.type === 'PDF';
|
||||
}
|
||||
if (activeFolder === 'xlsx') {
|
||||
return file.type === 'XLSX';
|
||||
}
|
||||
if (categories.includes(activeFolder)) {
|
||||
return file.course.category === activeFolder;
|
||||
}
|
||||
return true;
|
||||
}).sort((a, b) => {
|
||||
let cmp = 0;
|
||||
if (sortBy === 'name') cmp = a.name.localeCompare(b.name);
|
||||
if (sortBy === 'type') cmp = a.type.localeCompare(b.type);
|
||||
if (sortBy === 'size') cmp = a.size.localeCompare(b.size);
|
||||
if (sortBy === 'modified') cmp = a.modified.localeCompare(b.modified);
|
||||
return sortAsc ? cmp : -cmp;
|
||||
});
|
||||
}, [allFiles, searchTerm, activeFolder, typeFilter, sortBy, sortAsc, categories]);
|
||||
|
||||
const handleSort = (column: 'name' | 'type' | 'size' | 'modified') => {
|
||||
if (sortBy === column) {
|
||||
setSortAsc(!sortAsc);
|
||||
} else {
|
||||
setSortBy(column);
|
||||
setSortAsc(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = (item: MaterialFileItem) => {
|
||||
setDownloadingId(item.id);
|
||||
setContextMenu(prev => ({ ...prev, visible: false }));
|
||||
setTimeout(() => {
|
||||
setDownloadingId(null);
|
||||
showDownloadToast(item.name, item.size);
|
||||
const link = document.createElement('a');
|
||||
link.href = item.url;
|
||||
link.download = item.name;
|
||||
link.target = '_blank';
|
||||
link.rel = 'noreferrer';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}, 400);
|
||||
};
|
||||
|
||||
const handleContextMenu = (e: React.MouseEvent, item: MaterialFileItem) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setSelectedItem(item);
|
||||
setContextMenu({
|
||||
visible: true,
|
||||
x: Math.min(e.clientX, window.innerWidth - 240),
|
||||
y: Math.min(e.clientY, window.innerHeight - 280),
|
||||
item
|
||||
});
|
||||
};
|
||||
|
||||
// Color Coded File Icon Component with Dark Theme Styling
|
||||
const renderFileIcon = (type: string, sizeClass: string = "w-5 h-5") => {
|
||||
switch (type.toUpperCase()) {
|
||||
case 'XLSX':
|
||||
case 'SPREADSHEET':
|
||||
return <FileSpreadsheet className={`${sizeClass} text-emerald-400 shrink-0`} />;
|
||||
case 'PDF':
|
||||
return <FileText className={`${sizeClass} text-rose-400 shrink-0`} />;
|
||||
case 'ZIP':
|
||||
case 'RAR':
|
||||
return <FileArchive className={`${sizeClass} text-amber-400 shrink-0`} />;
|
||||
case 'DOC':
|
||||
case 'MD':
|
||||
case 'TXT':
|
||||
return <FileCode className={`${sizeClass} text-blue-400 shrink-0`} />;
|
||||
default:
|
||||
return <LinkIcon className={`${sizeClass} text-orange-400 shrink-0`} />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-black text-white selection:bg-orange-500/30 selection:text-white pt-24 pb-20 font-sans">
|
||||
|
||||
{/* Ambient background glow matching Home */}
|
||||
<div className="fixed inset-0 pointer-events-none overflow-hidden z-0">
|
||||
<div className="absolute top-10 left-1/2 -translate-x-1/2 w-[700px] h-[400px] bg-orange-500/5 rounded-full blur-[140px]" />
|
||||
<div className="absolute top-1/3 -right-20 w-[450px] h-[450px] bg-amber-500/5 rounded-full blur-[160px]" />
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 max-w-[1440px] mx-auto px-6 md:px-12 space-y-8">
|
||||
|
||||
{/* Breadcrumb + Header Hero */}
|
||||
<div className="flex flex-col md:flex-row md:items-end justify-between gap-6 pb-6 border-b border-white/10">
|
||||
<div className="space-y-3">
|
||||
{/* Breadcrumb */}
|
||||
<div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-gray-400">
|
||||
<button
|
||||
onClick={() => navigate('/')}
|
||||
className="hover:text-orange-400 transition-colors flex items-center gap-1.5"
|
||||
>
|
||||
<ArrowLeft className="w-3.5 h-3.5" />
|
||||
<span>Início</span>
|
||||
</button>
|
||||
<span className="text-gray-600">/</span>
|
||||
<span className="text-orange-400">Central de Recursos</span>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h1 className="text-3xl sm:text-4xl lg:text-5xl font-extrabold text-white tracking-tight leading-tight">
|
||||
Biblioteca de <span className="text-orange-500">Materiais</span>
|
||||
</h1>
|
||||
<p className="text-sm sm:text-base text-gray-300 max-w-2xl font-normal leading-relaxed">
|
||||
Explore e faça o download de todas as planilhas de ROI, modelos de criativos, relatórios e arquivos complementares das aulas em um ambiente integrado.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Quick Search & Switcher Controls */}
|
||||
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-3">
|
||||
{/* Search Input */}
|
||||
<div className="relative min-w-[260px] sm:min-w-[320px]">
|
||||
<Search className="w-4 h-4 text-gray-400 absolute left-4 top-1/2 -translate-y-1/2 pointer-events-none" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
placeholder="Pesquisar material, planilha ou aula..."
|
||||
className="w-full bg-zinc-900/90 rounded-[980px] pl-11 pr-10 py-2.5 text-sm text-white placeholder:text-gray-400 font-normal focus:outline-none focus:ring-2 focus:ring-orange-500/30 transition-all"
|
||||
/>
|
||||
{searchTerm && (
|
||||
<button
|
||||
onClick={() => setSearchTerm('')}
|
||||
className="absolute right-3.5 top-1/2 -translate-y-1/2 p-1 text-gray-400 hover:text-white transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* View Switcher: Grid vs List */}
|
||||
<div className="flex items-center bg-zinc-900/90 p-1 rounded-[980px] shrink-0 self-end sm:self-auto">
|
||||
<button
|
||||
onClick={() => setViewMode('grid')}
|
||||
className={`flex items-center gap-1.5 px-4 py-2 rounded-[980px] text-xs font-bold uppercase tracking-wider transition-all ${
|
||||
viewMode === 'grid'
|
||||
? 'bg-orange-500 text-white shadow-lg shadow-orange-500/25'
|
||||
: 'text-gray-400 hover:text-white'
|
||||
}`}
|
||||
title="Visualização em Grade de Cards"
|
||||
>
|
||||
<LayoutGrid className="w-3.5 h-3.5" />
|
||||
<span className="hidden sm:inline">Grade</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('list')}
|
||||
className={`flex items-center gap-1.5 px-4 py-2 rounded-[980px] text-xs font-bold uppercase tracking-wider transition-all ${
|
||||
viewMode === 'list'
|
||||
? 'bg-orange-500 text-white shadow-lg shadow-orange-500/25'
|
||||
: 'text-gray-400 hover:text-white'
|
||||
}`}
|
||||
title="Visualização em Lista de Tabela"
|
||||
>
|
||||
<List className="w-3.5 h-3.5" />
|
||||
<span className="hidden sm:inline">Lista</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content Explorer Area */}
|
||||
<div className="flex flex-col lg:flex-row items-start gap-6">
|
||||
|
||||
{/* Left Sidebar: Drive Structure */}
|
||||
<aside className="w-full lg:w-64 bg-zinc-950/80 rounded-2xl p-4 shadow-xl shrink-0 select-none">
|
||||
|
||||
<div className="flex items-center gap-2.5 px-3 py-2 text-xs font-bold uppercase tracking-wider text-orange-400 pb-3 mb-2">
|
||||
<HardDrive className="w-4 h-4" />
|
||||
<span>Navegação do Drive</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6 text-sm font-medium">
|
||||
|
||||
{/* Primary Folder */}
|
||||
<div className="space-y-1">
|
||||
<button
|
||||
onClick={() => { setActiveFolder('downloads'); setTypeFilter('all'); setSelectedItem(null); }}
|
||||
className={`w-full flex items-center justify-between px-3.5 py-2.5 rounded-xl transition-all ${
|
||||
activeFolder === 'downloads' && typeFilter === 'all'
|
||||
? 'bg-orange-500/20 text-orange-400 font-bold'
|
||||
: 'text-gray-300 hover:bg-white/5 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<HardDrive className="w-4 h-4 text-orange-400" />
|
||||
<span>Todos os Arquivos</span>
|
||||
</div>
|
||||
<span className="text-xs font-mono px-2 py-0.5 rounded-[980px] bg-white/10 text-gray-300 font-semibold">
|
||||
{allFiles.length}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Trilhas & Categorias */}
|
||||
<div>
|
||||
<div className="text-xs font-bold uppercase tracking-wider text-gray-400 px-3 pb-2 flex items-center justify-between">
|
||||
<span>Trilhas de Cursos</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
{categories.map(cat => {
|
||||
const count = allFiles.filter(f => f.course.category === cat).length;
|
||||
const isActive = activeFolder === cat;
|
||||
return (
|
||||
<button
|
||||
key={cat}
|
||||
onClick={() => { setActiveFolder(cat); setTypeFilter('all'); setSelectedItem(null); }}
|
||||
className={`w-full flex items-center justify-between px-3.5 py-2 rounded-xl text-xs transition-all ${
|
||||
isActive
|
||||
? 'bg-orange-500/20 text-orange-400 font-bold'
|
||||
: 'text-gray-400 hover:bg-white/5 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2.5 min-w-0 pr-1">
|
||||
<Folder className={`w-3.5 h-3.5 shrink-0 ${isActive ? 'text-orange-400 fill-orange-400/20' : 'text-gray-500'}`} />
|
||||
<span className="truncate">{cat}</span>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500 font-mono">{count}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Formatos Rápidos */}
|
||||
<div>
|
||||
<div className="text-xs font-bold uppercase tracking-wider text-gray-400 px-3 pb-2">
|
||||
Formatos de Download
|
||||
</div>
|
||||
<div className="space-y-1 text-xs">
|
||||
<button
|
||||
onClick={() => { setTypeFilter('xlsx'); setActiveFolder('downloads'); setSelectedItem(null); }}
|
||||
className={`w-full flex items-center gap-2.5 px-3.5 py-2 rounded-xl transition-all ${
|
||||
typeFilter === 'xlsx' ? 'bg-emerald-500/20 text-emerald-400 font-semibold' : 'text-gray-400 hover:bg-white/5 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<FileSpreadsheet className="w-3.5 h-3.5 text-emerald-400" />
|
||||
<span>Planilhas Excel (.xlsx)</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => { setTypeFilter('pdf'); setActiveFolder('downloads'); setSelectedItem(null); }}
|
||||
className={`w-full flex items-center gap-2.5 px-3.5 py-2 rounded-xl transition-all ${
|
||||
typeFilter === 'pdf' ? 'bg-rose-500/20 text-rose-400 font-semibold' : 'text-gray-400 hover:bg-white/5 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<FileText className="w-3.5 h-3.5 text-rose-400" />
|
||||
<span>Documentos PDF (.pdf)</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => { setTypeFilter('zip'); setActiveFolder('downloads'); setSelectedItem(null); }}
|
||||
className={`w-full flex items-center gap-2.5 px-3.5 py-2 rounded-xl transition-all ${
|
||||
typeFilter === 'zip' ? 'bg-amber-500/20 text-amber-400 font-semibold' : 'text-gray-400 hover:bg-white/5 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<FileArchive className="w-3.5 h-3.5 text-amber-400" />
|
||||
<span>Pacotes Compactados (.zip)</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Storage Gauge */}
|
||||
<div className="mt-8 pt-4 border-t border-white/5 text-xs">
|
||||
<div className="flex items-center gap-2 text-gray-300 font-semibold mb-2">
|
||||
<HardDrive className="w-4 h-4 text-orange-400" />
|
||||
<span>Espaço de Arquivos</span>
|
||||
</div>
|
||||
<div className="w-full h-1.5 bg-white/10 rounded-[980px] overflow-hidden mb-2">
|
||||
<div className="h-full w-1/3 bg-gradient-to-r from-orange-500 to-amber-500 rounded-[980px]"></div>
|
||||
</div>
|
||||
<p className="text-[11px] text-gray-400 leading-tight">
|
||||
Acesso Ilimitado de Alta Velocidade para Alunos Compor HUB.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</aside>
|
||||
|
||||
{/* Main Files Area */}
|
||||
<main className="flex-1 min-w-0 bg-zinc-950/80 rounded-2xl shadow-2xl overflow-hidden flex flex-col min-h-[700px]">
|
||||
|
||||
{/* Explorer Header */}
|
||||
<div className="p-5 sm:p-6 flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<h2 className="text-xl sm:text-2xl font-bold text-white capitalize flex items-center gap-2">
|
||||
<span>
|
||||
{activeFolder === 'downloads'
|
||||
? (typeFilter !== 'all' ? `Filtro: ${typeFilter.toUpperCase()}` : 'Todos os Materiais')
|
||||
: activeFolder}
|
||||
</span>
|
||||
</h2>
|
||||
<span className="text-xs font-mono px-3 py-1 rounded-[980px] bg-white/10 text-orange-400 font-bold">
|
||||
{displayedFiles.length} {displayedFiles.length === 1 ? 'item' : 'itens'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex items-center gap-2">
|
||||
{(searchTerm || typeFilter !== 'all' || activeFolder !== 'downloads') && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setSearchTerm('');
|
||||
setTypeFilter('all');
|
||||
setActiveFolder('downloads');
|
||||
showToast('Filtros redefinidos.', 'info');
|
||||
}}
|
||||
className="px-4 py-2 rounded-[980px] text-xs font-semibold bg-white/5 hover:bg-white/10 text-gray-300 hover:text-white flex items-center gap-1.5 transition-colors"
|
||||
>
|
||||
<Undo2 className="w-3.5 h-3.5 text-orange-400" />
|
||||
<span>Limpar Filtros</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
showToast('Materiais organizados por relevância.', 'success');
|
||||
setSortBy('type');
|
||||
setSortAsc(true);
|
||||
}}
|
||||
className="px-4 py-2 rounded-[980px] text-xs font-bold bg-orange-500 hover:bg-orange-600 active:scale-95 text-white flex items-center gap-1.5 shadow-lg shadow-orange-500/25 transition-all"
|
||||
>
|
||||
<Sparkles className="w-3.5 h-3.5" />
|
||||
<span>Ordenar por Tipo</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Files Content: Grid or List */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
|
||||
{/* EMPTY STATE */}
|
||||
{displayedFiles.length === 0 ? (
|
||||
<div className="py-32 text-center text-gray-400 px-6">
|
||||
<div className="w-16 h-16 rounded-full bg-white/5 flex items-center justify-center mx-auto mb-4 text-orange-400">
|
||||
<Folder className="w-8 h-8 opacity-60" />
|
||||
</div>
|
||||
<h3 className="text-lg font-bold text-white mb-1">Nenhum material encontrado</h3>
|
||||
<p className="text-sm text-gray-400 max-w-md mx-auto mb-6">
|
||||
Não encontramos arquivos correspondentes aos filtros selecionados. Tente ajustar o termo de pesquisa ou limpar os filtros.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSearchTerm('');
|
||||
setTypeFilter('all');
|
||||
setActiveFolder('downloads');
|
||||
}}
|
||||
className="px-6 py-2.5 rounded-[980px] bg-orange-500 hover:bg-orange-600 text-white font-bold text-xs uppercase tracking-wider transition-all shadow-lg shadow-orange-500/25"
|
||||
>
|
||||
Ver Todos os Arquivos
|
||||
</button>
|
||||
</div>
|
||||
) : viewMode === 'grid' ? (
|
||||
/* GRID VIEW */
|
||||
<div className="p-6 space-y-8">
|
||||
|
||||
{/* Category Fast-Folders (When in All or Category View) */}
|
||||
{activeFolder === 'downloads' && typeFilter === 'all' && !searchTerm && (
|
||||
<div>
|
||||
<h3 className="text-xs font-bold uppercase tracking-wider text-gray-400 mb-3.5 flex items-center gap-2">
|
||||
<Folder className="w-4 h-4 text-orange-400" />
|
||||
<span>Pastas por Módulo</span>
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-3.5">
|
||||
{categories.map(cat => {
|
||||
const count = allFiles.filter(f => f.course.category === cat).length;
|
||||
return (
|
||||
<div
|
||||
key={cat}
|
||||
onClick={() => setActiveFolder(cat)}
|
||||
className="bg-zinc-900/60 hover:bg-zinc-900 rounded-xl p-4 cursor-pointer hover:shadow-xl transition-all flex items-center justify-between group"
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0 pr-2">
|
||||
<div className="w-10 h-10 rounded-xl bg-orange-500/15 flex items-center justify-center text-orange-400 shrink-0 group-hover:scale-110 transition-transform">
|
||||
<Folder className="w-5 h-5 fill-orange-500/20" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h4 className="text-xs font-bold text-white truncate group-hover:text-orange-400 transition-colors">
|
||||
{cat}
|
||||
</h4>
|
||||
<p className="text-[11px] text-gray-400 font-mono mt-0.5">{count} arquivos</p>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight className="w-4 h-4 text-gray-500 group-hover:text-orange-400 group-hover:translate-x-1 transition-all shrink-0" />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Material Cards Grid */}
|
||||
<div>
|
||||
<h3 className="text-xs font-bold uppercase tracking-wider text-gray-400 mb-3.5 flex items-center gap-2">
|
||||
<HardDrive className="w-4 h-4 text-orange-400" />
|
||||
<span>Arquivos para Download</span>
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4 gap-4">
|
||||
{displayedFiles.map(file => {
|
||||
const isSelected = selectedItem?.id === file.id;
|
||||
const isDownloading = downloadingId === file.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={file.id}
|
||||
onClick={() => setSelectedItem(file)}
|
||||
onContextMenu={(e) => handleContextMenu(e, file)}
|
||||
className={`bg-zinc-900/60 hover:bg-zinc-900 rounded-xl overflow-hidden cursor-pointer transition-all hover:shadow-2xl flex flex-col justify-between group ${
|
||||
isSelected
|
||||
? 'ring-2 ring-orange-500 bg-zinc-900'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
{/* Card Header with Icon */}
|
||||
<div className="p-4 bg-black/20 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
{renderFileIcon(file.type, "w-6 h-6")}
|
||||
<div>
|
||||
<span className="text-[11px] font-bold font-mono uppercase px-2 py-0.5 rounded bg-white/10 text-gray-300">
|
||||
{file.type}
|
||||
</span>
|
||||
<span className="text-[11px] font-mono text-gray-500 ml-2">
|
||||
{file.size}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setPreviewItem(file);
|
||||
}}
|
||||
className="p-1.5 rounded-full hover:bg-white/10 text-gray-400 hover:text-white transition-colors"
|
||||
title="Pré-visualizar detalhes"
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Card Body */}
|
||||
<div className="p-4 flex-1 flex flex-col justify-between space-y-4">
|
||||
<div>
|
||||
<h4 className="text-sm font-bold text-white line-clamp-2 group-hover:text-orange-400 transition-colors leading-snug mb-1.5">
|
||||
{file.name}
|
||||
</h4>
|
||||
<p className="text-xs text-gray-400 line-clamp-1 font-medium flex items-center gap-1.5">
|
||||
<BookOpen className="w-3.5 h-3.5 text-orange-400 shrink-0" />
|
||||
<span className="truncate">{file.course.title}</span>
|
||||
</p>
|
||||
{file.lessonTitle && (
|
||||
<p className="text-[11px] text-gray-500 line-clamp-1 mt-1 font-normal">
|
||||
Aula: {file.lessonTitle}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Card Footer Actions */}
|
||||
<div className="pt-3 flex items-center justify-between gap-2">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onPlayCourse(file.course);
|
||||
}}
|
||||
className="text-xs text-gray-400 hover:text-orange-400 flex items-center gap-1 transition-colors truncate"
|
||||
title="Assistir aula associada"
|
||||
>
|
||||
<Play className="w-3 h-3 text-orange-400 shrink-0" />
|
||||
<span className="truncate">Ver Aula</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDownload(file);
|
||||
}}
|
||||
disabled={isDownloading}
|
||||
className="px-4 py-2 rounded-[980px] bg-orange-500 hover:bg-orange-600 active:scale-95 text-white text-xs font-bold flex items-center gap-1.5 shadow-md shadow-orange-500/20 transition-all shrink-0"
|
||||
>
|
||||
<Download className={`w-3.5 h-3.5 ${isDownloading ? 'animate-bounce' : ''}`} />
|
||||
<span>{isDownloading ? 'Baixando...' : 'Baixar'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
) : (
|
||||
/* LIST VIEW (TABLE) */
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-black/60 text-xs font-bold uppercase tracking-wider text-gray-400 select-none sticky top-0 z-10 backdrop-blur-md">
|
||||
|
||||
<th
|
||||
onClick={() => handleSort('name')}
|
||||
className="py-4 px-6 cursor-pointer hover:text-white transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span>Nome do Arquivo</span>
|
||||
{sortBy === 'name' && (
|
||||
<span className="text-orange-400 font-bold">{sortAsc ? '↑' : '↓'}</span>
|
||||
)}
|
||||
</div>
|
||||
</th>
|
||||
|
||||
<th className="py-4 px-6 hidden md:table-cell">
|
||||
<span>Curso / Trilha</span>
|
||||
</th>
|
||||
|
||||
<th className="py-4 px-6 text-center w-36">
|
||||
<span>Ações</span>
|
||||
</th>
|
||||
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody className="divide-y divide-white/5 text-sm font-medium">
|
||||
{displayedFiles.map((file) => {
|
||||
const isSelected = selectedItem?.id === file.id;
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={file.id}
|
||||
onClick={() => setSelectedItem(file)}
|
||||
onContextMenu={(e) => handleContextMenu(e, file)}
|
||||
className={`group transition-all cursor-pointer select-none ${
|
||||
isSelected
|
||||
? 'bg-orange-500/10'
|
||||
: 'hover:bg-white/[0.03]'
|
||||
}`}
|
||||
>
|
||||
{/* Name column */}
|
||||
<td className="py-4 px-6">
|
||||
<div className="flex items-center gap-3.5">
|
||||
{renderFileIcon(file.type, "w-6 h-6")}
|
||||
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`font-bold transition-colors truncate text-sm ${
|
||||
isSelected ? 'text-orange-400' : 'text-white group-hover:text-orange-400'
|
||||
}`}>
|
||||
{file.name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{file.lessonTitle && (
|
||||
<p className="text-xs text-gray-400 truncate mt-0.5 font-normal">
|
||||
Aula: {file.lessonTitle}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Course / Owner */}
|
||||
<td className="py-4 px-6 hidden md:table-cell text-gray-300">
|
||||
<span className="inline-flex items-center gap-1.5 bg-zinc-900 px-3 py-1 rounded-[980px] text-xs font-semibold text-gray-300">
|
||||
<BookOpen className="w-3.5 h-3.5 text-orange-400 shrink-0" />
|
||||
<span className="truncate max-w-[200px]">{file.course.title}</span>
|
||||
</span>
|
||||
</td>
|
||||
|
||||
{/* Actions */}
|
||||
<td className="py-4 px-6 text-center">
|
||||
<div className="flex items-center justify-center gap-1.5">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDownload(file);
|
||||
}}
|
||||
className="p-2 rounded-[980px] bg-orange-500/10 hover:bg-orange-500 text-orange-400 hover:text-white transition-all shadow-sm"
|
||||
title="Baixar arquivo"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setPreviewItem(file);
|
||||
}}
|
||||
className="p-2 rounded-[980px] bg-white/5 hover:bg-white/10 text-gray-400 hover:text-white transition-colors"
|
||||
title="Ver detalhes"
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onPlayCourse(file.course);
|
||||
}}
|
||||
className="p-2 rounded-[980px] bg-white/5 hover:bg-white/10 text-gray-400 hover:text-orange-400 transition-colors"
|
||||
title="Assistir aula"
|
||||
>
|
||||
<Play className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
{/* Table Footer */}
|
||||
<div className="p-4 px-6 bg-black/40 flex items-center justify-between text-xs text-gray-400">
|
||||
<span className="font-semibold">{displayedFiles.length} item(s) listados</span>
|
||||
<span className="hidden sm:inline">Dica: Clique com o botão direito para o menu de ações rápido</span>
|
||||
</div>
|
||||
|
||||
</main>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* QUICK PREVIEW MODAL */}
|
||||
{previewItem && (
|
||||
<div className="fixed inset-0 z-50 bg-black/80 backdrop-blur-md flex items-center justify-center p-4 animate-in fade-in duration-200">
|
||||
<div className="bg-zinc-950 border border-white/15 rounded-[12px] max-w-lg w-full p-6 shadow-2xl space-y-6 animate-in zoom-in-95 duration-200">
|
||||
<div className="flex items-center justify-between pb-4 border-b border-white/10">
|
||||
<div className="flex items-center gap-3">
|
||||
{renderFileIcon(previewItem.type)}
|
||||
<div>
|
||||
<h3 className="text-base font-bold text-white truncate max-w-xs">{previewItem.name}</h3>
|
||||
<p className="text-xs text-gray-400 font-mono">{previewItem.size} • {previewItem.type}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setPreviewItem(null)}
|
||||
className="p-1.5 rounded-full hover:bg-white/10 text-gray-400 hover:text-white"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 text-sm">
|
||||
<div className="p-4 rounded-[8px] bg-zinc-900 border border-white/10 space-y-2">
|
||||
<div className="flex justify-between text-xs">
|
||||
<span className="text-gray-400">Trilha:</span>
|
||||
<span className="text-white font-semibold">{previewItem.course.title}</span>
|
||||
</div>
|
||||
{previewItem.lessonTitle && (
|
||||
<div className="flex justify-between text-xs">
|
||||
<span className="text-gray-400">Aula:</span>
|
||||
<span className="text-white font-semibold">{previewItem.lessonTitle}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between text-xs">
|
||||
<span className="text-gray-400">Instrutor:</span>
|
||||
<span className="text-orange-400 font-semibold">{previewItem.course.instructor.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{previewItem.description && (
|
||||
<div className="text-xs text-gray-300 leading-relaxed bg-white/5 p-3 rounded-[8px] border border-white/10">
|
||||
{previewItem.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-3 pt-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
onPlayCourse(previewItem.course);
|
||||
setPreviewItem(null);
|
||||
}}
|
||||
className="px-5 py-2.5 rounded-[980px] bg-white/10 hover:bg-white/15 text-white text-xs font-bold flex items-center gap-2 transition-colors"
|
||||
>
|
||||
<Play className="w-4 h-4 text-orange-400" />
|
||||
<span>Assistir Aula</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
handleDownload(previewItem);
|
||||
setPreviewItem(null);
|
||||
}}
|
||||
className="px-6 py-2.5 rounded-[980px] bg-orange-500 hover:bg-orange-600 text-white text-xs font-bold flex items-center gap-2 shadow-lg shadow-orange-500/25 transition-all"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
<span>Baixar Material</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Context Menu Popup */}
|
||||
{contextMenu.visible && contextMenu.item && (
|
||||
<div
|
||||
ref={contextMenuRef}
|
||||
style={{ top: contextMenu.y, left: contextMenu.x }}
|
||||
className="fixed z-50 w-60 bg-zinc-950 border border-white/15 rounded-[12px] shadow-2xl py-2 text-xs text-gray-200 animate-in fade-in zoom-in-95 duration-100 select-none"
|
||||
>
|
||||
|
||||
<button
|
||||
onClick={() => handleDownload(contextMenu.item!)}
|
||||
className="w-full text-left px-4 py-2.5 hover:bg-orange-500 hover:text-white flex items-center gap-2.5 transition-colors font-bold text-orange-400"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
<span>Fazer Download</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
onPlayCourse(contextMenu.item!.course);
|
||||
setContextMenu(prev => ({ ...prev, visible: false }));
|
||||
}}
|
||||
className="w-full text-left px-4 py-2.5 hover:bg-white/10 flex items-center gap-2.5 transition-colors font-medium text-white"
|
||||
>
|
||||
<Play className="w-4 h-4 text-orange-400" />
|
||||
<span>Assistir Aula do Curso</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(window.location.origin + contextMenu.item!.url);
|
||||
showToast('Link do arquivo copiado!', 'info');
|
||||
setContextMenu(prev => ({ ...prev, visible: false }));
|
||||
}}
|
||||
className="w-full text-left px-4 py-2.5 hover:bg-white/10 flex items-center gap-2.5 transition-colors font-medium text-gray-300"
|
||||
>
|
||||
<Copy className="w-4 h-4 text-gray-400" />
|
||||
<span>Copiar Link do Arquivo</span>
|
||||
</button>
|
||||
|
||||
<div className="h-px bg-white/10 my-1"></div>
|
||||
|
||||
<button
|
||||
onClick={() => setContextMenu(prev => ({ ...prev, visible: false }))}
|
||||
className="w-full text-left px-4 py-2 hover:bg-white/10 text-gray-400 hover:text-white transition-colors"
|
||||
>
|
||||
<span>Fechar</span>
|
||||
</button>
|
||||
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user