@hed-hog/lms 0.0.353 → 0.0.355
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.
- package/dist/course/course-audio-transcription.service.d.ts +29 -0
- package/dist/course/course-audio-transcription.service.d.ts.map +1 -0
- package/dist/course/course-audio-transcription.service.js +291 -0
- package/dist/course/course-audio-transcription.service.js.map +1 -0
- package/dist/course/course-lesson.controller.d.ts +10 -0
- package/dist/course/course-lesson.controller.d.ts.map +1 -0
- package/dist/course/course-lesson.controller.js +62 -0
- package/dist/course/course-lesson.controller.js.map +1 -0
- package/dist/course/course-structure.controller.d.ts +41 -15
- package/dist/course/course-structure.controller.d.ts.map +1 -1
- package/dist/course/course-structure.controller.js +50 -6
- package/dist/course/course-structure.controller.js.map +1 -1
- package/dist/course/course-structure.service.d.ts +50 -15
- package/dist/course/course-structure.service.d.ts.map +1 -1
- package/dist/course/course-structure.service.js +238 -73
- package/dist/course/course-structure.service.js.map +1 -1
- package/dist/course/course-video-conversion.service.d.ts +20 -2
- package/dist/course/course-video-conversion.service.d.ts.map +1 -1
- package/dist/course/course-video-conversion.service.js +730 -10
- package/dist/course/course-video-conversion.service.js.map +1 -1
- package/dist/course/course.controller.d.ts +24 -8
- package/dist/course/course.controller.d.ts.map +1 -1
- package/dist/course/course.module.d.ts.map +1 -1
- package/dist/course/course.module.js +5 -3
- package/dist/course/course.module.js.map +1 -1
- package/dist/course/course.service.d.ts +24 -8
- package/dist/course/course.service.d.ts.map +1 -1
- package/dist/course/course.service.js +112 -176
- package/dist/course/course.service.js.map +1 -1
- package/dist/course/dto/create-course-lesson-frame.dto.d.ts +5 -0
- package/dist/course/dto/create-course-lesson-frame.dto.d.ts.map +1 -0
- package/dist/course/dto/create-course-lesson-frame.dto.js +30 -0
- package/dist/course/dto/create-course-lesson-frame.dto.js.map +1 -0
- package/dist/course/dto/create-course-structure-lesson.dto.d.ts +4 -2
- package/dist/course/dto/create-course-structure-lesson.dto.d.ts.map +1 -1
- package/dist/course/dto/create-course-structure-lesson.dto.js +10 -3
- package/dist/course/dto/create-course-structure-lesson.dto.js.map +1 -1
- package/dist/course/dto/create-course.dto.d.ts +1 -1
- package/dist/course/dto/create-course.dto.d.ts.map +1 -1
- package/dist/course/dto/create-course.dto.js +6 -6
- package/dist/course/dto/create-course.dto.js.map +1 -1
- package/dist/course/dto/update-course-lesson-frame.dto.d.ts +5 -0
- package/dist/course/dto/update-course-lesson-frame.dto.d.ts.map +1 -0
- package/dist/course/dto/update-course-lesson-frame.dto.js +32 -0
- package/dist/course/dto/update-course-lesson-frame.dto.js.map +1 -0
- package/dist/course/dto/update-course-resources.dto.d.ts +4 -2
- package/dist/course/dto/update-course-resources.dto.d.ts.map +1 -1
- package/dist/course/dto/update-course-resources.dto.js +10 -3
- package/dist/course/dto/update-course-resources.dto.js.map +1 -1
- package/dist/course/dto/update-transcription-segments.dto.d.ts +10 -0
- package/dist/course/dto/update-transcription-segments.dto.d.ts.map +1 -0
- package/dist/course/dto/update-transcription-segments.dto.js +38 -0
- package/dist/course/dto/update-transcription-segments.dto.js.map +1 -0
- package/dist/course/lms-setting.controller.d.ts +13 -0
- package/dist/course/lms-setting.controller.d.ts.map +1 -0
- package/dist/course/lms-setting.controller.js +53 -0
- package/dist/course/lms-setting.controller.js.map +1 -0
- package/dist/enterprise/training/training-admin.service.d.ts.map +1 -1
- package/dist/enterprise/training/training-admin.service.js +74 -33
- package/dist/enterprise/training/training-admin.service.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/lms.module.d.ts.map +1 -1
- package/dist/lms.module.js +6 -0
- package/dist/lms.module.js.map +1 -1
- package/hedhog/data/route.yaml +63 -0
- package/hedhog/data/setting_group.yaml +76 -0
- package/hedhog/frontend/app/courses/[id]/_components/CourseCertificateCard.tsx.ejs +3 -0
- package/hedhog/frontend/app/courses/[id]/_components/CourseClassificationCard.tsx.ejs +10 -1
- package/hedhog/frontend/app/courses/[id]/_components/CourseContentCard.tsx.ejs +7 -2
- package/hedhog/frontend/app/courses/[id]/_components/CourseDangerZoneCard.tsx.ejs +5 -2
- package/hedhog/frontend/app/courses/[id]/_components/CourseFlagsCard.tsx.ejs +7 -1
- package/hedhog/frontend/app/courses/[id]/_components/CourseMediaCard.tsx.ejs +3 -0
- package/hedhog/frontend/app/courses/[id]/_components/CourseMultiEntityPicker.tsx.ejs +95 -50
- package/hedhog/frontend/app/courses/[id]/_components/CourseRelationsCard.tsx.ejs +11 -36
- package/hedhog/frontend/app/courses/[id]/_components/CourseSectionCard.tsx.ejs +19 -5
- package/hedhog/frontend/app/courses/[id]/_components/course-edit-types.ts.ejs +1 -1
- package/hedhog/frontend/app/courses/[id]/page.tsx.ejs +30 -10
- package/hedhog/frontend/app/courses/[id]/structure/_components/CourseInstructorsSummaryCard.tsx.ejs +95 -0
- package/hedhog/frontend/app/courses/[id]/structure/_components/course-tree-dnd.tsx.ejs +29 -11
- package/hedhog/frontend/app/courses/[id]/structure/_components/course-tree-panel.tsx.ejs +42 -31
- package/hedhog/frontend/app/courses/[id]/structure/_components/course-tree.tsx.ejs +10 -1
- package/hedhog/frontend/app/courses/[id]/structure/_components/detail-lesson.tsx.ejs +51 -9
- package/hedhog/frontend/app/courses/[id]/structure/_components/drag-handle.tsx.ejs +25 -22
- package/hedhog/frontend/app/courses/[id]/structure/_components/editor-bulk.tsx.ejs +12 -9
- package/hedhog/frontend/app/courses/[id]/structure/_components/editor-course.tsx.ejs +315 -229
- package/hedhog/frontend/app/courses/[id]/structure/_components/editor-lesson.tsx.ejs +3220 -534
- package/hedhog/frontend/app/courses/[id]/structure/_components/editor-session.tsx.ejs +20 -15
- package/hedhog/frontend/app/courses/[id]/structure/_components/icon-action-tooltip.tsx.ejs +35 -0
- package/hedhog/frontend/app/courses/[id]/structure/_components/multi-select-bar.tsx.ejs +76 -67
- package/hedhog/frontend/app/courses/[id]/structure/_components/search-filter.tsx.ejs +13 -10
- package/hedhog/frontend/app/courses/[id]/structure/_components/shortcuts-help.tsx.ejs +18 -16
- package/hedhog/frontend/app/courses/[id]/structure/_components/sortable-tree-row.tsx.ejs +6 -0
- package/hedhog/frontend/app/courses/[id]/structure/_components/tree-context-menu.tsx.ejs +1 -1
- package/hedhog/frontend/app/courses/[id]/structure/_components/tree-display-settings-popover.tsx.ejs +23 -11
- package/hedhog/frontend/app/courses/[id]/structure/_components/tree-helpers.ts.ejs +48 -9
- package/hedhog/frontend/app/courses/[id]/structure/_components/tree-row-course.tsx.ejs +11 -2
- package/hedhog/frontend/app/courses/[id]/structure/_components/tree-row-lesson.tsx.ejs +121 -15
- package/hedhog/frontend/app/courses/[id]/structure/_components/tree-row-session.tsx.ejs +69 -14
- package/hedhog/frontend/app/courses/[id]/structure/_components/tree-row.tsx.ejs +11 -0
- package/hedhog/frontend/app/courses/[id]/structure/_components/types.ts.ejs +31 -0
- package/hedhog/frontend/app/courses/[id]/structure/_data/adapters/course-structure.adapter.ts.ejs +32 -6
- package/hedhog/frontend/app/courses/[id]/structure/_data/services/course-structure.service.ts.ejs +57 -3
- package/hedhog/frontend/app/courses/[id]/structure/_data/types/api-course.types.ts.ejs +46 -6
- package/hedhog/frontend/app/courses/[id]/structure/_data/use-course-structure-mutations.ts.ejs +14 -0
- package/hedhog/frontend/app/courses/[id]/structure/_data/use-lesson-audio-files.ts.ejs +23 -0
- package/hedhog/frontend/app/courses/[id]/structure/_data/use-lms-settings-query.ts.ejs +34 -0
- package/hedhog/frontend/app/courses/[id]/structure/_data/use-transcription-segments.ts.ejs +76 -0
- package/hedhog/frontend/messages/en.json +39 -3
- package/hedhog/frontend/messages/pt.json +39 -3
- package/hedhog/table/course.yaml +8 -0
- package/hedhog/table/course_lesson_file.yaml +12 -4
- package/hedhog/table/course_lesson_transcription_segment.yaml +22 -0
- package/hedhog/table/course_lesson_video_frame.yaml +25 -0
- package/package.json +8 -8
- package/src/course/course-audio-transcription.service.ts +393 -0
- package/src/course/course-lesson.controller.ts +28 -0
- package/src/course/course-structure.controller.ts +49 -3
- package/src/course/course-structure.service.ts +294 -32
- package/src/course/course-video-conversion.service.ts +972 -6
- package/src/course/course.module.ts +5 -3
- package/src/course/course.service.ts +87 -139
- package/src/course/dto/create-course-lesson-frame.dto.ts +14 -0
- package/src/course/dto/create-course-structure-lesson.dto.ts +19 -3
- package/src/course/dto/create-course.dto.ts +5 -5
- package/src/course/dto/update-course-lesson-frame.dto.ts +16 -0
- package/src/course/dto/update-course-resources.dto.ts +18 -3
- package/src/course/dto/update-transcription-segments.dto.ts +20 -0
- package/src/course/lms-setting.controller.ts +30 -0
- package/src/enterprise/training/training-admin.service.ts +77 -24
- package/src/index.ts +2 -0
- package/src/lms.module.ts +6 -0
- 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
|
-
|
|
179
|
-
|
|
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
|
-
|
|
182
|
-
|
|
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
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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:
|
|
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
|
|
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
|
|
370
|
-
const
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
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
|
-
|
|
378
|
-
const
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
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
|
|
397
|
-
segments
|
|
398
|
-
):
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
.
|
|
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
|
-
<
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
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
|
-
<
|
|
478
|
-
|
|
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={
|
|
498
|
-
className=
|
|
499
|
-
|
|
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
|
-
|
|
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
|
-
|
|
541
|
-
|
|
542
|
-
|
|
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
|
-
|
|
639
|
-
>(() =>
|
|
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
|
-
|
|
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 (
|
|
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
|
|
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
|
|
754
|
-
conversionJob?.
|
|
755
|
-
const
|
|
756
|
-
|
|
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 ||
|
|
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:
|
|
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
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
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
|
|
838
|
-
if (!
|
|
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
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
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
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
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
|
-
|
|
998
|
-
|
|
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
|
-
<
|
|
1155
|
-
|
|
1156
|
-
|
|
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
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
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
|
|
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
|
-
<
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
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
|
-
<
|
|
1502
|
-
|
|
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
|
-
<
|
|
1672
|
-
|
|
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
|
-
<
|
|
1680
|
-
|
|
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
|
-
|
|
1772
|
-
<
|
|
1773
|
-
<
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
<
|
|
1779
|
-
<div className="
|
|
1780
|
-
<div className="flex
|
|
1781
|
-
<
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
<div className="
|
|
1785
|
-
<div className="
|
|
1786
|
-
<
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
<
|
|
1805
|
-
|
|
1806
|
-
|
|
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
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
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
|
-
|
|
1846
|
-
|
|
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="
|
|
1850
|
-
|
|
1851
|
-
|
|
3431
|
+
variant="secondary"
|
|
3432
|
+
className="h-8 px-3 text-xs"
|
|
3433
|
+
disabled={isOriginalVideoUploadBlocked}
|
|
1852
3434
|
onClick={() =>
|
|
1853
|
-
|
|
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
|
-
<
|
|
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
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
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
|
-
|
|
3507
|
+
{focusedPipelineJob ? (
|
|
1875
3508
|
<Button
|
|
1876
3509
|
type="button"
|
|
1877
|
-
variant="
|
|
1878
|
-
|
|
1879
|
-
|
|
3510
|
+
variant="ghost"
|
|
3511
|
+
size="icon"
|
|
3512
|
+
className="size-6"
|
|
3513
|
+
aria-label={t(
|
|
3514
|
+
'lessonForm.videoJobToggleDetails'
|
|
3515
|
+
)}
|
|
1880
3516
|
onClick={() =>
|
|
1881
|
-
|
|
3517
|
+
setIsJobFeedbackCollapsed((prev) => !prev)
|
|
1882
3518
|
}
|
|
1883
3519
|
>
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
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
|
-
{
|
|
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={() =>
|
|
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
|
-
) : !
|
|
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
|
-
{
|
|
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-
|
|
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
|
-
<
|
|
1982
|
-
|
|
1983
|
-
|
|
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:
|
|
1992
|
-
total:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3644
|
+
focusedPipelineJob.status
|
|
2018
3645
|
)
|
|
2019
|
-
?
|
|
2020
|
-
:
|
|
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
|
-
{
|
|
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.${
|
|
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
|
-
|
|
3695
|
+
latestFocusedAttempt.attempt_number,
|
|
2042
3696
|
})}
|
|
2043
3697
|
{formatDurationLabel(
|
|
2044
|
-
|
|
3698
|
+
latestFocusedAttempt.duration_ms
|
|
2045
3699
|
)
|
|
2046
|
-
? ` · ${formatDurationLabel(
|
|
3700
|
+
? ` · ${formatDurationLabel(latestFocusedAttempt.duration_ms)}`
|
|
2047
3701
|
: ''}
|
|
2048
3702
|
</p>
|
|
2049
|
-
{
|
|
3703
|
+
{latestFocusedAttempt.error_message ? (
|
|
2050
3704
|
<p className="mt-2 text-xs text-destructive">
|
|
2051
|
-
{
|
|
3705
|
+
{latestFocusedAttempt.error_message}
|
|
2052
3706
|
</p>
|
|
2053
3707
|
) : null}
|
|
2054
3708
|
</div>
|
|
2055
3709
|
) : null}
|
|
2056
3710
|
|
|
2057
|
-
{
|
|
2058
|
-
<div
|
|
2059
|
-
|
|
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
|
|
2063
|
-
{
|
|
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
|
-
{
|
|
3748
|
+
{recentFocusedEvents.length === 0 ? (
|
|
2074
3749
|
<p className="text-xs text-muted-foreground">
|
|
2075
3750
|
{t('lessonForm.videoJobNoEvents')}
|
|
2076
3751
|
</p>
|
|
2077
3752
|
) : (
|
|
2078
|
-
|
|
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}`
|
|
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
|
-
{
|
|
3770
|
+
{getVideoJobProgressMessage(
|
|
3771
|
+
event,
|
|
3772
|
+
t
|
|
3773
|
+
) ? (
|
|
2096
3774
|
<p className="mt-1 text-[0.65rem] text-muted-foreground">
|
|
2097
|
-
{
|
|
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
|
|
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
|
-
|
|
2173
|
-
{
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
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
|
-
<
|
|
2233
|
-
|
|
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
|
-
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
|
|
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
|
-
<
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
|
|
2275
|
-
|
|
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
|
-
<
|
|
2281
|
-
|
|
2282
|
-
|
|
2283
|
-
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
|
|
2289
|
-
|
|
2290
|
-
|
|
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
|
-
<
|
|
2296
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
{
|
|
4618
|
+
{supplementaryResources.length > 0 && (
|
|
2556
4619
|
<p className="text-xs text-muted-foreground">
|
|
2557
4620
|
{t('lessonForm.resourcesCount', {
|
|
2558
|
-
count:
|
|
4621
|
+
count: supplementaryResources.length,
|
|
2559
4622
|
})}
|
|
2560
4623
|
</p>
|
|
2561
4624
|
)}
|
|
2562
4625
|
|
|
2563
4626
|
{/* Resource list */}
|
|
2564
|
-
{
|
|
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
|
-
{
|
|
2571
|
-
const
|
|
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
|
-
<
|
|
2578
|
-
|
|
2579
|
-
|
|
2580
|
-
|
|
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
|
-
<
|
|
2588
|
-
{
|
|
2589
|
-
|
|
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
|
-
|
|
2592
|
-
|
|
2593
|
-
|
|
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
|
-
|
|
2598
|
-
|
|
2599
|
-
|
|
2600
|
-
|
|
2601
|
-
|
|
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
|
-
</
|
|
2610
|
-
|
|
2611
|
-
<
|
|
2612
|
-
|
|
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
|
-
<
|
|
2622
|
-
|
|
2623
|
-
|
|
2624
|
-
|
|
2625
|
-
|
|
2626
|
-
|
|
2627
|
-
|
|
2628
|
-
|
|
2629
|
-
|
|
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
|
-
<
|
|
2634
|
-
|
|
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={
|
|
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={
|
|
5166
|
+
disabled={!hasPendingChanges || isSavingLesson}
|
|
2670
5167
|
>
|
|
2671
|
-
{
|
|
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
|
-
<
|
|
2928
|
-
|
|
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
|
-
<
|
|
2938
|
-
|
|
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
|
-
<
|
|
3035
|
-
|
|
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
|
-
<
|
|
3045
|
-
|
|
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
|
+
}
|