feat: add public instructor profiles
This commit is contained in:
31
pages/InstructorProfile.tsx
Normal file
31
pages/InstructorProfile.tsx
Normal file
@@ -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<InstructorProfileData | null>(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 <main className="min-h-[65vh] pt-32 flex items-center justify-center"><Loader2 className="h-8 w-8 animate-spin text-orange-400" /></main>;
|
||||
if (state === 'missing' || !profile) return <main className="mx-auto flex min-h-[65vh] max-w-5xl items-center px-6 pt-24"><div><p className="text-sm font-semibold uppercase tracking-widest text-orange-400">Perfil indisponível</p><h1 className="mt-3 text-3xl font-bold">Este perfil não está público.</h1></div></main>;
|
||||
|
||||
return <main className="min-h-screen pb-20 pt-20"><section className="border-b border-white/10 bg-gradient-to-br from-orange-500/15 via-zinc-950 to-black"><div className="mx-auto grid max-w-6xl gap-7 px-6 py-12 sm:px-8 md:grid-cols-[auto_1fr] md:items-center md:py-16"><div className="h-32 w-32 overflow-hidden rounded-full border-4 border-zinc-900 bg-zinc-800 shadow-2xl shadow-black/50">{profile.avatarImageUrl ? <CourseCoverImage source={profile.avatarImageUrl} alt={`Foto de ${profile.name}`} className="h-full w-full object-cover" /> : <div className="flex h-full w-full items-center justify-center bg-orange-500/15"><UserRound className="h-14 w-14 text-orange-300" /></div>}</div><div><p className="text-xs font-bold uppercase tracking-[0.18em] text-orange-400">Instrutor</p><h1 className="mt-2 text-3xl font-bold tracking-tight text-white sm:text-5xl">{profile.name}</h1>{profile.headline && <p className="mt-3 text-lg text-gray-300">{profile.headline}</p>}<div className="mt-5 flex flex-wrap gap-2">{networks.map(({ key, label, icon: Icon }) => { const url = profile[key]; return url ? <a key={key} href={url} target="_blank" rel="noreferrer" className="inline-flex items-center gap-2 rounded-xl border border-white/15 bg-white/5 px-3.5 py-2 text-sm font-semibold text-white transition hover:border-orange-500/40 hover:bg-orange-500/10"><Icon className="h-4 w-4 text-orange-400" />{label}<ExternalLink className="h-3 w-3 text-gray-500" /></a> : null; })}</div></div></div></section><div className="mx-auto grid max-w-6xl gap-12 px-6 py-12 sm:px-8 lg:grid-cols-[minmax(0,0.7fr)_minmax(0,1.3fr)]"><aside><p className="text-xs font-bold uppercase tracking-[0.18em] text-orange-400">Sobre</p><h2 className="mt-2 text-2xl font-bold text-white">Conheça {profile.name.split(' ')[0]}</h2><p className="mt-5 whitespace-pre-line text-sm leading-7 text-gray-300">{profile.bio || 'Este instrutor ainda não adicionou uma apresentação.'}</p></aside><section><div className="mb-6 flex items-end justify-between gap-4"><div><p className="text-xs font-bold uppercase tracking-[0.18em] text-orange-400">Conteúdo</p><h2 className="mt-2 text-2xl font-bold text-white">Cursos publicados</h2></div><span className="rounded-full bg-white/5 px-3 py-1.5 text-xs font-semibold text-gray-300">{profile.courses.length} {profile.courses.length === 1 ? 'curso' : 'cursos'}</span></div>{profile.courses.length ? <div className="grid gap-7 sm:grid-cols-2">{profile.courses.map((course) => <CourseCard key={course.id} course={course} variant="landscape" onClick={onPlay} />)}</div> : <div className="rounded-2xl border border-dashed border-white/15 p-8 text-center text-sm text-gray-500">Nenhum curso publicado ainda.</div>}</section></div></main>;
|
||||
};
|
||||
139
pages/ProfilePage.tsx
Normal file
139
pages/ProfilePage.tsx
Normal file
@@ -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<HTMLInputElement>(null);
|
||||
const [profile, setProfile] = useState<Profile | null>(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 <main className="min-h-[65vh] pt-32 flex items-center justify-center"><Loader2 className="h-8 w-8 animate-spin text-orange-400" /></main>;
|
||||
}
|
||||
|
||||
const update = <K extends keyof Profile>(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 (
|
||||
<main className="mx-auto min-h-screen w-full max-w-5xl px-5 pb-20 pt-28 sm:px-8">
|
||||
<div className="mb-8 flex flex-col justify-between gap-5 sm:flex-row sm:items-end">
|
||||
<div>
|
||||
<p className="mb-2 text-xs font-bold uppercase tracking-[0.18em] text-orange-400">Sua conta</p>
|
||||
<h1 className="text-3xl font-bold tracking-tight text-white sm:text-4xl">Meu perfil</h1>
|
||||
<p className="mt-2 max-w-xl text-sm text-gray-400">{canPublishProfile ? 'Apresente sua experiência e conecte os alunos aos seus cursos.' : 'Mantenha seus dados pessoais atualizados.'}</p>
|
||||
</div>
|
||||
{canPublishProfile && profile.isPublic && <button onClick={() => navigate(`/instrutores/${user.id}`)} className="inline-flex items-center justify-center gap-2 rounded-xl border border-white/15 bg-white/5 px-4 py-3 text-sm font-semibold text-white transition hover:bg-white/10"><ExternalLink className="h-4 w-4" /> Ver perfil público</button>}
|
||||
</div>
|
||||
|
||||
<form onSubmit={save} className="overflow-hidden rounded-3xl border border-white/10 bg-zinc-950/80 shadow-2xl shadow-black/30">
|
||||
<section className="border-b border-white/10 bg-gradient-to-r from-orange-500/15 via-zinc-950 to-zinc-950 p-6 sm:p-8">
|
||||
<div className="flex flex-col gap-5 sm:flex-row sm:items-center">
|
||||
<div className="relative h-28 w-28 shrink-0 overflow-hidden rounded-full border-4 border-zinc-950 bg-zinc-900 shadow-xl">
|
||||
{profile.avatarImageUrl ? <CourseCoverImage source={profile.avatarImageUrl} alt={`Foto de ${profile.name}`} className="h-full w-full object-cover" /> : <div className="flex h-full w-full items-center justify-center bg-orange-500/15"><UserRound className="h-11 w-11 text-orange-300" /></div>}
|
||||
{canPublishProfile && <button type="button" disabled={isUploading} onClick={() => fileInput.current?.click()} className="absolute inset-x-0 bottom-0 flex h-9 items-center justify-center bg-black/70 text-xs font-semibold text-white transition hover:bg-black/90 disabled:opacity-60">{isUploading ? <Loader2 className="h-4 w-4 animate-spin" /> : <><Camera className="mr-1.5 h-3.5 w-3.5" /> Alterar</>}</button>}
|
||||
</div>
|
||||
<div><h2 className="text-xl font-bold text-white">{profile.name}</h2><p className="mt-1 text-sm text-gray-400">{user.email}</p>{canPublishProfile && <p className="mt-3 text-xs text-orange-200/80">A foto e a apresentação ficam visíveis no seu perfil público.</p>}</div>
|
||||
</div>
|
||||
<input ref={fileInput} type="file" accept="image/jpeg,image/png,image/webp" className="hidden" onChange={(event) => { void uploadAvatar(event.target.files?.[0]); event.currentTarget.value = ''; }} />
|
||||
</section>
|
||||
|
||||
<div className="grid gap-8 p-6 sm:p-8 lg:grid-cols-[1fr_0.8fr]">
|
||||
<section className="space-y-5">
|
||||
<label className="block"><span className="mb-2 block text-sm font-semibold text-white">Nome</span><input value={profile.name} onChange={(event) => update('name', event.target.value)} className={inputClass} maxLength={120} required /></label>
|
||||
{canPublishProfile && <>
|
||||
<label className="block"><span className="mb-2 block text-sm font-semibold text-white">Título profissional</span><input value={profile.headline} onChange={(event) => update('headline', event.target.value)} placeholder="Ex.: Especialista em crescimento e mídia paga" className={inputClass} maxLength={180} /></label>
|
||||
<label className="block"><span className="mb-2 block text-sm font-semibold text-white">Sobre você</span><textarea value={profile.bio} onChange={(event) => update('bio', event.target.value)} placeholder="Conte brevemente sua experiência e o que os alunos podem esperar aprender com você." className={`${inputClass} min-h-40 resize-y`} maxLength={3000} /></label>
|
||||
</>}
|
||||
</section>
|
||||
|
||||
{canPublishProfile && <section className="rounded-2xl border border-white/10 bg-black/30 p-5 sm:p-6"><div className="mb-5"><h2 className="font-bold text-white">Links e visibilidade</h2><p className="mt-1 text-xs leading-relaxed text-gray-500">Use links completos, começando com https://.</p></div><div className="space-y-4"><UrlField icon={<Globe2 />} label="Site" value={profile.websiteUrl} onChange={(value) => update('websiteUrl', value || null)} placeholder="https://seusite.com" /><UrlField icon={<Linkedin />} label="LinkedIn" value={profile.linkedinUrl} onChange={(value) => update('linkedinUrl', value || null)} placeholder="https://linkedin.com/in/seu-perfil" /><UrlField icon={<Instagram />} label="Instagram" value={profile.instagramUrl} onChange={(value) => update('instagramUrl', value || null)} placeholder="https://instagram.com/seu-perfil" /><UrlField icon={<Youtube />} label="YouTube" value={profile.youtubeUrl} onChange={(value) => update('youtubeUrl', value || null)} placeholder="https://youtube.com/@seu-canal" /></div><label className="mt-6 flex cursor-pointer items-start gap-3 rounded-xl border border-white/10 bg-white/[0.03] p-4"><input type="checkbox" checked={profile.isPublic} onChange={(event) => update('isPublic', event.target.checked)} className="mt-0.5 h-4 w-4 accent-orange-500" /><span><span className="block text-sm font-semibold text-white">Perfil público</span><span className="mt-1 block text-xs leading-relaxed text-gray-500">Permite que alunos vejam sua apresentação e seus cursos publicados.</span></span></label></section>}
|
||||
</div>
|
||||
<div className="flex justify-end border-t border-white/10 bg-black/20 p-5 sm:px-8"><button disabled={isSaving} className="inline-flex min-w-40 items-center justify-center gap-2 rounded-xl bg-orange-500 px-5 py-3 text-sm font-bold text-white shadow-lg shadow-orange-500/20 transition hover:bg-orange-600 disabled:opacity-60">{isSaving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />} Salvar perfil</button></div>
|
||||
</form>
|
||||
</main>
|
||||
);
|
||||
};
|
||||
|
||||
const UrlField: React.FC<{ icon: React.ReactNode; label: string; value: string | null; placeholder: string; onChange: (value: string) => void }> = ({ icon, label, value, placeholder, onChange }) => <label className="block"><span className="mb-2 flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-gray-400"><span className="text-orange-400 [&>svg]:h-4 [&>svg]:w-4">{icon}</span>{label}</span><input type="url" value={value || ''} onChange={(event) => onChange(event.target.value)} placeholder={placeholder} className={inputClass} /></label>;
|
||||
Reference in New Issue
Block a user