feat: add resilient cover previews and zoom
All checks were successful
CI / Validate frontend and API (push) Successful in 1m10s
CI / Build and publish Docker images (push) Successful in 50s

This commit is contained in:
Cauê Faleiros
2026-09-04 14:29:07 -03:00
parent 1d5eafbbd0
commit ad860074fd
10 changed files with 86 additions and 17 deletions

View File

@@ -44,6 +44,7 @@ export const CourseCard: React.FC<CourseCardProps> = ({
draggable={false} draggable={false}
onDragStart={(e) => e.preventDefault()} onDragStart={(e) => e.preventDefault()}
className="h-full w-full object-cover transition-opacity duration-300 opacity-90 group-hover:opacity-100 rounded-[8px] select-none pointer-events-none" className="h-full w-full object-cover transition-opacity duration-300 opacity-90 group-hover:opacity-100 rounded-[8px] select-none pointer-events-none"
style={{ transform: `scale(${course.coverImageZoom || 1})` }}
/> />
{/* Badges on Thumbnail */} {/* Badges on Thumbnail */}

View File

@@ -24,6 +24,7 @@ export const Hero: React.FC<HeroProps> = ({ onPlay }) => {
src={featuredCourse.thumbnail} src={featuredCourse.thumbnail}
alt={featuredCourse.title} alt={featuredCourse.title}
className="w-full h-full object-cover object-center" className="w-full h-full object-cover object-center"
style={{ transform: `scale(${featuredCourse.coverImageZoom || 1})` }}
/> />
{/* Gradients for text readability and seamless transition */} {/* 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-t from-black via-black/60 to-black/75" />

View File

@@ -136,6 +136,7 @@ export const SearchModal: React.FC<SearchModalProps> = ({ isOpen, onClose, onPla
src={course.thumbnail} src={course.thumbnail}
alt={course.title} alt={course.title}
className="w-full h-full object-cover opacity-80 group-hover:opacity-100 group-hover:scale-105 transition-all duration-300 rounded-[8px]" className="w-full h-full object-cover opacity-80 group-hover:opacity-100 group-hover:scale-105 transition-all duration-300 rounded-[8px]"
style={{ transform: `scale(${course.coverImageZoom || 1})` }}
/> />
<div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity bg-black/40"> <div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity bg-black/40">
<PlayCircle className="w-10 h-10 text-orange-500" /> <PlayCircle className="w-10 h-10 text-orange-500" />

View File

@@ -10,6 +10,8 @@ import {
MessageSquare, MessageSquare,
Send, Send,
Image as ImageIcon, Image as ImageIcon,
Minus,
PlusCircle,
Video, Video,
Loader2, Loader2,
FileText, FileText,
@@ -48,7 +50,11 @@ const CourseModal: React.FC<{
const [category, setCategory] = useState(''); const [category, setCategory] = useState('');
const [status, setStatus] = useState<Course['status']>('draft'); const [status, setStatus] = useState<Course['status']>('draft');
const [thumbnailPreview, setThumbnailPreview] = useState<string>(''); const [thumbnailPreview, setThumbnailPreview] = useState<string>('');
const [coverImageUrl, setCoverImageUrl] = useState<string>('');
const [coverImageZoom, setCoverImageZoom] = useState(1);
const [coverPreviewError, setCoverPreviewError] = useState(false);
const coverFileRef = useRef<HTMLInputElement>(null); const coverFileRef = useRef<HTMLInputElement>(null);
const localCoverPreviewRef = useRef<string | null>(null);
const [categories, setCategories] = useState<string[]>([]); const [categories, setCategories] = useState<string[]>([]);
const [coverConfigured, setCoverConfigured] = useState<boolean | null>(null); const [coverConfigured, setCoverConfigured] = useState<boolean | null>(null);
const [coverUploadMessage, setCoverUploadMessage] = useState(''); const [coverUploadMessage, setCoverUploadMessage] = useState('');
@@ -90,6 +96,8 @@ const CourseModal: React.FC<{
setCategory(initialData.category); setCategory(initialData.category);
setStatus(initialData.status || 'published'); setStatus(initialData.status || 'published');
setThumbnailPreview(initialData.thumbnail); setThumbnailPreview(initialData.thumbnail);
setCoverImageUrl(initialData.thumbnail);
setCoverImageZoom(initialData.coverImageZoom || 1);
setTips(initialData.tips || []); setTips(initialData.tips || []);
setAttachments(initialData.attachments || []); setAttachments(initialData.attachments || []);
setLessons(initialData.lessons || []); setLessons(initialData.lessons || []);
@@ -99,10 +107,17 @@ const CourseModal: React.FC<{
setCategory(''); setCategory('');
setStatus('draft'); setStatus('draft');
setThumbnailPreview(''); setThumbnailPreview('');
setCoverImageUrl('');
setCoverImageZoom(1);
setTips([]); setTips([]);
setAttachments([]); setAttachments([]);
setLessons([]); setLessons([]);
} }
if (localCoverPreviewRef.current) {
URL.revokeObjectURL(localCoverPreviewRef.current);
localCoverPreviewRef.current = null;
}
setCoverPreviewError(false);
setNewLessonMedia(null); setNewLessonMedia(null);
setBunnyUploadState('idle'); setBunnyUploadState('idle');
setBunnyUploadMessage(''); setBunnyUploadMessage('');
@@ -122,6 +137,10 @@ const CourseModal: React.FC<{
} }
}, [isOpen, initialData]); }, [isOpen, initialData]);
useEffect(() => () => {
if (localCoverPreviewRef.current) URL.revokeObjectURL(localCoverPreviewRef.current);
}, []);
const toBunnyMedia = (video: BunnyVideo): LessonMedia => ({ const toBunnyMedia = (video: BunnyVideo): LessonMedia => ({
provider: 'bunny', provider: 'bunny',
externalId: video.id, externalId: video.id,
@@ -175,7 +194,12 @@ const CourseModal: React.FC<{
setCoverUploadMessage(`Enviando ${file.name}...`); setCoverUploadMessage(`Enviando ${file.name}...`);
try { try {
const response = await instructorApi.uploadCover(file); const response = await instructorApi.uploadCover(file);
setThumbnailPreview(response.data.coverImageUrl); if (localCoverPreviewRef.current) URL.revokeObjectURL(localCoverPreviewRef.current);
const localPreviewUrl = URL.createObjectURL(file);
localCoverPreviewRef.current = localPreviewUrl;
setThumbnailPreview(localPreviewUrl);
setCoverImageUrl(response.data.coverImageUrl);
setCoverPreviewError(false);
setCoverUploadMessage('Imagem de capa enviada com sucesso.'); setCoverUploadMessage('Imagem de capa enviada com sucesso.');
} catch (error) { } catch (error) {
setCoverUploadMessage(error instanceof Error ? error.message : 'Não foi possível enviar a imagem de capa.'); setCoverUploadMessage(error instanceof Error ? error.message : 'Não foi possível enviar a imagem de capa.');
@@ -351,7 +375,8 @@ const CourseModal: React.FC<{
description, description,
category: category || categories[0], category: category || categories[0],
status, status,
thumbnail: thumbnailPreview || '/course-placeholder.svg', thumbnail: coverImageUrl || '/course-placeholder.svg',
coverImageZoom,
duration: durationStr, duration: durationStr,
instructor: initialData?.instructor || '', instructor: initialData?.instructor || '',
views: initialData?.views || 0, views: initialData?.views || 0,
@@ -411,9 +436,40 @@ const CourseModal: React.FC<{
> >
{thumbnailPreview ? ( {thumbnailPreview ? (
<> <>
<img src={thumbnailPreview} alt="Preview" className="w-full h-full object-cover opacity-70 group-hover:opacity-40 transition-opacity" /> {coverPreviewError ? (
<div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"> <div className="p-6 text-center">
<span className="bg-orange-500 px-4 py-2 rounded-xl text-xs font-bold shadow-lg">Alterar Capa</span> <ImageIcon className="mx-auto mb-2 h-9 w-9 text-amber-400" />
<p className="text-sm font-semibold text-white">Não foi possível carregar a imagem</p>
<p className="mt-1 text-xs text-gray-400">Tente enviá-la novamente. Se o problema continuar, verifique a configuração de entrega de imagens.</p>
</div>
) : (
<img
src={thumbnailPreview}
alt="Prévia da imagem de capa"
onError={() => setCoverPreviewError(true)}
className="h-full w-full object-cover opacity-80 transition-transform duration-200"
style={{ transform: `scale(${coverImageZoom})` }}
/>
)}
<div className="absolute inset-x-3 bottom-3 flex items-center justify-between gap-3">
<span className="rounded-lg bg-black/70 px-3 py-2 text-xs font-semibold text-white backdrop-blur-sm">Clique na imagem para alterar</span>
<div className="flex items-center rounded-lg border border-white/10 bg-black/70 p-1 backdrop-blur-sm">
<button
type="button"
aria-label="Reduzir zoom da imagem"
disabled={coverImageZoom <= 1}
onClick={(event) => { event.stopPropagation(); setCoverImageZoom((current) => Math.max(1, Number((current - 0.1).toFixed(1)))); }}
className="rounded-md p-1.5 text-white transition-colors hover:bg-white/10 disabled:cursor-not-allowed disabled:opacity-30"
><Minus className="h-4 w-4" /></button>
<span className="min-w-12 text-center text-[11px] font-bold text-white">{Math.round(coverImageZoom * 100)}%</span>
<button
type="button"
aria-label="Aumentar zoom da imagem"
disabled={coverImageZoom >= 2.5}
onClick={(event) => { event.stopPropagation(); setCoverImageZoom((current) => Math.min(2.5, Number((current + 0.1).toFixed(1)))); }}
className="rounded-md p-1.5 text-white transition-colors hover:bg-white/10 disabled:cursor-not-allowed disabled:opacity-30"
><PlusCircle className="h-4 w-4" /></button>
</div>
</div> </div>
</> </>
) : ( ) : (
@@ -929,6 +985,7 @@ export const ManageCourses: React.FC = () => {
src={course.thumbnail} src={course.thumbnail}
alt={course.title} alt={course.title}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500" className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
style={{ transform: `scale(${course.coverImageZoom || 1})` }}
/> />
<div className="absolute top-3 left-3 bg-black/70 backdrop-blur-md px-2.5 py-1 rounded-[980px] text-caption font-semibold uppercase tracking-caption text-white border border-white/10"> <div className="absolute top-3 left-3 bg-black/70 backdrop-blur-md px-2.5 py-1 rounded-[980px] text-caption font-semibold uppercase tracking-caption text-white border border-white/10">
{course.category} {course.category}

View File

@@ -28,7 +28,7 @@ export const MyLearning: React.FC<{ onPlay: (course: Course) => void }> = ({ onP
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-5"> <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-5">
{courses.map((course) => ( {courses.map((course) => (
<button key={course.id} onClick={() => onPlay(course)} className="group overflow-hidden rounded-2xl border border-white/10 bg-zinc-950/80 text-left hover:border-orange-500/50 transition-colors"> <button key={course.id} onClick={() => onPlay(course)} className="group overflow-hidden rounded-2xl border border-white/10 bg-zinc-950/80 text-left hover:border-orange-500/50 transition-colors">
<div className="aspect-video relative bg-zinc-900"><img src={course.thumbnail} alt="" className="h-full w-full object-cover opacity-75 group-hover:opacity-100 transition-opacity" /><div className="absolute inset-0 bg-gradient-to-t from-black/80 to-transparent" /><span className="absolute left-4 bottom-4 inline-flex items-center gap-2 rounded-full bg-orange-500 px-3 py-2 text-xs font-bold text-white"><PlayCircle className="w-4 h-4" /> Continuar</span></div> <div className="aspect-video relative overflow-hidden bg-zinc-900"><img src={course.thumbnail} alt="" className="h-full w-full object-cover opacity-75 transition-opacity group-hover:opacity-100" style={{ transform: `scale(${course.coverImageZoom || 1})` }} /><div className="absolute inset-0 bg-gradient-to-t from-black/80 to-transparent" /><span className="absolute left-4 bottom-4 inline-flex items-center gap-2 rounded-full bg-orange-500 px-3 py-2 text-xs font-bold text-white"><PlayCircle className="w-4 h-4" /> Continuar</span></div>
<div className="p-5"><p className="text-xs text-orange-400 font-semibold uppercase tracking-wider">{course.category}</p><h2 className="mt-2 font-bold text-white">{course.title}</h2><div className="mt-4 h-1.5 overflow-hidden rounded-full bg-white/10"><div className="h-full rounded-full bg-orange-500" style={{ width: `${course.progress || 0}%` }} /></div><p className="mt-2 text-xs text-gray-400">{course.progress || 0}% concluído</p></div> <div className="p-5"><p className="text-xs text-orange-400 font-semibold uppercase tracking-wider">{course.category}</p><h2 className="mt-2 font-bold text-white">{course.title}</h2><div className="mt-4 h-1.5 overflow-hidden rounded-full bg-white/10"><div className="h-full rounded-full bg-orange-500" style={{ width: `${course.progress || 0}%` }} /></div><p className="mt-2 text-xs text-gray-400">{course.progress || 0}% concluído</p></div>
</button> </button>
))} ))}

View File

@@ -0,0 +1,3 @@
alter table courses
add column cover_image_zoom real not null default 1
check (cover_image_zoom >= 1 and cover_image_zoom <= 2.5);

View File

@@ -21,6 +21,7 @@ export const courseSelect = (publicOnly = false, includeUnreadyMedia = false) =>
coalesce(c.cover_image_url, ( coalesce(c.cover_image_url, (
select value #>> '{}' from platform_settings where key = 'media.defaultCoverImageUrl' select value #>> '{}' from platform_settings where key = 'media.defaultCoverImageUrl'
)) as "coverImageUrl", )) as "coverImageUrl",
c.cover_image_zoom as "coverImageZoom",
c.status, c.status,
c.published_at as "publishedAt", c.published_at as "publishedAt",
jsonb_build_object( jsonb_build_object(

View File

@@ -45,6 +45,7 @@ const courseSchema = z.object({
description: z.string().max(10000).default(''), description: z.string().max(10000).default(''),
category: z.string().trim().min(1).max(120), category: z.string().trim().min(1).max(120),
coverImageUrl: coverImageUrlSchema.nullable().optional(), coverImageUrl: coverImageUrlSchema.nullable().optional(),
coverImageZoom: z.number().min(1).max(2.5).default(1),
status: z.enum(['draft', 'published']).default('draft'), status: z.enum(['draft', 'published']).default('draft'),
lessons: z.array(lessonSchema).min(1), lessons: z.array(lessonSchema).min(1),
assets: z.array(assetSchema).default([]), assets: z.array(assetSchema).default([]),
@@ -213,10 +214,10 @@ export const manageCourseRoutes: FastifyPluginAsync = async (app) => {
await client.query('begin'); await client.query('begin');
const slug = await uniqueSlug(client, input.slug, input.title); const slug = await uniqueSlug(client, input.slug, input.title);
const course = await client.query<{ id: string }>( const course = await client.query<{ id: string }>(
`insert into courses (slug, title, description, category, cover_image_url, status, instructor_id, published_at) `insert into courses (slug, title, description, category, cover_image_url, cover_image_zoom, status, instructor_id, published_at)
values ($1, $2, $3, $4, $5, $6::course_status, $7, case when $6::course_status = 'published'::course_status then now() else null end) values ($1, $2, $3, $4, $5, $6, $7::course_status, $8, case when $7::course_status = 'published'::course_status then now() else null end)
returning id`, returning id`,
[slug, input.title, input.description, input.category, input.coverImageUrl ?? null, input.status, request.user.id], [slug, input.title, input.description, input.category, input.coverImageUrl ?? null, input.coverImageZoom, input.status, request.user.id],
); );
await replaceCourseContents(client, course.rows[0].id, input); await replaceCourseContents(client, course.rows[0].id, input);
await client.query('commit'); await client.query('commit');
@@ -245,10 +246,10 @@ export const manageCourseRoutes: FastifyPluginAsync = async (app) => {
const slug = await uniqueSlug(client, input.slug, input.title, courseId); const slug = await uniqueSlug(client, input.slug, input.title, courseId);
await client.query( await client.query(
`update courses `update courses
set slug = $2, title = $3, description = $4, category = $5, cover_image_url = $6, set slug = $2, title = $3, description = $4, category = $5, cover_image_url = $6, cover_image_zoom = $7,
status = $7::course_status, published_at = case when $7::course_status = 'published'::course_status then coalesce(published_at, now()) else null end status = $8::course_status, published_at = case when $8::course_status = 'published'::course_status then coalesce(published_at, now()) else null end
where id = $1`, where id = $1`,
[courseId, slug, input.title, input.description, input.category, input.coverImageUrl ?? null, input.status], [courseId, slug, input.title, input.description, input.category, input.coverImageUrl ?? null, input.coverImageZoom, input.status],
); );
await replaceCourseContents(client, courseId, input); await replaceCourseContents(client, courseId, input);
await client.query('commit'); await client.query('commit');
@@ -280,8 +281,8 @@ export const manageCourseRoutes: FastifyPluginAsync = async (app) => {
const client = await pool.connect(); const client = await pool.connect();
try { try {
await client.query('begin'); await client.query('begin');
const original = await client.query<{ title: string; description: string; category: string; cover_image_url: string | null; instructor_id: string }>( const original = await client.query<{ title: string; description: string; category: string; cover_image_url: string | null; cover_image_zoom: number; instructor_id: string }>(
`select title, description, category, cover_image_url, instructor_id from courses where id = $1`, [courseId], `select title, description, category, cover_image_url, cover_image_zoom, instructor_id from courses where id = $1`, [courseId],
); );
if (!original.rows[0]) { if (!original.rows[0]) {
await client.query('rollback'); await client.query('rollback');
@@ -291,9 +292,9 @@ export const manageCourseRoutes: FastifyPluginAsync = async (app) => {
const title = `${source.title} (cópia)`; const title = `${source.title} (cópia)`;
const slug = await uniqueSlug(client, undefined, title); const slug = await uniqueSlug(client, undefined, title);
const copiedCourse = await client.query<{ id: string }>( const copiedCourse = await client.query<{ id: string }>(
`insert into courses (slug, title, description, category, cover_image_url, status, instructor_id) `insert into courses (slug, title, description, category, cover_image_url, cover_image_zoom, status, instructor_id)
values ($1, $2, $3, $4, $5, 'draft', $6) returning id`, values ($1, $2, $3, $4, $5, $6, 'draft', $7) returning id`,
[slug, title, source.description, source.category, source.cover_image_url, source.instructor_id], [slug, title, source.description, source.category, source.cover_image_url, source.cover_image_zoom, source.instructor_id],
); );
const newCourseId = copiedCourse.rows[0].id; const newCourseId = copiedCourse.rows[0].id;
await client.query( await client.query(

View File

@@ -17,6 +17,7 @@ type ApiCourse = {
description: string; description: string;
category: string; category: string;
coverImageUrl: string | null; coverImageUrl: string | null;
coverImageZoom: number;
status: 'draft' | 'published' | 'archived'; status: 'draft' | 'published' | 'archived';
progress?: number; progress?: number;
lastActivity?: string | null; lastActivity?: string | null;
@@ -67,6 +68,7 @@ const toCourse = (course: ApiCourse): Course => ({
description: course.description, description: course.description,
category: course.category, category: course.category,
thumbnail: course.coverImageUrl || '/course-placeholder.svg', thumbnail: course.coverImageUrl || '/course-placeholder.svg',
coverImageZoom: course.coverImageZoom || 1,
instructor: course.instructor.name, instructor: course.instructor.name,
status: course.status === 'draft' ? 'draft' : 'published', status: course.status === 'draft' ? 'draft' : 'published',
progress: course.progress, progress: course.progress,
@@ -124,6 +126,7 @@ const toCoursePayload = (course: Course) => ({
description: course.description, description: course.description,
category: course.category, category: course.category,
coverImageUrl: course.thumbnail && /^https?:\/\//.test(course.thumbnail) ? course.thumbnail : null, coverImageUrl: course.thumbnail && /^https?:\/\//.test(course.thumbnail) ? course.thumbnail : null,
coverImageZoom: course.coverImageZoom || 1,
status: course.status || 'published', status: course.status || 'published',
assets: (course.attachments || []).filter((attachment) => attachment.url && attachment.url !== '#').map(toAssetPayload), assets: (course.attachments || []).filter((attachment) => attachment.url && attachment.url !== '#').map(toAssetPayload),
lessons: course.lessons.map((lesson) => ({ lessons: course.lessons.map((lesson) => ({

View File

@@ -42,6 +42,7 @@ export interface Course {
title: string; title: string;
category: string; category: string;
thumbnail: string; thumbnail: string;
coverImageZoom?: number;
lessons: Lesson[]; lessons: Lesson[];
progress?: number; // 0 to 100 progress?: number; // 0 to 100
views?: number; views?: number;