feat: manage homepage banner carousel
All checks were successful
CI / Validate frontend and API (push) Successful in 35s
CI / Build and publish Docker images (push) Successful in 20s

This commit is contained in:
Cauê Faleiros
2026-09-04 16:44:34 -03:00
parent 09d85a7123
commit 5b86d6ef0d
7 changed files with 150 additions and 81 deletions

View File

@@ -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<HeroProps> = ({ onPlay }) => {
const [featuredCourse, setFeaturedCourse] = useState<Course | null>(null);
useEffect(() => {
getCourses().then((courses) => setFeaturedCourse(courses[0] || null)).catch(() => setFeaturedCourse(null));
}, []);
if (!featuredCourse) return null;
return (
<div className="relative w-full min-h-[75vh] lg:min-h-[82vh] max-h-[800px] h-auto overflow-hidden group flex flex-col justify-end pt-28 pb-12 sm:pb-16 px-6 md:px-12">
{/* Background Image with Cinematic Gradient Overlay */}
<div className="absolute inset-0 transition-transform duration-[20s] ease-in-out group-hover:scale-105">
<CourseCoverImage
source={featuredCourse.thumbnail}
alt={featuredCourse.title}
className="w-full h-full object-cover object-center"
style={{
transform: `scale(${featuredCourse.coverImageZoom || 1})`,
objectPosition: `${featuredCourse.coverImagePositionX ?? 50}% ${featuredCourse.coverImagePositionY ?? 50}%`,
}}
/>
{/* Gradients for text readability and seamless transition */}
<div className="absolute inset-0 bg-gradient-to-t from-black via-black/60 to-black/75" />
<div className="absolute inset-0 bg-gradient-to-r from-black/95 via-black/65 to-transparent" />
</div>
{/* Content */}
<div className="relative z-10 max-w-[1440px] w-full mx-auto">
<div className="max-w-3xl lg:max-w-4xl animate-fade-in-up flex flex-col items-start">
{/* Metadata Badges / Tags */}
<div className="flex items-center gap-2.5 text-xs font-semibold uppercase tracking-wider text-white/90 mb-3.5">
<span className="bg-orange-500 text-white px-3.5 py-1 rounded-[980px] shadow-xl flex items-center gap-1">
<Sparkles className="w-3 h-3 fill-current" />
Destaque
</span>
<span className="text-gray-300">{featuredCourse.category}</span>
<span className="text-gray-500">•</span>
<span className="text-gray-300">{featuredCourse.duration}</span>
</div>
{/* Main Display Headline */}
<h1 className="font-display text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-extrabold text-white tracking-tight leading-tight drop-shadow-2xl mb-4">
{featuredCourse.title}
</h1>
{/* Subhead / Lead Description */}
<p className="text-sm sm:text-base md:text-lg font-normal text-gray-200 line-clamp-2 sm:line-clamp-3 max-w-2xl leading-relaxed opacity-90 mb-6 sm:mb-8">
{featuredCourse.description}
</p>
{/* Action Buttons with comfortable width, borders, and spacing */}
<div className="flex flex-wrap items-center gap-4">
{/* Primary CTA */}
<button
onClick={() => onPlay?.(featuredCourse)}
className="flex items-center justify-center gap-3 bg-white text-black px-8 sm:px-10 py-3.5 sm:py-4 rounded-[980px] text-sm sm:text-base font-bold hover:bg-gray-100 active:scale-95 transition-all duration-200 shadow-xl min-w-[200px]"
>
<Play className="w-4 h-4 sm:w-5 sm:h-5 fill-current" />
<span>Assistir Aula 1</span>
</button>
{/* Add to List */}
<button
className="flex items-center justify-center w-12 h-12 sm:w-14 sm:h-14 rounded-[980px] bg-white/10 hover:bg-white/20 border border-white/20 backdrop-blur-xl text-white transition-all duration-200 active:scale-95 group shadow-xl shrink-0"
title="Adicionar aos Favoritos"
>
<Plus className="w-5 h-5 group-hover:scale-110 transition-transform" />
</button>
</div>
</div>
</div>
</div>
);
const [banners, setBanners] = useState<PublicHomeBanner[]>([]);
const [courses, setCourses] = useState<Course[]>([]);
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 <div className="relative flex h-[75vh] max-h-[800px] min-h-[560px] w-full flex-col justify-end overflow-hidden px-6 pb-12 pt-28 md:px-12"><div className="absolute inset-0">{image && <CourseCoverImage source={image} alt={title} className="h-full w-full object-cover" style={{ transform: `scale(${banner?.coverImageZoom || course?.coverImageZoom || 1})`, objectPosition: `${banner?.coverImagePositionX ?? course?.coverImagePositionX ?? 50}% ${banner?.coverImagePositionY ?? course?.coverImagePositionY ?? 50}%` }} />}<div className="absolute inset-0 bg-gradient-to-t from-black via-black/60 to-black/75" /><div className="absolute inset-0 bg-gradient-to-r from-black/95 via-black/60 to-transparent" /></div><div className="relative z-10 mx-auto w-full max-w-[1440px]"><div className="max-w-3xl"><div className="mb-3 flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-white/90"><span className="flex items-center gap-1 rounded-full bg-orange-500 px-3 py-1"><Sparkles className="h-3 w-3" /> Destaque</span>{banner?.kind === 'course' && <span>{course?.category}</span>}</div><h1 className="font-display text-4xl font-extrabold leading-tight tracking-tight text-white md:text-6xl">{title}</h1>{description && <p className="mt-4 max-w-2xl text-base leading-relaxed text-gray-200 md:text-lg">{description}</p>}{canPlay && <button onClick={() => onPlay?.(course!)} className="mt-7 inline-flex items-center gap-3 rounded-full bg-white px-8 py-3.5 text-sm font-bold text-black"><Play className="h-5 w-5 fill-current" /> Assistir curso</button>}</div></div>{banners.length > 1 && <div className="absolute bottom-7 right-6 z-20 flex items-center gap-3 md:right-12"><button onClick={() => setIndex((index - 1 + banners.length) % banners.length)} className="rounded-full bg-black/50 p-2 text-white"><ChevronLeft className="h-5 w-5" /></button><div className="flex gap-1.5">{banners.map((item, itemIndex) => <button key={item.id} onClick={() => setIndex(itemIndex)} className={`h-2 rounded-full transition-all ${itemIndex === index ? 'w-6 bg-orange-500' : 'w-2 bg-white/50'}`} aria-label={`Banner ${itemIndex + 1}`} />)}</div><button onClick={() => setIndex((index + 1) % banners.length)} className="rounded-full bg-black/50 p-2 text-white"><ChevronRight className="h-5 w-5" /></button></div>}</div>;
};

View File

@@ -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' && <CoursesPanel courses={homeConfiguration?.courses || []} onManage={() => navigate('/gerenciar')} />}
{activeTab === 'trails' && <PathManager title="Trilhas" description="Sequências guiadas de cursos existentes." paths={paths.filter((path) => path.kind === 'trail')} onNew={() => openNewPath('trail')} onEdit={openEditPath} onDelete={deletePath} />}
{activeTab === 'formations' && <PathManager title="Formações" description="Currículos organizados a partir de cursos existentes." paths={paths.filter((path) => path.kind === 'formation')} onNew={() => openNewPath('formation')} onEdit={openEditPath} onDelete={deletePath} />}
{activeTab === 'home' && <HomePanel homeConfiguration={homeConfiguration} homeCourses={homeCourses} categories={categories} newCategory={newCategory} setNewCategory={setNewCategory} setHomeConfiguration={setHomeConfiguration} onCreateCategory={createCategory} onToggleCategory={toggleCategory} onMoveHomeCourse={moveHomeCourse} onSaveHome={saveHomeConfiguration} />}
{activeTab === 'home' && <><BannerManager courses={homeConfiguration?.courses || []} /><HomePanel homeConfiguration={homeConfiguration} homeCourses={homeCourses} categories={categories} newCategory={newCategory} setNewCategory={setNewCategory} setHomeConfiguration={setHomeConfiguration} onCreateCategory={createCategory} onToggleCategory={toggleCategory} onMoveHomeCourse={moveHomeCourse} onSaveHome={saveHomeConfiguration} /></>}
{activeTab === 'access' && <PeoplePanel users={filteredUsers} isLoading={isLoading} query={query} setQuery={setQuery} accessLink={accessLink} inviteEmail={inviteEmail} setInviteEmail={setInviteEmail} inviteRole={inviteRole} setInviteRole={setInviteRole} selectedUser={selectedUser} setSelectedUser={setSelectedUser} updatingUserId={updatingUserId} loadingDetailId={loadingDetailId} currentUserId={user?.id} onInvite={createInvite} onUpdateUser={updateUser} onShowDetail={showUserDetail} onResetPassword={resetPassword} onExport={exportUsers} />}
{isPathEditorOpen && <PathEditor input={pathInput} setInput={setPathInput} courses={homeConfiguration?.courses || []} selectedCourses={selectedPathCourses} editing={Boolean(editingPath)} isSaving={isSavingPath} isUploadingCover={isUploadingCover} coverInputRef={coverInputRef} onClose={closePathEditor} onSave={savePath} onUpload={uploadPathCover} onToggleCourse={togglePathCourse} onMoveCourse={movePathCourse} />}
</main>;
@@ -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 }) => <div className="rounded-2xl border border-white/10 bg-zinc-950/80 p-5"><div className="flex items-center gap-2 text-orange-400 mb-3">{icon}<span className="text-xs font-semibold uppercase tracking-wider">{label}</span></div><div className="text-3xl font-bold text-white">{value}</div></div>;
const DetailMetric: React.FC<{ label: string; value: string }> = ({ label, value }) => <div className="rounded-xl bg-white/[0.03] px-4 py-3"><div className="text-xs text-gray-500">{label}</div><div className="text-sm font-semibold text-white mt-1">{value}</div></div>;
const BannerManager: React.FC<{ courses: CourseOption[] }> = ({ courses }) => {
const { showToast } = useToast(); const [banners, setBanners] = useState<HomeBanner[]>([]); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const fileInput = useRef<HTMLInputElement>(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 <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 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>;

View File

@@ -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();

View File

@@ -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();

View File

@@ -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"

View File

@@ -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<void>(`/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) }); },
};

View File

@@ -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<PublicLearningPath[]> => {
return response.data;
};
export const getHomeBanners = async (): Promise<PublicHomeBanner[]> => {
const response = await apiRequest<{ data: PublicHomeBanner[] }>('/courses/home-banners');
return response.data;
};
export const getManagedCourses = async (): Promise<Course[]> => {
const response = await apiRequest<{ data: ApiCourse[] }>('/manage/courses');
return response.data.map(toCourse);