feat: group cms courses by trail
This commit is contained in:
@@ -4,9 +4,10 @@ import { adminApi, instructorApi, HomeBanner, HomeBannerInput, LearningPath, Lea
|
|||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
import { useToast } from '../context/ToastContext';
|
import { useToast } from '../context/ToastContext';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { CourseCoverImage } from '../components/CourseCoverImage';
|
||||||
|
|
||||||
const roleLabel: Record<ManagedUser['role'], string> = { admin: 'Superadmin', instructor: 'Instrutor', student: 'Aluno' };
|
const roleLabel: Record<ManagedUser['role'], string> = { admin: 'Superadmin', instructor: 'Instrutor', student: 'Aluno' };
|
||||||
type CourseOption = { id: string; title: string; status: string };
|
type CourseOption = { id: string; title: string; status: string; category: string; coverImageUrl: string | null };
|
||||||
type HomeConfiguration = { featuredCourseId: string | null; courseOrder: string[]; defaultCoverImageUrl: string | null; courses: CourseOption[] };
|
type HomeConfiguration = { featuredCourseId: string | null; courseOrder: string[]; defaultCoverImageUrl: string | null; courses: CourseOption[] };
|
||||||
const emptyPathInput = (): LearningPathInput => ({ title: '', description: '', coverImageUrl: null, coverImageZoom: 1, coverImagePositionX: 50, coverImagePositionY: 50, status: 'draft', courseIds: [] });
|
const emptyPathInput = (): LearningPathInput => ({ title: '', description: '', coverImageUrl: null, coverImageZoom: 1, coverImagePositionX: 50, coverImagePositionY: 50, status: 'draft', courseIds: [] });
|
||||||
const pathToInput = (path: LearningPath): LearningPathInput => ({ title: path.title, description: path.description, coverImageUrl: path.coverImageUrl, coverImageZoom: path.coverImageZoom, coverImagePositionX: path.coverImagePositionX, coverImagePositionY: path.coverImagePositionY, status: path.status === 'published' ? 'published' : 'draft', courseIds: path.courses.map((course) => course.id) });
|
const pathToInput = (path: LearningPath): LearningPathInput => ({ title: path.title, description: path.description, coverImageUrl: path.coverImageUrl, coverImageZoom: path.coverImageZoom, coverImagePositionX: path.coverImagePositionX, coverImagePositionY: path.coverImagePositionY, status: path.status === 'published' ? 'published' : 'draft', courseIds: path.courses.map((course) => course.id) });
|
||||||
@@ -83,7 +84,13 @@ const BannerManager: React.FC<{ courses: CourseOption[] }> = ({ courses }) => {
|
|||||||
return <section className="mb-6 rounded-2xl border border-white/10 bg-zinc-950/80 p-5"><div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"><div><h2 className="text-lg font-bold text-white">Banners da página inicial</h2><p className="mt-1 text-sm text-gray-500">Envie imagens próprias ou use a capa de cursos publicados. Mais de um banner vira um carrossel.</p></div><button onClick={save} disabled={saving || loading} className="rounded-xl bg-orange-500 px-4 py-2.5 text-sm font-semibold text-white disabled:opacity-50">{saving ? 'Salvando...' : 'Salvar banners'}</button></div><div className="mt-5 grid gap-3 md:grid-cols-2">{banners.map((banner, index) => <div key={banner.id} className="flex gap-3 rounded-xl border border-white/10 bg-black/20 p-3"><div className="h-16 w-28 shrink-0 overflow-hidden rounded-lg bg-zinc-900">{(banner.imageUrl || banner.courseImageUrl) && <img src={banner.imageUrl || banner.courseImageUrl || ''} alt="" className="h-full w-full object-cover" />}</div><div className="min-w-0 flex-1"><p className="truncate text-sm font-semibold text-white">{banner.kind === 'course' ? banner.courseTitle : banner.title || 'Banner personalizado'}</p><p className="mt-1 text-xs text-gray-500">{banner.kind === 'course' ? 'Capa do curso' : 'Imagem enviada'}</p><div className="mt-2 flex gap-1"><button onClick={() => move(index, -1)} disabled={index === 0} className="rounded bg-white/10 p-1 disabled:opacity-20"><ArrowUp className="h-3.5 w-3.5" /></button><button onClick={() => move(index, 1)} disabled={index === banners.length - 1} className="rounded bg-white/10 p-1 disabled:opacity-20"><ArrowDown className="h-3.5 w-3.5" /></button><button onClick={() => remove(banner.id)} className="rounded bg-red-500/15 p-1 text-red-400"><Trash2 className="h-3.5 w-3.5" /></button></div></div></div>)}</div><div className="mt-4 flex flex-col gap-2 sm:flex-row"><input ref={fileInput} type="file" accept="image/jpeg,image/png,image/webp" className="hidden" onChange={(event) => { const file = event.target.files?.[0]; if (file) void upload(file); event.currentTarget.value = ''; }} /><button onClick={() => fileInput.current?.click()} className="rounded-xl border border-dashed border-orange-500/50 px-4 py-2.5 text-sm font-semibold text-orange-300">Enviar imagem de banner</button><select defaultValue="" onChange={(event) => { addCourse(event.target.value); event.currentTarget.value = ''; }} className="min-w-0 flex-1 rounded-xl border border-white/10 bg-zinc-900 px-3 py-2.5 text-sm text-white"><option value="">Adicionar capa de um curso publicado</option>{courses.filter((course) => course.status === 'published').map((course) => <option key={course.id} value={course.id}>{course.title}</option>)}</select></div></section>;
|
return <section className="mb-6 rounded-2xl border border-white/10 bg-zinc-950/80 p-5"><div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"><div><h2 className="text-lg font-bold text-white">Banners da página inicial</h2><p className="mt-1 text-sm text-gray-500">Envie imagens próprias ou use a capa de cursos publicados. Mais de um banner vira um carrossel.</p></div><button onClick={save} disabled={saving || loading} className="rounded-xl bg-orange-500 px-4 py-2.5 text-sm font-semibold text-white disabled:opacity-50">{saving ? 'Salvando...' : 'Salvar banners'}</button></div><div className="mt-5 grid gap-3 md:grid-cols-2">{banners.map((banner, index) => <div key={banner.id} className="flex gap-3 rounded-xl border border-white/10 bg-black/20 p-3"><div className="h-16 w-28 shrink-0 overflow-hidden rounded-lg bg-zinc-900">{(banner.imageUrl || banner.courseImageUrl) && <img src={banner.imageUrl || banner.courseImageUrl || ''} alt="" className="h-full w-full object-cover" />}</div><div className="min-w-0 flex-1"><p className="truncate text-sm font-semibold text-white">{banner.kind === 'course' ? banner.courseTitle : banner.title || 'Banner personalizado'}</p><p className="mt-1 text-xs text-gray-500">{banner.kind === 'course' ? 'Capa do curso' : 'Imagem enviada'}</p><div className="mt-2 flex gap-1"><button onClick={() => move(index, -1)} disabled={index === 0} className="rounded bg-white/10 p-1 disabled:opacity-20"><ArrowUp className="h-3.5 w-3.5" /></button><button onClick={() => move(index, 1)} disabled={index === banners.length - 1} className="rounded bg-white/10 p-1 disabled:opacity-20"><ArrowDown className="h-3.5 w-3.5" /></button><button onClick={() => remove(banner.id)} className="rounded bg-red-500/15 p-1 text-red-400"><Trash2 className="h-3.5 w-3.5" /></button></div></div></div>)}</div><div className="mt-4 flex flex-col gap-2 sm:flex-row"><input ref={fileInput} type="file" accept="image/jpeg,image/png,image/webp" className="hidden" onChange={(event) => { const file = event.target.files?.[0]; if (file) void upload(file); event.currentTarget.value = ''; }} /><button onClick={() => fileInput.current?.click()} className="rounded-xl border border-dashed border-orange-500/50 px-4 py-2.5 text-sm font-semibold text-orange-300">Enviar imagem de banner</button><select defaultValue="" onChange={(event) => { addCourse(event.target.value); event.currentTarget.value = ''; }} className="min-w-0 flex-1 rounded-xl border border-white/10 bg-zinc-900 px-3 py-2.5 text-sm text-white"><option value="">Adicionar capa de um curso publicado</option>{courses.filter((course) => course.status === 'published').map((course) => <option key={course.id} value={course.id}>{course.title}</option>)}</select></div></section>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const CoursesPanel: React.FC<{ courses: CourseOption[]; onManage: () => void }> = ({ courses, onManage }) => <section className="rounded-2xl border border-white/10 bg-zinc-950/80 overflow-hidden"><div className="flex items-center justify-between gap-4 border-b border-white/10 p-5"><div><h2 className="text-lg font-bold text-white">Cursos</h2><p className="mt-1 text-sm text-gray-500">Edite ou exclua cursos rapidamente no gerenciador.</p></div><button onClick={onManage} className="rounded-xl bg-orange-500 px-4 py-2.5 text-sm font-semibold text-white">Gerenciar cursos</button></div><div className="divide-y divide-white/5">{courses.length === 0 ? <p className="p-8 text-sm text-gray-500">Nenhum curso cadastrado.</p> : courses.map((course) => <div key={course.id} className="flex items-center gap-4 p-4"><span className="min-w-0 flex-1 truncate font-medium text-white">{course.title}</span><span className={`rounded-full px-2.5 py-1 text-[10px] font-bold uppercase ${course.status === 'published' ? 'bg-emerald-500/15 text-emerald-400' : 'bg-amber-500/15 text-amber-300'}`}>{course.status === 'published' ? 'Publicado' : 'Rascunho'}</span><button onClick={onManage} className="rounded-lg border border-white/10 px-3 py-1.5 text-xs font-semibold text-gray-300 hover:bg-white/10">Editar</button></div>)}</div></section>;
|
const CoursesPanel: React.FC<{ courses: CourseOption[]; onManage: () => void }> = ({ courses, onManage }) => {
|
||||||
|
const byTrail: Record<string, CourseOption[]> = {};
|
||||||
|
courses.forEach((course) => {
|
||||||
|
(byTrail[course.category] ||= []).push(course);
|
||||||
|
});
|
||||||
|
return <section className="rounded-2xl border border-white/10 bg-zinc-950/80 overflow-hidden"><div className="flex items-center justify-between gap-4 border-b border-white/10 p-5"><div><h2 className="text-lg font-bold text-white">Cursos</h2><p className="mt-1 text-sm text-gray-500">Visualize cada curso pela sua capa e Trilha.</p></div><button onClick={onManage} className="rounded-xl bg-orange-500 px-4 py-2.5 text-sm font-semibold text-white">Gerenciar cursos</button></div>{courses.length === 0 ? <p className="p-8 text-sm text-gray-500">Nenhum curso cadastrado.</p> : <div className="divide-y divide-white/10">{Object.entries(byTrail).sort(([a], [b]) => a.localeCompare(b, 'pt-BR')).map(([trail, trailCourses]) => <section key={trail} className="p-5"><div className="mb-3 flex items-center gap-2"><h3 className="font-semibold text-orange-300">{trail}</h3><span className="text-xs text-gray-600">{trailCourses.length} {trailCourses.length === 1 ? 'curso' : 'cursos'}</span></div><div className="grid gap-3 xl:grid-cols-2">{trailCourses.map((course) => <div key={course.id} className="flex items-center gap-3 rounded-xl border border-white/10 bg-black/20 p-3"><div className="h-14 w-24 shrink-0 overflow-hidden rounded-lg bg-zinc-900">{course.coverImageUrl ? <CourseCoverImage source={course.coverImageUrl} alt="" className="h-full w-full object-cover" /> : <div className="h-full w-full bg-gradient-to-br from-zinc-800 to-zinc-950" />}</div><div className="min-w-0 flex-1"><p className="truncate font-semibold text-white">{course.title}</p><span className={`mt-1 inline-block rounded-full px-2 py-0.5 text-[10px] font-bold uppercase ${course.status === 'published' ? 'bg-emerald-500/15 text-emerald-400' : 'bg-amber-500/15 text-amber-300'}`}>{course.status === 'published' ? 'Publicado' : 'Rascunho'}</span></div><button onClick={onManage} className="rounded-lg border border-white/10 px-3 py-1.5 text-xs font-semibold text-gray-300 hover:bg-white/10">Editar</button></div>)}</div></section>)}</div>}</section>;
|
||||||
|
};
|
||||||
|
|
||||||
const PathManager: React.FC<{ title: string; description: string; paths: LearningPath[]; onNew: () => void; onEdit: (path: LearningPath) => void; onDelete: (path: LearningPath) => void }> = ({ title, description, paths, onNew, onEdit, onDelete }) => <section className="rounded-2xl border border-white/10 bg-zinc-950/80 overflow-hidden"><div className="flex items-center justify-between gap-4 border-b border-white/10 p-5"><div><h2 className="text-lg font-bold text-white">{title}</h2><p className="mt-1 text-sm text-gray-500">{description}</p></div><button onClick={onNew} className="inline-flex items-center gap-2 rounded-xl bg-orange-500 px-4 py-2.5 text-sm font-semibold text-white"><Plus className="h-4 w-4" /> Nova</button></div><div className="divide-y divide-white/5">{paths.length === 0 ? <div className="p-10 text-center text-sm text-gray-500">Nenhuma {title.toLowerCase()} criada ainda.</div> : paths.map((path) => <div key={path.id} className="flex items-center gap-4 p-4"><div className="h-12 w-20 shrink-0 overflow-hidden rounded-lg bg-zinc-900">{path.coverImageUrl ? <img src={path.coverImageUrl} alt="" className="h-full w-full object-cover" /> : <FolderKanban className="m-3 h-6 w-6 text-orange-400" />}</div><div className="min-w-0 flex-1"><p className="truncate font-semibold text-white">{path.title}</p><p className="mt-1 text-xs text-gray-500">{path.courses.length} {path.courses.length === 1 ? 'curso' : 'cursos'} · {path.status === 'published' ? 'Publicada' : 'Rascunho'}</p></div><button onClick={() => onEdit(path)} className="rounded-lg border border-white/10 p-2 text-gray-300 hover:bg-white/10" title="Editar"><Pencil className="h-4 w-4" /></button><button onClick={() => void onDelete(path)} className="rounded-lg border border-red-500/20 p-2 text-red-400 hover:bg-red-500/10" title="Excluir"><Trash2 className="h-4 w-4" /></button></div>)}</div></section>;
|
const PathManager: React.FC<{ title: string; description: string; paths: LearningPath[]; onNew: () => void; onEdit: (path: LearningPath) => void; onDelete: (path: LearningPath) => void }> = ({ title, description, paths, onNew, onEdit, onDelete }) => <section className="rounded-2xl border border-white/10 bg-zinc-950/80 overflow-hidden"><div className="flex items-center justify-between gap-4 border-b border-white/10 p-5"><div><h2 className="text-lg font-bold text-white">{title}</h2><p className="mt-1 text-sm text-gray-500">{description}</p></div><button onClick={onNew} className="inline-flex items-center gap-2 rounded-xl bg-orange-500 px-4 py-2.5 text-sm font-semibold text-white"><Plus className="h-4 w-4" /> Nova</button></div><div className="divide-y divide-white/5">{paths.length === 0 ? <div className="p-10 text-center text-sm text-gray-500">Nenhuma {title.toLowerCase()} criada ainda.</div> : paths.map((path) => <div key={path.id} className="flex items-center gap-4 p-4"><div className="h-12 w-20 shrink-0 overflow-hidden rounded-lg bg-zinc-900">{path.coverImageUrl ? <img src={path.coverImageUrl} alt="" className="h-full w-full object-cover" /> : <FolderKanban className="m-3 h-6 w-6 text-orange-400" />}</div><div className="min-w-0 flex-1"><p className="truncate font-semibold text-white">{path.title}</p><p className="mt-1 text-xs text-gray-500">{path.courses.length} {path.courses.length === 1 ? 'curso' : 'cursos'} · {path.status === 'published' ? 'Publicada' : 'Rascunho'}</p></div><button onClick={() => onEdit(path)} className="rounded-lg border border-white/10 p-2 text-gray-300 hover:bg-white/10" title="Editar"><Pencil className="h-4 w-4" /></button><button onClick={() => void onDelete(path)} className="rounded-lg border border-red-500/20 p-2 text-red-400 hover:bg-red-500/10" title="Excluir"><Trash2 className="h-4 w-4" /></button></div>)}</div></section>;
|
||||||
|
|
||||||
|
|||||||
@@ -266,7 +266,11 @@ export const adminRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
app.get('/home-configuration', adminAccess, async () => {
|
app.get('/home-configuration', adminAccess, async () => {
|
||||||
const [settings, courses] = await Promise.all([
|
const [settings, courses] = await Promise.all([
|
||||||
pool.query<{ key: string; value: unknown }>(`select key, value from platform_settings where key in ('home.featuredCourseId', 'home.courseOrder', 'media.defaultCoverImageUrl')`),
|
pool.query<{ key: string; value: unknown }>(`select key, value from platform_settings where key in ('home.featuredCourseId', 'home.courseOrder', 'media.defaultCoverImageUrl')`),
|
||||||
pool.query<{ id: string; title: string; status: string }>(`select id, title, status from courses where status <> 'archived' order by title`),
|
pool.query<{ id: string; title: string; status: string; category: string; coverImageUrl: string | null }>(
|
||||||
|
`select id, title, status, category,
|
||||||
|
coalesce(cover_image_url, (select value #>> '{}' from platform_settings where key = 'media.defaultCoverImageUrl')) as "coverImageUrl"
|
||||||
|
from courses where status <> 'archived' order by category, title`,
|
||||||
|
),
|
||||||
]);
|
]);
|
||||||
const values = new Map(settings.rows.map((row) => [row.key, row.value]));
|
const values = new Map(settings.rows.map((row) => [row.key, row.value]));
|
||||||
return { data: {
|
return { data: {
|
||||||
|
|||||||
@@ -184,7 +184,7 @@ export const adminApi = {
|
|||||||
async deletePath(pathId: string) { return apiRequest<void>(`/admin/paths/${pathId}`, { method: 'DELETE' }); },
|
async deletePath(pathId: string) { return apiRequest<void>(`/admin/paths/${pathId}`, { method: 'DELETE' }); },
|
||||||
async homeBanners() { return apiRequest<{ data: HomeBanner[] }>('/admin/home-banners'); },
|
async homeBanners() { return apiRequest<{ data: HomeBanner[] }>('/admin/home-banners'); },
|
||||||
async updateHomeBanners(banners: HomeBannerInput[]) { return apiRequest<{ data: HomeBanner[] }>('/admin/home-banners', { method: 'PUT', body: JSON.stringify({ banners }) }); },
|
async updateHomeBanners(banners: HomeBannerInput[]) { return apiRequest<{ data: HomeBanner[] }>('/admin/home-banners', { method: 'PUT', body: JSON.stringify({ banners }) }); },
|
||||||
async homeConfiguration() { return apiRequest<{ data: { featuredCourseId: string | null; courseOrder: string[]; defaultCoverImageUrl: string | null; courses: Array<{ id: string; title: string; status: string }> } }>('/admin/home-configuration'); },
|
async homeConfiguration() { return apiRequest<{ data: { featuredCourseId: string | null; courseOrder: string[]; defaultCoverImageUrl: string | null; courses: Array<{ id: string; title: string; status: string; category: string; coverImageUrl: string | null }> } }>('/admin/home-configuration'); },
|
||||||
async updateHomeConfiguration(input: { featuredCourseId: string | null; courseOrder: string[]; defaultCoverImageUrl: string | null }) { return apiRequest<{ data: typeof input }>('/admin/home-configuration', { method: 'PUT', body: JSON.stringify(input) }); },
|
async updateHomeConfiguration(input: { featuredCourseId: string | null; courseOrder: string[]; defaultCoverImageUrl: string | null }) { return apiRequest<{ data: typeof input }>('/admin/home-configuration', { method: 'PUT', body: JSON.stringify(input) }); },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user