feat: integrate Bunny Stream course videos
This commit is contained in:
@@ -25,12 +25,12 @@ import {
|
||||
Globe,
|
||||
Download
|
||||
} from 'lucide-react';
|
||||
import { Course, Comment, Attachment, Lesson, AttachmentType } from '../types';
|
||||
import { Course, Comment, Attachment, Lesson, AttachmentType, LessonMedia } from '../types';
|
||||
import { useToast } from '../context/ToastContext';
|
||||
import { getManagedCourses, saveCourse, deleteCourse, getComments, replyToComment, moderateComment } from '../services/db';
|
||||
import { CATEGORIES } from '../constants';
|
||||
import { getAttachmentIcon, getAttachmentBadge } from '../components/MaterialCard';
|
||||
import { instructorApi, InstructorAnalytics } from '../services/api';
|
||||
import { BunnyVideo, instructorApi, InstructorAnalytics } from '../services/api';
|
||||
|
||||
// 1. Create/Edit Course Modal
|
||||
const CourseModal: React.FC<{
|
||||
@@ -55,7 +55,11 @@ const CourseModal: React.FC<{
|
||||
const [newLessonDuration, setNewLessonDuration] = useState('');
|
||||
const [newLessonVideoUrl, setNewLessonVideoUrl] = useState('');
|
||||
const [newLessonProvider, setNewLessonProvider] = useState('external');
|
||||
const [newLessonMedia, setNewLessonMedia] = useState<LessonMedia | null>(null);
|
||||
const [newLessonIsFree, setNewLessonIsFree] = useState(false);
|
||||
const [bunnyConfigured, setBunnyConfigured] = useState<boolean | null>(null);
|
||||
const [bunnyUploadState, setBunnyUploadState] = useState<'idle' | 'uploading' | 'uploaded' | 'error'>('idle');
|
||||
const [bunnyUploadMessage, setBunnyUploadMessage] = useState('');
|
||||
|
||||
// Tips & Global Attachments
|
||||
const [tips, setTips] = useState<string[]>([]);
|
||||
@@ -96,16 +100,114 @@ const CourseModal: React.FC<{
|
||||
setAttachments([]);
|
||||
setLessons([]);
|
||||
}
|
||||
setNewLessonMedia(null);
|
||||
setBunnyUploadState('idle');
|
||||
setBunnyUploadMessage('');
|
||||
instructorApi.bunnyConfiguration()
|
||||
.then((response) => setBunnyConfigured(response.data.configured))
|
||||
.catch(() => setBunnyConfigured(false));
|
||||
}
|
||||
}, [isOpen, initialData]);
|
||||
|
||||
const toBunnyMedia = (video: BunnyVideo): LessonMedia => ({
|
||||
provider: 'bunny',
|
||||
externalId: video.id,
|
||||
status: video.status,
|
||||
durationSeconds: video.durationSeconds,
|
||||
});
|
||||
|
||||
const handleBunnyUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = '';
|
||||
if (!file) return;
|
||||
if (!newLessonTitle.trim()) {
|
||||
alert('Informe o título da aula antes de enviar o vídeo.');
|
||||
return;
|
||||
}
|
||||
if (!bunnyConfigured) {
|
||||
alert('A integração com Bunny Stream ainda não foi configurada pelo administrador.');
|
||||
return;
|
||||
}
|
||||
|
||||
setBunnyUploadState('uploading');
|
||||
setBunnyUploadMessage(`Enviando ${file.name} para Bunny Stream...`);
|
||||
try {
|
||||
const created = await instructorApi.createBunnyVideo(newLessonTitle.trim());
|
||||
const uploaded = await instructorApi.uploadBunnyVideo(created.data.id, file);
|
||||
const media = toBunnyMedia(uploaded.data);
|
||||
setNewLessonMedia(media);
|
||||
if (!newLessonDuration.trim() && uploaded.data.durationSeconds) {
|
||||
const minutes = Math.max(1, Math.round(uploaded.data.durationSeconds / 60));
|
||||
setNewLessonDuration(`${minutes}m`);
|
||||
}
|
||||
setBunnyUploadState('uploaded');
|
||||
setBunnyUploadMessage(uploaded.data.status === 'ready'
|
||||
? 'Vídeo pronto para ser adicionado à aula.'
|
||||
: 'Upload concluído. Bunny Stream está processando o vídeo; você já pode salvar a aula.');
|
||||
} catch (error) {
|
||||
setBunnyUploadState('error');
|
||||
setBunnyUploadMessage(error instanceof Error ? error.message : 'Não foi possível enviar o vídeo para Bunny Stream.');
|
||||
}
|
||||
};
|
||||
|
||||
const refreshLessonMedia = async (lessonId: string) => {
|
||||
const lesson = lessons.find((item) => item.id === lessonId);
|
||||
const media = lesson?.media?.[0];
|
||||
if (!lesson || !media || media.provider !== 'bunny') return;
|
||||
try {
|
||||
const response = await instructorApi.bunnyVideoStatus(media.externalId);
|
||||
const refreshed = toBunnyMedia(response.data);
|
||||
setLessons((current) => current.map((item) => item.id === lessonId ? {
|
||||
...item,
|
||||
duration: refreshed.durationSeconds ? `${Math.max(1, Math.round(refreshed.durationSeconds / 60))}m` : item.duration,
|
||||
media: [refreshed],
|
||||
} : item));
|
||||
} catch (error) {
|
||||
alert(error instanceof Error ? error.message : 'Não foi possível atualizar o status do vídeo.');
|
||||
}
|
||||
};
|
||||
|
||||
// Bunny encodes asynchronously. While the course editor stays open, keep
|
||||
// pending lessons up to date and persist the latest state on the API.
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const pending = lessons
|
||||
.map((lesson) => ({ lesson, media: lesson.media?.[0] }))
|
||||
.filter((item): item is { lesson: Lesson; media: LessonMedia } => item.media?.provider === 'bunny' && item.media.status === 'processing');
|
||||
if (!pending.length) return;
|
||||
|
||||
const timer = window.setTimeout(() => {
|
||||
Promise.all(pending.map(async ({ lesson, media }) => {
|
||||
try {
|
||||
const response = await instructorApi.bunnyVideoStatus(media.externalId);
|
||||
return { lessonId: lesson.id, media: toBunnyMedia(response.data) };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})).then((updates) => {
|
||||
const available = updates.filter((update): update is { lessonId: string; media: LessonMedia } => Boolean(update));
|
||||
if (!available.length) return;
|
||||
setLessons((current) => current.map((lesson) => {
|
||||
const update = available.find((item) => item.lessonId === lesson.id);
|
||||
if (!update) return lesson;
|
||||
return {
|
||||
...lesson,
|
||||
duration: update.media.durationSeconds ? `${Math.max(1, Math.round(update.media.durationSeconds / 60))}m` : lesson.duration,
|
||||
media: [update.media],
|
||||
};
|
||||
}));
|
||||
});
|
||||
}, 10_000);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [isOpen, lessons]);
|
||||
|
||||
const handleAddLesson = () => {
|
||||
if (!newLessonTitle.trim()) {
|
||||
alert("Digite um título para a aula.");
|
||||
return;
|
||||
}
|
||||
if (!newLessonVideoUrl.trim()) {
|
||||
alert("Enter a hosted video URL.");
|
||||
if (!newLessonMedia && !newLessonVideoUrl.trim()) {
|
||||
alert('Envie um vídeo para Bunny Stream ou informe uma URL de vídeo hospedado.');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -113,8 +215,9 @@ const CourseModal: React.FC<{
|
||||
id: `lesson-${Date.now()}`,
|
||||
title: newLessonTitle,
|
||||
duration: newLessonDuration || '15m',
|
||||
videoUrl: newLessonVideoUrl,
|
||||
mediaProvider: newLessonProvider || 'external',
|
||||
videoUrl: newLessonMedia?.playbackUrl || newLessonVideoUrl,
|
||||
mediaProvider: newLessonMedia?.provider || newLessonProvider || 'external',
|
||||
media: newLessonMedia ? [newLessonMedia] : undefined,
|
||||
isFree: newLessonIsFree,
|
||||
description: ''
|
||||
};
|
||||
@@ -125,7 +228,10 @@ const CourseModal: React.FC<{
|
||||
setNewLessonDuration('');
|
||||
setNewLessonVideoUrl('');
|
||||
setNewLessonProvider('external');
|
||||
setNewLessonMedia(null);
|
||||
setNewLessonIsFree(false);
|
||||
setBunnyUploadState('idle');
|
||||
setBunnyUploadMessage('');
|
||||
};
|
||||
|
||||
const removeLesson = (id: string) => {
|
||||
@@ -431,6 +537,11 @@ const CourseModal: React.FC<{
|
||||
<h4 className="text-white font-medium text-xs md:text-sm truncate">{lesson.title}</h4>
|
||||
<div className="flex items-center gap-2 text-[11px] text-gray-500 mt-0.5">
|
||||
<span className="flex items-center gap-1"><Video className="w-3 h-3 text-orange-400/70"/> {lesson.duration}</span>
|
||||
{lesson.media?.[0]?.provider === 'bunny' && (
|
||||
<span className={`font-bold px-1.5 py-0.2 rounded text-[9px] uppercase ${lesson.media[0].status === 'ready' ? 'text-emerald-400 bg-emerald-500/10' : lesson.media[0].status === 'failed' ? 'text-red-400 bg-red-500/10' : 'text-amber-400 bg-amber-500/10'}`}>
|
||||
Bunny: {lesson.media[0].status === 'ready' ? 'pronto' : lesson.media[0].status === 'failed' ? 'falhou' : 'processando'}
|
||||
</span>
|
||||
)}
|
||||
{lesson.isFree ? (
|
||||
<span className="text-emerald-400 font-bold bg-emerald-500/10 px-1.5 py-0.2 rounded text-[9px] uppercase">GRÁTIS</span>
|
||||
) : (
|
||||
@@ -440,6 +551,16 @@ const CourseModal: React.FC<{
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
{lesson.media?.[0]?.provider === 'bunny' && lesson.media[0].status !== 'ready' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => refreshLessonMedia(lesson.id)}
|
||||
className="px-2 py-1 text-[10px] text-orange-300 hover:bg-orange-500/10 rounded-lg transition-colors"
|
||||
title="Atualizar status do vídeo"
|
||||
>
|
||||
Atualizar
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleLessonAccess(lesson.id)}
|
||||
@@ -484,19 +605,46 @@ const CourseModal: React.FC<{
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="rounded-xl border border-orange-500/20 bg-orange-500/[0.04] p-3 space-y-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs font-bold text-orange-200">Vídeo Bunny Stream</p>
|
||||
<p className="text-[11px] text-gray-500">O vídeo será protegido e reproduzido no player seguro da Bunny.</p>
|
||||
</div>
|
||||
<label className={`cursor-pointer rounded-lg px-3 py-2 text-xs font-bold transition-colors ${bunnyConfigured && bunnyUploadState !== 'uploading' ? 'bg-orange-500 hover:bg-orange-600 text-white' : 'bg-white/10 text-gray-500 cursor-not-allowed'}`}>
|
||||
{bunnyUploadState === 'uploading' ? 'Enviando...' : 'Selecionar vídeo'}
|
||||
<input
|
||||
type="file"
|
||||
accept="video/*"
|
||||
className="hidden"
|
||||
disabled={!bunnyConfigured || bunnyUploadState === 'uploading'}
|
||||
onChange={handleBunnyUpload}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{bunnyConfigured === false && <p className="text-[11px] text-amber-300">Bunny Stream não está configurado no servidor. Peça ao administrador para adicionar as variáveis no Portainer.</p>}
|
||||
{bunnyUploadMessage && <p className={`text-[11px] ${bunnyUploadState === 'error' ? 'text-red-300' : bunnyUploadState === 'uploaded' ? 'text-emerald-300' : 'text-gray-400'}`}>{bunnyUploadMessage}</p>}
|
||||
{newLessonMedia && (
|
||||
<div className="flex items-center justify-between rounded-lg bg-black/30 px-3 py-2 text-[11px] text-gray-300">
|
||||
<span>Vídeo Bunny vinculado ({newLessonMedia.status === 'ready' ? 'pronto' : 'processando'})</span>
|
||||
<button type="button" onClick={() => setNewLessonMedia(null)} className="text-gray-400 hover:text-red-300">Remover</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 items-center pt-1">
|
||||
<input
|
||||
type="text"
|
||||
value={newLessonProvider}
|
||||
onChange={(event) => setNewLessonProvider(event.target.value)}
|
||||
placeholder="Provider (external, Panda, Vimeo...)"
|
||||
placeholder="Provider externo"
|
||||
className="w-40 bg-white/5 border border-white/10 rounded-xl px-3 py-2 text-xs text-white focus:outline-none"
|
||||
/>
|
||||
<input
|
||||
type="url"
|
||||
value={newLessonVideoUrl}
|
||||
onChange={(event) => setNewLessonVideoUrl(event.target.value)}
|
||||
placeholder="Hosted video URL"
|
||||
placeholder="Ou cole uma URL externa de vídeo"
|
||||
className="flex-1 bg-white/5 border border-white/10 rounded-xl px-3 py-2 text-xs text-white focus:outline-none"
|
||||
/>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user