diff --git a/App.tsx b/App.tsx index f405eeb..62bbe73 100644 --- a/App.tsx +++ b/App.tsx @@ -14,6 +14,8 @@ import { AccessTokenPage } from './pages/AccessTokenPage'; import { MyLearning } from './pages/MyLearning'; import { ProfilePage } from './pages/ProfilePage'; import { InstructorProfile } from './pages/InstructorProfile'; +import { TrailDetail } from './pages/TrailDetail'; +import { TrailPage } from './pages/TrailPage'; // Protected Route Component const ProtectedRoute: React.FC<{ children: React.ReactNode; allowedRoles?: UserRole[] }> = ({ children, allowedRoles }) => { @@ -94,6 +96,8 @@ function AppContent() { } /> + } /> + } /> } /> } /> } /> diff --git a/pages/ManageCourses.tsx b/pages/ManageCourses.tsx index 5eaef41..9983fc6 100644 --- a/pages/ManageCourses.tsx +++ b/pages/ManageCourses.tsx @@ -40,6 +40,7 @@ import { getManagedCourses, saveCourse, deleteCourse, duplicateCourse, getCommen import { getAttachmentIcon, getAttachmentBadge } from '../components/MaterialCard'; import { CourseCoverImage } from '../components/CourseCoverImage'; import { BunnyVideo, courseApi, instructorApi, InstructorAnalytics } from '../services/api'; +import { useSearchParams } from 'react-router-dom'; const formatVideoDuration = (seconds: number | null | undefined) => { if (!seconds || seconds < 1) return 'Processando'; @@ -904,6 +905,7 @@ const CourseModal: React.FC<{ }; export const ManageCourses: React.FC = () => { + const [searchParams, setSearchParams] = useSearchParams(); const [courses, setCourses] = useState([]); const [isModalOpen, setIsModalOpen] = useState(false); const [editingCourse, setEditingCourse] = useState(undefined); @@ -936,6 +938,17 @@ export const ManageCourses: React.FC = () => { loadData(); }, []); + useEffect(() => { + const courseId = searchParams.get('editar'); + if (!courseId || courses.length === 0) return; + const course = courses.find((item) => item.id === courseId); + if (course) { + setEditingCourse(course); + setIsModalOpen(true); + } + setSearchParams({}, { replace: true }); + }, [courses, searchParams, setSearchParams]); + const handleCreate = () => { setEditingCourse(undefined); setIsModalOpen(true); diff --git a/pages/SuperAdmin.tsx b/pages/SuperAdmin.tsx index 642c5ee..9a44fab 100644 --- a/pages/SuperAdmin.tsx +++ b/pages/SuperAdmin.tsx @@ -52,7 +52,7 @@ export const SuperAdmin: React.FC = () => {
} label="Usuários" value={analytics?.totalUsers ?? users.length} />} label="Contas ativas" value={analytics?.activeUsers ?? activeUsers} />} label="Instrutores" value={instructorUsers} />} label="Cursos publicados" value={analytics?.publishedCourses ?? 0} />} label="Aulas concluídas" value={analytics?.completedLessons ?? 0} />
setActiveTab('courses')} label="Cursos" /> setActiveTab('trails')} label="Trilhas" /> setActiveTab('home')} label="Página inicial" /> setActiveTab('access')} label="Acessos" />
{activeTab === 'courses' && navigate('/gerenciar')} />} - {activeTab === 'trails' && } + {activeTab === 'trails' && navigate('/admin/trilhas/nova')} onOpen={(trail) => navigate(`/admin/trilhas/${trail.id}`)} />} {activeTab === 'home' && <>} {activeTab === 'access' && } {isPathEditorOpen && } @@ -69,7 +69,7 @@ const TabButton: React.FC<{ active: boolean; onClick: () => void; label: string const StatCard: React.FC<{ icon: React.ReactNode; label: string; value: number }> = ({ icon, label, value }) =>
{icon}{label}
{value}
; const DetailMetric: React.FC<{ label: string; value: string }> = ({ label, value }) =>
{label}
{value}
; -const TrailManager: React.FC<{ trails: Array<{ id: string; name: string; position: number; isActive: boolean }>; newTrail: string; setNewTrail: (value: string) => void; onCreate: (event: React.FormEvent) => void; onToggle: (trail: { id: string; name: string; position: number; isActive: boolean }) => void }> = ({ trails, newTrail, setNewTrail, onCreate, onToggle }) =>

Trilhas

Organize os temas que agrupam os cursos da plataforma.

setNewTrail(event.target.value)} placeholder="Nome da nova trilha" className="min-w-0 flex-1 rounded-xl border border-white/10 bg-zinc-900 px-3 py-2.5 text-sm text-white" />
{trails.length === 0 ?

Nenhuma trilha criada ainda.

: trails.map((trail) =>
{trail.name}
)}
; +const TrailManager: React.FC<{ trails: LearningPath[]; onNew: () => void; onOpen: (trail: LearningPath) => void }> = ({ trails, onNew, onOpen }) =>

Trilhas

Sequências ordenadas de cursos. Abra uma trilha para editar sua capa, descrição e conteúdo.

{trails.length === 0 ?

Nenhuma trilha criada ainda.

: trails.map((trail) => )}
; const CategoryManager: React.FC<{ categories: Array<{ id: string; name: string; position: number; isActive: boolean }>; newCategory: string; setNewCategory: (value: string) => void; onCreate: (event: React.FormEvent) => void; onToggle: (category: { id: string; name: string; position: number; isActive: boolean }) => void }> = ({ categories, newCategory, setNewCategory, onCreate, onToggle }) =>

Categorias das trilhas

Organize os temas disponíveis para cursos e trilhas.

setNewCategory(event.target.value)} placeholder="Nome da categoria" className="min-w-0 flex-1 rounded-xl border border-white/10 bg-zinc-900 px-3 py-2.5 text-sm text-white" />
{categories.map((category) => )}
; diff --git a/pages/TrailDetail.tsx b/pages/TrailDetail.tsx new file mode 100644 index 0000000..a081699 --- /dev/null +++ b/pages/TrailDetail.tsx @@ -0,0 +1,100 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { ArrowDown, ArrowLeft, ArrowUp, BookOpen, ExternalLink, Loader2, Pencil, Plus, Save, Trash2, Upload, X } from 'lucide-react'; +import { useNavigate, useParams } from 'react-router-dom'; +import { adminApi, instructorApi, LearningPath, LearningPathInput } from '../services/api'; +import { deleteCourse } from '../services/db'; +import { CourseCoverImage } from '../components/CourseCoverImage'; +import { useToast } from '../context/ToastContext'; + +type CourseOption = { id: string; title: string; status: string; category: string; coverImageUrl: string | null }; +const emptyInput = (): LearningPathInput => ({ title: '', description: '', coverImageUrl: null, coverImageZoom: 1, coverImagePositionX: 50, coverImagePositionY: 50, status: 'draft', courseIds: [] }); +const toInput = (trail: LearningPath): LearningPathInput => ({ title: trail.title, description: trail.description, coverImageUrl: trail.coverImageUrl, coverImageZoom: trail.coverImageZoom, coverImagePositionX: trail.coverImagePositionX, coverImagePositionY: trail.coverImagePositionY, status: trail.status === 'published' ? 'published' : 'draft', courseIds: trail.courses.map((course) => course.id) }); + +export const TrailDetail: React.FC = () => { + const { trailId } = useParams<{ trailId: string }>(); + const isNew = trailId === 'nova'; + const navigate = useNavigate(); + const { showToast } = useToast(); + const coverInput = useRef(null); + const [trail, setTrail] = useState(null); + const [courses, setCourses] = useState([]); + const [input, setInput] = useState(emptyInput()); + const [isLoading, setIsLoading] = useState(true); + const [isSaving, setIsSaving] = useState(false); + const [isUploading, setIsUploading] = useState(false); + + const load = async () => { + setIsLoading(true); + try { + const [pathsResponse, homeResponse] = await Promise.all([adminApi.listPaths(), adminApi.homeConfiguration()]); + setCourses(homeResponse.data.courses); + if (!isNew) { + const found = pathsResponse.data.find((item) => item.id === trailId); + if (!found) { showToast('Trilha não encontrada.', 'error'); navigate('/admin'); return; } + setTrail(found); + setInput(toInput(found)); + } + } catch { + showToast('Não foi possível carregar a trilha.', 'error'); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { void load(); }, [trailId]); + + const selectedCourses = useMemo(() => input.courseIds.map((id) => courses.find((course) => course.id === id)).filter((course): course is CourseOption => Boolean(course)), [courses, input.courseIds]); + const availableCourses = useMemo(() => courses.filter((course) => !input.courseIds.includes(course.id)), [courses, input.courseIds]); + const moveCourse = (courseId: string, direction: -1 | 1) => setInput((current) => { + const index = current.courseIds.indexOf(courseId); const target = index + direction; + if (index < 0 || target < 0 || target >= current.courseIds.length) return current; + const courseIds = [...current.courseIds]; [courseIds[index], courseIds[target]] = [courseIds[target], courseIds[index]]; + return { ...current, courseIds }; + }); + + const uploadCover = async (file?: File) => { + if (!file) return; + setIsUploading(true); + try { + const response = await instructorApi.uploadCover(file); + setInput((current) => ({ ...current, coverImageUrl: response.data.coverImageUrl })); + showToast('Capa enviada. Salve a trilha para confirmar.', 'success'); + } catch { + showToast('Não foi possível enviar a capa.', 'error'); + } finally { setIsUploading(false); } + }; + + const save = async (event: React.FormEvent) => { + event.preventDefault(); + if (input.courseIds.length === 0) { showToast('Adicione pelo menos um curso à trilha.', 'error'); return; } + setIsSaving(true); + try { + const response = isNew ? await adminApi.createPath(input) : await adminApi.updatePath(trail!.id, input); + showToast(isNew ? 'Trilha criada.' : 'Trilha atualizada.', 'success'); + navigate(`/admin/trilhas/${response.data.id}`, { replace: true }); + setTrail(response.data); + setInput(toInput(response.data)); + } catch (error) { + showToast(error instanceof Error ? error.message : 'Não foi possível salvar a trilha.', 'error'); + } finally { setIsSaving(false); } + }; + + const removeCourse = (courseId: string) => setInput((current) => ({ ...current, courseIds: current.courseIds.filter((id) => id !== courseId) })); + const archiveCourse = async (course: CourseOption) => { + if (!window.confirm(`Arquivar “${course.title}”? Ele será removido desta trilha e deixará de aparecer para alunos.`)) return; + try { + await deleteCourse(course.id); + setCourses((current) => current.filter((item) => item.id !== course.id)); + removeCourse(course.id); + showToast('Curso arquivado e removido da trilha.', 'success'); + } catch { showToast('Não foi possível arquivar este curso.', 'error'); } + }; + const deleteTrail = async () => { + if (!trail || !window.confirm(`Excluir a trilha “${trail.title}”? Os cursos serão preservados.`)) return; + try { await adminApi.deletePath(trail.id); showToast('Trilha excluída. Os cursos foram preservados.', 'success'); navigate('/admin'); } catch { showToast('Não foi possível excluir a trilha.', 'error'); } + }; + + if (isLoading) return
; + + return
{trail?.status === 'published' && }
{ void uploadCover(event.target.files?.[0]); event.currentTarget.value = ''; }} />

{isNew ? 'Nova trilha' : 'Trilha de cursos'}