101 lines
12 KiB
TypeScript
101 lines
12 KiB
TypeScript
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<HTMLInputElement>(null);
|
|
const [trail, setTrail] = useState<LearningPath | null>(null);
|
|
const [courses, setCourses] = useState<CourseOption[]>([]);
|
|
const [input, setInput] = useState<LearningPathInput>(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 <main className="flex min-h-[65vh] items-center justify-center pt-24"><Loader2 className="h-8 w-8 animate-spin text-orange-400" /></main>;
|
|
|
|
return <main className="mx-auto min-h-screen max-w-6xl px-5 pb-20 pt-28 sm:px-8"><div className="mb-6 flex flex-wrap items-center justify-between gap-3"><button onClick={() => navigate('/admin')} className="inline-flex items-center gap-2 text-sm font-semibold text-gray-400 transition hover:text-orange-300"><ArrowLeft className="h-4 w-4" /> Voltar para trilhas</button>{trail?.status === 'published' && <button type="button" onClick={() => navigate(`/trilhas/${trail.id}`)} className="inline-flex items-center gap-2 rounded-xl border border-white/10 px-3 py-2 text-sm font-semibold text-gray-300 transition hover:bg-white/10 hover:text-white"><ExternalLink className="h-4 w-4 text-orange-400" /> Ver página pública</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-6 md:flex-row"><button type="button" onClick={() => coverInput.current?.click()} className="group relative aspect-video w-full shrink-0 overflow-hidden rounded-2xl border border-dashed border-orange-500/40 bg-black/30 md:w-64" disabled={isUploading}>{input.coverImageUrl ? <CourseCoverImage source={input.coverImageUrl} alt="Capa da trilha" className="h-full w-full object-cover transition group-hover:scale-105" /> : <div className="flex h-full flex-col items-center justify-center gap-2 text-sm text-gray-500"><Upload className="h-6 w-6 text-orange-400" /> Enviar capa</div>}<span className="absolute inset-x-0 bottom-0 bg-black/70 px-3 py-2 text-xs font-semibold text-white">{isUploading ? 'Enviando...' : 'Alterar capa'}</span></button><input ref={coverInput} type="file" accept="image/jpeg,image/png,image/webp" className="hidden" onChange={(event) => { void uploadCover(event.target.files?.[0]); event.currentTarget.value = ''; }} /><div className="min-w-0 flex-1"><p className="text-xs font-bold uppercase tracking-[0.18em] text-orange-400">{isNew ? 'Nova trilha' : 'Trilha de cursos'}</p><label className="mt-3 block"><span className="sr-only">Nome da trilha</span><input value={input.title} onChange={(event) => setInput((current) => ({ ...current, title: event.target.value }))} placeholder="Nome da trilha" maxLength={160} required className="w-full border-0 bg-transparent p-0 text-3xl font-bold tracking-tight text-white outline-none placeholder:text-gray-600 sm:text-4xl" /></label><textarea value={input.description} onChange={(event) => setInput((current) => ({ ...current, description: event.target.value }))} placeholder="Explique o objetivo desta trilha e para quem ela foi criada." maxLength={2000} rows={3} className="mt-4 w-full resize-none rounded-xl border border-white/10 bg-black/25 px-4 py-3 text-sm leading-6 text-gray-300 outline-none transition focus:border-orange-500/60" /></div></div></section><section className="p-6 sm:p-8"><div className="flex flex-col justify-between gap-4 border-b border-white/10 pb-5 sm:flex-row sm:items-end"><div><h2 className="text-xl font-bold text-white">Cursos da trilha</h2><p className="mt-1 text-sm text-gray-500">A ordem abaixo é a sequência que os alunos verão.</p></div><div className="flex items-center gap-3"><select value={input.status} onChange={(event) => setInput((current) => ({ ...current, status: event.target.value as LearningPathInput['status'] }))} className="rounded-xl border border-white/10 bg-zinc-900 px-3 py-2.5 text-sm text-white"><option value="draft">Não publicado</option><option value="published">Publicado</option></select>{!isNew && <button type="button" onClick={deleteTrail} className="rounded-xl border border-red-500/30 px-3 py-2.5 text-sm font-semibold text-red-400 transition hover:bg-red-500/10"><Trash2 className="h-4 w-4" /></button>}</div></div><div className="mt-5 flex gap-2"><select defaultValue="" onChange={(event) => { if (event.target.value) { setInput((current) => ({ ...current, courseIds: [...current.courseIds, event.target.value] })); event.target.value = ''; } }} className="min-w-0 flex-1 rounded-xl border border-white/10 bg-zinc-900 px-4 py-3 text-sm text-white"><option value="">Adicionar curso existente</option>{availableCourses.map((course) => <option key={course.id} value={course.id}>{course.title}</option>)}</select></div><div className="mt-5 space-y-3">{selectedCourses.length === 0 ? <div className="rounded-2xl border border-dashed border-white/15 px-5 py-12 text-center text-sm text-gray-500">Adicione o primeiro curso para criar esta trilha.</div> : selectedCourses.map((course, index) => <article key={course.id} className="flex items-center gap-3 rounded-2xl border border-white/10 bg-black/20 p-3 sm:gap-4"><span className="w-6 shrink-0 text-center text-xs font-bold text-orange-300">{index + 1}</span><div className="h-14 w-24 shrink-0 overflow-hidden rounded-xl bg-zinc-900">{course.coverImageUrl ? <CourseCoverImage source={course.coverImageUrl} alt="" className="h-full w-full object-cover" /> : <div className="flex h-full items-center justify-center"><BookOpen className="h-5 w-5 text-orange-400" /></div>}</div><div className="min-w-0 flex-1"><p className="truncate font-semibold text-white">{course.title}</p><p className="mt-1 text-xs text-gray-500">{course.status === 'published' ? 'Publicado' : 'Não publicado'} · {course.category}</p></div><div className="flex items-center gap-1"><button type="button" onClick={() => moveCourse(course.id, -1)} disabled={index === 0} className="rounded-lg p-2 text-gray-400 transition hover:bg-white/10 hover:text-white disabled:opacity-25" title="Mover para cima"><ArrowUp className="h-4 w-4" /></button><button type="button" onClick={() => moveCourse(course.id, 1)} disabled={index === selectedCourses.length - 1} className="rounded-lg p-2 text-gray-400 transition hover:bg-white/10 hover:text-white disabled:opacity-25" title="Mover para baixo"><ArrowDown className="h-4 w-4" /></button><button type="button" onClick={() => navigate(`/gerenciar?editar=${course.id}`)} className="rounded-lg p-2 text-gray-400 transition hover:bg-white/10 hover:text-orange-300" title="Editar curso"><Pencil className="h-4 w-4" /></button><button type="button" onClick={() => removeCourse(course.id)} className="rounded-lg p-2 text-gray-400 transition hover:bg-white/10 hover:text-white" title="Remover da trilha"><X className="h-4 w-4" /></button><button type="button" onClick={() => void archiveCourse(course)} className="rounded-lg p-2 text-gray-500 transition hover:bg-red-500/10 hover:text-red-400" title="Arquivar curso"><Trash2 className="h-4 w-4" /></button></div></article>)}</div></section><footer 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 trilha</button></footer></form></main>;
|
|
};
|