feat: derive course durations from video media
This commit is contained in:
@@ -40,6 +40,22 @@ import { getManagedCourses, saveCourse, deleteCourse, duplicateCourse, getCommen
|
|||||||
import { getAttachmentIcon, getAttachmentBadge } from '../components/MaterialCard';
|
import { getAttachmentIcon, getAttachmentBadge } from '../components/MaterialCard';
|
||||||
import { BunnyVideo, courseApi, instructorApi, InstructorAnalytics } from '../services/api';
|
import { BunnyVideo, courseApi, instructorApi, InstructorAnalytics } from '../services/api';
|
||||||
|
|
||||||
|
const formatVideoDuration = (seconds: number | null | undefined) => {
|
||||||
|
if (!seconds || seconds < 1) return 'Processando';
|
||||||
|
const totalSeconds = Math.round(seconds);
|
||||||
|
const hours = Math.floor(totalSeconds / 3600);
|
||||||
|
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||||
|
const remainingSeconds = totalSeconds % 60;
|
||||||
|
if (hours) return `${hours}h ${String(minutes).padStart(2, '0')}min`;
|
||||||
|
if (minutes) return `${minutes}min`;
|
||||||
|
return `${remainingSeconds}s`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const totalVideoDuration = (lessons: Lesson[]) => {
|
||||||
|
const seconds = lessons.reduce((total, lesson) => total + (lesson.media?.[0]?.durationSeconds || 0), 0);
|
||||||
|
return seconds ? formatVideoDuration(seconds) : 'Em processamento';
|
||||||
|
};
|
||||||
|
|
||||||
// 1. Create/Edit Course Modal
|
// 1. Create/Edit Course Modal
|
||||||
const CourseModal: React.FC<{
|
const CourseModal: React.FC<{
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -72,7 +88,6 @@ const CourseModal: React.FC<{
|
|||||||
|
|
||||||
// New Lesson State
|
// New Lesson State
|
||||||
const [newLessonTitle, setNewLessonTitle] = useState('');
|
const [newLessonTitle, setNewLessonTitle] = useState('');
|
||||||
const [newLessonDuration, setNewLessonDuration] = useState('');
|
|
||||||
const [newLessonMedia, setNewLessonMedia] = useState<LessonMedia | null>(null);
|
const [newLessonMedia, setNewLessonMedia] = useState<LessonMedia | null>(null);
|
||||||
const [newLessonIsFree, setNewLessonIsFree] = useState(false);
|
const [newLessonIsFree, setNewLessonIsFree] = useState(false);
|
||||||
const [bunnyConfigured, setBunnyConfigured] = useState<boolean | null>(null);
|
const [bunnyConfigured, setBunnyConfigured] = useState<boolean | null>(null);
|
||||||
@@ -179,10 +194,6 @@ const CourseModal: React.FC<{
|
|||||||
const uploaded = await instructorApi.uploadBunnyVideo(created.data.id, file);
|
const uploaded = await instructorApi.uploadBunnyVideo(created.data.id, file);
|
||||||
const media = toBunnyMedia(uploaded.data);
|
const media = toBunnyMedia(uploaded.data);
|
||||||
setNewLessonMedia(media);
|
setNewLessonMedia(media);
|
||||||
if (!newLessonDuration.trim() && uploaded.data.durationSeconds) {
|
|
||||||
const minutes = Math.max(1, Math.round(uploaded.data.durationSeconds / 60));
|
|
||||||
setNewLessonDuration(`${minutes}m`);
|
|
||||||
}
|
|
||||||
setBunnyUploadState('uploaded');
|
setBunnyUploadState('uploaded');
|
||||||
setBunnyUploadMessage(uploaded.data.status === 'ready'
|
setBunnyUploadMessage(uploaded.data.status === 'ready'
|
||||||
? 'Vídeo pronto para ser adicionado à aula.'
|
? 'Vídeo pronto para ser adicionado à aula.'
|
||||||
@@ -257,7 +268,7 @@ const CourseModal: React.FC<{
|
|||||||
const refreshed = toBunnyMedia(response.data);
|
const refreshed = toBunnyMedia(response.data);
|
||||||
setLessons((current) => current.map((item) => item.id === lessonId ? {
|
setLessons((current) => current.map((item) => item.id === lessonId ? {
|
||||||
...item,
|
...item,
|
||||||
duration: refreshed.durationSeconds ? `${Math.max(1, Math.round(refreshed.durationSeconds / 60))}m` : item.duration,
|
duration: formatVideoDuration(refreshed.durationSeconds),
|
||||||
media: [refreshed],
|
media: [refreshed],
|
||||||
} : item));
|
} : item));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -290,7 +301,7 @@ const CourseModal: React.FC<{
|
|||||||
if (!update) return lesson;
|
if (!update) return lesson;
|
||||||
return {
|
return {
|
||||||
...lesson,
|
...lesson,
|
||||||
duration: update.media.durationSeconds ? `${Math.max(1, Math.round(update.media.durationSeconds / 60))}m` : lesson.duration,
|
duration: formatVideoDuration(update.media.durationSeconds),
|
||||||
media: [update.media],
|
media: [update.media],
|
||||||
};
|
};
|
||||||
}));
|
}));
|
||||||
@@ -312,7 +323,7 @@ const CourseModal: React.FC<{
|
|||||||
const newLesson: Lesson = {
|
const newLesson: Lesson = {
|
||||||
id: `lesson-${Date.now()}`,
|
id: `lesson-${Date.now()}`,
|
||||||
title: newLessonTitle,
|
title: newLessonTitle,
|
||||||
duration: newLessonDuration || '15m',
|
duration: formatVideoDuration(newLessonMedia.durationSeconds),
|
||||||
videoUrl: newLessonMedia.playbackUrl || '',
|
videoUrl: newLessonMedia.playbackUrl || '',
|
||||||
mediaProvider: newLessonMedia.provider,
|
mediaProvider: newLessonMedia.provider,
|
||||||
media: [newLessonMedia],
|
media: [newLessonMedia],
|
||||||
@@ -323,7 +334,6 @@ const CourseModal: React.FC<{
|
|||||||
setLessons([...lessons, newLesson]);
|
setLessons([...lessons, newLesson]);
|
||||||
|
|
||||||
setNewLessonTitle('');
|
setNewLessonTitle('');
|
||||||
setNewLessonDuration('');
|
|
||||||
setNewLessonMedia(null);
|
setNewLessonMedia(null);
|
||||||
setNewLessonIsFree(false);
|
setNewLessonIsFree(false);
|
||||||
setBunnyUploadState('idle');
|
setBunnyUploadState('idle');
|
||||||
@@ -406,7 +416,7 @@ const CourseModal: React.FC<{
|
|||||||
|
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
|
|
||||||
const durationStr = `${lessons.length} Aulas`;
|
const durationStr = totalVideoDuration(lessons);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await onSave({
|
await onSave({
|
||||||
@@ -785,20 +795,13 @@ const CourseModal: React.FC<{
|
|||||||
Adicionar aula
|
Adicionar aula
|
||||||
</h4>
|
</h4>
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={newLessonTitle}
|
value={newLessonTitle}
|
||||||
onChange={(e) => setNewLessonTitle(e.target.value)}
|
onChange={(e) => setNewLessonTitle(e.target.value)}
|
||||||
placeholder="Ex.: Aula 01 — Configuração inicial do Pixel"
|
placeholder="Título da aula"
|
||||||
className="flex-1 bg-white/5 border border-white/10 rounded-xl px-3 py-2 text-xs text-white focus:outline-none focus:border-orange-500/50"
|
className="w-full bg-white/5 border border-white/10 rounded-xl px-3 py-2 text-xs text-white focus:outline-none focus:border-orange-500/50"
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={newLessonDuration}
|
|
||||||
onChange={(e) => setNewLessonDuration(e.target.value)}
|
|
||||||
placeholder="Ex.: 25 min"
|
|
||||||
className="w-28 bg-white/5 border border-white/10 rounded-xl px-3 py-2 text-xs text-white focus:outline-none focus:border-orange-500/50"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,14 @@ async function persistBunnyStatus(video: { id: string; status: string; durationS
|
|||||||
where provider = 'bunny' and external_id = $1`,
|
where provider = 'bunny' and external_id = $1`,
|
||||||
[video.id, video.status, video.durationSeconds],
|
[video.id, video.status, video.durationSeconds],
|
||||||
);
|
);
|
||||||
|
if (video.durationSeconds !== null) {
|
||||||
|
await pool.query(
|
||||||
|
`update lessons
|
||||||
|
set duration_seconds = $2
|
||||||
|
where id = (select lesson_id from lesson_media where provider = 'bunny' and external_id = $1)`,
|
||||||
|
[video.id, video.durationSeconds],
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const mediaRoutes: FastifyPluginAsync = async (app) => {
|
export const mediaRoutes: FastifyPluginAsync = async (app) => {
|
||||||
|
|||||||
@@ -64,6 +64,12 @@ const toDuration = (seconds: number | null) => {
|
|||||||
return minutes >= 60 ? `${Math.floor(minutes / 60)}h ${minutes % 60}m` : `${minutes}m`;
|
return minutes >= 60 ? `${Math.floor(minutes / 60)}h ${minutes % 60}m` : `${minutes}m`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const toCourseDuration = (lessons: ApiCourse['lessons']) => {
|
||||||
|
const seconds = lessons.reduce((total, lesson) => total + (lesson.media[0]?.durationSeconds ?? lesson.durationSeconds ?? 0), 0);
|
||||||
|
if (seconds) return toDuration(seconds);
|
||||||
|
return lessons.length ? 'Processando' : '—';
|
||||||
|
};
|
||||||
|
|
||||||
const toCourse = (course: ApiCourse): Course => ({
|
const toCourse = (course: ApiCourse): Course => ({
|
||||||
id: course.id,
|
id: course.id,
|
||||||
title: course.title,
|
title: course.title,
|
||||||
@@ -77,7 +83,7 @@ const toCourse = (course: ApiCourse): Course => ({
|
|||||||
status: course.status === 'draft' ? 'draft' : 'published',
|
status: course.status === 'draft' ? 'draft' : 'published',
|
||||||
progress: course.progress,
|
progress: course.progress,
|
||||||
lastActivity: course.lastActivity || undefined,
|
lastActivity: course.lastActivity || undefined,
|
||||||
duration: `${course.lessons.length} Aulas`,
|
duration: toCourseDuration(course.lessons),
|
||||||
lessons: course.lessons.map((lesson): Lesson => {
|
lessons: course.lessons.map((lesson): Lesson => {
|
||||||
const media: LessonMedia[] = lesson.media.map((item) => ({
|
const media: LessonMedia[] = lesson.media.map((item) => ({
|
||||||
id: item.id,
|
id: item.id,
|
||||||
@@ -92,7 +98,7 @@ const toCourse = (course: ApiCourse): Course => ({
|
|||||||
id: lesson.id,
|
id: lesson.id,
|
||||||
title: lesson.title,
|
title: lesson.title,
|
||||||
description: lesson.description,
|
description: lesson.description,
|
||||||
duration: toDuration(lesson.durationSeconds),
|
duration: toDuration(media[0]?.durationSeconds ?? lesson.durationSeconds),
|
||||||
isFree: lesson.accessLevel === 'public',
|
isFree: lesson.accessLevel === 'public',
|
||||||
videoUrl: media[0]?.playbackUrl || media[0]?.embedUrl || '',
|
videoUrl: media[0]?.playbackUrl || media[0]?.embedUrl || '',
|
||||||
mediaProvider: media[0]?.provider,
|
mediaProvider: media[0]?.provider,
|
||||||
@@ -139,7 +145,7 @@ const toCoursePayload = (course: Course) => ({
|
|||||||
id: /^[0-9a-f]{8}-[0-9a-f-]{27}$/i.test(lesson.id) ? lesson.id : undefined,
|
id: /^[0-9a-f]{8}-[0-9a-f-]{27}$/i.test(lesson.id) ? lesson.id : undefined,
|
||||||
title: lesson.title,
|
title: lesson.title,
|
||||||
description: lesson.description || '',
|
description: lesson.description || '',
|
||||||
durationSeconds: toSeconds(lesson.duration),
|
durationSeconds: lesson.media?.[0]?.durationSeconds ?? toSeconds(lesson.duration),
|
||||||
accessLevel: lesson.isFree ? 'public' as const : 'enrolled' as const,
|
accessLevel: lesson.isFree ? 'public' as const : 'enrolled' as const,
|
||||||
media: lesson.media?.length
|
media: lesson.media?.length
|
||||||
? lesson.media.map((media) => ({
|
? lesson.media.map((media) => ({
|
||||||
|
|||||||
Reference in New Issue
Block a user