From 10c3d03d9c302e8bedb33878e3a0c898322af424 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cau=C3=AA=20Faleiros?= Date: Tue, 8 Sep 2026 15:22:45 -0300 Subject: [PATCH] feat: simplify materials library and use real metadata --- pages/ManageCourses.tsx | 25 +- pages/MaterialsPage.tsx | 996 ++-------------------------- server/src/routes/courses.ts | 4 + server/src/routes/manage-courses.ts | 7 +- services/db.ts | 21 + types.ts | 2 + 6 files changed, 113 insertions(+), 942 deletions(-) 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 */} -
- - / - 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 && ( - - )} -
- - {/* View Switcher: Grid vs List */} -
- - -
-
-
- - {/* Main Content Explorer Area */} -
- - {/* Left Sidebar: Drive Structure */} - - - {/* 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') && ( - - )} - - -
-
- - {/* 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. -

- -
- ) : 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} - -
-
- - -
- - {/* Card Body */} -
-
-

- {file.name} -

-

- - {file.course.title} -

- {file.lessonTitle && ( -

- Aula: {file.lessonTitle} -

- )} -
- - {/* Card Footer Actions */} -
- - - -
-
-
- ); - })} -
-
- -
- ) : ( - /* LIST VIEW (TABLE) */ - - - - - - - - - - - - - - - {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 */} - - - {/* Course / Owner */} - - - {/* Actions */} - - - - ); - })} - -
handleSort('name')} - className="py-4 px-6 cursor-pointer hover:text-white transition-colors" - > -
- Nome do Arquivo - {sortBy === 'name' && ( - {sortAsc ? '↑' : '↓'} - )} -
-
- Curso / Trilha - - Ações -
-
- {renderFileIcon(file.type, "w-6 h-6")} - -
-
- - {file.name} - -
- - {file.lessonTitle && ( -

- Aula: {file.lessonTitle} -

- )} -
-
-
- - - {file.course.title} - - -
- - - - - -
-
- )} - -
- - {/* 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}

-
-
- -
- -
-
-
- Trilha: - {previewItem.course.title} -
- {previewItem.lessonTitle && ( -
- Aula: - {previewItem.lessonTitle} -
- )} -
- Instrutor: - {previewItem.course.instructor.name} -
-
- - {previewItem.description && ( -
- {previewItem.description} -
- )} -
- -
- - - -
-
-
- )} - - {/* Context Menu Popup */} - {contextMenu.visible && contextMenu.item && ( -
- - - - - - - -
- - - -
- )} - -
- ); + 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
+

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'}

+ +
{ 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 && }
+ +
{isLoading ?
: filteredMaterials.length === 0 ? 0} onClear={clearFilters} /> : view === 'grid' ?
{filteredMaterials.map((material) => )}
:
MaterialCurso e aulaAdicionado emAçõ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 }) => ; +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 && }
; +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)}
onOpen(material)} className="ml-auto inline-flex items-center gap-1.5 rounded-lg bg-orange-500 px-3 py-2 text-xs font-semibold text-white transition hover:bg-orange-600">{isLink ? : }{isLink ? 'Abrir' : 'Baixar'}
; }; +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 ; }; 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;