From ad860074fdb1ea435c414a219b12794672824517 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cau=C3=AA=20Faleiros?= Date: Fri, 4 Sep 2026 14:29:07 -0300 Subject: [PATCH] feat: add resilient cover previews and zoom --- components/CourseCard.tsx | 1 + components/Hero.tsx | 1 + components/SearchModal.tsx | 1 + pages/ManageCourses.tsx | 67 +++++++++++++++++++-- pages/MyLearning.tsx | 2 +- server/migrations/007_course_cover_zoom.sql | 3 + server/src/routes/courses.ts | 1 + server/src/routes/manage-courses.ts | 23 +++---- services/db.ts | 3 + types.ts | 1 + 10 files changed, 86 insertions(+), 17 deletions(-) create mode 100644 server/migrations/007_course_cover_zoom.sql diff --git a/components/CourseCard.tsx b/components/CourseCard.tsx index bd3c39f..3732eee 100644 --- a/components/CourseCard.tsx +++ b/components/CourseCard.tsx @@ -44,6 +44,7 @@ export const CourseCard: React.FC = ({ draggable={false} 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" + style={{ transform: `scale(${course.coverImageZoom || 1})` }} /> {/* Badges on Thumbnail */} diff --git a/components/Hero.tsx b/components/Hero.tsx index 029ff89..2231781 100644 --- a/components/Hero.tsx +++ b/components/Hero.tsx @@ -24,6 +24,7 @@ export const Hero: React.FC = ({ onPlay }) => { src={featuredCourse.thumbnail} alt={featuredCourse.title} className="w-full h-full object-cover object-center" + style={{ transform: `scale(${featuredCourse.coverImageZoom || 1})` }} /> {/* Gradients for text readability and seamless transition */}
diff --git a/components/SearchModal.tsx b/components/SearchModal.tsx index 403f39d..c9687c8 100644 --- a/components/SearchModal.tsx +++ b/components/SearchModal.tsx @@ -136,6 +136,7 @@ export const SearchModal: React.FC = ({ isOpen, onClose, onPla src={course.thumbnail} 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]" + style={{ transform: `scale(${course.coverImageZoom || 1})` }} />
diff --git a/pages/ManageCourses.tsx b/pages/ManageCourses.tsx index 0a2eba6..c894402 100644 --- a/pages/ManageCourses.tsx +++ b/pages/ManageCourses.tsx @@ -10,6 +10,8 @@ import { MessageSquare, Send, Image as ImageIcon, + Minus, + PlusCircle, Video, Loader2, FileText, @@ -48,7 +50,11 @@ const CourseModal: React.FC<{ const [category, setCategory] = useState(''); const [status, setStatus] = useState('draft'); const [thumbnailPreview, setThumbnailPreview] = useState(''); + const [coverImageUrl, setCoverImageUrl] = useState(''); + const [coverImageZoom, setCoverImageZoom] = useState(1); + const [coverPreviewError, setCoverPreviewError] = useState(false); const coverFileRef = useRef(null); + const localCoverPreviewRef = useRef(null); const [categories, setCategories] = useState([]); const [coverConfigured, setCoverConfigured] = useState(null); const [coverUploadMessage, setCoverUploadMessage] = useState(''); @@ -90,6 +96,8 @@ const CourseModal: React.FC<{ setCategory(initialData.category); setStatus(initialData.status || 'published'); setThumbnailPreview(initialData.thumbnail); + setCoverImageUrl(initialData.thumbnail); + setCoverImageZoom(initialData.coverImageZoom || 1); setTips(initialData.tips || []); setAttachments(initialData.attachments || []); setLessons(initialData.lessons || []); @@ -99,10 +107,17 @@ const CourseModal: React.FC<{ setCategory(''); setStatus('draft'); setThumbnailPreview(''); + setCoverImageUrl(''); + setCoverImageZoom(1); setTips([]); setAttachments([]); setLessons([]); } + if (localCoverPreviewRef.current) { + URL.revokeObjectURL(localCoverPreviewRef.current); + localCoverPreviewRef.current = null; + } + setCoverPreviewError(false); setNewLessonMedia(null); setBunnyUploadState('idle'); setBunnyUploadMessage(''); @@ -122,6 +137,10 @@ const CourseModal: React.FC<{ } }, [isOpen, initialData]); + useEffect(() => () => { + if (localCoverPreviewRef.current) URL.revokeObjectURL(localCoverPreviewRef.current); + }, []); + const toBunnyMedia = (video: BunnyVideo): LessonMedia => ({ provider: 'bunny', externalId: video.id, @@ -175,7 +194,12 @@ const CourseModal: React.FC<{ setCoverUploadMessage(`Enviando ${file.name}...`); try { 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.'); } catch (error) { 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, category: category || categories[0], status, - thumbnail: thumbnailPreview || '/course-placeholder.svg', + thumbnail: coverImageUrl || '/course-placeholder.svg', + coverImageZoom, duration: durationStr, instructor: initialData?.instructor || '', views: initialData?.views || 0, @@ -411,9 +436,40 @@ const CourseModal: React.FC<{ > {thumbnailPreview ? ( <> - Preview -
- Alterar Capa + {coverPreviewError ? ( +
+ +

Não foi possível carregar a imagem

+

Tente enviá-la novamente. Se o problema continuar, verifique a configuração de entrega de imagens.

+
+ ) : ( + Prévia da imagem de capa setCoverPreviewError(true)} + className="h-full w-full object-cover opacity-80 transition-transform duration-200" + style={{ transform: `scale(${coverImageZoom})` }} + /> + )} +
+ Clique na imagem para alterar +
+ + {Math.round(coverImageZoom * 100)}% + +
) : ( @@ -929,6 +985,7 @@ export const ManageCourses: React.FC = () => { src={course.thumbnail} alt={course.title} className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500" + style={{ transform: `scale(${course.coverImageZoom || 1})` }} />
{course.category} diff --git a/pages/MyLearning.tsx b/pages/MyLearning.tsx index f3a8df6..19ca0d7 100644 --- a/pages/MyLearning.tsx +++ b/pages/MyLearning.tsx @@ -28,7 +28,7 @@ export const MyLearning: React.FC<{ onPlay: (course: Course) => void }> = ({ onP
{courses.map((course) => ( ))} diff --git a/server/migrations/007_course_cover_zoom.sql b/server/migrations/007_course_cover_zoom.sql new file mode 100644 index 0000000..2af1bbe --- /dev/null +++ b/server/migrations/007_course_cover_zoom.sql @@ -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); diff --git a/server/src/routes/courses.ts b/server/src/routes/courses.ts index db8785a..725ba34 100644 --- a/server/src/routes/courses.ts +++ b/server/src/routes/courses.ts @@ -21,6 +21,7 @@ export const courseSelect = (publicOnly = false, includeUnreadyMedia = false) => coalesce(c.cover_image_url, ( select value #>> '{}' from platform_settings where key = 'media.defaultCoverImageUrl' )) as "coverImageUrl", + c.cover_image_zoom as "coverImageZoom", c.status, c.published_at as "publishedAt", jsonb_build_object( diff --git a/server/src/routes/manage-courses.ts b/server/src/routes/manage-courses.ts index 11ee4c9..454b982 100644 --- a/server/src/routes/manage-courses.ts +++ b/server/src/routes/manage-courses.ts @@ -45,6 +45,7 @@ const courseSchema = z.object({ description: z.string().max(10000).default(''), category: z.string().trim().min(1).max(120), coverImageUrl: coverImageUrlSchema.nullable().optional(), + coverImageZoom: z.number().min(1).max(2.5).default(1), status: z.enum(['draft', 'published']).default('draft'), lessons: z.array(lessonSchema).min(1), assets: z.array(assetSchema).default([]), @@ -213,10 +214,10 @@ export const manageCourseRoutes: FastifyPluginAsync = async (app) => { await client.query('begin'); const slug = await uniqueSlug(client, input.slug, input.title); const course = await client.query<{ id: string }>( - `insert into courses (slug, title, description, category, cover_image_url, 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) + `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, $7::course_status, $8, case when $7::course_status = 'published'::course_status then now() else null end) 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 client.query('commit'); @@ -245,10 +246,10 @@ export const manageCourseRoutes: FastifyPluginAsync = async (app) => { const slug = await uniqueSlug(client, input.slug, input.title, courseId); await client.query( `update courses - set slug = $2, title = $3, description = $4, category = $5, cover_image_url = $6, - status = $7::course_status, published_at = case when $7::course_status = 'published'::course_status then coalesce(published_at, now()) else null end + set slug = $2, title = $3, description = $4, category = $5, cover_image_url = $6, cover_image_zoom = $7, + 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`, - [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 client.query('commit'); @@ -280,8 +281,8 @@ export const manageCourseRoutes: FastifyPluginAsync = async (app) => { const client = await pool.connect(); try { await client.query('begin'); - const original = await client.query<{ title: string; description: string; category: string; cover_image_url: string | null; instructor_id: string }>( - `select title, description, category, cover_image_url, instructor_id from courses where id = $1`, [courseId], + 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, cover_image_zoom, instructor_id from courses where id = $1`, [courseId], ); if (!original.rows[0]) { await client.query('rollback'); @@ -291,9 +292,9 @@ export const manageCourseRoutes: FastifyPluginAsync = async (app) => { const title = `${source.title} (cópia)`; const slug = await uniqueSlug(client, undefined, title); const copiedCourse = await client.query<{ id: string }>( - `insert into courses (slug, title, description, category, cover_image_url, status, instructor_id) - values ($1, $2, $3, $4, $5, 'draft', $6) returning id`, - [slug, title, source.description, source.category, source.cover_image_url, source.instructor_id], + `insert into courses (slug, title, description, category, cover_image_url, cover_image_zoom, status, instructor_id) + values ($1, $2, $3, $4, $5, $6, 'draft', $7) returning id`, + [slug, title, source.description, source.category, source.cover_image_url, source.cover_image_zoom, source.instructor_id], ); const newCourseId = copiedCourse.rows[0].id; await client.query( diff --git a/services/db.ts b/services/db.ts index a8d0ea9..3b9fc1f 100644 --- a/services/db.ts +++ b/services/db.ts @@ -17,6 +17,7 @@ type ApiCourse = { description: string; category: string; coverImageUrl: string | null; + coverImageZoom: number; status: 'draft' | 'published' | 'archived'; progress?: number; lastActivity?: string | null; @@ -67,6 +68,7 @@ const toCourse = (course: ApiCourse): Course => ({ description: course.description, category: course.category, thumbnail: course.coverImageUrl || '/course-placeholder.svg', + coverImageZoom: course.coverImageZoom || 1, instructor: course.instructor.name, status: course.status === 'draft' ? 'draft' : 'published', progress: course.progress, @@ -124,6 +126,7 @@ const toCoursePayload = (course: Course) => ({ description: course.description, category: course.category, coverImageUrl: course.thumbnail && /^https?:\/\//.test(course.thumbnail) ? course.thumbnail : null, + coverImageZoom: course.coverImageZoom || 1, status: course.status || 'published', assets: (course.attachments || []).filter((attachment) => attachment.url && attachment.url !== '#').map(toAssetPayload), lessons: course.lessons.map((lesson) => ({ diff --git a/types.ts b/types.ts index bf66c29..d609ff1 100644 --- a/types.ts +++ b/types.ts @@ -42,6 +42,7 @@ export interface Course { title: string; category: string; thumbnail: string; + coverImageZoom?: number; lessons: Lesson[]; progress?: number; // 0 to 100 views?: number;