From ed00f60281fc2b269f41ff04841bd6e25b27f314 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cau=C3=AA=20Faleiros?= Date: Fri, 4 Sep 2026 15:26:06 -0300 Subject: [PATCH] fix: fall back to Bunny Storage for cover images --- components/CourseCard.tsx | 5 ++-- components/CourseCoverImage.tsx | 39 +++++++++++++++++++++++++++ components/Hero.tsx | 5 ++-- components/SearchModal.tsx | 5 ++-- pages/ManageCourses.tsx | 11 ++++---- pages/MyLearning.tsx | 3 ++- server/src/providers/bunny-storage.ts | 15 +++++++++++ server/src/routes/courses.ts | 19 +++++++++++++ 8 files changed, 90 insertions(+), 12 deletions(-) create mode 100644 components/CourseCoverImage.tsx diff --git a/components/CourseCard.tsx b/components/CourseCard.tsx index 23f4742..aac37be 100644 --- a/components/CourseCard.tsx +++ b/components/CourseCard.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { PlayCircle, Download, FileText, CheckCircle2 } from 'lucide-react'; import { Course, SectionVariant } from '../types'; +import { CourseCoverImage } from './CourseCoverImage'; interface CourseCardProps { course: Course; @@ -37,8 +38,8 @@ export const CourseCard: React.FC = ({ onClick={() => onClick?.(course)} className={`relative ${aspectRatioClass} w-full overflow-hidden rounded-[8px] bg-zinc-900 shadow-xl transition-all duration-300 ease-out group-hover:scale-[1.02] group-hover:shadow-2xl group-hover:shadow-orange-500/10 group-hover:z-10 ring-0 ring-white/0 group-hover:ring-2 group-hover:ring-orange-500/40`} > - {course.title}, 'src' | 'onError'> & { + source: string; + onFinalError?: () => void; +}; + +const fallbackCoverUrl = (source: string) => { + try { + const parsed = new URL(source, window.location.origin); + const filename = parsed.pathname.split('/').pop() || ''; + if (!/^[0-9a-f]{8}-[0-9a-f-]{27}\.(?:jpg|png|webp)$/i.test(filename)) return null; + const apiBase = import.meta.env.VITE_API_URL || '/api/v1'; + return `${apiBase}/courses/covers/${filename}`; + } catch { + return null; + } +}; + +export const CourseCoverImage: React.FC = ({ source, onFinalError, ...props }) => { + const fallbackUrl = useMemo(() => fallbackCoverUrl(source), [source]); + const [usingFallback, setUsingFallback] = useState(false); + + useEffect(() => setUsingFallback(false), [source]); + + return ( + { + if (!usingFallback && fallbackUrl) { + setUsingFallback(true); + return; + } + onFinalError?.(); + }} + /> + ); +}; diff --git a/components/Hero.tsx b/components/Hero.tsx index 58d7d9f..5e2f333 100644 --- a/components/Hero.tsx +++ b/components/Hero.tsx @@ -2,6 +2,7 @@ import React, { useEffect, useState } from 'react'; import { Play, Plus, Sparkles } from 'lucide-react'; import { Course } from '../types'; import { getCourses } from '../services/db'; +import { CourseCoverImage } from './CourseCoverImage'; interface HeroProps { onPlay?: (course: Course) => void; @@ -20,8 +21,8 @@ export const Hero: React.FC = ({ onPlay }) => {
{/* Background Image with Cinematic Gradient Overlay */}
- {featuredCourse.title} = ({ isOpen, onClose, onPla className="cursor-pointer group bg-zinc-900/60 rounded-[8px] p-3 border border-white/5 hover:border-orange-500/40 transition-all duration-300 shadow-xl" >
- {course.title} { @@ -514,10 +515,10 @@ const CourseModal: React.FC<{

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

) : ( - Prévia da imagem de capa setCoverPreviewError(true)} + onFinalError={() => setCoverPreviewError(true)} className="h-full w-full object-cover opacity-80 transition-transform duration-200" style={{ transform: `scale(${coverImageZoom})`, @@ -1078,8 +1079,8 @@ export const ManageCourses: React.FC = () => {
{/* Thumbnail */}
- {course.title} void }> = ({ onPlay }) => { const [courses, setCourses] = useState([]); @@ -28,7 +29,7 @@ export const MyLearning: React.FC<{ onPlay: (course: Course) => void }> = ({ onP
{courses.map((course) => ( ))} diff --git a/server/src/providers/bunny-storage.ts b/server/src/providers/bunny-storage.ts index 053231d..9140626 100644 --- a/server/src/providers/bunny-storage.ts +++ b/server/src/providers/bunny-storage.ts @@ -52,3 +52,18 @@ export async function uploadBunnyCover(image: CoverImage) { } return { key, coverImageUrl: `${cdnHost}/${key}` }; } + +export async function downloadBunnyCover(filename: string) { + const { zone, password, endpoint } = bunnyStorageConfiguration(); + const response = await fetch(`${endpoint}/${encodeURIComponent(zone)}/covers/${encodeURIComponent(filename)}`, { + headers: { AccessKey: password }, + }); + if (!response.ok) { + const detail = await response.text().catch(() => ''); + throw new BunnyStorageRequestError(`Bunny Storage download failed (${response.status})${detail ? `: ${detail.slice(0, 250)}` : ''}`); + } + return { + body: Buffer.from(await response.arrayBuffer()), + contentType: response.headers.get('content-type') || 'application/octet-stream', + }; +} diff --git a/server/src/routes/courses.ts b/server/src/routes/courses.ts index 6679242..96f8dd1 100644 --- a/server/src/routes/courses.ts +++ b/server/src/routes/courses.ts @@ -2,6 +2,7 @@ import type { FastifyPluginAsync } from 'fastify'; import { z } from 'zod'; import { pool } from '../db/pool.js'; import { signedBunnyEmbedUrl, BunnyConfigurationError } from '../providers/bunny.js'; +import { BunnyStorageConfigurationError, BunnyStorageRequestError, downloadBunnyCover } from '../providers/bunny-storage.js'; const paramsSchema = z.object({ courseId: z.string().uuid(), @@ -10,6 +11,9 @@ const playbackParamsSchema = z.object({ courseId: z.string().uuid(), lessonId: z.string().uuid(), }); +const coverParamsSchema = z.object({ + filename: z.string().regex(/^[0-9a-f]{8}-[0-9a-f-]{27}\.(?:jpg|png|webp)$/i), +}); export const courseSelect = (publicOnly = false, includeUnreadyMedia = false) => ` select @@ -110,6 +114,21 @@ export const courseRoutes: FastifyPluginAsync = async (app) => { return { data: result.rows.map((row) => row.name) }; }); + app.get('/covers/:filename', async (request, reply) => { + const { filename } = coverParamsSchema.parse(request.params); + try { + const cover = await downloadBunnyCover(filename); + reply.header('Content-Type', cover.contentType); + reply.header('Cache-Control', 'public, max-age=86400, stale-while-revalidate=604800'); + return reply.send(cover.body); + } catch (error) { + if (error instanceof BunnyStorageConfigurationError || error instanceof BunnyStorageRequestError) { + return reply.code(404).send({ error: 'Cover image was not found' }); + } + throw error; + } + }); + app.get('/', async (request) => { const authenticated = await hasSession(request); const result = await pool.query(