diff --git a/pages/ManageCourses.tsx b/pages/ManageCourses.tsx
index 74dd5cc..5eaef41 100644
--- a/pages/ManageCourses.tsx
+++ b/pages/ManageCourses.tsx
@@ -395,13 +395,23 @@ const CourseModal: React.FC<{
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() || '1.5 MB',
+ url: newAttUrl.trim(),
+ size: newAttSize.trim() || undefined,
description: newAttDesc.trim()
};
@@ -679,12 +689,21 @@ const CourseModal: React.FC<{
+
+ 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"
+ />
+
setNewAttSize(e.target.value)}
- placeholder="Ex.: 2,4 MB"
+ 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"
/>
void;
-}
+interface MaterialsPageProps { onPlayCourse: (course: Course) => void; }
+type Material = { id: string; attachment: Attachment; course: Course; lessonTitle?: string; };
+type SortOption = 'recent' | 'name' | 'type';
-interface MaterialFileItem {
- id: string;
- name: string;
- type: string;
- formatType: AttachmentType;
- size: string;
- modified: string;
- course: Course;
- lessonTitle?: string;
- description?: string;
- url: string;
-}
+const filterTypes: Array<{ value: 'all' | AttachmentType; label: string }> = [
+ { value: 'all', label: 'Todos os formatos' }, { value: 'pdf', label: 'PDF' }, { value: 'spreadsheet', label: 'Planilha' }, { value: 'doc', label: 'Documento' }, { value: 'zip', label: 'Arquivo compactado' }, { value: 'image', label: 'Imagem' }, { value: 'link', label: 'Link' },
+];
+
+const formatDate = (date?: string) => date ? new Intl.DateTimeFormat('pt-BR', { dateStyle: 'medium' }).format(new Date(date)) : 'Data não informada';
+const materialDate = (material: Material) => {
+ const value = material.attachment.createdAt ? new Date(material.attachment.createdAt).getTime() : 0;
+ return Number.isNaN(value) ? 0 : value;
+};
export const MaterialsPage: React.FC
= ({ onPlayCourse }) => {
- const [courses, setCourses] = useState([]);
- const [loading, setLoading] = useState(true);
- const [searchTerm, setSearchTerm] = useState('');
-
- // Navigation & View Mode State
- const [activeFolder, setActiveFolder] = useState('downloads');
- const [viewMode, setViewMode] = useState<'list' | 'grid'>('grid');
- const [selectedItem, setSelectedItem] = useState(null);
- const [previewItem, setPreviewItem] = useState(null);
-
- // Filter Chip State (All, XLSX, PDF, ZIP, DOC)
- const [typeFilter, setTypeFilter] = useState('all');
-
- // Table Sort State
- const [sortBy, setSortBy] = useState<'name' | 'type' | 'size' | 'modified'>('name');
- const [sortAsc, setSortAsc] = useState(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(null);
-
const navigate = useNavigate();
- const { showToast, showDownloadToast } = useToast();
- const contextMenuRef = useRef(null);
+ const { showDownloadToast, showToast } = useToast();
+ const [courses, setCourses] = useState([]);
+ const [isLoading, setIsLoading] = useState(true);
+ const [query, setQuery] = useState('');
+ const [selectedTrail, setSelectedTrail] = useState('all');
+ const [selectedCourse, setSelectedCourse] = useState('all');
+ const [selectedType, setSelectedType] = useState<'all' | AttachmentType>('all');
+ const [sort, setSort] = useState('recent');
+ const [view, setView] = useState<'grid' | 'list'>('grid');
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 ;
- case 'PDF':
- return ;
- case 'ZIP':
- case 'RAR':
- return ;
- case 'DOC':
- case 'MD':
- case 'TXT':
- return ;
- default:
- return ;
- }
- };
-
- return (
-
-
- {/* Ambient background glow matching Home */}
-
-
-
-
- {/* Breadcrumb + Header Hero */}
-
-
- {/* Breadcrumb */}
-
-
navigate('/')}
- className="hover:text-orange-400 transition-colors flex items-center gap-1.5"
- >
-
- Início
-
-
/
-
Central de Recursos
-
-
- {/* Title */}
-
- Biblioteca de Materiais
-
-
- 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.
-
-
-
- {/* Quick Search & Switcher Controls */}
-
- {/* Search Input */}
-
-
- 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 && (
- setSearchTerm('')}
- className="absolute right-3.5 top-1/2 -translate-y-1/2 p-1 text-gray-400 hover:text-white transition-colors"
- >
-
-
- )}
-
-
- {/* View Switcher: Grid vs List */}
-
- 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"
- >
-
- Grade
-
- 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"
- >
-
- Lista
-
-
-
-
-
- {/* Main Content Explorer Area */}
-
-
- {/* Left Sidebar: Drive Structure */}
-
-
-
-
- Navegação do Drive
-
-
-
-
- {/* Primary Folder */}
-
-
{ 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'
- }`}
- >
-
-
- Todos os Arquivos
-
-
- {allFiles.length}
-
-
-
-
- {/* Trilhas & Categorias */}
-
-
- Trilhas de Cursos
-
-
-
- {categories.map(cat => {
- const count = allFiles.filter(f => f.course.category === cat).length;
- const isActive = activeFolder === cat;
- return (
-
{ 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'
- }`}
- >
-
-
- {cat}
-
- {count}
-
- );
- })}
-
-
-
- {/* Formatos Rápidos */}
-
-
- Formatos de Download
-
-
- { 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'
- }`}
- >
-
- Planilhas Excel (.xlsx)
-
-
- { 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'
- }`}
- >
-
- Documentos PDF (.pdf)
-
-
- { 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'
- }`}
- >
-
- Pacotes Compactados (.zip)
-
-
-
-
-
-
- {/* Storage Gauge */}
-
-
-
- Espaço de Arquivos
-
-
-
- Acesso Ilimitado de Alta Velocidade para Alunos Compor HUB.
-
-
-
-
-
- {/* Main Files Area */}
-
-
- {/* Explorer Header */}
-
-
-
-
- {activeFolder === 'downloads'
- ? (typeFilter !== 'all' ? `Filtro: ${typeFilter.toUpperCase()}` : 'Todos os Materiais')
- : activeFolder}
-
-
-
- {displayedFiles.length} {displayedFiles.length === 1 ? 'item' : 'itens'}
-
-
-
- {/* Action Buttons */}
-
- {(searchTerm || typeFilter !== 'all' || activeFolder !== 'downloads') && (
- {
- 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"
- >
-
- Limpar Filtros
-
- )}
-
- {
- 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"
- >
-
- Ordenar por Tipo
-
-
-
-
- {/* Files Content: Grid or List */}
-
-
- {/* EMPTY STATE */}
- {displayedFiles.length === 0 ? (
-
-
-
-
-
Nenhum material encontrado
-
- Não encontramos arquivos correspondentes aos filtros selecionados. Tente ajustar o termo de pesquisa ou limpar os filtros.
-
-
{
- 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
-
-
- ) : viewMode === 'grid' ? (
- /* GRID VIEW */
-
-
- {/* Category Fast-Folders (When in All or Category View) */}
- {activeFolder === 'downloads' && typeFilter === 'all' && !searchTerm && (
-
-
-
- Pastas por Módulo
-
-
- {categories.map(cat => {
- const count = allFiles.filter(f => f.course.category === cat).length;
- return (
-
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"
- >
-
-
-
-
-
-
- {cat}
-
-
{count} arquivos
-
-
-
-
- );
- })}
-
-
- )}
-
- {/* Material Cards Grid */}
-
-
-
- Arquivos para Download
-
-
-
- {displayedFiles.map(file => {
- const isSelected = selectedItem?.id === file.id;
- const isDownloading = downloadingId === file.id;
-
- return (
-
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 */}
-
-
- {renderFileIcon(file.type, "w-6 h-6")}
-
-
- {file.type}
-
-
- {file.size}
-
-
-
-
-
{
- 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"
- >
-
-
-
-
- {/* Card Body */}
-
-
-
- {file.name}
-
-
-
- {file.course.title}
-
- {file.lessonTitle && (
-
- Aula: {file.lessonTitle}
-
- )}
-
-
- {/* Card Footer Actions */}
-
-
{
- 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"
- >
-
- Ver Aula
-
-
-
{
- 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"
- >
-
- {isDownloading ? 'Baixando...' : 'Baixar'}
-
-
-
-
- );
- })}
-
-
-
-
- ) : (
- /* LIST VIEW (TABLE) */
-
-
-
-
- handleSort('name')}
- className="py-4 px-6 cursor-pointer hover:text-white transition-colors"
- >
-
- Nome do Arquivo
- {sortBy === 'name' && (
- {sortAsc ? '↑' : '↓'}
- )}
-
-
-
-
- Curso / Trilha
-
-
-
- Ações
-
-
-
-
-
-
- {displayedFiles.map((file) => {
- const isSelected = selectedItem?.id === file.id;
-
- return (
- 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 */}
-
-
- {renderFileIcon(file.type, "w-6 h-6")}
-
-
-
-
- {file.name}
-
-
-
- {file.lessonTitle && (
-
- Aula: {file.lessonTitle}
-
- )}
-
-
-
-
- {/* Course / Owner */}
-
-
-
- {file.course.title}
-
-
-
- {/* Actions */}
-
-
-
{
- 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"
- >
-
-
-
-
{
- 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"
- >
-
-
-
-
{
- 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"
- >
-
-
-
-
-
-
- );
- })}
-
-
- )}
-
-
-
- {/* Table Footer */}
-
- {displayedFiles.length} item(s) listados
- Dica: Clique com o botão direito para o menu de ações rápido
-
-
-
-
-
-
-
-
- {/* QUICK PREVIEW MODAL */}
- {previewItem && (
-
-
-
-
- {renderFileIcon(previewItem.type)}
-
-
{previewItem.name}
-
{previewItem.size} • {previewItem.type}
-
-
-
setPreviewItem(null)}
- className="p-1.5 rounded-full hover:bg-white/10 text-gray-400 hover:text-white"
- >
-
-
-
-
-
-
-
- Trilha:
- {previewItem.course.title}
-
- {previewItem.lessonTitle && (
-
- Aula:
- {previewItem.lessonTitle}
-
- )}
-
- Instrutor:
- {previewItem.course.instructor.name}
-
-
-
- {previewItem.description && (
-
- {previewItem.description}
-
- )}
-
-
-
-
{
- 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"
- >
-
- Assistir Aula
-
-
-
{
- 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"
- >
-
- Baixar Material
-
-
-
-
- )}
-
- {/* Context Menu Popup */}
- {contextMenu.visible && contextMenu.item && (
-
-
-
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"
- >
-
- Fazer Download
-
-
-
{
- 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"
- >
-
- Assistir Aula do Curso
-
-
-
{
- 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"
- >
-
- Copiar Link do Arquivo
-
-
-
-
-
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"
- >
- Fechar
-
-
-
- )}
-
-
- );
+ getCourses().then(setCourses).catch(() => showToast('Não foi possível carregar os materiais.', 'error')).finally(() => setIsLoading(false));
+ }, [showToast]);
+
+ const materials = useMemo(() => courses.flatMap((course) => [
+ ...(course.attachments || []).map((attachment) => ({ id: `course-${course.id}-${attachment.id}`, attachment, course })),
+ ...course.lessons.flatMap((lesson) => (lesson.attachments || []).map((attachment) => ({ id: `lesson-${course.id}-${lesson.id}-${attachment.id}`, attachment, course, lessonTitle: lesson.title }))),
+ ]), [courses]);
+ const trails = useMemo(() => Array.from(new Set(courses.map((course) => course.category))).filter(Boolean).sort((a, b) => a.localeCompare(b, 'pt-BR')), [courses]);
+ const courseOptions = useMemo(() => courses.filter((course) => selectedTrail === 'all' || course.category === selectedTrail).sort((a, b) => a.title.localeCompare(b.title, 'pt-BR')), [courses, selectedTrail]);
+ const filteredMaterials = useMemo(() => materials.filter((material) => {
+ const normalized = query.trim().toLocaleLowerCase('pt-BR');
+ const searchMatches = !normalized || [material.attachment.name, material.attachment.description, material.course.title, material.course.category, material.lessonTitle].some((value) => value?.toLocaleLowerCase('pt-BR').includes(normalized));
+ return searchMatches && (selectedTrail === 'all' || material.course.category === selectedTrail) && (selectedCourse === 'all' || material.course.id === selectedCourse) && (selectedType === 'all' || material.attachment.type === selectedType);
+ }).sort((left, right) => {
+ if (sort === 'name') return left.attachment.name.localeCompare(right.attachment.name, 'pt-BR');
+ if (sort === 'type') return left.attachment.type.localeCompare(right.attachment.type, 'pt-BR') || left.attachment.name.localeCompare(right.attachment.name, 'pt-BR');
+ return materialDate(right) - materialDate(left) || left.attachment.name.localeCompare(right.attachment.name, 'pt-BR');
+ }), [materials, query, selectedTrail, selectedCourse, selectedType, sort]);
+ const hasFilters = Boolean(query) || selectedTrail !== 'all' || selectedCourse !== 'all' || selectedType !== 'all';
+ const clearFilters = () => { setQuery(''); setSelectedTrail('all'); setSelectedCourse('all'); setSelectedType('all'); };
+ const openMaterial = (material: Material) => material.attachment.type === 'link' ? showToast(`Abrindo “${material.attachment.name}”.`, 'info') : showDownloadToast(material.attachment.name, material.attachment.size);
+
+ return
+
navigate('/')} className="mb-5 inline-flex items-center gap-1.5 text-sm text-gray-400 transition hover:text-orange-300"> Início Central de recursos
Biblioteca de materiais Materiais disponibilizados pelos instrutores nos cursos e aulas da plataforma.
{materials.length}
{materials.length === 1 ? 'material disponível' : 'materiais disponíveis'}
+
+
setQuery(event.target.value)} placeholder="Buscar por material, curso ou aula" className="w-full rounded-xl border border-white/10 bg-zinc-900 py-3 pl-10 pr-10 text-sm text-white outline-none transition focus:border-orange-500/70 focus:ring-2 focus:ring-orange-500/15" />{query && setQuery('')} className="absolute right-3 top-1/2 -translate-y-1/2 rounded p-1 text-gray-400 hover:text-white" aria-label="Limpar busca"> } { setSelectedTrail(value); setSelectedCourse('all'); }} label="Trilha" options={[{ value: 'all', label: 'Todas as trilhas' }, ...trails.map((trail) => ({ value: trail, label: trail }))]} /> ({ value: course.id, label: course.title }))]} /> setSelectedType(value as 'all' | AttachmentType)} label="Formato" options={filterTypes} />
{filteredMaterials.length} {filteredMaterials.length === 1 ? 'resultado' : 'resultados'}
{hasFilters &&
Limpar filtros }
Ordenar por setSort(event.target.value as SortOption)} className="rounded-lg border border-white/10 bg-zinc-900 px-3 py-2 text-xs font-semibold text-white outline-none">Mais recentes Nome Formato setView('grid')} aria-label="Visualização em grade" className={`rounded p-1.5 ${view === 'grid' ? 'bg-orange-500 text-white' : 'text-gray-400 hover:text-white'}`}> setView('list')} aria-label="Visualização em lista" className={`rounded p-1.5 ${view === 'list' ? 'bg-orange-500 text-white' : 'text-gray-400 hover:text-white'}`}>
+
+
{isLoading ?
: filteredMaterials.length === 0 ? 0} onClear={clearFilters} /> : view === 'grid' ? {filteredMaterials.map((material) => )}
: Material Curso e aula Adicionado em Ações
{filteredMaterials.map((material) =>
)}
}
+
;
};
+
+const FilterSelect: React.FC<{ value: string; label: string; options: Array<{ value: string; label: string }>; onChange: (value: string) => void }> = ({ value, label, options, onChange }) => {label} onChange(event.target.value)} className="w-full rounded-xl border border-white/10 bg-zinc-900 px-3 py-3 text-sm text-white outline-none transition focus:border-orange-500/70">{label} {options.map((option) => {option.label} )} ;
+const EmptyState: React.FC<{ hasMaterials: boolean; onClear: () => void }> = ({ hasMaterials, onClear }) =>
{hasMaterials ? 'Nenhum material corresponde aos filtros' : 'A biblioteca ainda não tem materiais'} {hasMaterials ? 'Tente alterar a busca ou os filtros selecionados.' : 'Quando um instrutor adicionar materiais a um curso ou aula, eles aparecerão aqui.'}
{hasMaterials &&
Limpar filtros }
;
+const MaterialMeta: React.FC<{ material: Material }> = ({ material }) => {material.course.category}
{material.course.title}
{material.lessonTitle &&
Aula: {material.lessonTitle}
}
;
+const MaterialGridCard: React.FC<{ material: Material; onOpen: (material: Material) => void; onCourse: (course: Course) => void }> = ({ material, onOpen, onCourse }) => { const { attachment } = material; const badge = getAttachmentBadge(attachment.type); const isLink = attachment.type === 'link'; return {getAttachmentIcon(attachment.type)}
{badge.label} {attachment.name} {attachment.description &&
{attachment.description}
}
{attachment.size || 'Tamanho não informado'} {formatDate(attachment.createdAt)}
; };
+const MaterialListRow: React.FC<{ material: Material; onOpen: (material: Material) => void; onCourse: (course: Course) => void }> = ({ material, onOpen, onCourse }) => { const { attachment } = material; const isLink = attachment.type === 'link'; return {getAttachmentIcon(attachment.type)}
{attachment.name}
{attachment.size || 'Tamanho não informado'}
{formatDate(attachment.createdAt)}
; };
diff --git a/server/src/routes/courses.ts b/server/src/routes/courses.ts
index 235f9b5..657bde1 100644
--- a/server/src/routes/courses.ts
+++ b/server/src/routes/courses.ts
@@ -49,6 +49,8 @@ export const courseSelect = (publicOnly = false, includeUnreadyMedia = false) =>
'name', a.name,
'kind', a.kind,
'url', a.url,
+ 'sizeBytes', a.size_bytes,
+ 'createdAt', a.created_at,
'description', a.description,
'accessLevel', a.access_level
) order by a.created_at)
@@ -81,6 +83,8 @@ export const courseSelect = (publicOnly = false, includeUnreadyMedia = false) =>
'name', a.name,
'kind', a.kind,
'url', a.url,
+ 'sizeBytes', a.size_bytes,
+ 'createdAt', a.created_at,
'description', a.description,
'accessLevel', a.access_level
) order by a.created_at)
diff --git a/server/src/routes/manage-courses.ts b/server/src/routes/manage-courses.ts
index a21cca4..e66f11b 100644
--- a/server/src/routes/manage-courses.ts
+++ b/server/src/routes/manage-courses.ts
@@ -20,6 +20,7 @@ const assetSchema = z.object({
name: z.string().trim().min(1).max(255),
kind: z.enum(['document', 'spreadsheet', 'archive', 'image', 'link']),
url: z.string().url(),
+ sizeBytes: z.number().int().nonnegative().nullable().optional(),
description: z.string().max(2000).default(''),
accessLevel: z.enum(['public', 'enrolled']).default('enrolled'),
});
@@ -85,9 +86,9 @@ async function uniqueSlug(client: PoolClient, requestedSlug: string | undefined,
async function insertAssets(client: PoolClient, parent: { courseId?: string; lessonId?: string }, assets: CourseInput['assets']) {
for (const asset of assets) {
await client.query(
- `insert into assets (course_id, lesson_id, name, kind, url, description, access_level)
- values ($1, $2, $3, $4, $5, $6, $7)`,
- [parent.courseId ?? null, parent.lessonId ?? null, asset.name, asset.kind, asset.url, asset.description, asset.accessLevel],
+ `insert into assets (course_id, lesson_id, name, kind, url, size_bytes, description, access_level)
+ values ($1, $2, $3, $4, $5, $6, $7, $8)`,
+ [parent.courseId ?? null, parent.lessonId ?? null, asset.name, asset.kind, asset.url, asset.sizeBytes ?? null, asset.description, asset.accessLevel],
);
}
}
diff --git a/services/db.ts b/services/db.ts
index 2c8665c..96bb492 100644
--- a/services/db.ts
+++ b/services/db.ts
@@ -8,6 +8,8 @@ type ApiAsset = {
name: string;
kind: 'document' | 'spreadsheet' | 'archive' | 'image' | 'link';
url: string;
+ sizeBytes: number | null;
+ createdAt: string;
description: string;
};
@@ -88,9 +90,19 @@ const toAttachment = (asset: ApiAsset): Attachment => ({
name: asset.name,
type: toAttachmentType(asset.kind),
url: asset.url,
+ size: asset.sizeBytes === null ? undefined : formatBytes(asset.sizeBytes),
+ sizeBytes: asset.sizeBytes ?? undefined,
+ createdAt: asset.createdAt,
description: asset.description,
});
+const formatBytes = (bytes: number) => {
+ if (bytes < 1024) return `${bytes} B`;
+ const units = ['KB', 'MB', 'GB'];
+ const index = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)) - 1, units.length - 1);
+ return `${new Intl.NumberFormat('pt-BR', { maximumFractionDigits: 1 }).format(bytes / 1024 ** (index + 1))} ${units[index]}`;
+};
+
const toDuration = (seconds: number | null) => {
if (!seconds || seconds < 1) return '—';
const totalSeconds = Math.round(seconds);
@@ -166,10 +178,19 @@ const toAssetPayload = (attachment: Attachment) => ({
name: attachment.name,
kind: toAssetKind(attachment.type),
url: attachment.url,
+ sizeBytes: attachment.sizeBytes ?? parseSizeBytes(attachment.size),
description: attachment.description || '',
accessLevel: 'enrolled' as const,
});
+const parseSizeBytes = (value?: string) => {
+ if (!value) return null;
+ const match = value.trim().replace(',', '.').match(/^(\d+(?:\.\d+)?)\s*(B|KB|MB|GB)$/i);
+ if (!match) return null;
+ const units: Record = { B: 1, KB: 1024, MB: 1024 ** 2, GB: 1024 ** 3 };
+ return Math.round(Number(match[1]) * units[match[2].toUpperCase()]);
+};
+
const toCoursePayload = (course: Course) => ({
title: course.title,
description: course.description,
diff --git a/types.ts b/types.ts
index 7dfdbc4..57a93e3 100644
--- a/types.ts
+++ b/types.ts
@@ -8,6 +8,8 @@ export interface Attachment {
type: AttachmentType;
url: string;
size?: string; // e.g. "4.2 MB"
+ sizeBytes?: number;
+ createdAt?: string;
description?: string;
lessonId?: string; // Associated lesson or global course asset
downloadCount?: number;