diff --git a/App.tsx b/App.tsx index ca1df9b..8a329c9 100644 --- a/App.tsx +++ b/App.tsx @@ -12,6 +12,8 @@ import { Course, UserRole } from './types'; import { SuperAdmin } from './pages/SuperAdmin'; import { AccessTokenPage } from './pages/AccessTokenPage'; import { MyLearning } from './pages/MyLearning'; +import { ProfilePage } from './pages/ProfilePage'; +import { InstructorProfile } from './pages/InstructorProfile'; // Protected Route Component const ProtectedRoute: React.FC<{ children: React.ReactNode; allowedRoles?: UserRole[] }> = ({ children, allowedRoles }) => { @@ -93,6 +95,8 @@ function AppContent() { } /> } /> + } /> + } /> } /> } /> diff --git a/components/CourseCard.tsx b/components/CourseCard.tsx index aac37be..421151f 100644 --- a/components/CourseCard.tsx +++ b/components/CourseCard.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { PlayCircle, Download, FileText, CheckCircle2 } from 'lucide-react'; import { Course, SectionVariant } from '../types'; import { CourseCoverImage } from './CourseCoverImage'; +import { useNavigate } from 'react-router-dom'; interface CourseCardProps { course: Course; @@ -16,6 +17,7 @@ export const CourseCard: React.FC = ({ onClick, onOpenMaterials }) => { + const navigate = useNavigate(); const widthClass = variant === 'landscape' ? 'w-[320px] md:w-[420px]' : 'w-[200px] md:w-[260px]'; @@ -99,7 +101,7 @@ export const CourseCard: React.FC = ({ {course.instructor && ( <> - {course.instructor} + {course.instructorId ? : {course.instructor}} )} diff --git a/components/Navbar.tsx b/components/Navbar.tsx index 25e8901..885bf8a 100644 --- a/components/Navbar.tsx +++ b/components/Navbar.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect } from 'react'; -import { User as UserIcon, LogOut, Search, Folder, BookOpenCheck, ChevronDown, ShieldCheck, Settings } from 'lucide-react'; +import { User as UserIcon, LogOut, Search, Folder, BookOpenCheck, ChevronDown, ShieldCheck, Settings, UserRound } from 'lucide-react'; import { useAuth } from '../context/AuthContext'; import { LoginModal } from './LoginModal'; import { SearchModal } from './SearchModal'; @@ -111,6 +111,7 @@ export const Navbar: React.FC = ({ onPlay }) => { {showAccountMenu &&
+ {user.role === 'student' && } {user.role === 'professor' && } {user.role === 'superadmin' && <>} diff --git a/context/AuthContext.tsx b/context/AuthContext.tsx index e5d0a78..b9afbd4 100644 --- a/context/AuthContext.tsx +++ b/context/AuthContext.tsx @@ -1,12 +1,13 @@ import React, { createContext, useContext, useState, useEffect } from 'react'; import { User, UserRole } from '../types'; -import { authApi, clearSession, getSession, saveSession } from '../services/api'; +import { ApiUser, authApi, clearSession, getSession, saveSession } from '../services/api'; interface AuthContextType { user: User | null; login: (email: string, password: string) => Promise; register: (name: string, email: string, password: string) => Promise; logout: () => void; + updateCurrentUser: (user: ApiUser) => void; isLoading: boolean; } @@ -67,8 +68,14 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children clearSession(); }; + const updateCurrentUser = (apiUser: ApiUser) => { + const session = getSession(); + if (session) saveSession({ ...session, user: apiUser }); + setUser(toFrontendUser(apiUser)); + }; + return ( - + {children} ); diff --git a/pages/InstructorProfile.tsx b/pages/InstructorProfile.tsx new file mode 100644 index 0000000..15d5541 --- /dev/null +++ b/pages/InstructorProfile.tsx @@ -0,0 +1,31 @@ +import React, { useEffect, useState } from 'react'; +import { ExternalLink, Globe2, Instagram, Linkedin, Loader2, UserRound, Youtube } from 'lucide-react'; +import { useParams } from 'react-router-dom'; +import { Course } from '../types'; +import { getInstructorProfile, InstructorProfileData } from '../services/db'; +import { CourseCoverImage } from '../components/CourseCoverImage'; +import { CourseCard } from '../components/CourseCard'; + +const networks = [ + { key: 'websiteUrl', label: 'Site', icon: Globe2 }, + { key: 'linkedinUrl', label: 'LinkedIn', icon: Linkedin }, + { key: 'instagramUrl', label: 'Instagram', icon: Instagram }, + { key: 'youtubeUrl', label: 'YouTube', icon: Youtube }, +] as const; + +export const InstructorProfile: React.FC<{ onPlay: (course: Course) => void }> = ({ onPlay }) => { + const { instructorId } = useParams<{ instructorId: string }>(); + const [profile, setProfile] = useState(null); + const [state, setState] = useState<'loading' | 'ready' | 'missing'>('loading'); + + useEffect(() => { + if (!instructorId) return; + setState('loading'); + getInstructorProfile(instructorId).then((data) => { setProfile(data); setState('ready'); }).catch(() => setState('missing')); + }, [instructorId]); + + if (state === 'loading') return
; + if (state === 'missing' || !profile) return

Perfil indisponível

Este perfil não está público.

; + + return
{profile.avatarImageUrl ? :
}

Instrutor

{profile.name}

{profile.headline &&

{profile.headline}

}
{networks.map(({ key, label, icon: Icon }) => { const url = profile[key]; return url ? {label} : null; })}

Conteúdo

Cursos publicados

{profile.courses.length} {profile.courses.length === 1 ? 'curso' : 'cursos'}
{profile.courses.length ?
{profile.courses.map((course) => )}
:
Nenhum curso publicado ainda.
}
; +}; diff --git a/pages/ProfilePage.tsx b/pages/ProfilePage.tsx new file mode 100644 index 0000000..4a81b0e --- /dev/null +++ b/pages/ProfilePage.tsx @@ -0,0 +1,139 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { Camera, ExternalLink, Globe2, Instagram, Linkedin, Loader2, Save, UserRound, Youtube } from 'lucide-react'; +import { useAuth } from '../context/AuthContext'; +import { Profile, ProfileInput, instructorApi, profileApi } from '../services/api'; +import { CourseCoverImage } from '../components/CourseCoverImage'; +import { useToast } from '../context/ToastContext'; +import { useNavigate } from 'react-router-dom'; + +const emptyProfile = (user: { id: string; email: string; name: string; role: string }): Profile => ({ + id: user.id, + email: user.email, + name: user.name, + role: user.role === 'superadmin' ? 'admin' : user.role === 'professor' ? 'instructor' : 'student', + avatarImageUrl: null, + headline: '', + bio: '', + websiteUrl: null, + linkedinUrl: null, + instagramUrl: null, + youtubeUrl: null, + isPublic: true, +}); + +const inputClass = 'w-full rounded-xl border border-white/10 bg-zinc-900 px-4 py-3 text-sm text-white outline-none transition focus:border-orange-500/70 focus:ring-2 focus:ring-orange-500/15'; + +export const ProfilePage: React.FC = () => { + const { user, updateCurrentUser } = useAuth(); + const { showToast } = useToast(); + const navigate = useNavigate(); + const fileInput = useRef(null); + const [profile, setProfile] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [isSaving, setIsSaving] = useState(false); + const [isUploading, setIsUploading] = useState(false); + + const canPublishProfile = user?.role === 'professor' || user?.role === 'superadmin'; + + useEffect(() => { + if (!user) return; + profileApi.me() + .then((response) => setProfile(response.data)) + .catch(() => { + setProfile(emptyProfile(user)); + showToast('Não foi possível carregar todos os dados do perfil.', 'error'); + }) + .finally(() => setIsLoading(false)); + }, [showToast, user]); + + if (isLoading || !profile || !user) { + return
; + } + + const update = (key: K, value: Profile[K]) => setProfile((current) => current ? { ...current, [key]: value } : current); + + const uploadAvatar = async (file?: File) => { + if (!file) return; + if (!file.type.startsWith('image/')) { + showToast('Selecione uma imagem JPG, PNG ou WebP.', 'error'); + return; + } + setIsUploading(true); + try { + const response = await instructorApi.uploadCover(file); + update('avatarImageUrl', response.data.coverImageUrl); + showToast('Foto enviada. Salve o perfil para publicar a alteração.', 'success'); + } catch { + showToast('Não foi possível enviar a foto.', 'error'); + } finally { + setIsUploading(false); + } + }; + + const save = async (event: React.FormEvent) => { + event.preventDefault(); + const input: ProfileInput = { + name: profile.name, + avatarImageUrl: profile.avatarImageUrl, + headline: profile.headline, + bio: profile.bio, + websiteUrl: profile.websiteUrl, + linkedinUrl: profile.linkedinUrl, + instagramUrl: profile.instagramUrl, + youtubeUrl: profile.youtubeUrl, + isPublic: profile.isPublic, + }; + setIsSaving(true); + try { + const response = await profileApi.update(input); + setProfile(response.data); + updateCurrentUser({ id: user.id, email: user.email, name: response.data.name, role: response.data.role }); + showToast('Perfil salvo com sucesso.', 'success'); + } catch { + showToast('Não foi possível salvar o perfil. Verifique os links informados.', 'error'); + } finally { + setIsSaving(false); + } + }; + + return ( +
+
+
+

Sua conta

+

Meu perfil

+

{canPublishProfile ? 'Apresente sua experiência e conecte os alunos aos seus cursos.' : 'Mantenha seus dados pessoais atualizados.'}

+
+ {canPublishProfile && profile.isPublic && } +
+ +
+
+
+
+ {profile.avatarImageUrl ? :
} + {canPublishProfile && } +
+

{profile.name}

{user.email}

{canPublishProfile &&

A foto e a apresentação ficam visíveis no seu perfil público.

}
+
+ { void uploadAvatar(event.target.files?.[0]); event.currentTarget.value = ''; }} /> +
+ +
+
+ + {canPublishProfile && <> + +