Files
compor-academy/pages/MaterialsPage.tsx
2026-09-08 15:41:42 -03:00

73 lines
15 KiB
TypeScript

import React, { useEffect, useMemo, useState } from 'react';
import { ArrowLeft, BookOpen, Download, ExternalLink, Filter, FolderOpen, LayoutGrid, List, Loader2, Play, Search, X } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { Attachment, AttachmentType, Course } from '../types';
import { getCourses } from '../services/db';
import { getAttachmentBadge, getAttachmentIcon } from '../components/MaterialCard';
import { useToast } from '../context/ToastContext';
interface MaterialsPageProps { onPlayCourse: (course: Course) => void; }
type Material = { id: string; attachment: Attachment; course: Course; lessonTitle?: string; };
type SortOption = 'recent' | 'name' | 'type';
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<MaterialsPageProps> = ({ onPlayCourse }) => {
const navigate = useNavigate();
const { showDownloadToast, showToast } = useToast();
const [courses, setCourses] = useState<Course[]>([]);
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<SortOption>('recent');
const [view, setView] = useState<'grid' | 'list'>('grid');
useEffect(() => {
window.scrollTo(0, 0);
getCourses().then(setCourses).catch(() => showToast('Não foi possível carregar os materiais.', 'error')).finally(() => setIsLoading(false));
}, [showToast]);
const materials = useMemo<Material[]>(() => 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<string[]>(() => Array.from(new Set<string>(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 <main className="min-h-screen bg-black pb-20 pt-24 text-white"><div className="mx-auto max-w-7xl px-5 sm:px-8">
<header className="border-b border-white/10 pb-7"><button onClick={() => navigate('/')} className="mb-5 inline-flex items-center gap-1.5 text-sm text-gray-400 transition hover:text-orange-300"><ArrowLeft className="h-4 w-4" /> Início</button><div><p className="text-xs font-bold uppercase tracking-[0.18em] text-orange-400">Central de recursos</p><h1 className="mt-2 text-3xl font-bold tracking-tight sm:text-4xl">Biblioteca de materiais</h1><p className="mt-3 max-w-2xl text-sm leading-6 text-gray-400">Materiais disponibilizados pelos instrutores nos cursos e aulas da plataforma.</p></div></header>
<section className="mt-6 rounded-2xl border border-white/10 bg-zinc-950/80 p-4 sm:p-5"><div className="grid gap-3 lg:grid-cols-[minmax(0,1.5fr)_repeat(3,minmax(0,.7fr))]"><label className="relative block"><Search className="pointer-events-none absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-500" /><input value={query} onChange={(event) => 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 && <button onClick={() => 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"><X className="h-4 w-4" /></button>}</label><FilterSelect value={selectedTrail} onChange={(value) => { setSelectedTrail(value); setSelectedCourse('all'); }} label="Trilha" options={[{ value: 'all', label: 'Todas as trilhas' }, ...trails.map((trail) => ({ value: trail, label: trail }))]} /><FilterSelect value={selectedCourse} onChange={setSelectedCourse} label="Curso" options={[{ value: 'all', label: 'Todos os cursos' }, ...courseOptions.map((course) => ({ value: course.id, label: course.title }))]} /><FilterSelect value={selectedType} onChange={(value) => setSelectedType(value as 'all' | AttachmentType)} label="Formato" options={filterTypes} /></div><div className="mt-4 flex flex-wrap items-center justify-between gap-3 border-t border-white/5 pt-4"><div className="flex items-center gap-2 text-xs text-gray-500"><Filter className="h-3.5 w-3.5 text-orange-400" /> {filteredMaterials.length} {filteredMaterials.length === 1 ? 'resultado' : 'resultados'}</div><div className="flex items-center gap-2">{hasFilters && <button onClick={clearFilters} className="rounded-lg px-3 py-2 text-xs font-semibold text-gray-300 transition hover:bg-white/10 hover:text-white">Limpar filtros</button>}<label className="sr-only" htmlFor="materials-sort">Ordenar por</label><select id="materials-sort" value={sort} onChange={(event) => 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"><option value="recent">Mais recentes</option><option value="name">Nome</option><option value="type">Formato</option></select><div className="flex rounded-lg border border-white/10 bg-zinc-900 p-1"><button onClick={() => 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'}`}><LayoutGrid className="h-4 w-4" /></button><button onClick={() => 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'}`}><List className="h-4 w-4" /></button></div></div></div></section>
<section className="mt-6">{isLoading ? <div className="flex min-h-72 items-center justify-center"><Loader2 className="h-7 w-7 animate-spin text-orange-400" /></div> : filteredMaterials.length === 0 ? <EmptyState hasMaterials={materials.length > 0} onClear={clearFilters} /> : view === 'grid' ? <div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">{filteredMaterials.map((material) => <MaterialGridCard key={material.id} material={material} onOpen={openMaterial} onCourse={onPlayCourse} />)}</div> : <div className="overflow-hidden rounded-2xl border border-white/10 bg-zinc-950/80"><div className="hidden grid-cols-[minmax(0,1.5fr)_minmax(0,1fr)_10rem_8rem] gap-4 border-b border-white/10 px-5 py-3 text-xs font-semibold uppercase tracking-wider text-gray-500 md:grid"><span>Material</span><span>Curso e aula</span><span>Adicionado em</span><span className="text-right">Ações</span></div>{filteredMaterials.map((material) => <MaterialListRow key={material.id} material={material} onOpen={openMaterial} onCourse={onPlayCourse} />)}</div>}</section>
</div></main>;
};
const FilterSelect: React.FC<{ value: string; label: string; options: Array<{ value: string; label: string }>; onChange: (value: string) => void }> = ({ value, label, options, onChange }) => <label className="block"><span className="sr-only">{label}</span><select value={value} onChange={(event) => 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"><option value="" disabled>{label}</option>{options.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select></label>;
const EmptyState: React.FC<{ hasMaterials: boolean; onClear: () => void }> = ({ hasMaterials, onClear }) => <div className="flex min-h-80 flex-col items-center justify-center rounded-2xl border border-dashed border-white/15 bg-zinc-950/50 px-6 text-center"><div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-orange-500/10"><FolderOpen className="h-7 w-7 text-orange-400" /></div><h2 className="mt-5 text-lg font-bold text-white">{hasMaterials ? 'Nenhum material corresponde aos filtros' : 'A biblioteca ainda não tem materiais'}</h2><p className="mt-2 max-w-md text-sm leading-6 text-gray-500">{hasMaterials ? 'Tente alterar a busca ou os filtros selecionados.' : 'Quando um instrutor adicionar materiais a um curso ou aula, eles aparecerão aqui.'}</p>{hasMaterials && <button onClick={onClear} className="mt-5 rounded-xl bg-orange-500 px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-orange-600">Limpar filtros</button>}</div>;
const MaterialMeta: React.FC<{ material: Material }> = ({ material }) => <div className="min-w-0"><p className="truncate text-xs font-semibold text-orange-300">{material.course.category}</p><p className="mt-1 flex items-center gap-1.5 truncate text-xs text-gray-400"><BookOpen className="h-3.5 w-3.5 shrink-0" />{material.course.title}</p>{material.lessonTitle && <p className="mt-1 truncate text-xs text-gray-500">Aula: {material.lessonTitle}</p>}</div>;
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 <article className="flex min-h-64 flex-col rounded-2xl border border-white/10 bg-zinc-950/80 p-5 transition hover:border-orange-500/35 hover:bg-zinc-900/80"><div className="flex items-start justify-between gap-4"><div className="rounded-xl bg-white/5 p-3">{getAttachmentIcon(attachment.type)}</div><span className={`rounded-full px-2.5 py-1 text-[10px] font-bold uppercase tracking-wider ${badge.bg}`}>{badge.label}</span></div><div className="mt-5"><h2 className="line-clamp-2 text-base font-bold text-white">{attachment.name}</h2>{attachment.description && <p className="mt-2 line-clamp-2 text-sm leading-5 text-gray-400">{attachment.description}</p>}</div><div className="mt-5 border-t border-white/10 pt-4"><MaterialMeta material={material} /><div className="mt-4 flex items-center justify-between gap-3 text-xs text-gray-500"><span>{attachment.size || 'Tamanho não informado'}</span><span>{formatDate(attachment.createdAt)}</span></div></div><div className="mt-5 flex items-center gap-2"><button onClick={() => onCourse(material.course)} className="inline-flex items-center gap-1.5 rounded-lg border border-white/10 px-3 py-2 text-xs font-semibold text-gray-300 transition hover:bg-white/10 hover:text-white"><Play className="h-3.5 w-3.5 text-orange-400" /> Abrir curso</button><a href={attachment.url} target="_blank" rel="noreferrer" onClick={() => 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 ? <ExternalLink className="h-3.5 w-3.5" /> : <Download className="h-3.5 w-3.5" />}{isLink ? 'Abrir' : 'Baixar'}</a></div></article>; };
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 <article className="grid gap-4 border-b border-white/5 px-5 py-4 last:border-0 md:grid-cols-[minmax(0,1.5fr)_minmax(0,1fr)_10rem_8rem] md:items-center"><div className="flex min-w-0 items-center gap-3"><div className="shrink-0">{getAttachmentIcon(attachment.type)}</div><div className="min-w-0"><p className="truncate text-sm font-semibold text-white">{attachment.name}</p><p className="mt-1 text-xs text-gray-500">{attachment.size || 'Tamanho não informado'}</p></div></div><MaterialMeta material={material} /><p className="text-xs text-gray-500">{formatDate(attachment.createdAt)}</p><div className="flex items-center gap-2 md:justify-end"><button onClick={() => onCourse(material.course)} className="rounded-lg p-2 text-gray-400 transition hover:bg-white/10 hover:text-orange-300" title="Abrir curso"><Play className="h-4 w-4" /></button><a href={attachment.url} target="_blank" rel="noreferrer" onClick={() => onOpen(material)} className="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 ? <ExternalLink className="h-3.5 w-3.5" /> : <Download className="h-3.5 w-3.5" />}{isLink ? 'Abrir' : 'Baixar'}</a></div></article>; };