Compare commits

...

2 Commits

Author SHA1 Message Date
Cauê Faleiros
ed00f60281 fix: fall back to Bunny Storage for cover images
All checks were successful
CI / Validate frontend and API (push) Successful in 51s
CI / Build and publish Docker images (push) Successful in 21s
2026-09-04 15:26:06 -03:00
Cauê Faleiros
66671f65c3 feat: delete lesson videos from Bunny 2026-09-04 15:18:10 -03:00
11 changed files with 135 additions and 17 deletions

View File

@@ -1,6 +1,7 @@
import React from 'react'; import React from 'react';
import { PlayCircle, Download, FileText, CheckCircle2 } from 'lucide-react'; import { PlayCircle, Download, FileText, CheckCircle2 } from 'lucide-react';
import { Course, SectionVariant } from '../types'; import { Course, SectionVariant } from '../types';
import { CourseCoverImage } from './CourseCoverImage';
interface CourseCardProps { interface CourseCardProps {
course: Course; course: Course;
@@ -37,8 +38,8 @@ export const CourseCard: React.FC<CourseCardProps> = ({
onClick={() => onClick?.(course)} 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`} 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`}
> >
<img <CourseCoverImage
src={course.thumbnail} source={course.thumbnail}
alt={course.title} alt={course.title}
loading="lazy" loading="lazy"
draggable={false} draggable={false}

View File

@@ -0,0 +1,39 @@
import React, { useEffect, useMemo, useState } from 'react';
type CourseCoverImageProps = Omit<React.ImgHTMLAttributes<HTMLImageElement>, '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<CourseCoverImageProps> = ({ source, onFinalError, ...props }) => {
const fallbackUrl = useMemo(() => fallbackCoverUrl(source), [source]);
const [usingFallback, setUsingFallback] = useState(false);
useEffect(() => setUsingFallback(false), [source]);
return (
<img
{...props}
src={usingFallback && fallbackUrl ? fallbackUrl : source}
onError={() => {
if (!usingFallback && fallbackUrl) {
setUsingFallback(true);
return;
}
onFinalError?.();
}}
/>
);
};

View File

@@ -2,6 +2,7 @@ import React, { useEffect, useState } from 'react';
import { Play, Plus, Sparkles } from 'lucide-react'; import { Play, Plus, Sparkles } from 'lucide-react';
import { Course } from '../types'; import { Course } from '../types';
import { getCourses } from '../services/db'; import { getCourses } from '../services/db';
import { CourseCoverImage } from './CourseCoverImage';
interface HeroProps { interface HeroProps {
onPlay?: (course: Course) => void; onPlay?: (course: Course) => void;
@@ -20,8 +21,8 @@ export const Hero: React.FC<HeroProps> = ({ onPlay }) => {
<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"> <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 */} {/* Background Image with Cinematic Gradient Overlay */}
<div className="absolute inset-0 transition-transform duration-[20s] ease-in-out group-hover:scale-105"> <div className="absolute inset-0 transition-transform duration-[20s] ease-in-out group-hover:scale-105">
<img <CourseCoverImage
src={featuredCourse.thumbnail} source={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={{ style={{

View File

@@ -2,6 +2,7 @@ import React, { useState, useEffect, useRef } from 'react';
import { Search, X, Loader2, PlayCircle, Download } from 'lucide-react'; import { Search, X, Loader2, PlayCircle, Download } from 'lucide-react';
import { Course } from '../types'; import { Course } from '../types';
import { getCourses } from '../services/db'; import { getCourses } from '../services/db';
import { CourseCoverImage } from './CourseCoverImage';
interface SearchModalProps { interface SearchModalProps {
isOpen: boolean; isOpen: boolean;
@@ -132,8 +133,8 @@ export const SearchModal: React.FC<SearchModalProps> = ({ 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" 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"
> >
<div className="aspect-video bg-gray-900 rounded-[8px] overflow-hidden mb-3 relative"> <div className="aspect-video bg-gray-900 rounded-[8px] overflow-hidden mb-3 relative">
<img <CourseCoverImage
src={course.thumbnail} source={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={{ style={{

View File

@@ -38,6 +38,7 @@ import { Course, Comment, Attachment, Lesson, AttachmentType, LessonMedia } from
import { useToast } from '../context/ToastContext'; import { useToast } from '../context/ToastContext';
import { getManagedCourses, saveCourse, deleteCourse, duplicateCourse, getComments, replyToComment, moderateComment } from '../services/db'; import { getManagedCourses, saveCourse, deleteCourse, duplicateCourse, getComments, replyToComment, moderateComment } from '../services/db';
import { getAttachmentIcon, getAttachmentBadge } from '../components/MaterialCard'; import { getAttachmentIcon, getAttachmentBadge } from '../components/MaterialCard';
import { CourseCoverImage } from '../components/CourseCoverImage';
import { BunnyVideo, courseApi, instructorApi, InstructorAnalytics } from '../services/api'; import { BunnyVideo, courseApi, instructorApi, InstructorAnalytics } from '../services/api';
const formatVideoDuration = (seconds: number | null | undefined) => { const formatVideoDuration = (seconds: number | null | undefined) => {
@@ -93,6 +94,7 @@ const CourseModal: React.FC<{
const [bunnyConfigured, setBunnyConfigured] = useState<boolean | null>(null); const [bunnyConfigured, setBunnyConfigured] = useState<boolean | null>(null);
const [bunnyUploadState, setBunnyUploadState] = useState<'idle' | 'uploading' | 'uploaded' | 'error'>('idle'); const [bunnyUploadState, setBunnyUploadState] = useState<'idle' | 'uploading' | 'uploaded' | 'error'>('idle');
const [bunnyUploadMessage, setBunnyUploadMessage] = useState(''); const [bunnyUploadMessage, setBunnyUploadMessage] = useState('');
const [deletingVideoId, setDeletingVideoId] = useState<string | null>(null);
// Tips & Global Attachments // Tips & Global Attachments
const [tips, setTips] = useState<string[]>([]); const [tips, setTips] = useState<string[]>([]);
@@ -340,8 +342,26 @@ const CourseModal: React.FC<{
setBunnyUploadMessage(''); setBunnyUploadMessage('');
}; };
const removeLesson = (id: string) => { const removeLesson = async (lesson: Lesson) => {
setLessons(lessons.filter(l => l.id !== id)); const video = lesson.media?.find((media) => media.provider === 'bunny');
const message = video
? 'Excluir esta aula e o vídeo enviado? O vídeo será removido permanentemente e esta ação não poderá ser desfeita.'
: 'Excluir esta aula? Esta ação não poderá ser desfeita após salvar o curso.';
if (!window.confirm(message)) return;
if (video) {
setDeletingVideoId(video.externalId);
try {
await instructorApi.deleteBunnyVideo(video.externalId);
} catch (error) {
alert(error instanceof Error ? error.message : 'Não foi possível excluir o vídeo. Tente novamente.');
return;
} finally {
setDeletingVideoId(null);
}
}
setLessons((current) => current.filter((item) => item.id !== lesson.id));
}; };
const toggleLessonAccess = (id: string) => { const toggleLessonAccess = (id: string) => {
@@ -495,10 +515,10 @@ const CourseModal: React.FC<{
<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> <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> </div>
) : ( ) : (
<img <CourseCoverImage
src={thumbnailPreview} source={thumbnailPreview}
alt="Prévia da imagem de capa" alt="Prévia da imagem de capa"
onError={() => setCoverPreviewError(true)} onFinalError={() => setCoverPreviewError(true)}
className="h-full w-full object-cover opacity-80 transition-transform duration-200" className="h-full w-full object-cover opacity-80 transition-transform duration-200"
style={{ style={{
transform: `scale(${coverImageZoom})`, transform: `scale(${coverImageZoom})`,
@@ -778,10 +798,12 @@ const CourseModal: React.FC<{
</button> </button>
<button <button
type="button" type="button"
onClick={() => removeLesson(lesson.id)} disabled={Boolean(lesson.media?.[0]?.provider === 'bunny' && deletingVideoId === lesson.media[0].externalId)}
className="p-1.5 text-gray-400 hover:text-red-400 hover:bg-red-500/10 rounded-lg transition-colors" onClick={() => void removeLesson(lesson)}
className="p-1.5 text-gray-400 hover:text-red-400 hover:bg-red-500/10 rounded-lg transition-colors disabled:cursor-not-allowed disabled:opacity-40"
title="Excluir aula e vídeo"
> >
<Trash2 className="w-4 h-4" /> {lesson.media?.[0]?.provider === 'bunny' && deletingVideoId === lesson.media[0].externalId ? <Loader2 className="w-4 h-4 animate-spin" /> : <Trash2 className="w-4 h-4" />}
</button> </button>
</div> </div>
</div> </div>
@@ -1057,8 +1079,8 @@ export const ManageCourses: React.FC = () => {
<div> <div>
{/* Thumbnail */} {/* Thumbnail */}
<div className="aspect-video relative overflow-hidden bg-black rounded-t-[8px]"> <div className="aspect-video relative overflow-hidden bg-black rounded-t-[8px]">
<img <CourseCoverImage
src={course.thumbnail} source={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={{ style={{

View File

@@ -2,6 +2,7 @@ import React, { useEffect, useState } from 'react';
import { BookOpenCheck, Loader2, PlayCircle } from 'lucide-react'; import { BookOpenCheck, Loader2, PlayCircle } from 'lucide-react';
import { Course } from '../types'; import { Course } from '../types';
import { getMyLearningCourses } from '../services/db'; import { getMyLearningCourses } from '../services/db';
import { CourseCoverImage } from '../components/CourseCoverImage';
export const MyLearning: React.FC<{ onPlay: (course: Course) => void }> = ({ onPlay }) => { export const MyLearning: React.FC<{ onPlay: (course: Course) => void }> = ({ onPlay }) => {
const [courses, setCourses] = useState<Course[]>([]); const [courses, setCourses] = useState<Course[]>([]);
@@ -28,7 +29,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 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})`, objectPosition: `${course.coverImagePositionX ?? 50}% ${course.coverImagePositionY ?? 50}%` }} /><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"><CourseCoverImage source={course.thumbnail} alt="" className="h-full w-full object-cover opacity-75 transition-opacity group-hover:opacity-100" style={{ transform: `scale(${course.coverImageZoom || 1})`, objectPosition: `${course.coverImagePositionX ?? 50}% ${course.coverImagePositionY ?? 50}%` }} /><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

@@ -52,3 +52,18 @@ export async function uploadBunnyCover(image: CoverImage) {
} }
return { key, coverImageUrl: `${cdnHost}/${key}` }; 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',
};
}

View File

@@ -114,6 +114,10 @@ export async function getBunnyVideo(videoId: string) {
return toVideo(await response.json() as BunnyApiVideo); return toVideo(await response.json() as BunnyApiVideo);
} }
export async function deleteBunnyVideo(videoId: string) {
await bunnyRequest(`/videos/${encodeURIComponent(videoId)}`, { method: 'DELETE' });
}
export function signedBunnyEmbedUrl(videoId: string, validitySeconds = config.BUNNY_EMBED_TOKEN_TTL_SECONDS) { export function signedBunnyEmbedUrl(videoId: string, validitySeconds = config.BUNNY_EMBED_TOKEN_TTL_SECONDS) {
const { libraryId, embedTokenKey } = getBunnyConfiguration(); const { libraryId, embedTokenKey } = getBunnyConfiguration();
const expires = Math.floor(Date.now() / 1000) + validitySeconds; const expires = Math.floor(Date.now() / 1000) + validitySeconds;

View File

@@ -2,6 +2,7 @@ import type { FastifyPluginAsync } from 'fastify';
import { z } from 'zod'; import { z } from 'zod';
import { pool } from '../db/pool.js'; import { pool } from '../db/pool.js';
import { signedBunnyEmbedUrl, BunnyConfigurationError } from '../providers/bunny.js'; import { signedBunnyEmbedUrl, BunnyConfigurationError } from '../providers/bunny.js';
import { BunnyStorageConfigurationError, BunnyStorageRequestError, downloadBunnyCover } from '../providers/bunny-storage.js';
const paramsSchema = z.object({ const paramsSchema = z.object({
courseId: z.string().uuid(), courseId: z.string().uuid(),
@@ -10,6 +11,9 @@ const playbackParamsSchema = z.object({
courseId: z.string().uuid(), courseId: z.string().uuid(),
lessonId: 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) => ` export const courseSelect = (publicOnly = false, includeUnreadyMedia = false) => `
select select
@@ -110,6 +114,21 @@ export const courseRoutes: FastifyPluginAsync = async (app) => {
return { data: result.rows.map((row) => row.name) }; 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) => { app.get('/', async (request) => {
const authenticated = await hasSession(request); const authenticated = await hasSession(request);
const result = await pool.query( const result = await pool.query(

View File

@@ -6,6 +6,7 @@ import {
BunnyConfigurationError, BunnyConfigurationError,
BunnyRequestError, BunnyRequestError,
createBunnyVideo, createBunnyVideo,
deleteBunnyVideo,
getBunnyVideo, getBunnyVideo,
isBunnyConfigured, isBunnyConfigured,
uploadBunnyVideo, uploadBunnyVideo,
@@ -123,4 +124,15 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => {
return providerError(reply, error); return providerError(reply, error);
} }
}); });
app.delete('/bunny/videos/:videoId', manageAccess, async (request, reply) => {
const { videoId } = videoParamsSchema.parse(request.params);
try {
await deleteBunnyVideo(videoId);
await recordAudit({ actorId: request.user.id, action: 'media.video.deleted', subjectType: 'video', subjectId: videoId, ipAddress: request.ip });
return reply.code(204).send();
} catch (error) {
return providerError(reply, error);
}
});
}; };

View File

@@ -150,6 +150,9 @@ export const instructorApi = {
async bunnyVideoStatus(videoId: string) { async bunnyVideoStatus(videoId: string) {
return apiRequest<{ data: BunnyVideo }>(`/manage/media/bunny/videos/${videoId}`); return apiRequest<{ data: BunnyVideo }>(`/manage/media/bunny/videos/${videoId}`);
}, },
async deleteBunnyVideo(videoId: string) {
return apiRequest<void>(`/manage/media/bunny/videos/${videoId}`, { method: 'DELETE' });
},
async coverConfiguration() { return apiRequest<{ data: { configured: boolean; maxUploadBytes: number } }>('/manage/media/covers/config'); }, async coverConfiguration() { return apiRequest<{ data: { configured: boolean; maxUploadBytes: number } }>('/manage/media/covers/config'); },
async uploadCover(file: File) { return uploadImageRequest<{ data: { key: string; coverImageUrl: string } }>('/manage/media/covers', file); }, async uploadCover(file: File) { return uploadImageRequest<{ data: { key: string; coverImageUrl: string } }>('/manage/media/covers', file); },
}; };