From 5b86d6ef0d52cbdcc1b175e0bcb601dd81ca7624 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cau=C3=AA=20Faleiros?= Date: Fri, 4 Sep 2026 16:44:34 -0300 Subject: [PATCH] feat: manage homepage banner carousel --- components/Hero.tsx | 96 +++++--------------------- pages/SuperAdmin.tsx | 15 +++- server/migrations/011_home_banners.sql | 21 ++++++ server/src/routes/admin.ts | 49 +++++++++++++ server/src/routes/courses.ts | 13 ++++ services/api.ts | 16 +++++ services/db.ts | 21 ++++++ 7 files changed, 150 insertions(+), 81 deletions(-) create mode 100644 server/migrations/011_home_banners.sql diff --git a/components/Hero.tsx b/components/Hero.tsx index 5e2f333..dd1f049 100644 --- a/components/Hero.tsx +++ b/components/Hero.tsx @@ -1,86 +1,24 @@ import React, { useEffect, useState } from 'react'; -import { Play, Plus, Sparkles } from 'lucide-react'; +import { ChevronLeft, ChevronRight, Play, Sparkles } from 'lucide-react'; import { Course } from '../types'; -import { getCourses } from '../services/db'; +import { getCourses, getHomeBanners, PublicHomeBanner } from '../services/db'; import { CourseCoverImage } from './CourseCoverImage'; -interface HeroProps { - onPlay?: (course: Course) => void; -} +interface HeroProps { onPlay?: (course: Course) => void; } export const Hero: React.FC = ({ onPlay }) => { - const [featuredCourse, setFeaturedCourse] = useState(null); - - useEffect(() => { - getCourses().then((courses) => setFeaturedCourse(courses[0] || null)).catch(() => setFeaturedCourse(null)); - }, []); - - if (!featuredCourse) return null; - - return ( -
- {/* Background Image with Cinematic Gradient Overlay */} -
- - {/* Gradients for text readability and seamless transition */} -
-
-
- - {/* Content */} -
-
- - {/* Metadata Badges / Tags */} -
- - - Destaque - - {featuredCourse.category} - • - {featuredCourse.duration} -
- - {/* Main Display Headline */} -

- {featuredCourse.title} -

- - {/* Subhead / Lead Description */} -

- {featuredCourse.description} -

- - {/* Action Buttons with comfortable width, borders, and spacing */} -
- {/* Primary CTA */} - - - {/* Add to List */} - -
-
-
-
- ); + const [banners, setBanners] = useState([]); + const [courses, setCourses] = useState([]); + const [index, setIndex] = useState(0); + useEffect(() => { Promise.all([getHomeBanners(), getCourses()]).then(([loadedBanners, loadedCourses]) => { setBanners(loadedBanners); setCourses(loadedCourses); }).catch(() => setBanners([])); }, []); + useEffect(() => { if (banners.length < 2) return; const timer = window.setInterval(() => setIndex((current) => (current + 1) % banners.length), 7000); return () => window.clearInterval(timer); }, [banners.length]); + const fallback = courses[0]; + const banner = banners[index]; + const course = banner?.courseId ? courses.find((item) => item.id === banner.courseId) : fallback; + if (!banner && !fallback) return null; + const title = banner ? (banner.kind === 'course' ? banner.courseTitle || banner.title : banner.title) : fallback.title; + const description = banner ? (banner.kind === 'course' ? banner.courseDescription || banner.description : banner.description) : fallback.description; + const image = banner ? (banner.kind === 'course' ? banner.courseImageUrl : banner.imageUrl) : fallback.thumbnail; + const canPlay = Boolean(course && (banner?.kind !== 'custom' || banner.courseId)); + return
{image && }
Destaque{banner?.kind === 'course' && {course?.category}}

{title}

{description &&

{description}

}{canPlay && }
{banners.length > 1 &&
{banners.map((item, itemIndex) =>
}
; }; diff --git a/pages/SuperAdmin.tsx b/pages/SuperAdmin.tsx index 3e76ec3..2e3fd5e 100644 --- a/pages/SuperAdmin.tsx +++ b/pages/SuperAdmin.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useMemo, useRef, useState } from 'react'; import { ArrowDown, ArrowUp, Download, FolderKanban, Loader2, Pencil, Plus, RefreshCw, ShieldCheck, Trash2, Upload, UserCheck, UserX, Users, X } from 'lucide-react'; -import { adminApi, instructorApi, LearningPath, LearningPathInput, ManagedUser, ManagedUserDetail, PlatformAnalytics } from '../services/api'; +import { adminApi, instructorApi, HomeBanner, HomeBannerInput, LearningPath, LearningPathInput, ManagedUser, ManagedUserDetail, PlatformAnalytics } from '../services/api'; import { useAuth } from '../context/AuthContext'; import { useToast } from '../context/ToastContext'; import { useNavigate } from 'react-router-dom'; @@ -51,7 +51,7 @@ export const SuperAdmin: React.FC = () => { {activeTab === 'courses' && navigate('/gerenciar')} />} {activeTab === 'trails' && path.kind === 'trail')} onNew={() => openNewPath('trail')} onEdit={openEditPath} onDelete={deletePath} />} {activeTab === 'formations' && path.kind === 'formation')} onNew={() => openNewPath('formation')} onEdit={openEditPath} onDelete={deletePath} />} - {activeTab === 'home' && } + {activeTab === 'home' && <>} {activeTab === 'access' && } {isPathEditorOpen && } ; @@ -67,6 +67,17 @@ 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 BannerManager: React.FC<{ courses: CourseOption[] }> = ({ courses }) => { + const { showToast } = useToast(); const [banners, setBanners] = useState([]); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const fileInput = useRef(null); + useEffect(() => { adminApi.homeBanners().then((response) => setBanners(response.data)).catch(() => showToast('Não foi possível carregar os banners.', 'error')).finally(() => setLoading(false)); }, []); + const move = (index: number, direction: -1 | 1) => setBanners((current) => { const target = index + direction; if (target < 0 || target >= current.length) return current; const next = [...current]; [next[index], next[target]] = [next[target], next[index]]; return next; }); + const remove = (id: string) => setBanners((current) => current.filter((banner) => banner.id !== id)); + const addCourse = (courseId: string) => { if (!courseId || banners.some((banner) => banner.kind === 'course' && banner.courseId === courseId)) return; const course = courses.find((item) => item.id === courseId); if (!course) return; setBanners((current) => [...current, { id: crypto.randomUUID(), kind: 'course', courseId, imageUrl: null, title: '', description: '', position: current.length + 1, courseTitle: course.title, courseDescription: null, courseImageUrl: null }]); }; + const upload = async (file: File) => { if (!file.type.startsWith('image/')) { showToast('Selecione uma imagem válida.', 'error'); return; } try { const response = await instructorApi.uploadCover(file); setBanners((current) => [...current, { id: crypto.randomUUID(), kind: 'custom', courseId: null, imageUrl: response.data.coverImageUrl, title: '', description: '', position: current.length + 1, courseTitle: null, courseDescription: null, courseImageUrl: null }]); } catch { showToast('Não foi possível enviar o banner.', 'error'); } }; + const save = async () => { setSaving(true); try { const input: HomeBannerInput[] = banners.map(({ kind, courseId, imageUrl, title, description }) => ({ kind, courseId, imageUrl, title, description })); const response = await adminApi.updateHomeBanners(input); setBanners(response.data); showToast('Banners da página inicial salvos.', 'success'); } catch { showToast('Não foi possível salvar os banners.', 'error'); } finally { setSaving(false); } }; + return

Banners da página inicial

Envie imagens próprias ou use a capa de cursos publicados. Mais de um banner vira um carrossel.

{banners.map((banner, index) =>
{(banner.imageUrl || banner.courseImageUrl) && }

{banner.kind === 'course' ? banner.courseTitle : banner.title || 'Banner personalizado'}

{banner.kind === 'course' ? 'Capa do curso' : 'Imagem enviada'}

)}
{ const file = event.target.files?.[0]; if (file) void upload(file); event.currentTarget.value = ''; }} />
; +}; + const CoursesPanel: React.FC<{ courses: CourseOption[]; onManage: () => void }> = ({ courses, onManage }) =>

Cursos

Edite ou exclua cursos rapidamente no gerenciador.

{courses.length === 0 ?

Nenhum curso cadastrado.

: courses.map((course) =>
{course.title}{course.status === 'published' ? 'Publicado' : 'Rascunho'}
)}
; 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 }) =>

{title}

{description}

{paths.length === 0 ?
Nenhuma {title.toLowerCase()} criada ainda.
: paths.map((path) =>
{path.coverImageUrl ? : }

{path.title}

{path.courses.length} {path.courses.length === 1 ? 'curso' : 'cursos'} · {path.status === 'published' ? 'Publicada' : 'Rascunho'}

)}
; diff --git a/server/migrations/011_home_banners.sql b/server/migrations/011_home_banners.sql new file mode 100644 index 0000000..86fecda --- /dev/null +++ b/server/migrations/011_home_banners.sql @@ -0,0 +1,21 @@ +-- Homepage banners are independent editorial content. A banner can either +-- reference a course (and always use its current cover) or use an uploaded +-- image stored in Bunny Storage. +create table home_banners ( + id uuid primary key default gen_random_uuid(), + kind text not null check (kind in ('course', 'custom')), + course_id uuid references courses(id) on delete cascade, + image_url text, + title text not null default '', + description text not null default '', + position integer not null check (position > 0), + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + constraint home_banners_source_check check ( + (kind = 'course' and course_id is not null and image_url is null) or + (kind = 'custom' and course_id is null and image_url is not null) + ), + unique (position) +); + +create trigger home_banners_set_updated_at before update on home_banners for each row execute function set_updated_at(); diff --git a/server/src/routes/admin.ts b/server/src/routes/admin.ts index d04c1c8..50cdfd6 100644 --- a/server/src/routes/admin.ts +++ b/server/src/routes/admin.ts @@ -24,6 +24,29 @@ const homeConfigurationSchema = z.object({ courseOrder: z.array(z.string().uuid()).max(500), defaultCoverImageUrl: z.string().url().nullable(), }); +const homeBannersSchema = z.object({ + banners: z.array(z.object({ + kind: z.enum(['course', 'custom']), + courseId: z.string().uuid().nullable(), + imageUrl: z.string().url().nullable(), + title: z.string().trim().max(180).default(''), + description: z.string().trim().max(500).default(''), + }).superRefine((banner, context) => { + if (banner.kind === 'course' && !banner.courseId) context.addIssue({ code: z.ZodIssueCode.custom, message: 'Course banners need a course' }); + if (banner.kind === 'custom' && !banner.imageUrl) context.addIssue({ code: z.ZodIssueCode.custom, message: 'Custom banners need an image' }); + })).max(10), +}); + +const selectHomeBanners = async () => { + const result = await pool.query( + `select b.id, b.kind, b.course_id as "courseId", b.image_url as "imageUrl", b.title, b.description, b.position, + c.title as "courseTitle", c.description as "courseDescription", c.cover_image_url as "courseImageUrl" + from home_banners b + left join courses c on c.id = b.course_id + order by b.position`, + ); + return result.rows; +}; const learningPathParamsSchema = z.object({ pathId: z.string().uuid() }); const learningPathSchema = z.object({ title: z.string().trim().min(1).max(160), @@ -119,6 +142,32 @@ export const adminRoutes: FastifyPluginAsync = async (app) => { app.get('/paths', adminAccess, async () => ({ data: await getLearningPaths() })); + app.get('/home-banners', adminAccess, async () => ({ data: await selectHomeBanners() })); + + app.put('/home-banners', adminAccess, async (request) => { + const input = homeBannersSchema.parse(request.body); + const courseIds = input.banners.filter((banner) => banner.kind === 'course').map((banner) => banner.courseId!); + if (courseIds.length) { + const courses = await pool.query(`select id from courses where id = any($1::uuid[]) and status = 'published'`, [courseIds]); + if (courses.rowCount !== courseIds.length) return { error: 'Course banners must reference published courses' }; + } + const client = await pool.connect(); + try { + await client.query('begin'); + await client.query('delete from home_banners'); + for (const [index, banner] of input.banners.entries()) { + await client.query( + `insert into home_banners (kind, course_id, image_url, title, description, position) + values ($1, $2, $3, $4, $5, $6)`, + [banner.kind, banner.courseId, banner.imageUrl, banner.title, banner.description, index + 1], + ); + } + await client.query('commit'); + } catch (error) { await client.query('rollback'); throw error; } finally { client.release(); } + await recordAudit({ actorId: request.user.id, action: 'home.banners_updated', subjectType: 'platform', metadata: { count: input.banners.length }, ipAddress: request.ip }); + return { data: await selectHomeBanners() }; + }); + app.post('/paths', adminAccess, async (request, reply) => { const input = learningPathSchema.parse(request.body); const client = await pool.connect(); diff --git a/server/src/routes/courses.ts b/server/src/routes/courses.ts index 5cbacfc..2e9d23e 100644 --- a/server/src/routes/courses.ts +++ b/server/src/routes/courses.ts @@ -176,6 +176,19 @@ export const courseRoutes: FastifyPluginAsync = async (app) => { return { data: result.rows }; }); + app.get('/home-banners', async () => { + const result = await pool.query( + `select b.id, b.kind, b.course_id as "courseId", b.image_url as "imageUrl", b.title, b.description, b.position, + c.title as "courseTitle", c.description as "courseDescription", c.cover_image_url as "courseImageUrl", + c.cover_image_zoom as "coverImageZoom", c.cover_image_position_x as "coverImagePositionX", c.cover_image_position_y as "coverImagePositionY" + from home_banners b + left join courses c on c.id = b.course_id and c.status = 'published' + where b.kind = 'custom' or c.id is not null + order by b.position`, + ); + return { data: result.rows }; + }); + app.get('/me/learning', { preHandler: app.authenticate }, async (request) => { const result = await pool.query( `select base.*, coalesce(progress.progress, 0)::int as progress, progress."lastActivity" diff --git a/services/api.ts b/services/api.ts index 9ce3b86..d8799c9 100644 --- a/services/api.ts +++ b/services/api.ts @@ -59,6 +59,20 @@ export interface LearningPathInput { kind: 'trail' | 'formation'; courseIds: string[]; } +export interface HomeBannerInput { + kind: 'course' | 'custom'; + courseId: string | null; + imageUrl: string | null; + title: string; + description: string; +} +export interface HomeBanner extends HomeBannerInput { + id: string; + position: number; + courseTitle: string | null; + courseDescription: string | null; + courseImageUrl: string | null; +} interface Session { token: string; @@ -170,6 +184,8 @@ export const adminApi = { async createPath(input: LearningPathInput) { return apiRequest<{ data: LearningPath }>('/admin/paths', { method: 'POST', body: JSON.stringify(input) }); }, async updatePath(pathId: string, input: LearningPathInput) { return apiRequest<{ data: LearningPath }>(`/admin/paths/${pathId}`, { method: 'PATCH', body: JSON.stringify(input) }); }, async deletePath(pathId: string) { return apiRequest(`/admin/paths/${pathId}`, { method: 'DELETE' }); }, + 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 homeConfiguration() { return apiRequest<{ data: { featuredCourseId: string | null; courseOrder: string[]; defaultCoverImageUrl: string | null; courses: Array<{ id: string; title: string; status: string }> } }>('/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) }); }, }; diff --git a/services/db.ts b/services/db.ts index f30d20d..f0e5ceb 100644 --- a/services/db.ts +++ b/services/db.ts @@ -62,6 +62,22 @@ export type PublicLearningPath = { }>; }; +export type PublicHomeBanner = { + id: string; + kind: 'course' | 'custom'; + courseId: string | null; + imageUrl: string | null; + title: string; + description: string; + position: number; + courseTitle: string | null; + courseDescription: string | null; + courseImageUrl: string | null; + coverImageZoom: number | null; + coverImagePositionX: number | null; + coverImagePositionY: number | null; +}; + const toAttachmentType = (kind: ApiAsset['kind']): AttachmentType => { if (kind === 'document') return 'doc'; if (kind === 'archive') return 'zip'; @@ -196,6 +212,11 @@ export const getLearningPaths = async (): Promise => { return response.data; }; +export const getHomeBanners = async (): Promise => { + const response = await apiRequest<{ data: PublicHomeBanner[] }>('/courses/home-banners'); + return response.data; +}; + export const getManagedCourses = async (): Promise => { const response = await apiRequest<{ data: ApiCourse[] }>('/manage/courses'); return response.data.map(toCourse);