@hed-hog/lms 0.0.353 → 0.0.354

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (135) hide show
  1. package/dist/course/course-audio-transcription.service.d.ts +29 -0
  2. package/dist/course/course-audio-transcription.service.d.ts.map +1 -0
  3. package/dist/course/course-audio-transcription.service.js +291 -0
  4. package/dist/course/course-audio-transcription.service.js.map +1 -0
  5. package/dist/course/course-lesson.controller.d.ts +10 -0
  6. package/dist/course/course-lesson.controller.d.ts.map +1 -0
  7. package/dist/course/course-lesson.controller.js +62 -0
  8. package/dist/course/course-lesson.controller.js.map +1 -0
  9. package/dist/course/course-structure.controller.d.ts +41 -15
  10. package/dist/course/course-structure.controller.d.ts.map +1 -1
  11. package/dist/course/course-structure.controller.js +50 -6
  12. package/dist/course/course-structure.controller.js.map +1 -1
  13. package/dist/course/course-structure.service.d.ts +50 -15
  14. package/dist/course/course-structure.service.d.ts.map +1 -1
  15. package/dist/course/course-structure.service.js +238 -73
  16. package/dist/course/course-structure.service.js.map +1 -1
  17. package/dist/course/course-video-conversion.service.d.ts +20 -2
  18. package/dist/course/course-video-conversion.service.d.ts.map +1 -1
  19. package/dist/course/course-video-conversion.service.js +730 -10
  20. package/dist/course/course-video-conversion.service.js.map +1 -1
  21. package/dist/course/course.controller.d.ts +24 -8
  22. package/dist/course/course.controller.d.ts.map +1 -1
  23. package/dist/course/course.module.d.ts.map +1 -1
  24. package/dist/course/course.module.js +5 -3
  25. package/dist/course/course.module.js.map +1 -1
  26. package/dist/course/course.service.d.ts +24 -8
  27. package/dist/course/course.service.d.ts.map +1 -1
  28. package/dist/course/course.service.js +112 -176
  29. package/dist/course/course.service.js.map +1 -1
  30. package/dist/course/dto/create-course-lesson-frame.dto.d.ts +5 -0
  31. package/dist/course/dto/create-course-lesson-frame.dto.d.ts.map +1 -0
  32. package/dist/course/dto/create-course-lesson-frame.dto.js +30 -0
  33. package/dist/course/dto/create-course-lesson-frame.dto.js.map +1 -0
  34. package/dist/course/dto/create-course-structure-lesson.dto.d.ts +4 -2
  35. package/dist/course/dto/create-course-structure-lesson.dto.d.ts.map +1 -1
  36. package/dist/course/dto/create-course-structure-lesson.dto.js +10 -3
  37. package/dist/course/dto/create-course-structure-lesson.dto.js.map +1 -1
  38. package/dist/course/dto/create-course.dto.d.ts +1 -1
  39. package/dist/course/dto/create-course.dto.d.ts.map +1 -1
  40. package/dist/course/dto/create-course.dto.js +6 -6
  41. package/dist/course/dto/create-course.dto.js.map +1 -1
  42. package/dist/course/dto/update-course-lesson-frame.dto.d.ts +5 -0
  43. package/dist/course/dto/update-course-lesson-frame.dto.d.ts.map +1 -0
  44. package/dist/course/dto/update-course-lesson-frame.dto.js +32 -0
  45. package/dist/course/dto/update-course-lesson-frame.dto.js.map +1 -0
  46. package/dist/course/dto/update-course-resources.dto.d.ts +4 -2
  47. package/dist/course/dto/update-course-resources.dto.d.ts.map +1 -1
  48. package/dist/course/dto/update-course-resources.dto.js +10 -3
  49. package/dist/course/dto/update-course-resources.dto.js.map +1 -1
  50. package/dist/course/dto/update-transcription-segments.dto.d.ts +10 -0
  51. package/dist/course/dto/update-transcription-segments.dto.d.ts.map +1 -0
  52. package/dist/course/dto/update-transcription-segments.dto.js +38 -0
  53. package/dist/course/dto/update-transcription-segments.dto.js.map +1 -0
  54. package/dist/course/lms-setting.controller.d.ts +13 -0
  55. package/dist/course/lms-setting.controller.d.ts.map +1 -0
  56. package/dist/course/lms-setting.controller.js +53 -0
  57. package/dist/course/lms-setting.controller.js.map +1 -0
  58. package/dist/enterprise/training/training-admin.service.d.ts.map +1 -1
  59. package/dist/enterprise/training/training-admin.service.js +74 -33
  60. package/dist/enterprise/training/training-admin.service.js.map +1 -1
  61. package/dist/index.d.ts +2 -0
  62. package/dist/index.d.ts.map +1 -1
  63. package/dist/index.js +2 -0
  64. package/dist/index.js.map +1 -1
  65. package/dist/lms.module.d.ts.map +1 -1
  66. package/dist/lms.module.js +6 -0
  67. package/dist/lms.module.js.map +1 -1
  68. package/hedhog/data/route.yaml +63 -0
  69. package/hedhog/data/setting_group.yaml +76 -0
  70. package/hedhog/frontend/app/courses/[id]/_components/CourseCertificateCard.tsx.ejs +3 -0
  71. package/hedhog/frontend/app/courses/[id]/_components/CourseClassificationCard.tsx.ejs +10 -1
  72. package/hedhog/frontend/app/courses/[id]/_components/CourseContentCard.tsx.ejs +7 -2
  73. package/hedhog/frontend/app/courses/[id]/_components/CourseDangerZoneCard.tsx.ejs +5 -2
  74. package/hedhog/frontend/app/courses/[id]/_components/CourseFlagsCard.tsx.ejs +7 -1
  75. package/hedhog/frontend/app/courses/[id]/_components/CourseMediaCard.tsx.ejs +3 -0
  76. package/hedhog/frontend/app/courses/[id]/_components/CourseMultiEntityPicker.tsx.ejs +95 -50
  77. package/hedhog/frontend/app/courses/[id]/_components/CourseRelationsCard.tsx.ejs +11 -36
  78. package/hedhog/frontend/app/courses/[id]/_components/CourseSectionCard.tsx.ejs +19 -5
  79. package/hedhog/frontend/app/courses/[id]/_components/course-edit-types.ts.ejs +1 -1
  80. package/hedhog/frontend/app/courses/[id]/page.tsx.ejs +30 -10
  81. package/hedhog/frontend/app/courses/[id]/structure/_components/CourseInstructorsSummaryCard.tsx.ejs +95 -0
  82. package/hedhog/frontend/app/courses/[id]/structure/_components/course-tree-dnd.tsx.ejs +29 -11
  83. package/hedhog/frontend/app/courses/[id]/structure/_components/course-tree-panel.tsx.ejs +42 -31
  84. package/hedhog/frontend/app/courses/[id]/structure/_components/course-tree.tsx.ejs +10 -1
  85. package/hedhog/frontend/app/courses/[id]/structure/_components/detail-lesson.tsx.ejs +51 -9
  86. package/hedhog/frontend/app/courses/[id]/structure/_components/drag-handle.tsx.ejs +25 -22
  87. package/hedhog/frontend/app/courses/[id]/structure/_components/editor-bulk.tsx.ejs +12 -9
  88. package/hedhog/frontend/app/courses/[id]/structure/_components/editor-course.tsx.ejs +315 -229
  89. package/hedhog/frontend/app/courses/[id]/structure/_components/editor-lesson.tsx.ejs +3220 -534
  90. package/hedhog/frontend/app/courses/[id]/structure/_components/editor-session.tsx.ejs +20 -15
  91. package/hedhog/frontend/app/courses/[id]/structure/_components/icon-action-tooltip.tsx.ejs +35 -0
  92. package/hedhog/frontend/app/courses/[id]/structure/_components/multi-select-bar.tsx.ejs +76 -67
  93. package/hedhog/frontend/app/courses/[id]/structure/_components/search-filter.tsx.ejs +13 -10
  94. package/hedhog/frontend/app/courses/[id]/structure/_components/shortcuts-help.tsx.ejs +18 -16
  95. package/hedhog/frontend/app/courses/[id]/structure/_components/sortable-tree-row.tsx.ejs +6 -0
  96. package/hedhog/frontend/app/courses/[id]/structure/_components/tree-context-menu.tsx.ejs +1 -1
  97. package/hedhog/frontend/app/courses/[id]/structure/_components/tree-display-settings-popover.tsx.ejs +23 -11
  98. package/hedhog/frontend/app/courses/[id]/structure/_components/tree-helpers.ts.ejs +48 -9
  99. package/hedhog/frontend/app/courses/[id]/structure/_components/tree-row-course.tsx.ejs +11 -2
  100. package/hedhog/frontend/app/courses/[id]/structure/_components/tree-row-lesson.tsx.ejs +121 -15
  101. package/hedhog/frontend/app/courses/[id]/structure/_components/tree-row-session.tsx.ejs +69 -14
  102. package/hedhog/frontend/app/courses/[id]/structure/_components/tree-row.tsx.ejs +11 -0
  103. package/hedhog/frontend/app/courses/[id]/structure/_components/types.ts.ejs +31 -0
  104. package/hedhog/frontend/app/courses/[id]/structure/_data/adapters/course-structure.adapter.ts.ejs +32 -6
  105. package/hedhog/frontend/app/courses/[id]/structure/_data/services/course-structure.service.ts.ejs +57 -3
  106. package/hedhog/frontend/app/courses/[id]/structure/_data/types/api-course.types.ts.ejs +46 -6
  107. package/hedhog/frontend/app/courses/[id]/structure/_data/use-course-structure-mutations.ts.ejs +14 -0
  108. package/hedhog/frontend/app/courses/[id]/structure/_data/use-lesson-audio-files.ts.ejs +23 -0
  109. package/hedhog/frontend/app/courses/[id]/structure/_data/use-lms-settings-query.ts.ejs +34 -0
  110. package/hedhog/frontend/app/courses/[id]/structure/_data/use-transcription-segments.ts.ejs +76 -0
  111. package/hedhog/frontend/messages/en.json +39 -3
  112. package/hedhog/frontend/messages/pt.json +39 -3
  113. package/hedhog/table/course.yaml +8 -0
  114. package/hedhog/table/course_lesson_file.yaml +12 -4
  115. package/hedhog/table/course_lesson_transcription_segment.yaml +22 -0
  116. package/hedhog/table/course_lesson_video_frame.yaml +25 -0
  117. package/package.json +9 -9
  118. package/src/course/course-audio-transcription.service.ts +393 -0
  119. package/src/course/course-lesson.controller.ts +28 -0
  120. package/src/course/course-structure.controller.ts +49 -3
  121. package/src/course/course-structure.service.ts +294 -32
  122. package/src/course/course-video-conversion.service.ts +972 -6
  123. package/src/course/course.module.ts +5 -3
  124. package/src/course/course.service.ts +87 -139
  125. package/src/course/dto/create-course-lesson-frame.dto.ts +14 -0
  126. package/src/course/dto/create-course-structure-lesson.dto.ts +19 -3
  127. package/src/course/dto/create-course.dto.ts +5 -5
  128. package/src/course/dto/update-course-lesson-frame.dto.ts +16 -0
  129. package/src/course/dto/update-course-resources.dto.ts +18 -3
  130. package/src/course/dto/update-transcription-segments.dto.ts +20 -0
  131. package/src/course/lms-setting.controller.ts +30 -0
  132. package/src/enterprise/training/training-admin.service.ts +77 -24
  133. package/src/index.ts +2 -0
  134. package/src/lms.module.ts +6 -0
  135. package/hedhog/table/course_instructor.yaml +0 -27
@@ -2,6 +2,10 @@
2
2
 
3
3
  import { zodResolver } from '@hookform/resolvers/zod';
4
4
  import {
5
+ AlertTriangle,
6
+ CheckCircle2,
7
+ ChevronDown,
8
+ ChevronUp,
5
9
  CircleDot,
6
10
  CircleOff,
7
11
  ClipboardList,
@@ -10,11 +14,10 @@ import {
10
14
  ExternalLink,
11
15
  Eye,
12
16
  EyeOff,
13
- File as FileIcon,
14
- FileImage,
15
17
  FileText,
16
18
  GripVertical,
17
19
  HelpCircle,
20
+ Image,
18
21
  ListChecks,
19
22
  Loader2,
20
23
  Lock,
@@ -37,8 +40,16 @@ import { toast } from 'sonner';
37
40
  import { z } from 'zod';
38
41
 
39
42
  import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
43
+ import { Badge } from '@/components/ui/badge';
40
44
  import { Button } from '@/components/ui/button';
41
45
  import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
46
+ import {
47
+ Dialog,
48
+ DialogContent,
49
+ DialogDescription,
50
+ DialogHeader,
51
+ DialogTitle,
52
+ } from '@/components/ui/dialog';
42
53
  import { EntityPicker } from '@/components/ui/entity-picker';
43
54
  import {
44
55
  Form,
@@ -67,6 +78,7 @@ import {
67
78
  SheetHeader,
68
79
  SheetTitle,
69
80
  } from '@/components/ui/sheet';
81
+ import { Switch } from '@/components/ui/switch';
70
82
  import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
71
83
  import { Textarea } from '@/components/ui/textarea';
72
84
  import { cn } from '@/lib/utils';
@@ -88,13 +100,18 @@ import {
88
100
  } from '@dnd-kit/sortable';
89
101
  import { CSS } from '@dnd-kit/utilities';
90
102
 
103
+ import { FileTypeIcon } from '@/components/file-type-icon';
91
104
  import { RichTextEditor } from '@/components/rich-text-editor';
92
105
  import { useApp, useQuery } from '@hed-hog/next-app-provider';
93
106
  import { useQueryClient } from '@tanstack/react-query';
107
+ import { JobDetailSheet } from '../../../../../queue/jobs/_components/job-detail-sheet';
94
108
  import {
109
+ createLessonFrame,
95
110
  deleteFile,
111
+ deleteLessonFrame,
96
112
  enqueueLessonVideoConversion,
97
113
  getQueueJob,
114
+ updateLessonFrame,
98
115
  uploadFile,
99
116
  type QueueJobResponse,
100
117
  type QueueJobStatus,
@@ -107,45 +124,85 @@ import {
107
124
  courseStructureQueryKey,
108
125
  type CourseStructureCacheData,
109
126
  } from '../_data/use-course-structure-query';
127
+ import { useLmsSettingsQuery } from '../_data/use-lms-settings-query';
128
+ import {
129
+ useTranscriptionSegmentsQuery,
130
+ useUpdateTranscriptionSegmentsMutation,
131
+ } from '../_data/use-transcription-segments';
132
+ import { IconActionTooltip } from './icon-action-tooltip';
110
133
  import { useStructureStore } from './store';
111
134
  import type {
112
135
  LessonStatus,
136
+ TranscriptionSegment as LessonTranscriptionSegment,
113
137
  LessonType,
114
138
  Resource,
139
+ VideoFrame,
115
140
  VideoProvider,
116
141
  } from './types';
117
142
 
118
- // ── Resource helpers ──────────────────────────────────────────────────────────
119
-
120
- function getResourceIcon(type: string): LucideIcon {
121
- if (type === 'application/pdf' || type.endsWith('pdf')) return FileText;
122
- if (type.startsWith('image/')) return FileImage;
123
- return FileIcon;
124
- }
125
-
126
- function getResourceIconColor(type: string): string {
127
- if (type === 'application/pdf' || type.endsWith('pdf')) return 'text-red-500';
128
- if (type.startsWith('image/')) return 'text-blue-500';
129
- return 'text-muted-foreground';
130
- }
131
-
132
143
  function formatFileSize(bytes: number): string {
133
144
  if (bytes < 1024) return `${bytes} B`;
134
145
  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
135
146
  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
136
147
  }
137
148
 
149
+ function formatDateTimeLabel(value?: string | null): string | null {
150
+ if (!value) return null;
151
+
152
+ const parsed = new Date(value);
153
+ if (Number.isNaN(parsed.getTime())) return null;
154
+
155
+ return new Intl.DateTimeFormat('pt-BR', {
156
+ dateStyle: 'short',
157
+ timeStyle: 'short',
158
+ }).format(parsed);
159
+ }
160
+
138
161
  function videoProfileResourceType(profileId: number): string {
139
162
  return `video_profile:${profileId}`;
140
163
  }
141
164
 
142
165
  const MAX_VIDEO_UPLOAD_SIZE_BYTES = 100 * 1024 * 1024;
143
166
 
167
+ type LessonTypeLabelKey = `types.${LessonType}`;
168
+ type VideoJobStatusMessageKey = `lessonForm.videoJobStatuses.${QueueJobStatus}`;
169
+ type VideoAttemptStatus =
170
+ QueueJobResponse['queue_job_attempt'][number]['status'];
171
+ type VideoAttemptStatusMessageKey =
172
+ `lessonForm.videoAttemptStatuses.${VideoAttemptStatus}`;
173
+ type VideoJobEventType =
174
+ QueueJobResponse['queue_job_event'][number]['event_type'];
175
+ type VideoJobEventMessageKey = `lessonForm.videoJobEvents.${VideoJobEventType}`;
176
+ type VideoJobProgressPhase =
177
+ | 'download_original'
178
+ | 'probe_duration'
179
+ | 'convert_profile'
180
+ | 'extract_frames'
181
+ | 'extract_frames_done'
182
+ | 'extract_audio'
183
+ | 'queue_transcription'
184
+ | 'queue_transcription_done'
185
+ | 'queue_transcription_skipped'
186
+ | 'transcription_download_audio'
187
+ | 'transcription_split_audio'
188
+ | 'transcription_split_done'
189
+ | 'transcription_ai_chunk'
190
+ | 'transcription_save'
191
+ | 'transcription_done';
192
+ type VideoJobProgressMessageKey =
193
+ `lessonForm.videoJobProgress.${VideoJobProgressPhase}`;
194
+ type TranslateFn = (
195
+ key: string,
196
+ values?: Record<string, string | number>
197
+ ) => string;
198
+
144
199
  type LessonEditorTab =
145
200
  | 'dados'
146
201
  | 'conteudo'
147
202
  | 'videos'
203
+ | 'imagens'
148
204
  | 'transcricao'
205
+ | 'audios'
149
206
  | 'recursos';
150
207
 
151
208
  const ACTIVE_VIDEO_JOB_STATUSES: QueueJobStatus[] = [
@@ -162,6 +219,21 @@ const TERMINAL_VIDEO_JOB_STATUSES: QueueJobStatus[] = [
162
219
  'dead_letter',
163
220
  ];
164
221
 
222
+ const VIDEO_JOB_FEEDBACK_COLLAPSED_STORAGE_KEY =
223
+ 'lms:global:videoJobFeedbackCollapsed';
224
+
225
+ function readVideoJobFeedbackCollapsedPreference(): boolean {
226
+ if (typeof window === 'undefined') return false;
227
+ try {
228
+ return (
229
+ window.localStorage.getItem(VIDEO_JOB_FEEDBACK_COLLAPSED_STORAGE_KEY) ===
230
+ 'true'
231
+ );
232
+ } catch {
233
+ return false;
234
+ }
235
+ }
236
+
165
237
  const VIDEO_JOB_STATUS_COLORS: Record<QueueJobStatus, string> = {
166
238
  pending: 'bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300',
167
239
  scheduled: 'bg-sky-100 text-sky-700 dark:bg-sky-900 dark:text-sky-300',
@@ -175,16 +247,57 @@ const VIDEO_JOB_STATUS_COLORS: Record<QueueJobStatus, string> = {
175
247
  dead_letter: 'bg-rose-100 text-rose-700 dark:bg-rose-900 dark:text-rose-300',
176
248
  };
177
249
 
178
- function formatDateTimeLabel(value?: string | null): string | null {
179
- if (!value) return null;
250
+ const VIDEO_JOB_PROGRESS_PHASES = new Set<VideoJobProgressPhase>([
251
+ 'download_original',
252
+ 'probe_duration',
253
+ 'convert_profile',
254
+ 'extract_frames',
255
+ 'extract_frames_done',
256
+ 'extract_audio',
257
+ 'queue_transcription',
258
+ 'queue_transcription_done',
259
+ 'queue_transcription_skipped',
260
+ 'transcription_download_audio',
261
+ 'transcription_split_audio',
262
+ 'transcription_split_done',
263
+ 'transcription_ai_chunk',
264
+ 'transcription_save',
265
+ 'transcription_done',
266
+ ]);
180
267
 
181
- const parsed = new Date(value);
182
- if (Number.isNaN(parsed.getTime())) return null;
268
+ function getVideoJobProgressPhase(
269
+ event?: QueueJobResponse['queue_job_event'][number] | null
270
+ ): VideoJobProgressPhase | null {
271
+ const phase = event?.metadata?.phase;
272
+ if (typeof phase !== 'string') return null;
183
273
 
184
- return new Intl.DateTimeFormat('pt-BR', {
185
- dateStyle: 'short',
186
- timeStyle: 'short',
187
- }).format(parsed);
274
+ return VIDEO_JOB_PROGRESS_PHASES.has(phase as VideoJobProgressPhase)
275
+ ? (phase as VideoJobProgressPhase)
276
+ : null;
277
+ }
278
+
279
+ function getVideoJobTimerPhase(
280
+ status?: QueueJobStatus
281
+ ): 'waiting' | 'running' | 'completed' | 'error' {
282
+ if (!status) return 'waiting';
283
+
284
+ if (status === 'processing' || status === 'retrying') {
285
+ return 'running';
286
+ }
287
+
288
+ if (status === 'completed') {
289
+ return 'completed';
290
+ }
291
+
292
+ if (
293
+ status === 'failed' ||
294
+ status === 'dead_letter' ||
295
+ status === 'canceled'
296
+ ) {
297
+ return 'error';
298
+ }
299
+
300
+ return 'waiting';
188
301
  }
189
302
 
190
303
  function formatDurationLabel(durationMs?: number | null): string | null {
@@ -197,11 +310,114 @@ function formatDurationLabel(durationMs?: number | null): string | null {
197
310
  return `${(seconds / 60).toFixed(1)} min`;
198
311
  }
199
312
 
313
+ function formatTimecodeLabel(timeSeconds?: number | null): string {
314
+ const totalSeconds = Math.max(0, Math.floor(timeSeconds ?? 0));
315
+ const hours = Math.floor(totalSeconds / 3600);
316
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
317
+ const seconds = totalSeconds % 60;
318
+
319
+ if (hours > 0) {
320
+ return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
321
+ }
322
+
323
+ return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
324
+ }
325
+
326
+ function formatElapsedTimer(durationMs?: number | null): string | null {
327
+ if (durationMs == null || !Number.isFinite(durationMs)) return null;
328
+
329
+ const totalSeconds = Math.max(0, Math.floor(durationMs / 1000));
330
+ const hours = Math.floor(totalSeconds / 3600);
331
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
332
+ const seconds = totalSeconds % 60;
333
+
334
+ return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
335
+ }
336
+
337
+ function getVideoJobProgressMessage(
338
+ event: QueueJobResponse['queue_job_event'][number] | null | undefined,
339
+ t: TranslateFn
340
+ ): string | null {
341
+ if (!event) return null;
342
+
343
+ const phase = getVideoJobProgressPhase(event);
344
+ const metadata = event.metadata ?? {};
345
+
346
+ if (phase === 'convert_profile') {
347
+ const profileName =
348
+ typeof metadata.profileName === 'string' && metadata.profileName.trim()
349
+ ? metadata.profileName.trim()
350
+ : typeof metadata.profileId === 'number'
351
+ ? `#${metadata.profileId}`
352
+ : '—';
353
+
354
+ return t('lessonForm.videoJobProgress.convert_profile', {
355
+ profileName,
356
+ });
357
+ }
358
+
359
+ if (phase === 'extract_frames_done') {
360
+ const count =
361
+ typeof metadata.frames === 'number' && Number.isFinite(metadata.frames)
362
+ ? metadata.frames
363
+ : 0;
364
+
365
+ return t('lessonForm.videoJobProgress.extract_frames_done', { count });
366
+ }
367
+
368
+ if (phase === 'transcription_split_done') {
369
+ const count =
370
+ typeof metadata.chunks === 'number' && Number.isFinite(metadata.chunks)
371
+ ? metadata.chunks
372
+ : 0;
373
+
374
+ return t('lessonForm.videoJobProgress.transcription_split_done', {
375
+ count,
376
+ });
377
+ }
378
+
379
+ if (phase === 'transcription_ai_chunk') {
380
+ const current =
381
+ typeof metadata.chunkIndex === 'number' &&
382
+ Number.isFinite(metadata.chunkIndex)
383
+ ? metadata.chunkIndex
384
+ : 1;
385
+ const total =
386
+ typeof metadata.chunkCount === 'number' &&
387
+ Number.isFinite(metadata.chunkCount)
388
+ ? metadata.chunkCount
389
+ : current;
390
+
391
+ return t('lessonForm.videoJobProgress.transcription_ai_chunk', {
392
+ current,
393
+ total,
394
+ });
395
+ }
396
+
397
+ if (phase === 'transcription_done') {
398
+ const count =
399
+ typeof metadata.segments === 'number' &&
400
+ Number.isFinite(metadata.segments)
401
+ ? metadata.segments
402
+ : 0;
403
+
404
+ return t('lessonForm.videoJobProgress.transcription_done', { count });
405
+ }
406
+
407
+ if (phase) {
408
+ return t(
409
+ `lessonForm.videoJobProgress.${phase}` as VideoJobProgressMessageKey
410
+ );
411
+ }
412
+
413
+ return event.message?.trim() || null;
414
+ }
415
+
200
416
  // ── Config maps ───────────────────────────────────────────────────────────────
201
417
 
202
418
  const TYPE_CONFIG: Record<
203
419
  LessonType,
204
- { icon: LucideIcon; color: string; bg: string; labelKey: string }
420
+ { icon: LucideIcon; color: string; bg: string; labelKey: LessonTypeLabelKey }
205
421
  > = {
206
422
  video: {
207
423
  icon: Video,
@@ -320,7 +536,7 @@ type FormValues = {
320
536
  questionId?: string | null;
321
537
  };
322
538
 
323
- type TranscriptionSegment = {
539
+ type EditableTranscriptionSegment = {
324
540
  id: string;
325
541
  start: string;
326
542
  end: string;
@@ -366,45 +582,39 @@ function normalizeTimeInput(input: string): string {
366
582
  return `${String(mm).padStart(2, '0')}:${String(ss).padStart(2, '0')}`;
367
583
  }
368
584
 
369
- function parseTranscriptionSegments(raw?: string): TranscriptionSegment[] {
370
- const lines = String(raw ?? '')
371
- .split('\n')
372
- .map((line) => line.trim())
373
- .filter(Boolean);
374
- if (lines.length === 0) {
375
- return [{ id: segmentId(), start: '00:00', end: '00:15', text: '' }];
585
+ function secondsToHMS(totalSeconds: number): string {
586
+ const h = Math.floor(totalSeconds / 3600);
587
+ const m = Math.floor((totalSeconds % 3600) / 60);
588
+ const s = Math.floor(totalSeconds % 60);
589
+ return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
590
+ }
591
+
592
+ function hmsToSeconds(hms: string): number {
593
+ const parts = hms.split(':').map(Number);
594
+ if (parts.length === 3) {
595
+ const [hours = 0, minutes = 0, seconds = 0] = parts;
596
+ return hours * 3600 + minutes * 60 + seconds;
376
597
  }
377
- return lines.map((line) => {
378
- const match = line.match(/^\[(.+?)\s*-->\s*(.+?)\]\s*(.*)$/);
379
- if (match) {
380
- return {
381
- id: segmentId(),
382
- start: normalizeTimeInput(match[1] ?? ''),
383
- end: normalizeTimeInput(match[2] ?? ''),
384
- text: match[3] ?? '',
385
- };
386
- }
387
- return {
388
- id: segmentId(),
389
- start: '00:00',
390
- end: '00:15',
391
- text: line,
392
- };
393
- });
598
+ if (parts.length === 2) {
599
+ const [minutes = 0, seconds = 0] = parts;
600
+ return minutes * 60 + seconds;
601
+ }
602
+ return Number(hms) || 0;
394
603
  }
395
604
 
396
- function serializeTranscriptionSegments(
397
- segments: TranscriptionSegment[]
398
- ): string {
399
- return segments
400
- .map((segment) => ({
401
- start: normalizeTimeInput(segment.start),
402
- end: normalizeTimeInput(segment.end),
403
- text: segment.text.trim(),
404
- }))
405
- .filter((segment) => segment.text.length > 0)
406
- .map((segment) => `[${segment.start} --> ${segment.end}] ${segment.text}`)
407
- .join('\n');
605
+ function toEditableTranscriptionSegments(
606
+ segments?: LessonTranscriptionSegment[]
607
+ ): EditableTranscriptionSegment[] {
608
+ if (!segments?.length) {
609
+ return [{ id: segmentId(), start: '00:00', end: '00:15', text: '' }];
610
+ }
611
+
612
+ return segments.map((segment) => ({
613
+ id: String(segment.id),
614
+ start: secondsToHMS(segment.startSeconds),
615
+ end: secondsToHMS(segment.endSeconds),
616
+ text: segment.text,
617
+ }));
408
618
  }
409
619
 
410
620
  // ── SortableAlternativa ───────────────────────────────────────────────────────
@@ -455,15 +665,17 @@ function SortableAlternativa({
455
665
  alt.correta ? 'border-foreground/30 bg-muted/50' : 'bg-background'
456
666
  )}
457
667
  >
458
- <button
459
- type="button"
460
- className="cursor-grab touch-none text-muted-foreground hover:text-foreground"
461
- {...attributes}
462
- {...listeners}
463
- aria-label={dragLabel}
464
- >
465
- <GripVertical className="size-4" />
466
- </button>
668
+ <IconActionTooltip label={dragLabel}>
669
+ <button
670
+ type="button"
671
+ className="cursor-grab touch-none text-muted-foreground hover:text-foreground"
672
+ {...attributes}
673
+ {...listeners}
674
+ aria-label={dragLabel}
675
+ >
676
+ <GripVertical className="size-4" />
677
+ </button>
678
+ </IconActionTooltip>
467
679
  <span className="flex size-6 shrink-0 items-center justify-center rounded-full border text-xs font-medium">
468
680
  {String.fromCharCode(65 + index)}
469
681
  </span>
@@ -474,32 +686,38 @@ function SortableAlternativa({
474
686
  placeholder={textPlaceholder}
475
687
  disabled={disableText}
476
688
  />
477
- <button
478
- type="button"
479
- onClick={onToggleCorrect}
480
- className={cn(
481
- 'shrink-0 rounded-full p-1 transition-colors',
482
- alt.correta
483
- ? 'text-foreground'
484
- : 'text-muted-foreground hover:text-foreground'
485
- )}
486
- aria-label={alt.correta ? markIncorrectLabel : markCorrectLabel}
689
+ <IconActionTooltip
690
+ label={alt.correta ? markIncorrectLabel : markCorrectLabel}
487
691
  >
488
- {alt.correta ? (
489
- <CircleDot className="size-5" />
490
- ) : (
491
- <CircleOff className="size-5" />
492
- )}
493
- </button>
494
- {canRemove && (
495
692
  <button
496
693
  type="button"
497
- onClick={onRemove}
498
- className="shrink-0 rounded p-1 text-muted-foreground transition-colors hover:text-destructive"
499
- aria-label={removeLabel}
694
+ onClick={onToggleCorrect}
695
+ className={cn(
696
+ 'shrink-0 rounded-full p-1 transition-colors',
697
+ alt.correta
698
+ ? 'text-foreground'
699
+ : 'text-muted-foreground hover:text-foreground'
700
+ )}
701
+ aria-label={alt.correta ? markIncorrectLabel : markCorrectLabel}
500
702
  >
501
- <X className="size-4" />
703
+ {alt.correta ? (
704
+ <CircleDot className="size-5" />
705
+ ) : (
706
+ <CircleOff className="size-5" />
707
+ )}
502
708
  </button>
709
+ </IconActionTooltip>
710
+ {canRemove && (
711
+ <IconActionTooltip label={removeLabel}>
712
+ <button
713
+ type="button"
714
+ onClick={onRemove}
715
+ className="shrink-0 rounded p-1 text-muted-foreground transition-colors hover:text-destructive"
716
+ aria-label={removeLabel}
717
+ >
718
+ <X className="size-4" />
719
+ </button>
720
+ </IconActionTooltip>
503
721
  )}
504
722
  </div>
505
723
  );
@@ -518,17 +736,51 @@ type VideoProfileOption = {
518
736
  status: string;
519
737
  };
520
738
 
739
+ type FramePreviewSource = {
740
+ key: string;
741
+ label: string;
742
+ resource: Resource;
743
+ };
744
+
745
+ type FileMetadata = {
746
+ size?: number;
747
+ uploadedAt?: string | null;
748
+ };
749
+
750
+ type FrameAssetMetadata = {
751
+ url?: string;
752
+ sizeLabel: string;
753
+ };
754
+
521
755
  export function EditorLesson({ lessonId }: EditorLessonProps) {
522
756
  const t = useTranslations('lms.CoursesPage.StructurePage');
757
+ const tabAudiosLabel = (() => {
758
+ try {
759
+ return t('lessonForm.tabAudios');
760
+ } catch {
761
+ return 'Áudios';
762
+ }
763
+ })();
523
764
  const lesson = useStructureStore((s) =>
524
765
  s.lessons.find((l) => l.id === lessonId)
525
766
  );
767
+ const persistedVideoProvider: VideoProvider | undefined =
768
+ lesson?.videoProvider === 'youtube' || lesson?.videoProvider === 'vimeo'
769
+ ? lesson.videoProvider
770
+ : lesson?.videoProvider
771
+ ? 'file_storage'
772
+ : undefined;
773
+ const videoFrames = lesson?.frames ?? [];
526
774
  const updateLesson = useUpdateLessonMutation();
775
+ const updateTranscriptionSegments = useUpdateTranscriptionSegmentsMutation(
776
+ lesson?.id ?? null
777
+ );
527
778
  const deleteLesson = useDeleteLessonMutation();
528
779
  const showConfirm = useStructureStore((s) => s.showConfirm);
529
780
  const courseId = useStructureStore((s) => s.courseId);
530
- const { request } = useApp();
781
+ const { request, getSettingValue } = useApp();
531
782
  const queryClient = useQueryClient();
783
+ const lmsSettings = useLmsSettingsQuery();
532
784
  const statusLabels: Record<LessonStatus, string> = {
533
785
  preparada: t('statuses.preparada'),
534
786
  gravada: t('statuses.gravada'),
@@ -536,11 +788,17 @@ export function EditorLesson({ lessonId }: EditorLessonProps) {
536
788
  finalizada: t('statuses.finalizada'),
537
789
  publicada: t('statuses.publicada'),
538
790
  };
539
- const videoProviders: { value: VideoProvider; label: string }[] = [
540
- { value: 'youtube', label: 'YouTube' },
541
- { value: 'vimeo', label: 'Vimeo' },
542
- { value: 'file_storage', label: t('providers.fileStorage') },
543
- ];
791
+ const videoProviders: { value: VideoProvider; label: string }[] = (
792
+ [
793
+ { value: 'youtube' as VideoProvider, label: 'YouTube' },
794
+ { value: 'vimeo' as VideoProvider, label: 'Vimeo' },
795
+ { value: 'file_storage' as VideoProvider, label: t('providers.fileStorage') },
796
+ ] as { value: VideoProvider; label: string }[]
797
+ ).filter((p) => {
798
+ if (p.value === 'youtube') return lmsSettings.youtubeEnabled;
799
+ if (p.value === 'vimeo') return lmsSettings.vimeoEnabled;
800
+ return true;
801
+ });
544
802
  const questionTypeLabels: Record<QuestionType, string> = {
545
803
  multiple_choice: t('questionEditor.types.multipleChoice'),
546
804
  true_false: t('questionEditor.types.trueFalse'),
@@ -548,6 +806,7 @@ export function EditorLesson({ lessonId }: EditorLessonProps) {
548
806
  fill_blank: t('questionEditor.types.fillBlank'),
549
807
  matching: t('questionEditor.types.matching'),
550
808
  };
809
+ const isVideoConversionEnabled = lmsSettings.videoConversionEnabled;
551
810
  const schema = z.object({
552
811
  code: z.string().min(1, t('questionEditor.validation.codeRequired')),
553
812
  title: z.string().min(1, t('questionEditor.validation.titleRequired')),
@@ -618,7 +877,12 @@ export function EditorLesson({ lessonId }: EditorLessonProps) {
618
877
  const [localResources, setLocalResources] = useState<Resource[]>(
619
878
  () => lesson?.resources ?? []
620
879
  );
880
+ const [jobTimerNowMs, setJobTimerNowMs] = useState<number>(() => Date.now());
621
881
  const [activeTab, setActiveTab] = useState<LessonEditorTab>('dados');
882
+ const [isJobFeedbackCollapsed, setIsJobFeedbackCollapsed] = useState<boolean>(
883
+ () => readVideoJobFeedbackCollapsedPreference()
884
+ );
885
+ const [jobDetailOpen, setJobDetailOpen] = useState(false);
622
886
  const [resourcesDirty, setResourcesDirty] = useState(false);
623
887
  const [conversionJobId, setConversionJobId] = useState<number | null>(null);
624
888
  const [videoUploadError, setVideoUploadError] = useState<string | null>(null);
@@ -627,16 +891,145 @@ export function EditorLesson({ lessonId }: EditorLessonProps) {
627
891
  const [originalUploadProgress, setOriginalUploadProgress] = useState<
628
892
  number | null
629
893
  >(null);
894
+ const [isRequeueingOriginalVideo, setIsRequeueingOriginalVideo] =
895
+ useState(false);
630
896
  const [profileUploadProgress, setProfileUploadProgress] = useState<
631
897
  Record<number, number>
632
898
  >({});
633
899
  const [isResolvingVideoPreview, setIsResolvingVideoPreview] = useState(false);
900
+ const [videoPreviewOpen, setVideoPreviewOpen] = useState(false);
901
+ const [videoPreviewResource, setVideoPreviewResource] =
902
+ useState<Resource | null>(null);
903
+ const [videoPreviewUrl, setVideoPreviewUrl] = useState<string | null>(null);
904
+ const [videoPreviewError, setVideoPreviewError] = useState<string | null>(
905
+ null
906
+ );
907
+ const [framePreviewOpen, setFramePreviewOpen] = useState(false);
908
+ const [framePreviewFrame, setFramePreviewFrame] = useState<VideoFrame | null>(
909
+ null
910
+ );
911
+ const [framePreviewSourceKey, setFramePreviewSourceKey] = useState('');
912
+ const [framePreviewUrl, setFramePreviewUrl] = useState<string | null>(null);
913
+ const [framePreviewError, setFramePreviewError] = useState<string | null>(
914
+ null
915
+ );
916
+ const [isResolvingFramePreview, setIsResolvingFramePreview] = useState(false);
917
+ const [frameImageDialogOpen, setFrameImageDialogOpen] = useState(false);
918
+ const [frameImageDialogFrame, setFrameImageDialogFrame] =
919
+ useState<VideoFrame | null>(null);
920
+ const [downloadingFrameIds, setDownloadingFrameIds] = useState<Set<string>>(
921
+ () => new Set<string>()
922
+ );
923
+ const [frameEditSheetOpen, setFrameEditSheetOpen] = useState(false);
924
+ const [editingFrame, setEditingFrame] = useState<VideoFrame | null>(null);
925
+ const [editingFrameTime, setEditingFrameTime] = useState('00:00');
926
+ const [editingFramePreviewUrl, setEditingFramePreviewUrl] = useState<
927
+ string | null
928
+ >(null);
929
+ const [editingFrameFile, setEditingFrameFile] = useState<File | null>(null);
930
+ const [isSavingFrameEdit, setIsSavingFrameEdit] = useState(false);
931
+ const [frameCreateSheetOpen, setFrameCreateSheetOpen] = useState(false);
932
+ const [creatingFrameTime, setCreatingFrameTime] = useState('00:00');
933
+ const [creatingFrameFile, setCreatingFrameFile] = useState<File | null>(null);
934
+ const [creatingFramePreviewUrl, setCreatingFramePreviewUrl] = useState<
935
+ string | null
936
+ >(null);
937
+ const [isSavingFrameCreate, setIsSavingFrameCreate] = useState(false);
938
+ const [resourceMetadataByFileId, setResourceMetadataByFileId] = useState<
939
+ Record<string, FileMetadata>
940
+ >({});
941
+ const [downloadingResourceKeys, setDownloadingResourceKeys] = useState<
942
+ Set<string>
943
+ >(() => new Set<string>());
944
+ const [frameAssetMetadataById, setFrameAssetMetadataById] = useState<
945
+ Record<string, FrameAssetMetadata>
946
+ >({});
947
+ const [isDeletingAllFrames, setIsDeletingAllFrames] = useState(false);
948
+ const [frameImageErrorIds, setFrameImageErrorIds] = useState<Set<string>>(
949
+ () => new Set<string>()
950
+ );
634
951
  const resourceInputRef = useRef<HTMLInputElement>(null);
635
952
  const originalVideoInputRef = useRef<HTMLInputElement>(null);
953
+ const lastPersistedVideoProviderRef = useRef<VideoProvider | undefined>(
954
+ persistedVideoProvider
955
+ );
956
+ const isAutoSavingVideoProviderRef = useRef(false);
636
957
  const lastTerminalJobStatusRef = useRef<string | null>(null);
958
+ const autoCollapsedCompletedJobRef = useRef<number | null>(null);
959
+ const videoPreviewRequestIdRef = useRef(0);
960
+
961
+ useEffect(() => {
962
+ lastPersistedVideoProviderRef.current = persistedVideoProvider;
963
+ isAutoSavingVideoProviderRef.current = false;
964
+ }, [lesson?.id, persistedVideoProvider]);
965
+
966
+ useEffect(() => {
967
+ if (!lesson) return;
968
+ if (watchedType !== 'video') return;
969
+ if (!watchedVideoProvider) return;
970
+ if (isAutoSavingVideoProviderRef.current) return;
971
+
972
+ const currentPersistedProvider =
973
+ lastPersistedVideoProviderRef.current ?? persistedVideoProvider;
974
+
975
+ // Skip autosave when persisted provider is unknown to avoid patching legacy rows implicitly.
976
+ if (!currentPersistedProvider) return;
977
+ if (watchedVideoProvider === currentPersistedProvider) return;
978
+
979
+ isAutoSavingVideoProviderRef.current = true;
980
+ const previousProvider = currentPersistedProvider;
981
+
982
+ updateLesson.mutate(
983
+ {
984
+ lessonId,
985
+ sessionId: lesson.sessionId,
986
+ formValues: {
987
+ videoProvider: watchedVideoProvider,
988
+ videoUrl:
989
+ watchedVideoProvider === 'file_storage'
990
+ ? ''
991
+ : form.getValues('videoUrl'),
992
+ },
993
+ },
994
+ {
995
+ onSuccess: () => {
996
+ lastPersistedVideoProviderRef.current = watchedVideoProvider;
997
+ if (watchedVideoProvider === 'file_storage') {
998
+ form.setValue('videoUrl', '', { shouldDirty: false });
999
+ }
1000
+ },
1001
+ onError: () => {
1002
+ form.setValue('videoProvider', previousProvider, {
1003
+ shouldDirty: false,
1004
+ });
1005
+ },
1006
+ onSettled: () => {
1007
+ isAutoSavingVideoProviderRef.current = false;
1008
+ },
1009
+ }
1010
+ );
1011
+ }, [
1012
+ form,
1013
+ lesson,
1014
+ lessonId,
1015
+ persistedVideoProvider,
1016
+ updateLesson,
1017
+ watchedType,
1018
+ watchedVideoProvider,
1019
+ ]);
1020
+ const framePreviewRequestIdRef = useRef(0);
1021
+ const framePreviewVideoRef = useRef<HTMLVideoElement>(null);
1022
+ const frameEditInputRef = useRef<HTMLInputElement>(null);
1023
+ const frameCreateInputRef = useRef<HTMLInputElement>(null);
1024
+ const resourceMetadataLoadedRef = useRef<Set<string>>(new Set());
1025
+ const frameMetadataLoadedRef = useRef<Set<string>>(new Set());
637
1026
  const [transcriptionSegments, setTranscriptionSegments] = useState<
638
- TranscriptionSegment[]
639
- >(() => parseTranscriptionSegments(lesson?.transcription));
1027
+ EditableTranscriptionSegment[]
1028
+ >(() => toEditableTranscriptionSegments());
1029
+ const [transcriptionDirty, setTranscriptionDirty] = useState(false);
1030
+
1031
+ const { data: fetchedTranscriptionSegments = [] } =
1032
+ useTranscriptionSegmentsQuery(lesson?.id ?? null);
640
1033
 
641
1034
  const {
642
1035
  data: courseVideoProfiles = [],
@@ -673,11 +1066,36 @@ export function EditorLesson({ lessonId }: EditorLessonProps) {
673
1066
  return ACTIVE_VIDEO_JOB_STATUSES.includes(status) ? 3000 : false;
674
1067
  },
675
1068
  });
1069
+ const transcriptionJobId =
1070
+ typeof conversionJob?.result?.transcriptionJobId === 'number'
1071
+ ? conversionJob.result.transcriptionJobId
1072
+ : null;
1073
+ const {
1074
+ data: transcriptionJob,
1075
+ isFetching: isFetchingTranscriptionJob,
1076
+ isError: hasTranscriptionJobError,
1077
+ refetch: refetchTranscriptionJob,
1078
+ } = useQuery<QueueJobResponse>({
1079
+ queryKey: ['queue-job', 'transcription', transcriptionJobId],
1080
+ enabled: Boolean(transcriptionJobId),
1081
+ retry: 1,
1082
+ queryFn: async () => getQueueJob(request, transcriptionJobId!),
1083
+ refetchInterval: (query) => {
1084
+ const status = (query.state.data as QueueJobResponse | undefined)?.status;
1085
+ if (!status) return 2000;
1086
+
1087
+ return ACTIVE_VIDEO_JOB_STATUSES.includes(status) ? 2000 : false;
1088
+ },
1089
+ });
676
1090
 
677
1091
  // ── Instructors state ────────────────────────────────────────────────────
678
1092
  const [selectedInstructorIds, setSelectedInstructorIds] = useState<string[]>(
679
1093
  () => lesson?.instructors?.map((i) => i.id) ?? []
680
1094
  );
1095
+ const [instructorPickerResetKey, setInstructorPickerResetKey] = useState(0);
1096
+ const persistedInstructorIdsRef = useRef<string[]>(
1097
+ lesson?.instructors?.map((i) => String(i.id)) ?? []
1098
+ );
681
1099
 
682
1100
  // ── Question sheet state ────────────────────────────────────────────────────
683
1101
  const [questionSheetOpen, setQuestionSheetOpen] = useState(false);
@@ -704,20 +1122,213 @@ export function EditorLesson({ lessonId }: EditorLessonProps) {
704
1122
  setResourcesDirty(false);
705
1123
  setConversionJobId(lesson?.videoConversionJobId ?? null);
706
1124
  setSelectedInstructorIds(lesson?.instructors?.map((i) => i.id) ?? []);
707
- setTranscriptionSegments(parseTranscriptionSegments(lesson?.transcription));
1125
+ persistedInstructorIdsRef.current =
1126
+ lesson?.instructors?.map((i) => String(i.id)) ?? [];
1127
+ setResourceMetadataByFileId({});
1128
+ setFrameAssetMetadataById({});
1129
+ setFrameImageErrorIds(new Set<string>());
1130
+ resourceMetadataLoadedRef.current.clear();
1131
+ frameMetadataLoadedRef.current.clear();
708
1132
  }, [lesson?.id, lesson?.resources, lesson?.videoConversionJobId]); // eslint-disable-line react-hooks/exhaustive-deps
709
1133
 
1134
+ useEffect(() => {
1135
+ setTranscriptionSegments(
1136
+ toEditableTranscriptionSegments(fetchedTranscriptionSegments)
1137
+ );
1138
+ setTranscriptionDirty(false);
1139
+ }, [lesson?.id, fetchedTranscriptionSegments]);
1140
+
1141
+ useEffect(() => {
1142
+ const frameIds = new Set(videoFrames.map((frame) => frame.id));
1143
+
1144
+ setFrameAssetMetadataById((current) => {
1145
+ const nextEntries = Object.entries(current).filter(([id]) =>
1146
+ frameIds.has(id)
1147
+ );
1148
+ return Object.fromEntries(nextEntries);
1149
+ });
1150
+
1151
+ setFrameImageErrorIds((current) => {
1152
+ const next = new Set<string>();
1153
+ for (const id of current) {
1154
+ if (frameIds.has(id)) next.add(id);
1155
+ }
1156
+ return next;
1157
+ });
1158
+
1159
+ const frameSignatures = new Set(
1160
+ videoFrames.map((frame) => `${frame.id}:${frame.fileId ?? 'no-file'}`)
1161
+ );
1162
+
1163
+ frameMetadataLoadedRef.current = new Set(
1164
+ [...frameMetadataLoadedRef.current].filter((signature) =>
1165
+ frameSignatures.has(signature)
1166
+ )
1167
+ );
1168
+
1169
+ const pendingFrames = videoFrames.filter((frame) => {
1170
+ if (!frame.fileId) return false;
1171
+ const signature = `${frame.id}:${frame.fileId}`;
1172
+ return !frameMetadataLoadedRef.current.has(signature);
1173
+ });
1174
+
1175
+ if (pendingFrames.length === 0) return;
1176
+
1177
+ let cancelled = false;
1178
+
1179
+ void Promise.allSettled(
1180
+ pendingFrames.map(async (frame) => {
1181
+ const signature = `${frame.id}:${frame.fileId}`;
1182
+ frameMetadataLoadedRef.current.add(signature);
1183
+
1184
+ const fallbackUrl = frame.url ?? `/file/open/${frame.fileId}`;
1185
+ let resolvedUrl = fallbackUrl;
1186
+ let sizeLabel = '—';
1187
+
1188
+ try {
1189
+ const openResponse = await request<{ url?: string }>({
1190
+ url: `/file/open/${frame.fileId}`,
1191
+ method: 'PUT',
1192
+ });
1193
+ resolvedUrl = openResponse.data?.url ?? fallbackUrl;
1194
+ } catch {
1195
+ resolvedUrl = fallbackUrl;
1196
+ }
1197
+
1198
+ try {
1199
+ const metadataResponse = await request<{ size?: number }>({
1200
+ url: `/file/${frame.fileId}`,
1201
+ method: 'GET',
1202
+ });
1203
+ if (typeof metadataResponse.data?.size === 'number') {
1204
+ sizeLabel = formatFileSize(metadataResponse.data.size);
1205
+ }
1206
+ } catch {
1207
+ // Não bloqueia o card quando a metadata não está disponível.
1208
+ }
1209
+
1210
+ return {
1211
+ frameId: frame.id,
1212
+ metadata: {
1213
+ url: resolvedUrl,
1214
+ sizeLabel,
1215
+ },
1216
+ };
1217
+ })
1218
+ ).then((results) => {
1219
+ if (cancelled) return;
1220
+
1221
+ setFrameAssetMetadataById((current) => {
1222
+ const next = { ...current };
1223
+ for (const result of results) {
1224
+ if (result.status !== 'fulfilled') continue;
1225
+ next[result.value.frameId] = result.value.metadata;
1226
+ }
1227
+ return next;
1228
+ });
1229
+ });
1230
+
1231
+ return () => {
1232
+ cancelled = true;
1233
+ };
1234
+ }, [request, videoFrames]);
1235
+
1236
+ useEffect(() => {
1237
+ const pendingResources = localResources.filter((resource) => {
1238
+ if (!resource.fileId) return false;
1239
+ const metadataKey = String(resource.fileId);
1240
+ if (resourceMetadataLoadedRef.current.has(metadataKey)) return false;
1241
+ if (resource.size && resource.uploadedAt) return false;
1242
+ return true;
1243
+ });
1244
+
1245
+ if (pendingResources.length === 0) return;
1246
+
1247
+ let cancelled = false;
1248
+
1249
+ void Promise.allSettled(
1250
+ pendingResources.map(async (resource) => {
1251
+ const metadataKey = String(resource.fileId);
1252
+ resourceMetadataLoadedRef.current.add(metadataKey);
1253
+
1254
+ const response = await request<{
1255
+ size?: number;
1256
+ created_at?: string;
1257
+ createdAt?: string;
1258
+ }>({
1259
+ url: `/file/${resource.fileId}`,
1260
+ method: 'GET',
1261
+ });
1262
+
1263
+ return {
1264
+ metadataKey,
1265
+ size: response.data?.size,
1266
+ uploadedAt:
1267
+ response.data?.created_at ?? response.data?.createdAt ?? null,
1268
+ };
1269
+ })
1270
+ ).then((results) => {
1271
+ if (cancelled) return;
1272
+
1273
+ setResourceMetadataByFileId((current) => {
1274
+ const next = { ...current };
1275
+ for (const result of results) {
1276
+ if (result.status !== 'fulfilled') continue;
1277
+ next[result.value.metadataKey] = {
1278
+ size: result.value.size,
1279
+ uploadedAt: result.value.uploadedAt,
1280
+ };
1281
+ }
1282
+ return next;
1283
+ });
1284
+ });
1285
+
1286
+ return () => {
1287
+ cancelled = true;
1288
+ };
1289
+ }, [localResources, request]);
1290
+
710
1291
  useEffect(() => {
711
1292
  if (watchedType === 'video') return;
712
- if (activeTab === 'videos' || activeTab === 'transcricao') {
1293
+ if (
1294
+ activeTab === 'videos' ||
1295
+ activeTab === 'imagens' ||
1296
+ activeTab === 'transcricao' ||
1297
+ activeTab === 'audios'
1298
+ ) {
713
1299
  setActiveTab('conteudo');
714
1300
  }
715
1301
  }, [activeTab, watchedType]);
716
1302
 
1303
+ useEffect(() => {
1304
+ if (typeof window === 'undefined') return;
1305
+ try {
1306
+ window.localStorage.setItem(
1307
+ VIDEO_JOB_FEEDBACK_COLLAPSED_STORAGE_KEY,
1308
+ String(isJobFeedbackCollapsed)
1309
+ );
1310
+ } catch {
1311
+ // Ignora indisponibilidade de storage (modo privado/segurança do browser).
1312
+ }
1313
+ }, [isJobFeedbackCollapsed]);
1314
+
717
1315
  useEffect(() => {
718
1316
  lastTerminalJobStatusRef.current = null;
1317
+ autoCollapsedCompletedJobRef.current = null;
719
1318
  }, [conversionJobId]);
720
1319
 
1320
+ useEffect(() => {
1321
+ if (!conversionJob) return;
1322
+
1323
+ if (
1324
+ conversionJob.status === 'completed' &&
1325
+ autoCollapsedCompletedJobRef.current !== conversionJob.id
1326
+ ) {
1327
+ autoCollapsedCompletedJobRef.current = conversionJob.id;
1328
+ setIsJobFeedbackCollapsed(true);
1329
+ }
1330
+ }, [conversionJob]);
1331
+
721
1332
  useEffect(() => {
722
1333
  if (!conversionJobId || !conversionJob) return;
723
1334
  if (!TERMINAL_VIDEO_JOB_STATUSES.includes(conversionJob.status)) return;
@@ -731,32 +1342,204 @@ export function EditorLesson({ lessonId }: EditorLessonProps) {
731
1342
  });
732
1343
  }, [conversionJob, conversionJobId, courseId, queryClient]);
733
1344
 
1345
+ useEffect(() => {
1346
+ const startedAt = conversionJob?.started_at;
1347
+ const status = conversionJob?.status;
1348
+ const isActive = status
1349
+ ? ACTIVE_VIDEO_JOB_STATUSES.includes(status)
1350
+ : false;
1351
+ if (!startedAt) return;
1352
+ if (!isActive) return;
1353
+
1354
+ setJobTimerNowMs(Date.now());
1355
+
1356
+ const interval = window.setInterval(() => {
1357
+ setJobTimerNowMs(Date.now());
1358
+ }, 1000);
1359
+
1360
+ return () => window.clearInterval(interval);
1361
+ }, [conversionJob?.started_at, conversionJob?.status]);
1362
+
734
1363
  if (!lesson) return null;
735
1364
 
736
1365
  const cfg = TYPE_CONFIG[lesson.type];
737
1366
  const Icon = cfg.icon;
738
- const lessonTypeLabel = t(cfg.labelKey as any);
1367
+ const lessonTypeLabel = t(cfg.labelKey);
739
1368
  const originalVideoResource =
740
1369
  localResources.find((res) => res.type === 'video_original') ?? null;
1370
+ const isDownloadingOriginalVideo = originalVideoResource
1371
+ ? downloadingResourceKeys.has(
1372
+ String(originalVideoResource.fileId ?? originalVideoResource.id)
1373
+ )
1374
+ : false;
741
1375
  const profileVideoResources = new Map(
742
1376
  localResources
743
1377
  .filter((res) => res.type.startsWith('video_profile:'))
744
1378
  .map((res) => [Number(res.type.replace('video_profile:', '')), res])
745
1379
  );
1380
+ const audioResources = localResources.filter(
1381
+ (res) => res.type === 'lesson_audio'
1382
+ );
746
1383
  const genericResources = localResources.filter(
747
1384
  (res) =>
748
1385
  res.type !== 'video_original' && !res.type.startsWith('video_profile:')
749
1386
  );
1387
+ const supplementaryResources = genericResources.filter(
1388
+ (res) => res.type === 'supplementary_material'
1389
+ );
750
1390
  const isConversionJobActive = conversionJob
751
1391
  ? ACTIVE_VIDEO_JOB_STATUSES.includes(conversionJob.status)
752
1392
  : false;
753
- const latestConversionAttempt =
754
- conversionJob?.queue_job_attempt.at(-1) ?? null;
755
- const recentConversionEvents =
756
- conversionJob?.queue_job_event.slice(-3).reverse() ?? [];
1393
+ const isConversionJobCompletedSuccessfully =
1394
+ conversionJob?.status === 'completed';
1395
+ const isConversionJobStatusResolving =
1396
+ Boolean(conversionJobId) && !conversionJob && isFetchingConversionJob;
1397
+ const shouldTrackTranscriptionJob = Boolean(transcriptionJobId);
1398
+ const focusedPipelineJob = shouldTrackTranscriptionJob
1399
+ ? transcriptionJob
1400
+ : conversionJob;
1401
+ const focusedPipelineJobIsLoading = shouldTrackTranscriptionJob
1402
+ ? isFetchingTranscriptionJob
1403
+ : isFetchingConversionJob;
1404
+ const hasFocusedPipelineJobError = shouldTrackTranscriptionJob
1405
+ ? hasTranscriptionJobError
1406
+ : hasConversionJobError;
1407
+ const refetchFocusedPipelineJob = shouldTrackTranscriptionJob
1408
+ ? refetchTranscriptionJob
1409
+ : refetchConversionJob;
1410
+ const latestFocusedAttempt =
1411
+ focusedPipelineJob?.queue_job_attempt.at(-1) ?? null;
1412
+ const recentFocusedEvents =
1413
+ focusedPipelineJob?.queue_job_event.slice(-3).reverse() ?? [];
1414
+ const recentTranscriptionEvents =
1415
+ transcriptionJob?.queue_job_event.slice(-3).reverse() ?? [];
1416
+ const normalizedLastFocusedJobError =
1417
+ focusedPipelineJob?.last_error?.trim() ?? '';
1418
+ const isStaleLockRecoveryMessage =
1419
+ !shouldTrackTranscriptionJob &&
1420
+ normalizedLastFocusedJobError.toLowerCase() ===
1421
+ 'stale lock released by worker';
1422
+ const shouldShowLastFocusedJobError =
1423
+ normalizedLastFocusedJobError.length > 0 &&
1424
+ !(focusedPipelineJob?.status === 'completed' && isStaleLockRecoveryMessage);
1425
+ const isTranscriptionJobCompletedSuccessfully =
1426
+ transcriptionJob?.status === 'completed';
1427
+ const shouldHidePipelineCard =
1428
+ isConversionJobCompletedSuccessfully &&
1429
+ Boolean(transcriptionJobId) &&
1430
+ isTranscriptionJobCompletedSuccessfully;
1431
+ const conversionJobStartedAtMs = focusedPipelineJob?.started_at
1432
+ ? new Date(focusedPipelineJob.started_at).getTime()
1433
+ : null;
1434
+ const conversionJobFinishedAtMs = focusedPipelineJob?.finished_at
1435
+ ? new Date(focusedPipelineJob.finished_at).getTime()
1436
+ : null;
1437
+ const conversionJobElapsedMs =
1438
+ conversionJobStartedAtMs == null
1439
+ ? null
1440
+ : TERMINAL_VIDEO_JOB_STATUSES.includes(
1441
+ focusedPipelineJob?.status ?? 'pending'
1442
+ ) && conversionJobFinishedAtMs != null
1443
+ ? conversionJobFinishedAtMs - conversionJobStartedAtMs
1444
+ : jobTimerNowMs - conversionJobStartedAtMs;
1445
+ const conversionJobElapsedLabel = formatElapsedTimer(conversionJobElapsedMs);
1446
+ const videoJobTimerPhase = getVideoJobTimerPhase(focusedPipelineJob?.status);
1447
+ const VideoJobTimerIcon =
1448
+ videoJobTimerPhase === 'running'
1449
+ ? Loader2
1450
+ : videoJobTimerPhase === 'completed'
1451
+ ? CheckCircle2
1452
+ : videoJobTimerPhase === 'error'
1453
+ ? AlertTriangle
1454
+ : Clock;
757
1455
  const isOriginalVideoUploadBlocked =
758
- originalUploadProgress !== null || isConversionJobActive;
1456
+ originalUploadProgress !== null ||
1457
+ isConversionJobActive ||
1458
+ isConversionJobStatusResolving ||
1459
+ isRequeueingOriginalVideo ||
1460
+ (watchedVideoProvider === 'file_storage' &&
1461
+ persistedVideoProvider !== 'file_storage');
1462
+ const canRequeueSavedOriginalVideo =
1463
+ Boolean(originalVideoResource?.fileId) && !isOriginalVideoUploadBlocked;
759
1464
  const isProfileVideoUploadBlocked = isConversionJobActive;
1465
+ const currentQueueJobId =
1466
+ focusedPipelineJob?.id ?? transcriptionJobId ?? conversionJobId;
1467
+ const isTranscriptionJobActive = transcriptionJob
1468
+ ? ACTIVE_VIDEO_JOB_STATUSES.includes(transcriptionJob.status)
1469
+ : false;
1470
+ const latestPipelineEvent =
1471
+ (shouldTrackTranscriptionJob
1472
+ ? (transcriptionJob?.queue_job_event.at(-1) ??
1473
+ conversionJob?.queue_job_event.at(-1))
1474
+ : conversionJob?.queue_job_event.at(-1)) ?? null;
1475
+ const livePipelineMessage =
1476
+ getVideoJobProgressMessage(latestPipelineEvent, t) ||
1477
+ t('lessonForm.awaitingConversion');
1478
+ const shouldShowLivePipelineMessage =
1479
+ isConversionJobActive ||
1480
+ isConversionJobStatusResolving ||
1481
+ isTranscriptionJobActive ||
1482
+ (Boolean(transcriptionJobId) && isFetchingTranscriptionJob);
1483
+ const videoPreviewSpinnerActive =
1484
+ isResolvingVideoPreview && Boolean(videoPreviewResource);
1485
+ const hasInstructorChanges =
1486
+ selectedInstructorIds.length !== persistedInstructorIdsRef.current.length ||
1487
+ selectedInstructorIds.some(
1488
+ (id, index) => id !== persistedInstructorIdsRef.current[index]
1489
+ );
1490
+ const hasPendingChanges =
1491
+ isDirty || resourcesDirty || transcriptionDirty || hasInstructorChanges;
1492
+ const isSavingLesson =
1493
+ updateLesson.isPending || updateTranscriptionSegments.isPending;
1494
+
1495
+ const framePreviewSources: FramePreviewSource[] = [
1496
+ ...(originalVideoResource
1497
+ ? [
1498
+ {
1499
+ key: 'original',
1500
+ label: 'Original',
1501
+ resource: originalVideoResource,
1502
+ },
1503
+ ]
1504
+ : []),
1505
+ ...courseVideoProfiles.flatMap((profile) => {
1506
+ const resource = profileVideoResources.get(profile.id);
1507
+ if (!resource) return [];
1508
+
1509
+ return [
1510
+ {
1511
+ key: `profile:${profile.id}`,
1512
+ label: profile.name,
1513
+ resource,
1514
+ },
1515
+ ];
1516
+ }),
1517
+ ];
1518
+ const activeFramePreviewSource =
1519
+ framePreviewSources.find(
1520
+ (source) => source.key === framePreviewSourceKey
1521
+ ) ??
1522
+ framePreviewSources[0] ??
1523
+ null;
1524
+
1525
+ async function enqueueOriginalVideoConversion(originalFileId: number) {
1526
+ const queued = await enqueueLessonVideoConversion(
1527
+ request,
1528
+ courseId,
1529
+ lesson!.sessionId,
1530
+ lessonId,
1531
+ originalFileId
1532
+ );
1533
+ setConversionJobId(queued.queueJobId);
1534
+ toast.success(
1535
+ t('lessonForm.videoConversionQueued', {
1536
+ id: queued.queueJobId,
1537
+ })
1538
+ );
1539
+ void queryClient.invalidateQueries({
1540
+ queryKey: courseStructureQueryKey(courseId),
1541
+ });
1542
+ }
760
1543
 
761
1544
  async function handleResourceFiles(files: File[]) {
762
1545
  setIsUploading(true);
@@ -768,7 +1551,7 @@ export function EditorLesson({ lessonId }: EditorLessonProps) {
768
1551
  fileId: res.id,
769
1552
  name: f.name,
770
1553
  size: formatFileSize(f.size),
771
- type: f.type || f.name.split('.').pop() || 'file',
1554
+ type: 'supplementary_material',
772
1555
  public: false,
773
1556
  url: undefined,
774
1557
  }))
@@ -792,20 +1575,60 @@ export function EditorLesson({ lessonId }: EditorLessonProps) {
792
1575
  }
793
1576
  }
794
1577
 
795
- async function removeResource(id: string) {
796
- const res = localResources.find((r) => r.id === id);
797
- const fileId = res?.fileId ?? Number(id);
798
- if (Number.isInteger(fileId) && fileId > 0) {
799
- try {
800
- await deleteFile(request, fileId);
801
- } catch {
802
- toast.error(t('questionEditor.resourceRemoveError'));
803
- return;
804
- }
805
- } else {
806
- if (res?.url?.startsWith('blob:')) URL.revokeObjectURL(res.url);
807
- }
808
- setLocalResources((prev) => prev.filter((r) => r.id !== id));
1578
+ async function handleAudioFiles(files: File[]) {
1579
+ setIsUploading(true);
1580
+ try {
1581
+ const results = await Promise.allSettled(
1582
+ files.map((f) =>
1583
+ uploadFile(request, f, 'lms/lessons/audio').then<Resource>((res) => ({
1584
+ id: `new-${res.id}`,
1585
+ fileId: res.id,
1586
+ name: f.name,
1587
+ size: formatFileSize(f.size),
1588
+ type: 'lesson_audio',
1589
+ public: false,
1590
+ url: undefined,
1591
+ uploadedAt: new Date().toISOString(),
1592
+ }))
1593
+ )
1594
+ );
1595
+
1596
+ const succeeded = results
1597
+ .filter(
1598
+ (r): r is PromiseFulfilledResult<Resource> => r.status === 'fulfilled'
1599
+ )
1600
+ .map((r) => r.value);
1601
+ const failedCount = results.filter((r) => r.status === 'rejected').length;
1602
+
1603
+ if (failedCount > 0) {
1604
+ toast.error(
1605
+ t('questionEditor.resourceUploadFailed', { count: failedCount })
1606
+ );
1607
+ }
1608
+
1609
+ if (succeeded.length > 0) {
1610
+ setLocalResources((prev) => [...prev, ...succeeded]);
1611
+ setResourcesDirty(true);
1612
+ }
1613
+ } finally {
1614
+ setIsUploading(false);
1615
+ }
1616
+ }
1617
+
1618
+ async function removeResource(id: string) {
1619
+ const res = localResources.find((r) => r.id === id);
1620
+ const fileId = res?.fileId ?? Number(id);
1621
+ if (Number.isInteger(fileId) && fileId > 0) {
1622
+ try {
1623
+ await deleteFile(request, fileId);
1624
+ } catch {
1625
+ toast.error(t('questionEditor.resourceRemoveError'));
1626
+ return;
1627
+ }
1628
+ } else {
1629
+ if (res?.url?.startsWith('blob:')) URL.revokeObjectURL(res.url);
1630
+ }
1631
+ setLocalResources((prev) => prev.filter((r) => r.id !== id));
809
1632
  setResourcesDirty(true);
810
1633
  }
811
1634
 
@@ -824,6 +1647,24 @@ export function EditorLesson({ lessonId }: EditorLessonProps) {
824
1647
  }
825
1648
  }
826
1649
 
1650
+ function resolveResourceMetadata(res: Resource) {
1651
+ const metadataKey = res.fileId ? String(res.fileId) : res.id;
1652
+ const metadata = resourceMetadataByFileId[metadataKey];
1653
+ const sizeLabel = res.size?.trim()
1654
+ ? res.size
1655
+ : metadata?.size != null
1656
+ ? formatFileSize(metadata.size)
1657
+ : '—';
1658
+ const uploadedAtLabel =
1659
+ formatDateTimeLabel(res.uploadedAt ?? metadata?.uploadedAt ?? null) ??
1660
+ '—';
1661
+
1662
+ return {
1663
+ sizeLabel,
1664
+ uploadedAtLabel,
1665
+ };
1666
+ }
1667
+
827
1668
  async function openResource(res: Resource) {
828
1669
  const url = await resolveResourceUrl(res);
829
1670
  if (!url) {
@@ -834,31 +1675,585 @@ export function EditorLesson({ lessonId }: EditorLessonProps) {
834
1675
  }
835
1676
 
836
1677
  async function handleResourceDownload(res: Resource) {
837
- const resourceUrl = await resolveResourceUrl(res);
838
- if (!resourceUrl) {
1678
+ const fileId = res.fileId ?? Number(res.id);
1679
+ if (!Number.isInteger(fileId) || fileId <= 0) {
839
1680
  toast(t('questionEditor.resourceDownloadSoon', { name: res.name }));
840
1681
  return;
841
1682
  }
842
- const a = document.createElement('a');
843
- a.href = resourceUrl;
844
- a.download = res.name;
845
- a.click();
1683
+
1684
+ const downloadKey = String(res.fileId ?? res.id);
1685
+ setDownloadingResourceKeys((current) => {
1686
+ const next = new Set(current);
1687
+ next.add(downloadKey);
1688
+ return next;
1689
+ });
1690
+
1691
+ try {
1692
+ const response = await request<{ url?: string }>({
1693
+ url: `/file/download/${fileId}`,
1694
+ method: 'PUT',
1695
+ });
1696
+
1697
+ const downloadUrl = response?.data?.url;
1698
+ if (!downloadUrl) {
1699
+ toast(t('questionEditor.resourceDownloadSoon', { name: res.name }));
1700
+ return;
1701
+ }
1702
+
1703
+ try {
1704
+ // Forca download mesmo para tipos reproduziveis (audio/video) via blob local.
1705
+ const downloadResponse = await fetch(downloadUrl);
1706
+ if (!downloadResponse.ok) {
1707
+ throw new Error('download request failed');
1708
+ }
1709
+
1710
+ const blob = await downloadResponse.blob();
1711
+ const objectUrl = window.URL.createObjectURL(blob);
1712
+ const anchor = document.createElement('a');
1713
+ anchor.href = objectUrl;
1714
+ anchor.download = res.name || `arquivo-${fileId}`;
1715
+ anchor.rel = 'noopener noreferrer';
1716
+ document.body.appendChild(anchor);
1717
+ anchor.click();
1718
+ anchor.remove();
1719
+ window.URL.revokeObjectURL(objectUrl);
1720
+ } catch {
1721
+ // Fallback para navegacao direta no endpoint da API (attachment).
1722
+ const anchor = document.createElement('a');
1723
+ anchor.href = downloadUrl;
1724
+ anchor.rel = 'noopener noreferrer';
1725
+ document.body.appendChild(anchor);
1726
+ anchor.click();
1727
+ anchor.remove();
1728
+ }
1729
+ } catch {
1730
+ toast(t('questionEditor.resourceDownloadSoon', { name: res.name }));
1731
+ } finally {
1732
+ setDownloadingResourceKeys((current) => {
1733
+ const next = new Set(current);
1734
+ next.delete(downloadKey);
1735
+ return next;
1736
+ });
1737
+ }
846
1738
  }
847
1739
 
848
1740
  async function openVideoPreview(res: Resource) {
1741
+ const requestId = ++videoPreviewRequestIdRef.current;
1742
+ setVideoPreviewResource(res);
1743
+ setVideoPreviewOpen(true);
1744
+ setVideoPreviewUrl(null);
1745
+ setVideoPreviewError(null);
849
1746
  setIsResolvingVideoPreview(true);
850
1747
  try {
851
1748
  const resourceUrl = await resolveResourceUrl(res);
852
1749
  if (!resourceUrl) {
853
1750
  toast.error(t('questionEditor.resourceOpenError'));
1751
+ if (videoPreviewRequestIdRef.current === requestId) {
1752
+ setVideoPreviewError(t('questionEditor.resourceOpenError'));
1753
+ }
1754
+ return;
1755
+ }
1756
+ if (videoPreviewRequestIdRef.current !== requestId) return;
1757
+ setVideoPreviewUrl(resourceUrl);
1758
+ } finally {
1759
+ if (videoPreviewRequestIdRef.current === requestId) {
1760
+ setIsResolvingVideoPreview(false);
1761
+ }
1762
+ }
1763
+ }
1764
+
1765
+ function closeVideoPreview() {
1766
+ videoPreviewRequestIdRef.current += 1;
1767
+ setVideoPreviewOpen(false);
1768
+ setVideoPreviewResource(null);
1769
+ setVideoPreviewUrl(null);
1770
+ setVideoPreviewError(null);
1771
+ setIsResolvingVideoPreview(false);
1772
+ }
1773
+
1774
+ async function resolveFramePreviewSource(
1775
+ sourceKey: string,
1776
+ frame = framePreviewFrame
1777
+ ) {
1778
+ const source =
1779
+ framePreviewSources.find((item) => item.key === sourceKey) ??
1780
+ framePreviewSources[0] ??
1781
+ null;
1782
+
1783
+ if (!source || !frame) {
1784
+ setFramePreviewError(t('questionEditor.resourceOpenError'));
1785
+ setFramePreviewUrl(null);
1786
+ setIsResolvingFramePreview(false);
1787
+ return;
1788
+ }
1789
+
1790
+ const requestId = ++framePreviewRequestIdRef.current;
1791
+ setFramePreviewSourceKey(source.key);
1792
+ setFramePreviewUrl(null);
1793
+ setFramePreviewError(null);
1794
+ setIsResolvingFramePreview(true);
1795
+
1796
+ try {
1797
+ const resourceUrl = await resolveResourceUrl(source.resource);
1798
+ if (!resourceUrl) {
1799
+ if (framePreviewRequestIdRef.current === requestId) {
1800
+ setFramePreviewError(t('questionEditor.resourceOpenError'));
1801
+ }
1802
+ return;
1803
+ }
1804
+
1805
+ if (framePreviewRequestIdRef.current !== requestId) return;
1806
+ setFramePreviewUrl(resourceUrl);
1807
+ } finally {
1808
+ if (framePreviewRequestIdRef.current === requestId) {
1809
+ setIsResolvingFramePreview(false);
1810
+ }
1811
+ }
1812
+ }
1813
+
1814
+ async function openFramePreview(frame: VideoFrame) {
1815
+ setFramePreviewFrame(frame);
1816
+ setFramePreviewOpen(true);
1817
+
1818
+ const defaultSource = framePreviewSources[0] ?? null;
1819
+ if (!defaultSource) {
1820
+ setFramePreviewError(t('questionEditor.resourceOpenError'));
1821
+ setFramePreviewUrl(null);
1822
+ setIsResolvingFramePreview(false);
1823
+ return;
1824
+ }
1825
+
1826
+ await resolveFramePreviewSource(defaultSource.key, frame);
1827
+ }
1828
+
1829
+ function closeFramePreview() {
1830
+ framePreviewRequestIdRef.current += 1;
1831
+ setFramePreviewOpen(false);
1832
+ setFramePreviewFrame(null);
1833
+ setFramePreviewSourceKey('');
1834
+ setFramePreviewUrl(null);
1835
+ setFramePreviewError(null);
1836
+ setIsResolvingFramePreview(false);
1837
+ }
1838
+
1839
+ function openFrameImageDialog(frame: VideoFrame) {
1840
+ setFrameImageDialogFrame(frame);
1841
+ setFrameImageDialogOpen(true);
1842
+ }
1843
+
1844
+ function closeFrameImageDialog() {
1845
+ setFrameImageDialogOpen(false);
1846
+ setFrameImageDialogFrame(null);
1847
+ }
1848
+
1849
+ function openFrameEditSheet(frame: VideoFrame) {
1850
+ if (editingFramePreviewUrl?.startsWith('blob:')) {
1851
+ URL.revokeObjectURL(editingFramePreviewUrl);
1852
+ }
1853
+
1854
+ setEditingFrame(frame);
1855
+ setEditingFrameTime(secondsToHMS(frame.timeSeconds));
1856
+ setEditingFrameFile(null);
1857
+ setEditingFramePreviewUrl(resolveFrameCardUrl(frame) ?? null);
1858
+ setFrameEditSheetOpen(true);
1859
+ }
1860
+
1861
+ function closeFrameEditSheet() {
1862
+ if (editingFramePreviewUrl?.startsWith('blob:')) {
1863
+ URL.revokeObjectURL(editingFramePreviewUrl);
1864
+ }
1865
+
1866
+ setFrameEditSheetOpen(false);
1867
+ setEditingFrame(null);
1868
+ setEditingFrameTime('00:00');
1869
+ setEditingFrameFile(null);
1870
+ setEditingFramePreviewUrl(null);
1871
+ setIsSavingFrameEdit(false);
1872
+ }
1873
+
1874
+ function openFrameCreateSheet() {
1875
+ if (creatingFramePreviewUrl?.startsWith('blob:')) {
1876
+ URL.revokeObjectURL(creatingFramePreviewUrl);
1877
+ }
1878
+
1879
+ setCreatingFrameTime('00:00');
1880
+ setCreatingFrameFile(null);
1881
+ setCreatingFramePreviewUrl(null);
1882
+ setFrameCreateSheetOpen(true);
1883
+ }
1884
+
1885
+ function closeFrameCreateSheet() {
1886
+ if (creatingFramePreviewUrl?.startsWith('blob:')) {
1887
+ URL.revokeObjectURL(creatingFramePreviewUrl);
1888
+ }
1889
+
1890
+ setFrameCreateSheetOpen(false);
1891
+ setCreatingFrameTime('00:00');
1892
+ setCreatingFrameFile(null);
1893
+ setCreatingFramePreviewUrl(null);
1894
+ setIsSavingFrameCreate(false);
1895
+ }
1896
+
1897
+ function handleFrameCreateSelect(file?: File | null) {
1898
+ if (!file) return;
1899
+
1900
+ if (!file.type.startsWith('image/')) {
1901
+ toast.error('Selecione um arquivo de imagem valido.');
1902
+ return;
1903
+ }
1904
+
1905
+ if (creatingFramePreviewUrl?.startsWith('blob:')) {
1906
+ URL.revokeObjectURL(creatingFramePreviewUrl);
1907
+ }
1908
+
1909
+ setCreatingFrameFile(file);
1910
+ setCreatingFramePreviewUrl(URL.createObjectURL(file));
1911
+ }
1912
+
1913
+ async function handleSaveFrameCreate() {
1914
+ if (!lesson) return;
1915
+
1916
+ const parsedTimeSeconds = parseTimeToSeconds(creatingFrameTime);
1917
+ if (parsedTimeSeconds === null) {
1918
+ toast.error('Informe um tempo valido (mm:ss ou hh:mm:ss).');
1919
+ return;
1920
+ }
1921
+
1922
+ if (!creatingFrameFile) {
1923
+ toast.error('Selecione uma imagem para adicionar.');
1924
+ return;
1925
+ }
1926
+
1927
+ setIsSavingFrameCreate(true);
1928
+ try {
1929
+ const uploaded = await uploadFile(
1930
+ request,
1931
+ creatingFrameFile,
1932
+ 'lms/lessons/frames'
1933
+ );
1934
+
1935
+ const createdFrame = await createLessonFrame(
1936
+ request,
1937
+ courseId,
1938
+ lesson.sessionId,
1939
+ lessonId,
1940
+ {
1941
+ timeSeconds: parsedTimeSeconds,
1942
+ fileId: uploaded.id,
1943
+ }
1944
+ );
1945
+
1946
+ queryClient.setQueryData<CourseStructureCacheData | undefined>(
1947
+ courseStructureQueryKey(courseId),
1948
+ (current) => {
1949
+ if (!current) return current;
1950
+
1951
+ return {
1952
+ ...current,
1953
+ lessons: current.lessons.map((item) => {
1954
+ if (item.id !== lessonId) return item;
1955
+
1956
+ return {
1957
+ ...item,
1958
+ frames: [
1959
+ ...(item.frames ?? []),
1960
+ {
1961
+ id: String(createdFrame.id),
1962
+ fileId: createdFrame.fileId,
1963
+ name: creatingFrameFile.name,
1964
+ timeSeconds: createdFrame.timeSeconds,
1965
+ url: `/file/open/${createdFrame.fileId}`,
1966
+ },
1967
+ ],
1968
+ };
1969
+ }),
1970
+ };
1971
+ }
1972
+ );
1973
+
1974
+ toast.success('Imagem extraida adicionada com sucesso.');
1975
+ closeFrameCreateSheet();
1976
+ void queryClient.invalidateQueries({
1977
+ queryKey: courseStructureQueryKey(courseId),
1978
+ });
1979
+ } catch {
1980
+ toast.error('Nao foi possivel adicionar a imagem extraida.');
1981
+ } finally {
1982
+ setIsSavingFrameCreate(false);
1983
+ }
1984
+ }
1985
+
1986
+ function handleFrameReplacementSelect(file?: File | null) {
1987
+ if (!file) return;
1988
+
1989
+ if (!file.type.startsWith('image/')) {
1990
+ toast.error('Selecione um arquivo de imagem valido.');
1991
+ return;
1992
+ }
1993
+
1994
+ if (editingFramePreviewUrl?.startsWith('blob:')) {
1995
+ URL.revokeObjectURL(editingFramePreviewUrl);
1996
+ }
1997
+
1998
+ setEditingFrameFile(file);
1999
+ setEditingFramePreviewUrl(URL.createObjectURL(file));
2000
+ }
2001
+
2002
+ async function handleSaveFrameEdit() {
2003
+ if (!lesson || !editingFrame) return;
2004
+
2005
+ const parsedTimeSeconds = parseTimeToSeconds(editingFrameTime);
2006
+ if (parsedTimeSeconds === null) {
2007
+ toast.error('Informe um tempo valido (mm:ss ou hh:mm:ss).');
2008
+ return;
2009
+ }
2010
+
2011
+ setIsSavingFrameEdit(true);
2012
+ try {
2013
+ let uploadedFileId: number | undefined;
2014
+
2015
+ if (editingFrameFile) {
2016
+ const uploaded = await uploadFile(
2017
+ request,
2018
+ editingFrameFile,
2019
+ 'lms/lessons/frames'
2020
+ );
2021
+ uploadedFileId = uploaded.id;
2022
+ }
2023
+
2024
+ const updatedFrame = await updateLessonFrame(
2025
+ request,
2026
+ courseId,
2027
+ lesson.sessionId,
2028
+ lessonId,
2029
+ Number(editingFrame.id),
2030
+ {
2031
+ timeSeconds: parsedTimeSeconds,
2032
+ ...(uploadedFileId ? { fileId: uploadedFileId } : {}),
2033
+ }
2034
+ );
2035
+
2036
+ queryClient.setQueryData<CourseStructureCacheData | undefined>(
2037
+ courseStructureQueryKey(courseId),
2038
+ (current) => {
2039
+ if (!current) return current;
2040
+
2041
+ return {
2042
+ ...current,
2043
+ lessons: current.lessons.map((item) => {
2044
+ if (item.id !== lessonId) return item;
2045
+
2046
+ return {
2047
+ ...item,
2048
+ frames: (item.frames ?? []).map((frame) => {
2049
+ if (frame.id !== String(updatedFrame.id)) return frame;
2050
+
2051
+ const nextFileId =
2052
+ typeof updatedFrame.fileId === 'number'
2053
+ ? updatedFrame.fileId
2054
+ : frame.fileId;
2055
+
2056
+ return {
2057
+ ...frame,
2058
+ ...(nextFileId ? { fileId: nextFileId } : {}),
2059
+ ...(editingFrameFile
2060
+ ? { name: editingFrameFile.name }
2061
+ : {}),
2062
+ timeSeconds: updatedFrame.timeSeconds,
2063
+ url: nextFileId ? `/file/open/${nextFileId}` : frame.url,
2064
+ };
2065
+ }),
2066
+ };
2067
+ }),
2068
+ };
2069
+ }
2070
+ );
2071
+
2072
+ toast.success('Imagem extraida atualizada com sucesso.');
2073
+ closeFrameEditSheet();
2074
+ void queryClient.invalidateQueries({
2075
+ queryKey: courseStructureQueryKey(courseId),
2076
+ });
2077
+ } catch {
2078
+ toast.error('Nao foi possivel atualizar a imagem extraida.');
2079
+ } finally {
2080
+ setIsSavingFrameEdit(false);
2081
+ }
2082
+ }
2083
+
2084
+ async function handleDownloadFrame(frame: VideoFrame) {
2085
+ if (!frame.fileId) {
2086
+ toast.error('Imagem sem arquivo para download.');
2087
+ return;
2088
+ }
2089
+
2090
+ const downloadKey = String(frame.id);
2091
+ setDownloadingFrameIds((current) => {
2092
+ const next = new Set(current);
2093
+ next.add(downloadKey);
2094
+ return next;
2095
+ });
2096
+
2097
+ try {
2098
+ const response = await request<{ url?: string }>({
2099
+ url: `/file/download/${frame.fileId}`,
2100
+ method: 'PUT',
2101
+ });
2102
+
2103
+ const downloadUrl = response?.data?.url;
2104
+ if (!downloadUrl) {
2105
+ toast.error('Nao foi possivel iniciar o download da imagem.');
854
2106
  return;
855
2107
  }
856
- window.open(resourceUrl, '_blank', 'noopener,noreferrer');
2108
+
2109
+ try {
2110
+ const downloadResponse = await fetch(downloadUrl);
2111
+ if (!downloadResponse.ok) {
2112
+ throw new Error('download request failed');
2113
+ }
2114
+
2115
+ const blob = await downloadResponse.blob();
2116
+ const objectUrl = window.URL.createObjectURL(blob);
2117
+ const anchor = document.createElement('a');
2118
+ anchor.href = objectUrl;
2119
+ anchor.download = frame.name || `frame-${frame.id}.jpg`;
2120
+ anchor.rel = 'noopener noreferrer';
2121
+ document.body.appendChild(anchor);
2122
+ anchor.click();
2123
+ anchor.remove();
2124
+ window.URL.revokeObjectURL(objectUrl);
2125
+ } catch {
2126
+ const anchor = document.createElement('a');
2127
+ anchor.href = downloadUrl;
2128
+ anchor.rel = 'noopener noreferrer';
2129
+ document.body.appendChild(anchor);
2130
+ anchor.click();
2131
+ anchor.remove();
2132
+ }
2133
+ } catch {
2134
+ toast.error('Nao foi possivel iniciar o download da imagem.');
2135
+ } finally {
2136
+ setDownloadingFrameIds((current) => {
2137
+ const next = new Set(current);
2138
+ next.delete(downloadKey);
2139
+ return next;
2140
+ });
2141
+ }
2142
+ }
2143
+
2144
+ async function handleDeleteFrame(frame: VideoFrame) {
2145
+ if (!lesson) return;
2146
+
2147
+ showConfirm({
2148
+ title: `Remover ${frame.name}?`,
2149
+ description: 'A imagem será excluída do vídeo e removida da lista.',
2150
+ onConfirm: async () => {
2151
+ try {
2152
+ await deleteLessonFrame(
2153
+ request,
2154
+ courseId,
2155
+ lesson.sessionId,
2156
+ lessonId,
2157
+ Number(frame.id)
2158
+ );
2159
+ toast.success('Imagem removida com sucesso.');
2160
+ void queryClient.invalidateQueries({
2161
+ queryKey: courseStructureQueryKey(courseId),
2162
+ });
2163
+ } catch {
2164
+ toast.error('Não foi possível remover a imagem.');
2165
+ }
2166
+ },
2167
+ });
2168
+ }
2169
+
2170
+ async function handleDeleteAllFrames() {
2171
+ if (!lesson || videoFrames.length === 0) return;
2172
+
2173
+ setIsDeletingAllFrames(true);
2174
+ try {
2175
+ const results = await Promise.allSettled(
2176
+ videoFrames.map((frame) =>
2177
+ deleteLessonFrame(
2178
+ request,
2179
+ courseId,
2180
+ lesson.sessionId,
2181
+ lessonId,
2182
+ Number(frame.id)
2183
+ )
2184
+ )
2185
+ );
2186
+
2187
+ const successCount = results.filter(
2188
+ (result) => result.status === 'fulfilled'
2189
+ ).length;
2190
+ const failedCount = results.length - successCount;
2191
+
2192
+ if (successCount > 0) {
2193
+ toast.success(
2194
+ successCount === 1
2195
+ ? '1 imagem removida com sucesso.'
2196
+ : `${successCount} imagens removidas com sucesso.`
2197
+ );
2198
+ }
2199
+
2200
+ if (failedCount > 0) {
2201
+ toast.error(
2202
+ failedCount === 1
2203
+ ? '1 imagem não pôde ser removida.'
2204
+ : `${failedCount} imagens não puderam ser removidas.`
2205
+ );
2206
+ }
2207
+
2208
+ void queryClient.invalidateQueries({
2209
+ queryKey: courseStructureQueryKey(courseId),
2210
+ });
857
2211
  } finally {
858
- setIsResolvingVideoPreview(false);
2212
+ setIsDeletingAllFrames(false);
859
2213
  }
860
2214
  }
861
2215
 
2216
+ function confirmDeleteAllFrames() {
2217
+ if (videoFrames.length === 0 || isDeletingAllFrames) return;
2218
+
2219
+ showConfirm({
2220
+ title:
2221
+ videoFrames.length === 1
2222
+ ? 'Excluir a imagem extraída?'
2223
+ : `Excluir todas as ${videoFrames.length} imagens extraídas?`,
2224
+ description:
2225
+ 'Esta ação remove permanentemente os frames extraídos desta aula.',
2226
+ onConfirm: () => {
2227
+ void handleDeleteAllFrames();
2228
+ },
2229
+ });
2230
+ }
2231
+
2232
+ function confirmRegenerateFrames() {
2233
+ if (!originalVideoResource?.fileId || isOriginalVideoUploadBlocked) return;
2234
+
2235
+ showConfirm({
2236
+ title: 'Regenerar imagens extraídas?',
2237
+ description:
2238
+ 'O vídeo original será reenviado para a fila de processamento e os frames serão extraídos novamente.',
2239
+ onConfirm: () => {
2240
+ void handleRequeueOriginalVideo();
2241
+ },
2242
+ });
2243
+ }
2244
+
2245
+ function resolveFrameCardUrl(frame: VideoFrame): string | undefined {
2246
+ return (
2247
+ frameAssetMetadataById[frame.id]?.url ??
2248
+ frame.url ??
2249
+ (frame.fileId ? `/file/open/${frame.fileId}` : undefined)
2250
+ );
2251
+ }
2252
+
2253
+ function resolveFrameCardSize(frame: VideoFrame): string {
2254
+ return frameAssetMetadataById[frame.id]?.sizeLabel ?? '—';
2255
+ }
2256
+
862
2257
  async function handleVideoProfileFile(profileId: number, file: File) {
863
2258
  if (file.size > MAX_VIDEO_UPLOAD_SIZE_BYTES) {
864
2259
  const message = t('lessonForm.videoUploadMaxSizeError', {
@@ -891,6 +2286,7 @@ export function EditorLesson({ lessonId }: EditorLessonProps) {
891
2286
  size: formatFileSize(file.size),
892
2287
  type,
893
2288
  public: false,
2289
+ uploadedAt: new Date().toISOString(),
894
2290
  url: undefined,
895
2291
  };
896
2292
  setLocalResources((prev) => [
@@ -910,6 +2306,21 @@ export function EditorLesson({ lessonId }: EditorLessonProps) {
910
2306
  }
911
2307
 
912
2308
  async function handleOriginalVideoFile(file: File) {
2309
+ if (!isVideoConversionEnabled) {
2310
+ toast.error(t('lessonForm.videoConversionFailed'));
2311
+ return;
2312
+ }
2313
+
2314
+ if (
2315
+ watchedVideoProvider === 'file_storage' &&
2316
+ persistedVideoProvider !== 'file_storage'
2317
+ ) {
2318
+ const message = t('lessonForm.videoProviderSavePending');
2319
+ setVideoUploadError(message);
2320
+ toast.error(message);
2321
+ return;
2322
+ }
2323
+
913
2324
  if (file.size > MAX_VIDEO_UPLOAD_SIZE_BYTES) {
914
2325
  const message = t('lessonForm.videoUploadMaxSizeError', {
915
2326
  size: '100MB',
@@ -950,22 +2361,7 @@ export function EditorLesson({ lessonId }: EditorLessonProps) {
950
2361
  ]);
951
2362
  setResourcesDirty(true);
952
2363
 
953
- const queued = await enqueueLessonVideoConversion(
954
- request,
955
- courseId,
956
- lesson!.sessionId,
957
- lessonId,
958
- uploaded.id
959
- );
960
- setConversionJobId(queued.queueJobId);
961
- toast.success(
962
- t('lessonForm.videoConversionQueued', {
963
- id: queued.queueJobId,
964
- })
965
- );
966
- void queryClient.invalidateQueries({
967
- queryKey: courseStructureQueryKey(courseId),
968
- });
2364
+ await enqueueOriginalVideoConversion(uploaded.id);
969
2365
  } catch {
970
2366
  toast.error(t('lessonForm.videoConversionFailed'));
971
2367
  } finally {
@@ -973,29 +2369,83 @@ export function EditorLesson({ lessonId }: EditorLessonProps) {
973
2369
  }
974
2370
  }
975
2371
 
2372
+ async function handleRequeueOriginalVideo() {
2373
+ if (!originalVideoResource?.fileId || isOriginalVideoUploadBlocked) return;
2374
+
2375
+ setVideoUploadError(null);
2376
+ setIsRequeueingOriginalVideo(true);
2377
+ try {
2378
+ await enqueueOriginalVideoConversion(originalVideoResource.fileId);
2379
+ } catch {
2380
+ toast.error(t('lessonForm.videoConversionRetryFailed'));
2381
+ } finally {
2382
+ setIsRequeueingOriginalVideo(false);
2383
+ }
2384
+ }
2385
+
2386
+ function updateTranscriptionSegmentsState(
2387
+ updater: (
2388
+ previous: EditableTranscriptionSegment[]
2389
+ ) => EditableTranscriptionSegment[]
2390
+ ) {
2391
+ setTranscriptionDirty(true);
2392
+ setTranscriptionSegments(updater);
2393
+ }
2394
+
976
2395
  function onSubmit(values: FormValues) {
977
- const transcriptionValue =
978
- values.type === 'video'
979
- ? serializeTranscriptionSegments(transcriptionSegments)
980
- : values.transcription;
2396
+ if (values.type === 'video') {
2397
+ const segmentsPayload = transcriptionSegments
2398
+ .map((segment) => ({
2399
+ startSeconds: hmsToSeconds(normalizeTimeInput(segment.start)),
2400
+ endSeconds: hmsToSeconds(normalizeTimeInput(segment.end)),
2401
+ text: segment.text.trim(),
2402
+ }))
2403
+ .filter((segment) => segment.text.length > 0);
981
2404
 
982
- updateLesson.mutate({
983
- lessonId,
984
- sessionId: lesson!.sessionId,
985
- formValues: {
986
- ...values,
987
- videoUrl:
988
- values.type === 'video' && values.videoProvider === 'file_storage'
989
- ? ''
990
- : values.videoUrl,
991
- transcription: transcriptionValue,
992
- videoConversionJobId: conversionJobId ?? lesson?.videoConversionJobId,
993
- resources: localResources,
994
- instructorIds: selectedInstructorIds.map(Number),
2405
+ updateTranscriptionSegments.mutate(segmentsPayload, {
2406
+ onSuccess: () => {
2407
+ setTranscriptionDirty(false);
2408
+ setTranscriptionSegments(
2409
+ segmentsPayload.length > 0
2410
+ ? segmentsPayload.map((segment) => ({
2411
+ id: segmentId(),
2412
+ start: secondsToHMS(segment.startSeconds),
2413
+ end: secondsToHMS(segment.endSeconds),
2414
+ text: segment.text,
2415
+ }))
2416
+ : toEditableTranscriptionSegments([])
2417
+ );
2418
+ void queryClient.invalidateQueries({
2419
+ queryKey: ['lesson-transcription-segments', lesson?.id ?? null],
2420
+ });
2421
+ },
2422
+ });
2423
+ }
2424
+
2425
+ updateLesson.mutate(
2426
+ {
2427
+ lessonId,
2428
+ sessionId: lesson!.sessionId,
2429
+ formValues: {
2430
+ ...values,
2431
+ videoUrl:
2432
+ values.type === 'video' && values.videoProvider === 'file_storage'
2433
+ ? ''
2434
+ : values.videoUrl,
2435
+ transcription: values.transcription,
2436
+ videoConversionJobId: conversionJobId ?? lesson?.videoConversionJobId,
2437
+ resources: localResources,
2438
+ instructorIds: selectedInstructorIds.map(Number),
2439
+ },
995
2440
  },
996
- });
997
- form.reset({ ...values, transcription: transcriptionValue });
998
- setResourcesDirty(false);
2441
+ {
2442
+ onSuccess: () => {
2443
+ persistedInstructorIdsRef.current = [...selectedInstructorIds];
2444
+ form.reset({ ...values, transcription: values.transcription });
2445
+ setResourcesDirty(false);
2446
+ },
2447
+ }
2448
+ );
999
2449
  }
1000
2450
 
1001
2451
  // ── Question sheet helpers ────────────────────────────────────────────────
@@ -1151,22 +2601,26 @@ export function EditorLesson({ lessonId }: EditorLessonProps) {
1151
2601
  {statusLabels[watchedStatus]}
1152
2602
  </span>
1153
2603
  )}
1154
- <Button
1155
- type="button"
1156
- variant="ghost"
1157
- size="icon"
1158
- className="size-7 text-destructive/60 hover:text-destructive shrink-0"
1159
- title={t('lesson.delete')}
1160
- aria-label={t('lesson.delete')}
1161
- disabled={deleteLesson.isPending}
1162
- onClick={handleDelete}
2604
+ <IconActionTooltip
2605
+ label={t('lesson.delete')}
2606
+ asWrapper={deleteLesson.isPending}
1163
2607
  >
1164
- {deleteLesson.isPending ? (
1165
- <Loader2 className="size-3.5 animate-spin" />
1166
- ) : (
1167
- <Trash2 className="size-3.5" />
1168
- )}
1169
- </Button>
2608
+ <Button
2609
+ type="button"
2610
+ variant="ghost"
2611
+ size="icon"
2612
+ className="size-7 text-destructive/60 hover:text-destructive shrink-0"
2613
+ aria-label={t('lesson.delete')}
2614
+ disabled={deleteLesson.isPending}
2615
+ onClick={handleDelete}
2616
+ >
2617
+ {deleteLesson.isPending ? (
2618
+ <Loader2 className="size-3.5 animate-spin" />
2619
+ ) : (
2620
+ <Trash2 className="size-3.5" />
2621
+ )}
2622
+ </Button>
2623
+ </IconActionTooltip>
1170
2624
  </div>
1171
2625
 
1172
2626
  {/* ── Tabs ─────────────────────────────────────────────────────────── */}
@@ -1196,6 +2650,14 @@ export function EditorLesson({ lessonId }: EditorLessonProps) {
1196
2650
  {t('lessonForm.tabVideos')}
1197
2651
  </TabsTrigger>
1198
2652
  )}
2653
+ {watchedType === 'video' && (
2654
+ <TabsTrigger
2655
+ value="imagens"
2656
+ className="h-6 px-2 text-[11px] shrink-0 sm:h-7 sm:px-2.5 sm:text-xs"
2657
+ >
2658
+ Imagens extraídas
2659
+ </TabsTrigger>
2660
+ )}
1199
2661
  {watchedType === 'video' && (
1200
2662
  <TabsTrigger
1201
2663
  value="transcricao"
@@ -1204,6 +2666,14 @@ export function EditorLesson({ lessonId }: EditorLessonProps) {
1204
2666
  {t('lessonForm.tabTranscription')}
1205
2667
  </TabsTrigger>
1206
2668
  )}
2669
+ {watchedType === 'video' && (
2670
+ <TabsTrigger
2671
+ value="audios"
2672
+ className="h-6 px-2 text-[11px] shrink-0 sm:h-7 sm:px-2.5 sm:text-xs"
2673
+ >
2674
+ {tabAudiosLabel}
2675
+ </TabsTrigger>
2676
+ )}
1207
2677
  <TabsTrigger
1208
2678
  value="recursos"
1209
2679
  className="h-6 px-2 text-[11px] shrink-0 sm:h-7 sm:px-2.5 sm:text-xs"
@@ -1294,7 +2764,7 @@ export function EditorLesson({ lessonId }: EditorLessonProps) {
1294
2764
  <Ic
1295
2765
  className={cn('size-3', cfg.color)}
1296
2766
  />
1297
- {t(cfg.labelKey as any)}
2767
+ {t(cfg.labelKey)}
1298
2768
  </span>
1299
2769
  </SelectItem>
1300
2770
  );
@@ -1432,14 +2902,17 @@ export function EditorLesson({ lessonId }: EditorLessonProps) {
1432
2902
  <CardContent className="px-3 pb-2 flex flex-col gap-2">
1433
2903
  {/* Picker para adicionar */}
1434
2904
  <EntityPicker<{ id: number; name: string }>
2905
+ key={instructorPickerResetKey}
1435
2906
  value={null}
1436
2907
  onChange={(val) => {
1437
- if (val != null)
2908
+ if (val != null) {
1438
2909
  setSelectedInstructorIds((prev) =>
1439
2910
  prev.includes(String(val))
1440
2911
  ? prev
1441
2912
  : [...prev, String(val)]
1442
2913
  );
2914
+ setInstructorPickerResetKey((current) => current + 1);
2915
+ }
1443
2916
  }}
1444
2917
  placeholder={t('questionEditor.addInstructor')}
1445
2918
  searchPlaceholder={t('questionEditor.searchInstructor')}
@@ -1481,25 +2954,31 @@ export function EditorLesson({ lessonId }: EditorLessonProps) {
1481
2954
  <span className="text-xs flex-1 truncate">
1482
2955
  {displayName}
1483
2956
  </span>
1484
- <Button
1485
- type="button"
1486
- variant="ghost"
1487
- size="icon"
1488
- className="size-5 shrink-0 text-muted-foreground hover:text-destructive"
1489
- onClick={() =>
1490
- setSelectedInstructorIds((prev) =>
1491
- prev.filter((id) => id !== sid)
1492
- )
1493
- }
1494
- aria-label={t(
1495
- 'questionEditor.removeInstructor',
1496
- {
1497
- name: displayName,
1498
- }
1499
- )}
2957
+ <IconActionTooltip
2958
+ label={t('questionEditor.removeInstructor', {
2959
+ name: displayName,
2960
+ })}
1500
2961
  >
1501
- <X className="size-3" />
1502
- </Button>
2962
+ <Button
2963
+ type="button"
2964
+ variant="ghost"
2965
+ size="icon"
2966
+ className="size-5 shrink-0 text-muted-foreground hover:text-destructive"
2967
+ onClick={() =>
2968
+ setSelectedInstructorIds((prev) =>
2969
+ prev.filter((id) => id !== sid)
2970
+ )
2971
+ }
2972
+ aria-label={t(
2973
+ 'questionEditor.removeInstructor',
2974
+ {
2975
+ name: displayName,
2976
+ }
2977
+ )}
2978
+ >
2979
+ <X className="size-3" />
2980
+ </Button>
2981
+ </IconActionTooltip>
1503
2982
  </div>
1504
2983
  );
1505
2984
  })}
@@ -1668,16 +3147,20 @@ export function EditorLesson({ lessonId }: EditorLessonProps) {
1668
3147
  ` · ${selectedQuestion.points} ${t('questionEditor.pointsSuffix')}`}
1669
3148
  </p>
1670
3149
  </div>
1671
- <Button
1672
- type="button"
1673
- variant="ghost"
1674
- size="icon"
1675
- className="size-7 shrink-0"
1676
- onClick={() => openEditQuestion(selectedQuestion)}
1677
- aria-label={t('questionEditor.editQuestion')}
3150
+ <IconActionTooltip
3151
+ label={t('questionEditor.editQuestion')}
1678
3152
  >
1679
- <Pencil className="size-3.5" />
1680
- </Button>
3153
+ <Button
3154
+ type="button"
3155
+ variant="ghost"
3156
+ size="icon"
3157
+ className="size-7 shrink-0"
3158
+ onClick={() => openEditQuestion(selectedQuestion)}
3159
+ aria-label={t('questionEditor.editQuestion')}
3160
+ >
3161
+ <Pencil className="size-3.5" />
3162
+ </Button>
3163
+ </IconActionTooltip>
1681
3164
  </div>
1682
3165
  )}
1683
3166
  </CardContent>
@@ -1763,192 +3246,296 @@ export function EditorLesson({ lessonId }: EditorLessonProps) {
1763
3246
  {watchedVideoProvider === 'file_storage' && (
1764
3247
  <>
1765
3248
  {videoUploadError ? (
1766
- <p className="text-xs text-destructive">
3249
+ <p className="order-1 text-xs text-destructive">
1767
3250
  {videoUploadError}
1768
3251
  </p>
1769
3252
  ) : null}
1770
3253
 
1771
- <Card className="bg-muted/20 py-2 gap-2">
1772
- <CardHeader className="px-3 pt-2 pb-1">
1773
- <CardTitle className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
1774
- {t('lessonForm.originalVideoTitle')}
1775
- </CardTitle>
1776
- </CardHeader>
1777
- <CardContent className="px-3 pb-2 flex flex-col gap-3">
1778
- <div className="rounded-lg border bg-background/90 p-3 shadow-sm">
1779
- <div className="flex items-start gap-3">
1780
- <div className="flex size-8 shrink-0 items-center justify-center rounded-md bg-blue-500/10 text-blue-600">
1781
- <Video className="size-4" />
1782
- </div>
1783
- <div className="min-w-0 flex-1 space-y-1">
1784
- <div className="flex items-start justify-between gap-2">
1785
- <div className="min-w-0">
1786
- <p className="truncate text-sm font-medium">
1787
- {originalVideoResource
1788
- ? originalVideoResource.name
1789
- : t('lessonForm.originalVideoTitle')}
1790
- </p>
1791
- <p className="text-xs text-muted-foreground">
1792
- {conversionJobId
1793
- ? t('lessonForm.videoConversionJob', {
1794
- id: conversionJobId,
1795
- })
1796
- : t('lessonForm.originalVideoHint')}
1797
- </p>
1798
- <p className="text-[0.65rem] text-muted-foreground">
1799
- {t('lessonForm.originalVideoPurpose')}
1800
- </p>
1801
- </div>
1802
- {originalVideoResource && (
1803
- <div className="flex shrink-0 items-center gap-1">
1804
- <Button
1805
- type="button"
1806
- variant="ghost"
1807
- size="icon"
1808
- className="size-7 shrink-0"
1809
- disabled={isResolvingVideoPreview}
1810
- onClick={() =>
1811
- void openVideoPreview(
1812
- originalVideoResource
1813
- )
1814
- }
1815
- aria-label={t(
1816
- 'lessonForm.playVideoAria',
1817
- {
3254
+ {isVideoConversionEnabled ? (
3255
+ <Card className="bg-muted/20 py-2 gap-2 order-3">
3256
+ <CardHeader className="px-3 pt-2 pb-1">
3257
+ <CardTitle className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
3258
+ {t('lessonForm.originalVideoTitle')}
3259
+ </CardTitle>
3260
+ </CardHeader>
3261
+ <CardContent className="px-3 pb-2 flex flex-col gap-3">
3262
+ <div className="rounded-lg border bg-background/90 p-3 shadow-sm">
3263
+ <div className="flex items-start gap-3">
3264
+ <div className="flex size-8 shrink-0 items-center justify-center rounded-md bg-blue-500/10 text-blue-600">
3265
+ <Video className="size-4" />
3266
+ </div>
3267
+ <div className="min-w-0 flex-1 space-y-1">
3268
+ <div className="flex items-start justify-between gap-2">
3269
+ <div className="min-w-0">
3270
+ <p className="truncate text-sm font-medium">
3271
+ {originalVideoResource
3272
+ ? originalVideoResource.name
3273
+ : t('lessonForm.originalVideoTitle')}
3274
+ </p>
3275
+ <p className="text-xs text-muted-foreground">
3276
+ {conversionJobId
3277
+ ? t('lessonForm.videoConversionJob', {
3278
+ id: conversionJobId,
3279
+ })
3280
+ : t('lessonForm.originalVideoHint')}
3281
+ </p>
3282
+ <p className="text-[0.65rem] text-muted-foreground">
3283
+ {t('lessonForm.originalVideoPurpose')}
3284
+ </p>
3285
+ </div>
3286
+ {originalVideoResource && (
3287
+ <div className="flex shrink-0 items-center gap-1">
3288
+ <IconActionTooltip
3289
+ label={t('lessonForm.playVideoAria', {
1818
3290
  name: originalVideoResource.name,
1819
- }
1820
- )}
1821
- >
1822
- {isResolvingVideoPreview ? (
1823
- <Loader2 className="size-3 animate-spin" />
1824
- ) : (
1825
- <Play className="size-3" />
1826
- )}
1827
- </Button>
1828
- <Button
1829
- type="button"
1830
- variant="ghost"
1831
- size="icon"
1832
- className="size-7 shrink-0"
1833
- onClick={() =>
1834
- void handleResourceDownload(
1835
- originalVideoResource
1836
- )
1837
- }
1838
- aria-label={t(
1839
- 'lessonForm.downloadVideoAria',
1840
- {
3291
+ })}
3292
+ asWrapper={isResolvingVideoPreview}
3293
+ >
3294
+ <Button
3295
+ type="button"
3296
+ variant="ghost"
3297
+ size="icon"
3298
+ className="size-7 shrink-0 text-muted-foreground transition-colors hover:text-emerald-600"
3299
+ disabled={isResolvingVideoPreview}
3300
+ onClick={() =>
3301
+ void openVideoPreview(
3302
+ originalVideoResource
3303
+ )
3304
+ }
3305
+ aria-label={t(
3306
+ 'lessonForm.playVideoAria',
3307
+ {
3308
+ name: originalVideoResource.name,
3309
+ }
3310
+ )}
3311
+ >
3312
+ {isResolvingVideoPreview ? (
3313
+ <Loader2 className="size-3 animate-spin" />
3314
+ ) : (
3315
+ <Play className="size-3" />
3316
+ )}
3317
+ </Button>
3318
+ </IconActionTooltip>
3319
+ <IconActionTooltip
3320
+ label={t(
3321
+ 'lessonForm.downloadVideoAria',
3322
+ {
3323
+ name: originalVideoResource.name,
3324
+ }
3325
+ )}
3326
+ asWrapper={isDownloadingOriginalVideo}
3327
+ >
3328
+ <Button
3329
+ type="button"
3330
+ variant="ghost"
3331
+ size="icon"
3332
+ className="size-7 shrink-0 text-muted-foreground transition-colors hover:text-amber-600"
3333
+ disabled={
3334
+ isDownloadingOriginalVideo
3335
+ }
3336
+ onClick={() =>
3337
+ void handleResourceDownload(
3338
+ originalVideoResource
3339
+ )
3340
+ }
3341
+ aria-label={t(
3342
+ 'lessonForm.downloadVideoAria',
3343
+ {
3344
+ name: originalVideoResource.name,
3345
+ }
3346
+ )}
3347
+ >
3348
+ {isDownloadingOriginalVideo ? (
3349
+ <Loader2 className="size-3 animate-spin" />
3350
+ ) : (
3351
+ <Download className="size-3" />
3352
+ )}
3353
+ </Button>
3354
+ </IconActionTooltip>
3355
+ <IconActionTooltip
3356
+ label={t('lessonForm.openVideoAria', {
1841
3357
  name: originalVideoResource.name,
3358
+ })}
3359
+ >
3360
+ <Button
3361
+ type="button"
3362
+ variant="ghost"
3363
+ size="icon"
3364
+ className="size-7 shrink-0 text-muted-foreground transition-colors hover:text-blue-600"
3365
+ onClick={() =>
3366
+ void openResource(
3367
+ originalVideoResource
3368
+ )
3369
+ }
3370
+ aria-label={t(
3371
+ 'lessonForm.openVideoAria',
3372
+ {
3373
+ name: originalVideoResource.name,
3374
+ }
3375
+ )}
3376
+ >
3377
+ <ExternalLink className="size-3" />
3378
+ </Button>
3379
+ </IconActionTooltip>
3380
+ </div>
3381
+ )}
3382
+ </div>
3383
+ {originalVideoResource?.size ? (
3384
+ <div className="inline-flex rounded-full bg-muted px-2 py-0.5 text-[0.65rem] font-medium text-muted-foreground">
3385
+ {originalVideoResource.size}
3386
+ </div>
3387
+ ) : null}
3388
+ <div className="flex flex-wrap items-center gap-2 pt-1">
3389
+ {originalVideoResource ? (
3390
+ <>
3391
+ <Button
3392
+ type="button"
3393
+ variant="secondary"
3394
+ className="h-8 px-3 text-xs"
3395
+ disabled={
3396
+ isOriginalVideoUploadBlocked
1842
3397
  }
1843
- )}
1844
- >
1845
- <Download className="size-3" />
1846
- </Button>
3398
+ onClick={() =>
3399
+ originalVideoInputRef.current?.click()
3400
+ }
3401
+ >
3402
+ <UploadCloud className="size-3.5 mr-1" />
3403
+ {t(
3404
+ 'lessonForm.replaceOriginalForConversion'
3405
+ )}
3406
+ </Button>
3407
+ <Button
3408
+ type="button"
3409
+ variant="outline"
3410
+ className="h-8 px-3 text-xs"
3411
+ disabled={
3412
+ !canRequeueSavedOriginalVideo
3413
+ }
3414
+ onClick={() =>
3415
+ void handleRequeueOriginalVideo()
3416
+ }
3417
+ >
3418
+ {isRequeueingOriginalVideo ? (
3419
+ <Loader2 className="size-3.5 mr-1 animate-spin" />
3420
+ ) : (
3421
+ <RefreshCw className="size-3.5 mr-1" />
3422
+ )}
3423
+ {t(
3424
+ 'lessonForm.retryConversionWithSavedOriginal'
3425
+ )}
3426
+ </Button>
3427
+ </>
3428
+ ) : (
1847
3429
  <Button
1848
3430
  type="button"
1849
- variant="ghost"
1850
- size="icon"
1851
- className="size-7 shrink-0"
3431
+ variant="secondary"
3432
+ className="h-8 px-3 text-xs"
3433
+ disabled={isOriginalVideoUploadBlocked}
1852
3434
  onClick={() =>
1853
- void openResource(
1854
- originalVideoResource
1855
- )
3435
+ originalVideoInputRef.current?.click()
1856
3436
  }
1857
- aria-label={t(
1858
- 'lessonForm.openVideoAria',
1859
- {
1860
- name: originalVideoResource.name,
1861
- }
1862
- )}
1863
3437
  >
1864
- <ExternalLink className="size-3" />
3438
+ <UploadCloud className="size-3.5 mr-1" />
3439
+ {t(
3440
+ 'lessonForm.uploadOriginalForConversion'
3441
+ )}
1865
3442
  </Button>
3443
+ )}
3444
+ <span className="text-[0.65rem] text-muted-foreground">
3445
+ {isConversionJobActive
3446
+ ? t(
3447
+ 'lessonForm.videoUploadBlockedWhileProcessing'
3448
+ )
3449
+ : isConversionJobStatusResolving
3450
+ ? t('lessonForm.videoJobStateLoading')
3451
+ : t('lessonForm.originalVideoHint')}
3452
+ </span>
3453
+ </div>
3454
+ {originalUploadProgress !== null ? (
3455
+ <div className="space-y-1 pt-1">
3456
+ <Progress
3457
+ value={originalUploadProgress}
3458
+ className="h-1.5"
3459
+ />
3460
+ <p className="text-[0.65rem] text-muted-foreground">
3461
+ {originalUploadProgress}%
3462
+ </p>
1866
3463
  </div>
1867
- )}
3464
+ ) : null}
1868
3465
  </div>
1869
- {originalVideoResource?.size ? (
1870
- <div className="inline-flex rounded-full bg-muted px-2 py-0.5 text-[0.65rem] font-medium text-muted-foreground">
1871
- {originalVideoResource.size}
1872
- </div>
3466
+ </div>
3467
+ <input
3468
+ ref={originalVideoInputRef}
3469
+ type="file"
3470
+ accept="video/*"
3471
+ className="hidden"
3472
+ onChange={(event) => {
3473
+ const file = event.target.files?.[0];
3474
+ if (file && !isOriginalVideoUploadBlocked) {
3475
+ void handleOriginalVideoFile(file);
3476
+ }
3477
+ event.target.value = '';
3478
+ }}
3479
+ />
3480
+ </div>
3481
+ </CardContent>
3482
+ </Card>
3483
+ ) : null}
3484
+
3485
+ {conversionJobId && !shouldHidePipelineCard ? (
3486
+ <Card className="bg-muted/20 py-2 gap-2 order-4">
3487
+ <CardHeader className="px-3 pt-2 pb-1">
3488
+ <CardTitle className="text-xs font-medium text-muted-foreground uppercase tracking-wider flex items-center justify-between gap-2">
3489
+ <span>
3490
+ {t('lessonForm.videoJobFeedbackTitle')}
3491
+ </span>
3492
+ <div className="flex items-center gap-1.5">
3493
+ {focusedPipelineJob ? (
3494
+ <span
3495
+ className={cn(
3496
+ 'rounded-full px-2 py-0.5 text-[0.65rem] font-medium',
3497
+ VIDEO_JOB_STATUS_COLORS[
3498
+ focusedPipelineJob.status
3499
+ ]
3500
+ )}
3501
+ >
3502
+ {t(
3503
+ `lessonForm.videoJobStatuses.${focusedPipelineJob.status}` satisfies VideoJobStatusMessageKey
3504
+ )}
3505
+ </span>
1873
3506
  ) : null}
1874
- <div className="flex flex-wrap items-center gap-2 pt-1">
3507
+ {focusedPipelineJob ? (
1875
3508
  <Button
1876
3509
  type="button"
1877
- variant="secondary"
1878
- className="h-8 px-3 text-xs"
1879
- disabled={isOriginalVideoUploadBlocked}
3510
+ variant="ghost"
3511
+ size="icon"
3512
+ className="size-6"
3513
+ aria-label={t(
3514
+ 'lessonForm.videoJobToggleDetails'
3515
+ )}
1880
3516
  onClick={() =>
1881
- originalVideoInputRef.current?.click()
3517
+ setIsJobFeedbackCollapsed((prev) => !prev)
1882
3518
  }
1883
3519
  >
1884
- <UploadCloud className="size-3.5 mr-1" />
1885
- {t(
1886
- 'lessonForm.uploadOriginalForConversion'
3520
+ {isJobFeedbackCollapsed ? (
3521
+ <ChevronDown className="size-3.5" />
3522
+ ) : (
3523
+ <ChevronUp className="size-3.5" />
1887
3524
  )}
1888
3525
  </Button>
1889
- <span className="text-[0.65rem] text-muted-foreground">
1890
- {isConversionJobActive
1891
- ? t(
1892
- 'lessonForm.videoUploadBlockedWhileProcessing'
1893
- )
1894
- : t('lessonForm.originalVideoHint')}
1895
- </span>
1896
- </div>
1897
- {originalUploadProgress !== null ? (
1898
- <div className="space-y-1 pt-1">
1899
- <Progress
1900
- value={originalUploadProgress}
1901
- className="h-1.5"
1902
- />
1903
- <p className="text-[0.65rem] text-muted-foreground">
1904
- {originalUploadProgress}%
1905
- </p>
1906
- </div>
1907
3526
  ) : null}
1908
3527
  </div>
1909
- </div>
1910
- <input
1911
- ref={originalVideoInputRef}
1912
- type="file"
1913
- accept="video/*"
1914
- className="hidden"
1915
- onChange={(event) => {
1916
- const file = event.target.files?.[0];
1917
- if (file && !isOriginalVideoUploadBlocked) {
1918
- void handleOriginalVideoFile(file);
1919
- }
1920
- event.target.value = '';
1921
- }}
1922
- />
1923
- </div>
1924
- </CardContent>
1925
- </Card>
1926
-
1927
- {conversionJobId ? (
1928
- <Card className="bg-muted/20 py-2 gap-2">
1929
- <CardHeader className="px-3 pt-2 pb-1">
1930
- <CardTitle className="text-xs font-medium text-muted-foreground uppercase tracking-wider flex items-center justify-between gap-2">
1931
- <span>
1932
- {t('lessonForm.videoJobFeedbackTitle')}
1933
- </span>
1934
- {conversionJob ? (
1935
- <span
1936
- className={cn(
1937
- 'rounded-full px-2 py-0.5 text-[0.65rem] font-medium',
1938
- VIDEO_JOB_STATUS_COLORS[
1939
- conversionJob.status
1940
- ]
1941
- )}
1942
- >
1943
- {t(
1944
- `lessonForm.videoJobStatuses.${conversionJob.status}` as any
1945
- )}
1946
- </span>
1947
- ) : null}
1948
3528
  </CardTitle>
1949
3529
  </CardHeader>
1950
3530
  <CardContent className="px-3 pb-2 flex flex-col gap-3">
1951
- {hasConversionJobError ? (
3531
+ {shouldShowLivePipelineMessage ? (
3532
+ <div className="flex items-center gap-2 rounded-md border border-blue-200 bg-blue-50/70 px-3 py-2 text-xs text-blue-700 dark:border-blue-800 dark:bg-blue-950/30 dark:text-blue-200">
3533
+ <Loader2 className="size-3.5 animate-spin" />
3534
+ <span>{livePipelineMessage}</span>
3535
+ </div>
3536
+ ) : null}
3537
+
3538
+ {hasFocusedPipelineJobError ? (
1952
3539
  <div className="flex flex-col gap-2 rounded-md border border-destructive/30 bg-destructive/5 p-3">
1953
3540
  <p className="text-xs text-destructive">
1954
3541
  {t('lessonForm.videoJobLoadError')}
@@ -1958,29 +3545,69 @@ export function EditorLesson({ lessonId }: EditorLessonProps) {
1958
3545
  variant="outline"
1959
3546
  size="sm"
1960
3547
  className="h-7 w-fit px-2 text-xs"
1961
- onClick={() => void refetchConversionJob()}
3548
+ onClick={() =>
3549
+ void refetchFocusedPipelineJob()
3550
+ }
1962
3551
  >
1963
3552
  <RefreshCw className="size-3 mr-1" />
1964
3553
  {t('lessonForm.retryLoadVideoJob')}
1965
3554
  </Button>
1966
3555
  </div>
1967
- ) : !conversionJob ? (
3556
+ ) : !focusedPipelineJob ? (
1968
3557
  <div className="flex items-center gap-2 rounded-md border bg-background/70 px-3 py-2 text-xs text-muted-foreground">
1969
3558
  <Loader2 className="size-3.5 animate-spin" />
1970
- {isFetchingConversionJob
3559
+ {focusedPipelineJobIsLoading
1971
3560
  ? t('lessonForm.videoJobLoading')
1972
3561
  : t('lessonForm.videoJobPendingLoad')}
1973
3562
  </div>
3563
+ ) : isJobFeedbackCollapsed ? (
3564
+ <div
3565
+ className={cn(
3566
+ 'rounded-md border px-3 py-2',
3567
+ focusedPipelineJob.status === 'completed'
3568
+ ? 'bg-emerald-50/50 dark:bg-emerald-950/20'
3569
+ : 'bg-background/70'
3570
+ )}
3571
+ >
3572
+ {focusedPipelineJob.status === 'completed' ? (
3573
+ <p className="text-xs text-muted-foreground">
3574
+ {t('lessonForm.videoJobCollapsedSummary')}
3575
+ </p>
3576
+ ) : (
3577
+ <div className="flex flex-wrap items-center gap-1 text-xs text-muted-foreground">
3578
+ <span>
3579
+ {t('lessonForm.videoJobIdLabel')}:
3580
+ </span>
3581
+ <button
3582
+ type="button"
3583
+ className="font-medium text-primary hover:underline"
3584
+ onClick={() => setJobDetailOpen(true)}
3585
+ >
3586
+ #{focusedPipelineJob.id}
3587
+ </button>
3588
+ <span>·</span>
3589
+ <span>
3590
+ {t(
3591
+ `lessonForm.videoJobStatuses.${focusedPipelineJob.status}` satisfies VideoJobStatusMessageKey
3592
+ )}
3593
+ </span>
3594
+ </div>
3595
+ )}
3596
+ </div>
1974
3597
  ) : (
1975
3598
  <>
1976
- <div className="grid grid-cols-1 gap-2 sm:grid-cols-2 xl:grid-cols-4">
3599
+ <div className="grid grid-cols-1 gap-2 sm:grid-cols-2 xl:grid-cols-5">
1977
3600
  <div className="rounded-md border bg-background/70 p-2">
1978
3601
  <p className="text-[0.65rem] text-muted-foreground">
1979
3602
  {t('lessonForm.videoJobIdLabel')}
1980
3603
  </p>
1981
- <p className="text-xs font-medium">
1982
- #{conversionJob.id}
1983
- </p>
3604
+ <button
3605
+ type="button"
3606
+ className="text-xs font-medium text-primary hover:underline"
3607
+ onClick={() => setJobDetailOpen(true)}
3608
+ >
3609
+ #{focusedPipelineJob.id}
3610
+ </button>
1984
3611
  </div>
1985
3612
  <div className="rounded-md border bg-background/70 p-2">
1986
3613
  <p className="text-[0.65rem] text-muted-foreground">
@@ -1988,8 +3615,8 @@ export function EditorLesson({ lessonId }: EditorLessonProps) {
1988
3615
  </p>
1989
3616
  <p className="text-xs font-medium">
1990
3617
  {t('lessonForm.videoJobAttemptsValue', {
1991
- current: conversionJob.attempts,
1992
- total: conversionJob.max_attempts,
3618
+ current: focusedPipelineJob.attempts,
3619
+ total: focusedPipelineJob.max_attempts,
1993
3620
  })}
1994
3621
  </p>
1995
3622
  </div>
@@ -1999,14 +3626,14 @@ export function EditorLesson({ lessonId }: EditorLessonProps) {
1999
3626
  </p>
2000
3627
  <p className="text-xs font-medium">
2001
3628
  {formatDateTimeLabel(
2002
- conversionJob.created_at
3629
+ focusedPipelineJob.created_at
2003
3630
  ) ?? '—'}
2004
3631
  </p>
2005
3632
  </div>
2006
3633
  <div className="rounded-md border bg-background/70 p-2">
2007
3634
  <p className="text-[0.65rem] text-muted-foreground">
2008
3635
  {TERMINAL_VIDEO_JOB_STATUSES.includes(
2009
- conversionJob.status
3636
+ focusedPipelineJob.status
2010
3637
  )
2011
3638
  ? t('lessonForm.videoJobFinishedAt')
2012
3639
  : t('lessonForm.videoJobStartedAt')}
@@ -2014,16 +3641,43 @@ export function EditorLesson({ lessonId }: EditorLessonProps) {
2014
3641
  <p className="text-xs font-medium">
2015
3642
  {formatDateTimeLabel(
2016
3643
  TERMINAL_VIDEO_JOB_STATUSES.includes(
2017
- conversionJob.status
3644
+ focusedPipelineJob.status
2018
3645
  )
2019
- ? conversionJob.finished_at
2020
- : conversionJob.started_at
3646
+ ? focusedPipelineJob.finished_at
3647
+ : focusedPipelineJob.started_at
2021
3648
  ) ?? '—'}
2022
3649
  </p>
2023
3650
  </div>
3651
+ <div className="rounded-md border bg-background/70 p-2">
3652
+ <div className="flex items-center justify-between gap-2">
3653
+ <p className="text-[0.65rem] text-muted-foreground">
3654
+ {TERMINAL_VIDEO_JOB_STATUSES.includes(
3655
+ focusedPipelineJob.status
3656
+ )
3657
+ ? t('lessonForm.videoJobTotalTime')
3658
+ : t('lessonForm.videoJobRunningTime')}
3659
+ </p>
3660
+ <VideoJobTimerIcon
3661
+ className={cn(
3662
+ 'size-3.5',
3663
+ videoJobTimerPhase === 'running' &&
3664
+ 'animate-spin text-blue-600',
3665
+ videoJobTimerPhase === 'waiting' &&
3666
+ 'text-amber-600',
3667
+ videoJobTimerPhase === 'completed' &&
3668
+ 'text-emerald-600',
3669
+ videoJobTimerPhase === 'error' &&
3670
+ 'text-destructive'
3671
+ )}
3672
+ />
3673
+ </div>
3674
+ <p className="text-xs font-medium font-mono">
3675
+ {conversionJobElapsedLabel ?? '—'}
3676
+ </p>
3677
+ </div>
2024
3678
  </div>
2025
3679
 
2026
- {latestConversionAttempt ? (
3680
+ {latestFocusedAttempt ? (
2027
3681
  <div className="rounded-md border bg-background/70 p-3">
2028
3682
  <div className="flex items-center justify-between gap-2">
2029
3683
  <p className="text-xs font-medium">
@@ -2031,36 +3685,57 @@ export function EditorLesson({ lessonId }: EditorLessonProps) {
2031
3685
  </p>
2032
3686
  <span className="text-[0.65rem] text-muted-foreground">
2033
3687
  {t(
2034
- `lessonForm.videoAttemptStatuses.${latestConversionAttempt.status}` as any
3688
+ `lessonForm.videoAttemptStatuses.${latestFocusedAttempt.status}` satisfies VideoAttemptStatusMessageKey
2035
3689
  )}
2036
3690
  </span>
2037
3691
  </div>
2038
3692
  <p className="mt-1 text-[0.65rem] text-muted-foreground">
2039
3693
  {t('lessonForm.videoJobAttemptValue', {
2040
3694
  count:
2041
- latestConversionAttempt.attempt_number,
3695
+ latestFocusedAttempt.attempt_number,
2042
3696
  })}
2043
3697
  {formatDurationLabel(
2044
- latestConversionAttempt.duration_ms
3698
+ latestFocusedAttempt.duration_ms
2045
3699
  )
2046
- ? ` · ${formatDurationLabel(latestConversionAttempt.duration_ms)}`
3700
+ ? ` · ${formatDurationLabel(latestFocusedAttempt.duration_ms)}`
2047
3701
  : ''}
2048
3702
  </p>
2049
- {latestConversionAttempt.error_message ? (
3703
+ {latestFocusedAttempt.error_message ? (
2050
3704
  <p className="mt-2 text-xs text-destructive">
2051
- {latestConversionAttempt.error_message}
3705
+ {latestFocusedAttempt.error_message}
2052
3706
  </p>
2053
3707
  ) : null}
2054
3708
  </div>
2055
3709
  ) : null}
2056
3710
 
2057
- {conversionJob.last_error ? (
2058
- <div className="rounded-md border border-destructive/30 bg-destructive/5 p-3">
2059
- <p className="text-[0.65rem] font-medium text-destructive">
3711
+ {shouldShowLastFocusedJobError ? (
3712
+ <div
3713
+ className={cn(
3714
+ 'rounded-md border p-3',
3715
+ isStaleLockRecoveryMessage
3716
+ ? 'border-amber-500/30 bg-amber-500/10'
3717
+ : 'border-destructive/30 bg-destructive/5'
3718
+ )}
3719
+ >
3720
+ <p
3721
+ className={cn(
3722
+ 'text-[0.65rem] font-medium',
3723
+ isStaleLockRecoveryMessage
3724
+ ? 'text-amber-700 dark:text-amber-300'
3725
+ : 'text-destructive'
3726
+ )}
3727
+ >
2060
3728
  {t('lessonForm.videoJobLastError')}
2061
3729
  </p>
2062
- <p className="mt-1 text-xs text-destructive">
2063
- {conversionJob.last_error}
3730
+ <p
3731
+ className={cn(
3732
+ 'mt-1 text-xs',
3733
+ isStaleLockRecoveryMessage
3734
+ ? 'text-amber-700/90 dark:text-amber-200'
3735
+ : 'text-destructive'
3736
+ )}
3737
+ >
3738
+ {normalizedLastFocusedJobError}
2064
3739
  </p>
2065
3740
  </div>
2066
3741
  ) : null}
@@ -2070,12 +3745,12 @@ export function EditorLesson({ lessonId }: EditorLessonProps) {
2070
3745
  {t('lessonForm.videoJobRecentEvents')}
2071
3746
  </p>
2072
3747
  <div className="mt-2 flex flex-col gap-2">
2073
- {recentConversionEvents.length === 0 ? (
3748
+ {recentFocusedEvents.length === 0 ? (
2074
3749
  <p className="text-xs text-muted-foreground">
2075
3750
  {t('lessonForm.videoJobNoEvents')}
2076
3751
  </p>
2077
3752
  ) : (
2078
- recentConversionEvents.map((event) => (
3753
+ recentFocusedEvents.map((event) => (
2079
3754
  <div
2080
3755
  key={event.id}
2081
3756
  className="rounded-md border border-border/60 px-2.5 py-2"
@@ -2083,7 +3758,7 @@ export function EditorLesson({ lessonId }: EditorLessonProps) {
2083
3758
  <div className="flex items-center justify-between gap-2">
2084
3759
  <p className="text-xs font-medium">
2085
3760
  {t(
2086
- `lessonForm.videoJobEvents.${event.event_type}` as any
3761
+ `lessonForm.videoJobEvents.${event.event_type}` satisfies VideoJobEventMessageKey
2087
3762
  )}
2088
3763
  </p>
2089
3764
  <span className="text-[0.65rem] text-muted-foreground">
@@ -2092,9 +3767,15 @@ export function EditorLesson({ lessonId }: EditorLessonProps) {
2092
3767
  ) ?? '—'}
2093
3768
  </span>
2094
3769
  </div>
2095
- {event.message ? (
3770
+ {getVideoJobProgressMessage(
3771
+ event,
3772
+ t
3773
+ ) ? (
2096
3774
  <p className="mt-1 text-[0.65rem] text-muted-foreground">
2097
- {event.message}
3775
+ {getVideoJobProgressMessage(
3776
+ event,
3777
+ t
3778
+ )}
2098
3779
  </p>
2099
3780
  ) : null}
2100
3781
  </div>
@@ -2102,13 +3783,58 @@ export function EditorLesson({ lessonId }: EditorLessonProps) {
2102
3783
  )}
2103
3784
  </div>
2104
3785
  </div>
3786
+
3787
+ {!shouldTrackTranscriptionJob &&
3788
+ recentTranscriptionEvents.length > 0 ? (
3789
+ <div className="rounded-md border bg-background/70 p-3">
3790
+ <p className="text-xs font-medium">
3791
+ {t(
3792
+ 'lessonForm.videoJobTranscriptionEvents'
3793
+ )}
3794
+ </p>
3795
+ <div className="mt-2 flex flex-col gap-2">
3796
+ {recentTranscriptionEvents.map(
3797
+ (event) => (
3798
+ <div
3799
+ key={`transcription-${event.id}`}
3800
+ className="rounded-md border border-border/60 px-2.5 py-2"
3801
+ >
3802
+ <div className="flex items-center justify-between gap-2">
3803
+ <p className="text-xs font-medium">
3804
+ {t(
3805
+ `lessonForm.videoJobEvents.${event.event_type}` satisfies VideoJobEventMessageKey
3806
+ )}
3807
+ </p>
3808
+ <span className="text-[0.65rem] text-muted-foreground">
3809
+ {formatDateTimeLabel(
3810
+ event.created_at
3811
+ ) ?? '—'}
3812
+ </span>
3813
+ </div>
3814
+ {getVideoJobProgressMessage(
3815
+ event,
3816
+ t
3817
+ ) ? (
3818
+ <p className="mt-1 text-[0.65rem] text-muted-foreground">
3819
+ {getVideoJobProgressMessage(
3820
+ event,
3821
+ t
3822
+ )}
3823
+ </p>
3824
+ ) : null}
3825
+ </div>
3826
+ )
3827
+ )}
3828
+ </div>
3829
+ </div>
3830
+ ) : null}
2105
3831
  </>
2106
3832
  )}
2107
3833
  </CardContent>
2108
3834
  </Card>
2109
3835
  ) : null}
2110
3836
 
2111
- <Card className="bg-muted/20 py-2 gap-2">
3837
+ <Card className="bg-muted/20 py-2 gap-2 order-2">
2112
3838
  <CardHeader className="px-3 pt-2 pb-1">
2113
3839
  <CardTitle className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
2114
3840
  {t('lessonForm.fileStorageVideosByResolution')}
@@ -2155,6 +3881,12 @@ export function EditorLesson({ lessonId }: EditorLessonProps) {
2155
3881
  const res = profileVideoResources.get(
2156
3882
  profile.id
2157
3883
  );
3884
+ const hasVideo = Boolean(res);
3885
+ const isDownloadingResource = res
3886
+ ? downloadingResourceKeys.has(
3887
+ String(res.fileId ?? res.id)
3888
+ )
3889
+ : false;
2158
3890
  const currentUploadProgress =
2159
3891
  profileUploadProgress[profile.id];
2160
3892
  const inputId = `lesson-video-profile-${profile.id}`;
@@ -2164,18 +3896,39 @@ export function EditorLesson({ lessonId }: EditorLessonProps) {
2164
3896
  key={profile.id}
2165
3897
  className="flex items-center gap-2 rounded-md border bg-background/80 px-2.5 py-2"
2166
3898
  >
2167
- <Video className="size-3.5 shrink-0 text-blue-500" />
3899
+ <Video
3900
+ className={cn(
3901
+ 'size-3.5 shrink-0',
3902
+ hasVideo
3903
+ ? 'text-emerald-600'
3904
+ : 'text-blue-600'
3905
+ )}
3906
+ />
2168
3907
  <div className="flex-1 min-w-0">
2169
3908
  <p className="text-xs truncate font-medium">
2170
3909
  {profile.name}
2171
3910
  </p>
2172
- <p className="text-[0.65rem] text-muted-foreground">
2173
- {res
2174
- ? `${res.name}${res.size ? ` · ${res.size}` : ''}`
2175
- : t(
2176
- 'lessonForm.videoProfileMissing'
2177
- )}
2178
- </p>
3911
+ {res ? (
3912
+ (() => {
3913
+ const metadata =
3914
+ resolveResourceMetadata(res);
3915
+
3916
+ return (
3917
+ <p className="text-[0.65rem] text-muted-foreground">
3918
+ {`${metadata.sizeLabel} · ${metadata.uploadedAtLabel}`}
3919
+ </p>
3920
+ );
3921
+ })()
3922
+ ) : (
3923
+ <Badge className="mt-1 inline-flex items-center gap-1 border border-blue-200 bg-blue-100 text-blue-700 hover:bg-blue-100 dark:border-blue-800 dark:bg-blue-950/40 dark:text-blue-200 dark:hover:bg-blue-950/50">
3924
+ {isConversionJobActive ? (
3925
+ <Loader2 className="size-3 animate-spin" />
3926
+ ) : (
3927
+ <Clock className="size-3" />
3928
+ )}
3929
+ {t('lessonForm.awaitingConversion')}
3930
+ </Badge>
3931
+ )}
2179
3932
  {currentUploadProgress !== undefined ? (
2180
3933
  <div className="mt-1 space-y-1">
2181
3934
  <Progress
@@ -2229,71 +3982,106 @@ export function EditorLesson({ lessonId }: EditorLessonProps) {
2229
3982
  </Button>
2230
3983
  {res && (
2231
3984
  <>
2232
- <Button
2233
- type="button"
2234
- variant="ghost"
2235
- size="icon"
2236
- className="size-6 shrink-0"
2237
- disabled={isResolvingVideoPreview}
2238
- onClick={() =>
2239
- void openVideoPreview(res)
2240
- }
2241
- aria-label={t(
3985
+ <IconActionTooltip
3986
+ label={t(
2242
3987
  'lessonForm.playVideoAria',
2243
3988
  { name: res.name }
2244
3989
  )}
3990
+ asWrapper={isResolvingVideoPreview}
2245
3991
  >
2246
- {isResolvingVideoPreview ? (
2247
- <Loader2 className="size-3 animate-spin" />
2248
- ) : (
2249
- <Play className="size-3" />
2250
- )}
2251
- </Button>
2252
- <Button
2253
- type="button"
2254
- variant="ghost"
2255
- size="icon"
2256
- className="size-6 shrink-0"
2257
- onClick={() =>
2258
- void openResource(res)
2259
- }
2260
- aria-label={t(
3992
+ <Button
3993
+ type="button"
3994
+ variant="ghost"
3995
+ size="icon"
3996
+ className="size-6 shrink-0 text-muted-foreground transition-colors hover:text-emerald-600"
3997
+ disabled={isResolvingVideoPreview}
3998
+ onClick={() =>
3999
+ void openVideoPreview(res)
4000
+ }
4001
+ aria-label={t(
4002
+ 'lessonForm.playVideoAria',
4003
+ { name: res.name }
4004
+ )}
4005
+ >
4006
+ {isResolvingVideoPreview ? (
4007
+ <Loader2 className="size-3 animate-spin" />
4008
+ ) : (
4009
+ <Play className="size-3" />
4010
+ )}
4011
+ </Button>
4012
+ </IconActionTooltip>
4013
+ <IconActionTooltip
4014
+ label={t(
2261
4015
  'lessonForm.openVideoAria',
2262
4016
  { name: res.name }
2263
4017
  )}
2264
4018
  >
2265
- <ExternalLink className="size-3" />
2266
- </Button>
2267
- <Button
2268
- type="button"
2269
- variant="ghost"
2270
- size="icon"
2271
- className="size-6 shrink-0"
2272
- onClick={() =>
2273
- void handleResourceDownload(res)
2274
- }
2275
- aria-label={t(
4019
+ <Button
4020
+ type="button"
4021
+ variant="ghost"
4022
+ size="icon"
4023
+ className="size-6 shrink-0 text-muted-foreground transition-colors hover:text-blue-600"
4024
+ onClick={() =>
4025
+ void openResource(res)
4026
+ }
4027
+ aria-label={t(
4028
+ 'lessonForm.openVideoAria',
4029
+ { name: res.name }
4030
+ )}
4031
+ >
4032
+ <ExternalLink className="size-3" />
4033
+ </Button>
4034
+ </IconActionTooltip>
4035
+ <IconActionTooltip
4036
+ label={t(
2276
4037
  'lessonForm.downloadVideoAria',
2277
4038
  { name: res.name }
2278
4039
  )}
4040
+ asWrapper={isDownloadingResource}
2279
4041
  >
2280
- <Download className="size-3" />
2281
- </Button>
2282
- <Button
2283
- type="button"
2284
- variant="ghost"
2285
- size="icon"
2286
- className="size-6 shrink-0 text-muted-foreground hover:text-destructive"
2287
- onClick={() =>
2288
- void removeResource(res.id)
2289
- }
2290
- aria-label={t(
4042
+ <Button
4043
+ type="button"
4044
+ variant="ghost"
4045
+ size="icon"
4046
+ className="size-6 shrink-0 text-muted-foreground transition-colors hover:text-amber-600"
4047
+ disabled={isDownloadingResource}
4048
+ onClick={() =>
4049
+ void handleResourceDownload(res)
4050
+ }
4051
+ aria-label={t(
4052
+ 'lessonForm.downloadVideoAria',
4053
+ { name: res.name }
4054
+ )}
4055
+ >
4056
+ {isDownloadingResource ? (
4057
+ <Loader2 className="size-3 animate-spin" />
4058
+ ) : (
4059
+ <Download className="size-3" />
4060
+ )}
4061
+ </Button>
4062
+ </IconActionTooltip>
4063
+ <IconActionTooltip
4064
+ label={t(
2291
4065
  'lessonForm.removeVideoAria',
2292
4066
  { name: res.name }
2293
4067
  )}
2294
4068
  >
2295
- <X className="size-3" />
2296
- </Button>
4069
+ <Button
4070
+ type="button"
4071
+ variant="ghost"
4072
+ size="icon"
4073
+ className="size-6 shrink-0 text-muted-foreground transition-colors hover:text-destructive"
4074
+ onClick={() =>
4075
+ void removeResource(res.id)
4076
+ }
4077
+ aria-label={t(
4078
+ 'lessonForm.removeVideoAria',
4079
+ { name: res.name }
4080
+ )}
4081
+ >
4082
+ <X className="size-3" />
4083
+ </Button>
4084
+ </IconActionTooltip>
2297
4085
  </>
2298
4086
  )}
2299
4087
  </div>
@@ -2311,6 +4099,262 @@ export function EditorLesson({ lessonId }: EditorLessonProps) {
2311
4099
  </TabsContent>
2312
4100
  )}
2313
4101
 
4102
+ {watchedType === 'video' && (
4103
+ <TabsContent value="imagens" className="flex-1 min-h-0 mt-0">
4104
+ <ScrollArea className="h-full">
4105
+ <div className="flex flex-col gap-2 p-2 sm:gap-3 sm:p-3">
4106
+ <Card className="bg-muted/20 py-2 gap-2">
4107
+ <CardHeader className="px-3 pt-2 pb-1">
4108
+ <CardTitle className="text-xs font-medium text-muted-foreground uppercase tracking-wider flex items-center justify-between gap-2">
4109
+ <div className="flex flex-col gap-0.5">
4110
+ <span className="flex items-center gap-1.5">
4111
+ <Image className="size-3 text-blue-500" />
4112
+ Imagens extraídas do vídeo
4113
+ </span>
4114
+ <span className="text-[0.65rem] normal-case text-muted-foreground">
4115
+ Total de imagens: {videoFrames.length}
4116
+ </span>
4117
+ </div>
4118
+ <div className="flex items-center gap-1.5">
4119
+ <Button
4120
+ type="button"
4121
+ variant="outline"
4122
+ size="sm"
4123
+ className="h-7 px-2 text-[11px]"
4124
+ disabled={isSavingFrameCreate}
4125
+ onClick={openFrameCreateSheet}
4126
+ >
4127
+ {isSavingFrameCreate ? (
4128
+ <Loader2 className="size-3 mr-1 animate-spin" />
4129
+ ) : (
4130
+ <Plus className="size-3 mr-1" />
4131
+ )}
4132
+ Adicionar imagem
4133
+ </Button>
4134
+ <Button
4135
+ type="button"
4136
+ variant="outline"
4137
+ size="sm"
4138
+ className="h-7 px-2 text-[11px]"
4139
+ disabled={
4140
+ videoFrames.length === 0 || isDeletingAllFrames
4141
+ }
4142
+ onClick={confirmDeleteAllFrames}
4143
+ >
4144
+ {isDeletingAllFrames ? (
4145
+ <Loader2 className="size-3 mr-1 animate-spin" />
4146
+ ) : (
4147
+ <Trash2 className="size-3 mr-1" />
4148
+ )}
4149
+ Excluir todas
4150
+ </Button>
4151
+ <Button
4152
+ type="button"
4153
+ variant="secondary"
4154
+ size="sm"
4155
+ className="h-7 px-2 text-[11px]"
4156
+ disabled={!canRequeueSavedOriginalVideo}
4157
+ onClick={confirmRegenerateFrames}
4158
+ >
4159
+ {isRequeueingOriginalVideo ? (
4160
+ <Loader2 className="size-3 mr-1 animate-spin" />
4161
+ ) : (
4162
+ <RefreshCw className="size-3 mr-1" />
4163
+ )}
4164
+ Regenerar imagens
4165
+ </Button>
4166
+ </div>
4167
+ </CardTitle>
4168
+ </CardHeader>
4169
+ <CardContent className="px-3 pb-2 flex flex-col gap-3">
4170
+ {videoFrames.length === 0 ? (
4171
+ <div className="flex flex-col items-center justify-center gap-2 rounded-lg border border-dashed bg-background/70 px-4 py-8 text-center">
4172
+ <Image className="size-8 text-muted-foreground/60" />
4173
+ <p className="text-sm font-medium">
4174
+ Nenhuma imagem extraída ainda.
4175
+ </p>
4176
+ <p className="text-xs text-muted-foreground max-w-md">
4177
+ Reprocesse o vídeo original para gerar os frames de
4178
+ captura.
4179
+ </p>
4180
+ <Button
4181
+ type="button"
4182
+ variant="secondary"
4183
+ size="sm"
4184
+ className="mt-1"
4185
+ onClick={openFrameCreateSheet}
4186
+ >
4187
+ <Plus className="size-3 mr-1" />
4188
+ Adicionar manualmente
4189
+ </Button>
4190
+ <Button
4191
+ type="button"
4192
+ variant="secondary"
4193
+ size="sm"
4194
+ className="mt-1"
4195
+ disabled={!canRequeueSavedOriginalVideo}
4196
+ onClick={confirmRegenerateFrames}
4197
+ >
4198
+ {isRequeueingOriginalVideo ? (
4199
+ <Loader2 className="size-3 mr-1 animate-spin" />
4200
+ ) : (
4201
+ <RefreshCw className="size-3 mr-1" />
4202
+ )}
4203
+ Reprocessar vídeo
4204
+ </Button>
4205
+ </div>
4206
+ ) : (
4207
+ <div className="grid gap-2 sm:grid-cols-2 xl:grid-cols-3">
4208
+ {videoFrames.map((frame) => {
4209
+ const frameCardUrl = resolveFrameCardUrl(frame);
4210
+ const hasImageError = frameImageErrorIds.has(
4211
+ frame.id
4212
+ );
4213
+ const isDownloadingFrame = downloadingFrameIds.has(
4214
+ String(frame.id)
4215
+ );
4216
+
4217
+ return (
4218
+ <div
4219
+ key={frame.id}
4220
+ className="overflow-hidden rounded-lg border bg-background/80 shadow-sm"
4221
+ >
4222
+ <div
4223
+ className={cn(
4224
+ 'aspect-video bg-muted/40',
4225
+ frameCardUrl && !hasImageError
4226
+ ? 'cursor-pointer'
4227
+ : ''
4228
+ )}
4229
+ onClick={() => {
4230
+ if (frameCardUrl && !hasImageError) {
4231
+ openFrameImageDialog(frame);
4232
+ }
4233
+ }}
4234
+ >
4235
+ {frameCardUrl && !hasImageError ? (
4236
+ <img
4237
+ src={frameCardUrl}
4238
+ alt={frame.name}
4239
+ className="h-full w-full object-cover"
4240
+ loading="lazy"
4241
+ onError={() => {
4242
+ setFrameImageErrorIds((current) => {
4243
+ if (current.has(frame.id))
4244
+ return current;
4245
+ const next = new Set(current);
4246
+ next.add(frame.id);
4247
+ return next;
4248
+ });
4249
+ }}
4250
+ />
4251
+ ) : (
4252
+ <div className="flex h-full flex-col items-center justify-center gap-1 text-muted-foreground">
4253
+ <Image className="size-8" />
4254
+ <span className="text-[0.65rem]">
4255
+ Prévia indisponível
4256
+ </span>
4257
+ </div>
4258
+ )}
4259
+ </div>
4260
+ <div className="flex items-start gap-2 p-2.5">
4261
+ <div className="min-w-0 flex-1">
4262
+ <p className="truncate text-xs font-medium">
4263
+ {frame.name}
4264
+ </p>
4265
+ <p className="text-[0.65rem] text-muted-foreground">
4266
+ Aparece em{' '}
4267
+ {formatTimecodeLabel(frame.timeSeconds)}
4268
+ </p>
4269
+ <p className="text-[0.65rem] text-muted-foreground">
4270
+ Tamanho: {resolveFrameCardSize(frame)}
4271
+ </p>
4272
+ </div>
4273
+ <div className="flex shrink-0 items-center gap-1">
4274
+ <IconActionTooltip
4275
+ label={`Editar imagem de ${formatTimecodeLabel(frame.timeSeconds)}`}
4276
+ >
4277
+ <Button
4278
+ type="button"
4279
+ variant="ghost"
4280
+ size="icon"
4281
+ className="size-7 text-muted-foreground hover:text-blue-600"
4282
+ onClick={() =>
4283
+ openFrameEditSheet(frame)
4284
+ }
4285
+ aria-label={`Editar imagem de ${formatTimecodeLabel(frame.timeSeconds)}`}
4286
+ >
4287
+ <Pencil className="size-3" />
4288
+ </Button>
4289
+ </IconActionTooltip>
4290
+ <IconActionTooltip
4291
+ label={`Reproduzir vídeo em ${formatTimecodeLabel(frame.timeSeconds)}`}
4292
+ >
4293
+ <Button
4294
+ type="button"
4295
+ variant="ghost"
4296
+ size="icon"
4297
+ className="size-7 text-muted-foreground hover:text-emerald-600"
4298
+ onClick={() =>
4299
+ void openFramePreview(frame)
4300
+ }
4301
+ aria-label={`Reproduzir vídeo em ${formatTimecodeLabel(frame.timeSeconds)}`}
4302
+ >
4303
+ <Play className="size-3" />
4304
+ </Button>
4305
+ </IconActionTooltip>
4306
+ <IconActionTooltip
4307
+ label={`Baixar imagem de ${formatTimecodeLabel(frame.timeSeconds)}`}
4308
+ asWrapper={isDownloadingFrame}
4309
+ >
4310
+ <Button
4311
+ type="button"
4312
+ variant="ghost"
4313
+ size="icon"
4314
+ className="size-7 text-muted-foreground hover:text-amber-600"
4315
+ disabled={isDownloadingFrame}
4316
+ onClick={() =>
4317
+ void handleDownloadFrame(frame)
4318
+ }
4319
+ aria-label={`Baixar imagem de ${formatTimecodeLabel(frame.timeSeconds)}`}
4320
+ >
4321
+ {isDownloadingFrame ? (
4322
+ <Loader2 className="size-3 animate-spin" />
4323
+ ) : (
4324
+ <Download className="size-3" />
4325
+ )}
4326
+ </Button>
4327
+ </IconActionTooltip>
4328
+ <IconActionTooltip
4329
+ label={`Remover imagem de ${formatTimecodeLabel(frame.timeSeconds)}`}
4330
+ >
4331
+ <Button
4332
+ type="button"
4333
+ variant="ghost"
4334
+ size="icon"
4335
+ className="size-7 text-muted-foreground hover:text-destructive"
4336
+ onClick={() =>
4337
+ void handleDeleteFrame(frame)
4338
+ }
4339
+ aria-label={`Remover imagem de ${formatTimecodeLabel(frame.timeSeconds)}`}
4340
+ >
4341
+ <Trash2 className="size-3" />
4342
+ </Button>
4343
+ </IconActionTooltip>
4344
+ </div>
4345
+ </div>
4346
+ </div>
4347
+ );
4348
+ })}
4349
+ </div>
4350
+ )}
4351
+ </CardContent>
4352
+ </Card>
4353
+ </div>
4354
+ </ScrollArea>
4355
+ </TabsContent>
4356
+ )}
4357
+
2314
4358
  {/* ── Tab Transcrição ─────────────────────────────────────────── */}
2315
4359
  {watchedType === 'video' && (
2316
4360
  <TabsContent value="transcricao" className="flex-1 min-h-0 mt-0">
@@ -2326,7 +4370,7 @@ export function EditorLesson({ lessonId }: EditorLessonProps) {
2326
4370
  size="sm"
2327
4371
  className="h-6 text-xs px-2"
2328
4372
  onClick={() =>
2329
- setTranscriptionSegments((prev) => [
4373
+ updateTranscriptionSegmentsState((prev) => [
2330
4374
  ...prev,
2331
4375
  {
2332
4376
  id: segmentId(),
@@ -2352,7 +4396,7 @@ export function EditorLesson({ lessonId }: EditorLessonProps) {
2352
4396
  <Input
2353
4397
  value={segment.start}
2354
4398
  onChange={(event) =>
2355
- setTranscriptionSegments((prev) =>
4399
+ updateTranscriptionSegmentsState((prev) =>
2356
4400
  prev.map((item) =>
2357
4401
  item.id === segment.id
2358
4402
  ? {
@@ -2364,7 +4408,7 @@ export function EditorLesson({ lessonId }: EditorLessonProps) {
2364
4408
  )
2365
4409
  }
2366
4410
  onBlur={() =>
2367
- setTranscriptionSegments((prev) =>
4411
+ updateTranscriptionSegmentsState((prev) =>
2368
4412
  prev.map((item) =>
2369
4413
  item.id === segment.id
2370
4414
  ? {
@@ -2381,7 +4425,7 @@ export function EditorLesson({ lessonId }: EditorLessonProps) {
2381
4425
  <Input
2382
4426
  value={segment.end}
2383
4427
  onChange={(event) =>
2384
- setTranscriptionSegments((prev) =>
4428
+ updateTranscriptionSegmentsState((prev) =>
2385
4429
  prev.map((item) =>
2386
4430
  item.id === segment.id
2387
4431
  ? {
@@ -2393,7 +4437,7 @@ export function EditorLesson({ lessonId }: EditorLessonProps) {
2393
4437
  )
2394
4438
  }
2395
4439
  onBlur={() =>
2396
- setTranscriptionSegments((prev) =>
4440
+ updateTranscriptionSegmentsState((prev) =>
2397
4441
  prev.map((item) =>
2398
4442
  item.id === segment.id
2399
4443
  ? {
@@ -2410,7 +4454,7 @@ export function EditorLesson({ lessonId }: EditorLessonProps) {
2410
4454
  <Textarea
2411
4455
  value={segment.text}
2412
4456
  onChange={(event) =>
2413
- setTranscriptionSegments((prev) =>
4457
+ updateTranscriptionSegmentsState((prev) =>
2414
4458
  prev.map((item) =>
2415
4459
  item.id === segment.id
2416
4460
  ? {
@@ -2436,7 +4480,7 @@ export function EditorLesson({ lessonId }: EditorLessonProps) {
2436
4480
  size="icon"
2437
4481
  className="size-8 text-muted-foreground hover:text-destructive"
2438
4482
  onClick={() =>
2439
- setTranscriptionSegments((prev) => {
4483
+ updateTranscriptionSegmentsState((prev) => {
2440
4484
  if (prev.length === 1) {
2441
4485
  const first = prev[0];
2442
4486
  return first
@@ -2471,6 +4515,25 @@ export function EditorLesson({ lessonId }: EditorLessonProps) {
2471
4515
  </TabsContent>
2472
4516
  )}
2473
4517
 
4518
+ {watchedType === 'video' && (
4519
+ <TabsContent value="audios" className="flex-1 min-h-0 mt-0">
4520
+ <ScrollArea className="h-full">
4521
+ <AudioFilesTab
4522
+ audioResources={audioResources}
4523
+ isUploading={isUploading}
4524
+ onAddAudio={handleAudioFiles}
4525
+ onOpenAudio={openResource}
4526
+ onDownloadAudio={handleResourceDownload}
4527
+ isAudioDownloading={(res) =>
4528
+ downloadingResourceKeys.has(String(res.fileId ?? res.id))
4529
+ }
4530
+ onDeleteAudio={removeResource}
4531
+ resolveResourceMetadata={resolveResourceMetadata}
4532
+ />
4533
+ </ScrollArea>
4534
+ </TabsContent>
4535
+ )}
4536
+
2474
4537
  {/* ── Tab Recursos ─────────────────────────────────────────────── */}
2475
4538
  <TabsContent value="recursos" className="flex-1 min-h-0 mt-0">
2476
4539
  <ScrollArea className="h-full">
@@ -2552,86 +4615,130 @@ export function EditorLesson({ lessonId }: EditorLessonProps) {
2552
4615
  </div>
2553
4616
 
2554
4617
  {/* Counter */}
2555
- {genericResources.length > 0 && (
4618
+ {supplementaryResources.length > 0 && (
2556
4619
  <p className="text-xs text-muted-foreground">
2557
4620
  {t('lessonForm.resourcesCount', {
2558
- count: genericResources.length,
4621
+ count: supplementaryResources.length,
2559
4622
  })}
2560
4623
  </p>
2561
4624
  )}
2562
4625
 
2563
4626
  {/* Resource list */}
2564
- {genericResources.length === 0 ? (
4627
+ {supplementaryResources.length === 0 ? (
2565
4628
  <p className="text-center text-xs text-muted-foreground py-1">
2566
4629
  {t('questionEditor.noLinkedResources')}
2567
4630
  </p>
2568
4631
  ) : (
2569
4632
  <div className="flex flex-col gap-1">
2570
- {genericResources.map((res) => {
2571
- const ResIcon = getResourceIcon(res.type);
4633
+ {supplementaryResources.map((res) => {
4634
+ const metadata = resolveResourceMetadata(res);
4635
+ const isDownloadingResource = downloadingResourceKeys.has(
4636
+ String(res.fileId ?? res.id)
4637
+ );
4638
+
2572
4639
  return (
2573
4640
  <div
2574
4641
  key={res.id}
2575
4642
  className="flex items-center gap-2 rounded-md border bg-muted/20 px-2.5 py-2"
2576
4643
  >
2577
- <ResIcon
2578
- className={cn(
2579
- 'size-3.5 shrink-0',
2580
- getResourceIconColor(res.type)
2581
- )}
4644
+ <FileTypeIcon
4645
+ filename={res.name}
4646
+ mimeType={res.type}
4647
+ size={14}
2582
4648
  />
2583
4649
  <div className="flex-1 min-w-0">
2584
4650
  <p className="text-xs truncate font-medium">
2585
4651
  {res.name}
2586
4652
  </p>
2587
- <p className="text-[0.65rem] text-muted-foreground">
2588
- {res.size}
2589
- </p>
4653
+ <div className="mt-0.5 flex flex-wrap items-center gap-2 text-[0.65rem] text-muted-foreground">
4654
+ <span>{metadata.sizeLabel}</span>
4655
+ <span>·</span>
4656
+ <span>{metadata.uploadedAtLabel}</span>
4657
+ </div>
2590
4658
  </div>
2591
- {res.public && (
2592
- <Eye
2593
- className="size-3 text-emerald-500 shrink-0"
4659
+
4660
+ <div className="flex items-center gap-1.5 pr-1">
4661
+ <Switch
4662
+ checked={Boolean(res.public)}
4663
+ onCheckedChange={(checked) => {
4664
+ setLocalResources((prev) =>
4665
+ prev.map((item) =>
4666
+ item.id === res.id
4667
+ ? { ...item, public: checked }
4668
+ : item
4669
+ )
4670
+ );
4671
+ setResourcesDirty(true);
4672
+ }}
2594
4673
  aria-label={t('lessonForm.public')}
2595
4674
  />
2596
- )}
2597
- {res.url && (
2598
- <a
2599
- href={res.url}
2600
- target="_blank"
2601
- rel="noopener noreferrer"
4675
+ <span className="text-[0.65rem] text-muted-foreground whitespace-nowrap">
4676
+ {res.public
4677
+ ? t('lessonForm.public')
4678
+ : t('lessonForm.private')}
4679
+ </span>
4680
+ </div>
4681
+
4682
+ <IconActionTooltip
4683
+ label={t('questionEditor.openInNewTab', {
4684
+ name: res.name,
4685
+ })}
4686
+ >
4687
+ <Button
4688
+ type="button"
4689
+ variant="ghost"
4690
+ size="icon"
4691
+ className="size-6 shrink-0 text-muted-foreground hover:text-foreground"
4692
+ onClick={() => void openResource(res)}
2602
4693
  aria-label={t('questionEditor.openInNewTab', {
2603
4694
  name: res.name,
2604
4695
  })}
2605
- onClick={(e) => e.stopPropagation()}
2606
- className="inline-flex items-center justify-center size-6 rounded-md text-muted-foreground hover:text-foreground hover:bg-accent transition-colors shrink-0"
2607
4696
  >
2608
4697
  <ExternalLink className="size-3" />
2609
- </a>
2610
- )}
2611
- <Button
2612
- type="button"
2613
- variant="ghost"
2614
- size="icon"
2615
- className="size-6 shrink-0 text-muted-foreground hover:text-foreground"
2616
- onClick={() => handleResourceDownload(res)}
2617
- aria-label={t('questionEditor.downloadResource', {
4698
+ </Button>
4699
+ </IconActionTooltip>
4700
+ <IconActionTooltip
4701
+ label={t('questionEditor.downloadResource', {
2618
4702
  name: res.name,
2619
4703
  })}
4704
+ asWrapper={isDownloadingResource}
2620
4705
  >
2621
- <Download className="size-3" />
2622
- </Button>
2623
- <Button
2624
- type="button"
2625
- variant="ghost"
2626
- size="icon"
2627
- className="size-6 shrink-0 text-muted-foreground hover:text-destructive"
2628
- onClick={() => void removeResource(res.id)}
2629
- aria-label={t('questionEditor.removeResource', {
4706
+ <Button
4707
+ type="button"
4708
+ variant="ghost"
4709
+ size="icon"
4710
+ className="size-6 shrink-0 text-muted-foreground hover:text-foreground"
4711
+ disabled={isDownloadingResource}
4712
+ onClick={() => handleResourceDownload(res)}
4713
+ aria-label={t('questionEditor.downloadResource', {
4714
+ name: res.name,
4715
+ })}
4716
+ >
4717
+ {isDownloadingResource ? (
4718
+ <Loader2 className="size-3 animate-spin" />
4719
+ ) : (
4720
+ <Download className="size-3" />
4721
+ )}
4722
+ </Button>
4723
+ </IconActionTooltip>
4724
+ <IconActionTooltip
4725
+ label={t('questionEditor.removeResource', {
2630
4726
  name: res.name,
2631
4727
  })}
2632
4728
  >
2633
- <X className="size-3" />
2634
- </Button>
4729
+ <Button
4730
+ type="button"
4731
+ variant="ghost"
4732
+ size="icon"
4733
+ className="size-6 shrink-0 text-muted-foreground hover:text-destructive"
4734
+ onClick={() => void removeResource(res.id)}
4735
+ aria-label={t('questionEditor.removeResource', {
4736
+ name: res.name,
4737
+ })}
4738
+ >
4739
+ <X className="size-3" />
4740
+ </Button>
4741
+ </IconActionTooltip>
2635
4742
  </div>
2636
4743
  );
2637
4744
  })}
@@ -2642,6 +4749,392 @@ export function EditorLesson({ lessonId }: EditorLessonProps) {
2642
4749
  </TabsContent>
2643
4750
  </Tabs>
2644
4751
 
4752
+ <Dialog
4753
+ open={videoPreviewOpen}
4754
+ onOpenChange={(open) => {
4755
+ if (!open) closeVideoPreview();
4756
+ }}
4757
+ >
4758
+ <DialogContent className="max-w-[min(96vw,72rem)] gap-3 overflow-hidden p-0">
4759
+ <DialogHeader className="border-b px-5 py-4 text-left">
4760
+ <DialogTitle className="text-base">
4761
+ {videoPreviewResource?.name ?? t('lessonForm.tabVideos')}
4762
+ </DialogTitle>
4763
+ <DialogDescription>
4764
+ {videoPreviewResource
4765
+ ? t('lessonForm.fileStorageVideosByResolution')
4766
+ : t('lessonForm.tabVideos')}
4767
+ </DialogDescription>
4768
+ </DialogHeader>
4769
+ <div className="px-5 pb-5">
4770
+ {videoPreviewError ? (
4771
+ <div className="flex min-h-72 items-center justify-center rounded-lg border border-dashed bg-muted/30 px-6 text-center text-sm text-muted-foreground">
4772
+ {videoPreviewError}
4773
+ </div>
4774
+ ) : videoPreviewUrl ? (
4775
+ <video
4776
+ key={videoPreviewUrl}
4777
+ src={videoPreviewUrl}
4778
+ controls
4779
+ autoPlay
4780
+ playsInline
4781
+ className="max-h-[70vh] w-full rounded-lg bg-black"
4782
+ />
4783
+ ) : (
4784
+ <div className="flex min-h-72 items-center justify-center rounded-lg border border-dashed bg-muted/30 px-6 text-center text-sm text-muted-foreground">
4785
+ {isResolvingVideoPreview
4786
+ ? t('lessonForm.loadingVideoProfiles')
4787
+ : t('questionEditor.resourceOpenError')}
4788
+ </div>
4789
+ )}
4790
+ </div>
4791
+ </DialogContent>
4792
+ </Dialog>
4793
+
4794
+ <Dialog
4795
+ open={framePreviewOpen}
4796
+ onOpenChange={(open) => {
4797
+ if (!open) closeFramePreview();
4798
+ }}
4799
+ >
4800
+ <DialogContent className="max-w-[min(96vw,80rem)] gap-3 overflow-hidden p-0">
4801
+ <DialogHeader className="border-b px-5 py-4 text-left">
4802
+ <DialogTitle className="text-base">
4803
+ {framePreviewFrame?.name ?? 'Imagem extraída'}
4804
+ </DialogTitle>
4805
+ <DialogDescription>
4806
+ Reprodução iniciando em{' '}
4807
+ {formatTimecodeLabel(framePreviewFrame?.timeSeconds ?? 0)}
4808
+ </DialogDescription>
4809
+ </DialogHeader>
4810
+
4811
+ <div className="grid gap-3 px-5 pb-5 lg:grid-cols-[18rem_1fr]">
4812
+ <div className="space-y-3">
4813
+ <div className="rounded-lg border bg-muted/20 p-3">
4814
+ <p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
4815
+ Fonte de reprodução
4816
+ </p>
4817
+ <div className="mt-2 flex flex-col gap-1.5">
4818
+ {framePreviewSources.length === 0 ? (
4819
+ <p className="text-xs text-muted-foreground">
4820
+ Nenhuma fonte disponível.
4821
+ </p>
4822
+ ) : (
4823
+ framePreviewSources.map((source) => (
4824
+ <Button
4825
+ key={source.key}
4826
+ type="button"
4827
+ variant={
4828
+ activeFramePreviewSource?.key === source.key
4829
+ ? 'secondary'
4830
+ : 'outline'
4831
+ }
4832
+ className="h-8 justify-start px-3 text-xs"
4833
+ onClick={() =>
4834
+ void resolveFramePreviewSource(source.key)
4835
+ }
4836
+ >
4837
+ <Video className="mr-2 size-3.5" />
4838
+ {source.label}
4839
+ </Button>
4840
+ ))
4841
+ )}
4842
+ </div>
4843
+ </div>
4844
+
4845
+ <div className="rounded-lg border bg-muted/20 p-3 text-xs text-muted-foreground">
4846
+ <p>
4847
+ O player começa exatamente no timestamp da imagem extraída.
4848
+ </p>
4849
+ </div>
4850
+ </div>
4851
+
4852
+ <div>
4853
+ {framePreviewError ? (
4854
+ <div className="flex min-h-72 items-center justify-center rounded-lg border border-dashed bg-muted/30 px-6 text-center text-sm text-muted-foreground">
4855
+ {framePreviewError}
4856
+ </div>
4857
+ ) : framePreviewUrl ? (
4858
+ <video
4859
+ ref={framePreviewVideoRef}
4860
+ key={framePreviewUrl}
4861
+ src={framePreviewUrl}
4862
+ controls
4863
+ autoPlay
4864
+ playsInline
4865
+ className="max-h-[72vh] w-full rounded-lg bg-black"
4866
+ onLoadedMetadata={() => {
4867
+ const video = framePreviewVideoRef.current;
4868
+ if (!video || !framePreviewFrame) return;
4869
+
4870
+ const seekTo = Math.max(0, framePreviewFrame.timeSeconds);
4871
+ if (Math.abs(video.currentTime - seekTo) > 0.25) {
4872
+ video.currentTime = seekTo;
4873
+ }
4874
+ }}
4875
+ />
4876
+ ) : (
4877
+ <div className="flex min-h-72 items-center justify-center rounded-lg border border-dashed bg-muted/30 px-6 text-center text-sm text-muted-foreground">
4878
+ {isResolvingFramePreview
4879
+ ? 'Carregando vídeo...'
4880
+ : 'Selecione uma fonte para reprodução.'}
4881
+ </div>
4882
+ )}
4883
+ </div>
4884
+ </div>
4885
+ </DialogContent>
4886
+ </Dialog>
4887
+
4888
+ <Dialog
4889
+ open={frameImageDialogOpen}
4890
+ onOpenChange={(open) => {
4891
+ if (!open) closeFrameImageDialog();
4892
+ }}
4893
+ >
4894
+ <DialogContent className="h-[96vh] w-[96vw] max-w-none overflow-hidden p-0">
4895
+ <DialogHeader className="border-b px-4 py-3 text-left">
4896
+ <DialogTitle className="text-sm">
4897
+ {frameImageDialogFrame?.name ?? 'Imagem extraída'}
4898
+ </DialogTitle>
4899
+ <DialogDescription>
4900
+ Visualização ampliada da imagem extraída.
4901
+ </DialogDescription>
4902
+ </DialogHeader>
4903
+
4904
+ <div className="flex h-full items-center justify-center bg-black/95 p-4">
4905
+ {frameImageDialogFrame ? (
4906
+ <img
4907
+ src={resolveFrameCardUrl(frameImageDialogFrame)}
4908
+ alt={frameImageDialogFrame.name}
4909
+ className="max-h-full max-w-full object-contain"
4910
+ />
4911
+ ) : null}
4912
+ </div>
4913
+ </DialogContent>
4914
+ </Dialog>
4915
+
4916
+ <Sheet
4917
+ open={frameEditSheetOpen}
4918
+ onOpenChange={(open) => {
4919
+ if (!open) closeFrameEditSheet();
4920
+ else setFrameEditSheetOpen(true);
4921
+ }}
4922
+ >
4923
+ <ResizableSheetContent
4924
+ sheetId="lms-course-structure-frame-edit-sheet"
4925
+ defaultWidth={520}
4926
+ minWidth={420}
4927
+ maxWidth={920}
4928
+ side="right"
4929
+ className="flex w-full flex-col sm:max-w-lg overflow-y-auto"
4930
+ >
4931
+ <SheetHeader>
4932
+ <SheetTitle>Editar imagem extraida</SheetTitle>
4933
+ </SheetHeader>
4934
+
4935
+ <div className="flex flex-1 flex-col gap-4 px-4 pb-4">
4936
+ <div className="rounded-lg border bg-muted/20 p-3">
4937
+ <p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
4938
+ Preview
4939
+ </p>
4940
+ <div className="mt-2 flex min-h-52 items-center justify-center overflow-hidden rounded-md border bg-background/60">
4941
+ {editingFramePreviewUrl ? (
4942
+ <img
4943
+ src={editingFramePreviewUrl}
4944
+ alt={editingFrame?.name ?? 'Imagem extraida'}
4945
+ className="max-h-72 w-full object-contain"
4946
+ />
4947
+ ) : (
4948
+ <p className="text-xs text-muted-foreground">
4949
+ Preview indisponivel.
4950
+ </p>
4951
+ )}
4952
+ </div>
4953
+ </div>
4954
+
4955
+ <div className="flex flex-col gap-2">
4956
+ <Label className="text-sm font-medium">Tempo no video</Label>
4957
+ <Input
4958
+ value={editingFrameTime}
4959
+ onChange={(event) => setEditingFrameTime(event.target.value)}
4960
+ onBlur={() =>
4961
+ setEditingFrameTime((current) =>
4962
+ normalizeTimeInput(current || '')
4963
+ )
4964
+ }
4965
+ placeholder="00:00"
4966
+ className="font-mono"
4967
+ />
4968
+ <p className="text-xs text-muted-foreground">
4969
+ Use mm:ss ou hh:mm:ss para definir onde a imagem aparece.
4970
+ </p>
4971
+ </div>
4972
+
4973
+ <div className="flex flex-col gap-2">
4974
+ <Label className="text-sm font-medium">Substituir imagem</Label>
4975
+ <div className="flex items-center gap-2">
4976
+ <Button
4977
+ type="button"
4978
+ variant="outline"
4979
+ onClick={() => frameEditInputRef.current?.click()}
4980
+ >
4981
+ <UploadCloud className="size-4 mr-1" />
4982
+ {editingFrameFile ? 'Trocar arquivo' : 'Selecionar imagem'}
4983
+ </Button>
4984
+ {editingFrameFile ? (
4985
+ <span className="text-xs text-muted-foreground truncate">
4986
+ {editingFrameFile.name}
4987
+ </span>
4988
+ ) : null}
4989
+ </div>
4990
+ <input
4991
+ ref={frameEditInputRef}
4992
+ type="file"
4993
+ accept="image/*"
4994
+ className="hidden"
4995
+ onChange={(event) => {
4996
+ handleFrameReplacementSelect(event.target.files?.[0]);
4997
+ event.target.value = '';
4998
+ }}
4999
+ />
5000
+ </div>
5001
+ </div>
5002
+
5003
+ <SheetFooter className="px-4 pb-4">
5004
+ <Button
5005
+ type="button"
5006
+ variant="outline"
5007
+ onClick={closeFrameEditSheet}
5008
+ >
5009
+ Cancelar
5010
+ </Button>
5011
+ <Button
5012
+ type="button"
5013
+ onClick={() => void handleSaveFrameEdit()}
5014
+ disabled={isSavingFrameEdit}
5015
+ >
5016
+ {isSavingFrameEdit ? (
5017
+ <Loader2 className="size-4 mr-1 animate-spin" />
5018
+ ) : (
5019
+ <Save className="size-4 mr-1" />
5020
+ )}
5021
+ Salvar
5022
+ </Button>
5023
+ </SheetFooter>
5024
+ </ResizableSheetContent>
5025
+ </Sheet>
5026
+
5027
+ <Sheet
5028
+ open={frameCreateSheetOpen}
5029
+ onOpenChange={(open) => {
5030
+ if (!open) closeFrameCreateSheet();
5031
+ else setFrameCreateSheetOpen(true);
5032
+ }}
5033
+ >
5034
+ <ResizableSheetContent
5035
+ sheetId="lms-course-structure-frame-create-sheet"
5036
+ defaultWidth={520}
5037
+ minWidth={420}
5038
+ maxWidth={920}
5039
+ side="right"
5040
+ className="flex w-full flex-col sm:max-w-lg overflow-y-auto"
5041
+ >
5042
+ <SheetHeader>
5043
+ <SheetTitle>Adicionar imagem extraida</SheetTitle>
5044
+ </SheetHeader>
5045
+
5046
+ <div className="flex flex-1 flex-col gap-4 px-4 pb-4">
5047
+ <div className="rounded-lg border bg-muted/20 p-3">
5048
+ <p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
5049
+ Preview
5050
+ </p>
5051
+ <div className="mt-2 flex min-h-52 items-center justify-center overflow-hidden rounded-md border bg-background/60">
5052
+ {creatingFramePreviewUrl ? (
5053
+ <img
5054
+ src={creatingFramePreviewUrl}
5055
+ alt={creatingFrameFile?.name ?? 'Nova imagem extraida'}
5056
+ className="max-h-72 w-full object-contain"
5057
+ />
5058
+ ) : (
5059
+ <p className="text-xs text-muted-foreground">
5060
+ Selecione uma imagem para visualizar o preview.
5061
+ </p>
5062
+ )}
5063
+ </div>
5064
+ </div>
5065
+
5066
+ <div className="flex flex-col gap-2">
5067
+ <Label className="text-sm font-medium">Tempo no video</Label>
5068
+ <Input
5069
+ value={creatingFrameTime}
5070
+ onChange={(event) => setCreatingFrameTime(event.target.value)}
5071
+ onBlur={() =>
5072
+ setCreatingFrameTime((current) =>
5073
+ normalizeTimeInput(current || '')
5074
+ )
5075
+ }
5076
+ placeholder="00:00"
5077
+ className="font-mono"
5078
+ />
5079
+ <p className="text-xs text-muted-foreground">
5080
+ Use mm:ss ou hh:mm:ss para definir onde a imagem aparece.
5081
+ </p>
5082
+ </div>
5083
+
5084
+ <div className="flex flex-col gap-2">
5085
+ <Label className="text-sm font-medium">Imagem</Label>
5086
+ <div className="flex items-center gap-2">
5087
+ <Button
5088
+ type="button"
5089
+ variant="outline"
5090
+ onClick={() => frameCreateInputRef.current?.click()}
5091
+ >
5092
+ <UploadCloud className="size-4 mr-1" />
5093
+ {creatingFrameFile ? 'Trocar arquivo' : 'Selecionar imagem'}
5094
+ </Button>
5095
+ {creatingFrameFile ? (
5096
+ <span className="text-xs text-muted-foreground truncate">
5097
+ {creatingFrameFile.name}
5098
+ </span>
5099
+ ) : null}
5100
+ </div>
5101
+ <input
5102
+ ref={frameCreateInputRef}
5103
+ type="file"
5104
+ accept="image/*"
5105
+ className="hidden"
5106
+ onChange={(event) => {
5107
+ handleFrameCreateSelect(event.target.files?.[0]);
5108
+ event.target.value = '';
5109
+ }}
5110
+ />
5111
+ </div>
5112
+ </div>
5113
+
5114
+ <SheetFooter className="px-4 pb-4">
5115
+ <Button
5116
+ type="button"
5117
+ variant="outline"
5118
+ onClick={closeFrameCreateSheet}
5119
+ >
5120
+ Cancelar
5121
+ </Button>
5122
+ <Button
5123
+ type="button"
5124
+ onClick={() => void handleSaveFrameCreate()}
5125
+ disabled={isSavingFrameCreate}
5126
+ >
5127
+ {isSavingFrameCreate ? (
5128
+ <Loader2 className="size-4 mr-1 animate-spin" />
5129
+ ) : (
5130
+ <Save className="size-4 mr-1" />
5131
+ )}
5132
+ Salvar
5133
+ </Button>
5134
+ </SheetFooter>
5135
+ </ResizableSheetContent>
5136
+ </Sheet>
5137
+
2645
5138
  {/* ── Footer ───────────────────────────────────────────────────────── */}
2646
5139
  <div className="shrink-0 border-t bg-background">
2647
5140
  <Separator />
@@ -2651,11 +5144,15 @@ export function EditorLesson({ lessonId }: EditorLessonProps) {
2651
5144
  variant="ghost"
2652
5145
  size="sm"
2653
5146
  className="h-6 text-[11px] sm:h-7 sm:text-xs"
2654
- disabled={(!isDirty && !resourcesDirty) || updateLesson.isPending}
5147
+ disabled={!hasPendingChanges || isSavingLesson}
2655
5148
  onClick={() => {
2656
5149
  form.reset();
2657
5150
  setLocalResources(lesson?.resources ?? []);
2658
5151
  setResourcesDirty(false);
5152
+ setTranscriptionSegments(
5153
+ toEditableTranscriptionSegments(fetchedTranscriptionSegments)
5154
+ );
5155
+ setTranscriptionDirty(false);
2659
5156
  }}
2660
5157
  >
2661
5158
  <Undo2 className="size-3 mr-1" />
@@ -2666,9 +5163,9 @@ export function EditorLesson({ lessonId }: EditorLessonProps) {
2666
5163
  type="submit"
2667
5164
  size="sm"
2668
5165
  className="h-6 text-[11px] sm:h-7 sm:text-xs"
2669
- disabled={(!isDirty && !resourcesDirty) || updateLesson.isPending}
5166
+ disabled={!hasPendingChanges || isSavingLesson}
2670
5167
  >
2671
- {updateLesson.isPending ? (
5168
+ {isSavingLesson ? (
2672
5169
  <Loader2 className="size-3 mr-1 animate-spin" />
2673
5170
  ) : (
2674
5171
  <Save className="size-3 mr-1" />
@@ -2924,18 +5421,22 @@ export function EditorLesson({ lessonId }: EditorLessonProps) {
2924
5421
  })}
2925
5422
  </span>
2926
5423
  {qSheetFillBlanks.length > 1 && (
2927
- <button
2928
- type="button"
2929
- className="text-muted-foreground hover:text-destructive transition-colors"
2930
- onClick={() =>
2931
- setQSheetFillBlanks((prev) =>
2932
- prev.filter((f) => f.id !== fb.id)
2933
- )
2934
- }
2935
- aria-label={t('questionEditor.removeBlank')}
5424
+ <IconActionTooltip
5425
+ label={t('questionEditor.removeBlank')}
2936
5426
  >
2937
- <X className="size-3.5" />
2938
- </button>
5427
+ <button
5428
+ type="button"
5429
+ className="text-muted-foreground hover:text-destructive transition-colors"
5430
+ onClick={() =>
5431
+ setQSheetFillBlanks((prev) =>
5432
+ prev.filter((f) => f.id !== fb.id)
5433
+ )
5434
+ }
5435
+ aria-label={t('questionEditor.removeBlank')}
5436
+ >
5437
+ <X className="size-3.5" />
5438
+ </button>
5439
+ </IconActionTooltip>
2939
5440
  )}
2940
5441
  </div>
2941
5442
  <Input
@@ -3031,18 +5532,22 @@ export function EditorLesson({ lessonId }: EditorLessonProps) {
3031
5532
  className="flex-1"
3032
5533
  />
3033
5534
  {qSheetPairs.length > 1 && (
3034
- <button
3035
- type="button"
3036
- className="text-muted-foreground hover:text-destructive transition-colors"
3037
- onClick={() =>
3038
- setQSheetPairs((prev) =>
3039
- prev.filter((p) => p.id !== pair.id)
3040
- )
3041
- }
3042
- aria-label={t('questionEditor.removePair')}
5535
+ <IconActionTooltip
5536
+ label={t('questionEditor.removePair')}
3043
5537
  >
3044
- <X className="size-4" />
3045
- </button>
5538
+ <button
5539
+ type="button"
5540
+ className="text-muted-foreground hover:text-destructive transition-colors"
5541
+ onClick={() =>
5542
+ setQSheetPairs((prev) =>
5543
+ prev.filter((p) => p.id !== pair.id)
5544
+ )
5545
+ }
5546
+ aria-label={t('questionEditor.removePair')}
5547
+ >
5548
+ <X className="size-4" />
5549
+ </button>
5550
+ </IconActionTooltip>
3046
5551
  )}
3047
5552
  </div>
3048
5553
  ))}
@@ -3075,7 +5580,188 @@ export function EditorLesson({ lessonId }: EditorLessonProps) {
3075
5580
  </SheetFooter>
3076
5581
  </ResizableSheetContent>
3077
5582
  </Sheet>
5583
+
5584
+ <JobDetailSheet
5585
+ open={jobDetailOpen}
5586
+ onOpenChange={setJobDetailOpen}
5587
+ jobId={currentQueueJobId}
5588
+ />
3078
5589
  </form>
3079
5590
  </Form>
3080
5591
  );
3081
5592
  }
5593
+
5594
+ function AudioFilesTab({
5595
+ audioResources,
5596
+ isUploading,
5597
+ onAddAudio,
5598
+ onOpenAudio,
5599
+ onDownloadAudio,
5600
+ isAudioDownloading,
5601
+ onDeleteAudio,
5602
+ resolveResourceMetadata,
5603
+ }: {
5604
+ audioResources: Resource[];
5605
+ isUploading: boolean;
5606
+ onAddAudio: (files: File[]) => Promise<void>;
5607
+ onOpenAudio: (res: Resource) => Promise<void>;
5608
+ onDownloadAudio: (res: Resource) => Promise<void>;
5609
+ isAudioDownloading: (res: Resource) => boolean;
5610
+ onDeleteAudio: (resourceId: string) => Promise<void>;
5611
+ resolveResourceMetadata: (res: Resource) => {
5612
+ sizeLabel: string;
5613
+ uploadedAtLabel: string;
5614
+ };
5615
+ }) {
5616
+ const fileInputRef = useRef<HTMLInputElement>(null);
5617
+
5618
+ if (!audioResources.length) {
5619
+ return (
5620
+ <div className="flex flex-col gap-4 p-4">
5621
+ <div className="flex items-center justify-between gap-2">
5622
+ <p className="text-sm text-muted-foreground">
5623
+ Nenhum áudio disponível para esta aula.
5624
+ </p>
5625
+ <Button
5626
+ type="button"
5627
+ variant="outline"
5628
+ size="sm"
5629
+ className="h-7 px-2 text-xs"
5630
+ disabled={isUploading}
5631
+ onClick={() => fileInputRef.current?.click()}
5632
+ >
5633
+ {isUploading ? (
5634
+ <Loader2 className="size-3.5 mr-1 animate-spin" />
5635
+ ) : (
5636
+ <Plus className="size-3.5 mr-1" />
5637
+ )}
5638
+ Adicionar áudio
5639
+ </Button>
5640
+ <input
5641
+ ref={fileInputRef}
5642
+ type="file"
5643
+ accept="audio/*"
5644
+ multiple
5645
+ className="hidden"
5646
+ onChange={(e) => {
5647
+ if (e.target.files) {
5648
+ void onAddAudio(Array.from(e.target.files));
5649
+ e.target.value = '';
5650
+ }
5651
+ }}
5652
+ />
5653
+ </div>
5654
+ <div className="flex flex-col items-center gap-2 py-8 text-center">
5655
+ <p className="text-sm text-muted-foreground">
5656
+ Nenhum áudio disponível para esta aula.
5657
+ </p>
5658
+ </div>
5659
+ </div>
5660
+ );
5661
+ }
5662
+
5663
+ return (
5664
+ <div className="space-y-2 p-4">
5665
+ <div className="flex items-center justify-between gap-2">
5666
+ <p className="text-xs text-muted-foreground">
5667
+ {audioResources.length} áudio(s)
5668
+ </p>
5669
+ <Button
5670
+ type="button"
5671
+ variant="outline"
5672
+ size="sm"
5673
+ className="h-7 px-2 text-xs"
5674
+ disabled={isUploading}
5675
+ onClick={() => fileInputRef.current?.click()}
5676
+ >
5677
+ {isUploading ? (
5678
+ <Loader2 className="size-3.5 mr-1 animate-spin" />
5679
+ ) : (
5680
+ <Plus className="size-3.5 mr-1" />
5681
+ )}
5682
+ Adicionar áudio
5683
+ </Button>
5684
+ <input
5685
+ ref={fileInputRef}
5686
+ type="file"
5687
+ accept="audio/*"
5688
+ multiple
5689
+ className="hidden"
5690
+ onChange={(e) => {
5691
+ if (e.target.files) {
5692
+ void onAddAudio(Array.from(e.target.files));
5693
+ e.target.value = '';
5694
+ }
5695
+ }}
5696
+ />
5697
+ </div>
5698
+
5699
+ {audioResources.map((res) => {
5700
+ const metadata = resolveResourceMetadata(res);
5701
+ const isDownloading = isAudioDownloading(res);
5702
+
5703
+ return (
5704
+ <div
5705
+ key={res.id}
5706
+ className="flex items-center gap-2 rounded-md border bg-muted/20 px-2.5 py-2"
5707
+ >
5708
+ <FileTypeIcon filename={res.name} mimeType={res.type} size={14} />
5709
+ <div className="flex-1 min-w-0">
5710
+ <p className="text-xs truncate font-medium">{res.name}</p>
5711
+ <div className="mt-0.5 flex flex-wrap items-center gap-2 text-[0.65rem] text-muted-foreground">
5712
+ <span>{metadata.sizeLabel}</span>
5713
+ <span>·</span>
5714
+ <span>{metadata.uploadedAtLabel}</span>
5715
+ </div>
5716
+ </div>
5717
+
5718
+ <IconActionTooltip label={`Abrir áudio ${res.name}`}>
5719
+ <Button
5720
+ type="button"
5721
+ variant="ghost"
5722
+ size="icon"
5723
+ className="size-6 shrink-0 text-muted-foreground hover:text-blue-600"
5724
+ onClick={() => void onOpenAudio(res)}
5725
+ aria-label={`Abrir áudio ${res.name}`}
5726
+ >
5727
+ <ExternalLink className="size-3" />
5728
+ </Button>
5729
+ </IconActionTooltip>
5730
+ <IconActionTooltip
5731
+ label={`Baixar áudio ${res.name}`}
5732
+ asWrapper={isDownloading}
5733
+ >
5734
+ <Button
5735
+ type="button"
5736
+ variant="ghost"
5737
+ size="icon"
5738
+ className="size-6 shrink-0 text-muted-foreground hover:text-foreground"
5739
+ disabled={isDownloading}
5740
+ onClick={() => void onDownloadAudio(res)}
5741
+ aria-label={`Baixar áudio ${res.name}`}
5742
+ >
5743
+ {isDownloading ? (
5744
+ <Loader2 className="size-3 animate-spin" />
5745
+ ) : (
5746
+ <Download className="size-3" />
5747
+ )}
5748
+ </Button>
5749
+ </IconActionTooltip>
5750
+ <IconActionTooltip label={`Excluir áudio ${res.name}`}>
5751
+ <Button
5752
+ type="button"
5753
+ variant="ghost"
5754
+ size="icon"
5755
+ className="size-6 shrink-0 text-muted-foreground hover:text-destructive"
5756
+ onClick={() => void onDeleteAudio(res.id)}
5757
+ aria-label={`Excluir áudio ${res.name}`}
5758
+ >
5759
+ <X className="size-3" />
5760
+ </Button>
5761
+ </IconActionTooltip>
5762
+ </div>
5763
+ );
5764
+ })}
5765
+ </div>
5766
+ );
5767
+ }