@hed-hog/lms 0.0.350 → 0.0.351

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (160) hide show
  1. package/dist/certificate/certificate.controller.d.ts +2 -2
  2. package/dist/certificate/certificate.controller.d.ts.map +1 -1
  3. package/dist/certificate/certificate.controller.js +8 -6
  4. package/dist/certificate/certificate.controller.js.map +1 -1
  5. package/dist/certificate/certificate.service.d.ts +5 -2
  6. package/dist/certificate/certificate.service.d.ts.map +1 -1
  7. package/dist/certificate/certificate.service.js +70 -6
  8. package/dist/certificate/certificate.service.js.map +1 -1
  9. package/dist/course/course-structure.controller.d.ts +24 -10
  10. package/dist/course/course-structure.controller.d.ts.map +1 -1
  11. package/dist/course/course-structure.controller.js +23 -2
  12. package/dist/course/course-structure.controller.js.map +1 -1
  13. package/dist/course/course-structure.service.d.ts +16 -8
  14. package/dist/course/course-structure.service.d.ts.map +1 -1
  15. package/dist/course/course-structure.service.js +61 -30
  16. package/dist/course/course-structure.service.js.map +1 -1
  17. package/dist/course/course-video-conversion.service.d.ts +37 -0
  18. package/dist/course/course-video-conversion.service.d.ts.map +1 -0
  19. package/dist/course/course-video-conversion.service.js +308 -0
  20. package/dist/course/course-video-conversion.service.js.map +1 -0
  21. package/dist/course/course.controller.d.ts +17 -0
  22. package/dist/course/course.controller.d.ts.map +1 -1
  23. package/dist/course/course.controller.js +23 -0
  24. package/dist/course/course.controller.js.map +1 -1
  25. package/dist/course/course.module.d.ts.map +1 -1
  26. package/dist/course/course.module.js +15 -2
  27. package/dist/course/course.module.js.map +1 -1
  28. package/dist/course/course.service.d.ts +15 -0
  29. package/dist/course/course.service.d.ts.map +1 -1
  30. package/dist/course/course.service.js +103 -49
  31. package/dist/course/course.service.js.map +1 -1
  32. package/dist/course/dto/create-course-structure-lesson.dto.d.ts +5 -1
  33. package/dist/course/dto/create-course-structure-lesson.dto.d.ts.map +1 -1
  34. package/dist/course/dto/create-course-structure-lesson.dto.js +16 -2
  35. package/dist/course/dto/create-course-structure-lesson.dto.js.map +1 -1
  36. package/dist/course/dto/create-course.dto.d.ts +1 -0
  37. package/dist/course/dto/create-course.dto.d.ts.map +1 -1
  38. package/dist/course/dto/create-course.dto.js +9 -0
  39. package/dist/course/dto/create-course.dto.js.map +1 -1
  40. package/dist/enterprise/enterprise.controller.d.ts +3 -3
  41. package/dist/enterprise/enterprise.controller.d.ts.map +1 -1
  42. package/dist/enterprise/enterprise.controller.js +0 -1
  43. package/dist/enterprise/enterprise.controller.js.map +1 -1
  44. package/dist/enterprise/enterprise.service.d.ts +3 -3
  45. package/dist/evaluation/evaluation.service.d.ts.map +1 -1
  46. package/dist/evaluation/evaluation.service.js +9 -2
  47. package/dist/evaluation/evaluation.service.js.map +1 -1
  48. package/dist/index.d.ts +1 -0
  49. package/dist/index.d.ts.map +1 -1
  50. package/dist/index.js +1 -0
  51. package/dist/index.js.map +1 -1
  52. package/dist/lms.module.d.ts.map +1 -1
  53. package/dist/lms.module.js +3 -0
  54. package/dist/lms.module.js.map +1 -1
  55. package/dist/video-resolution-profile/dto/create-video-resolution-profile.dto.d.ts +6 -0
  56. package/dist/video-resolution-profile/dto/create-video-resolution-profile.dto.d.ts.map +1 -0
  57. package/dist/video-resolution-profile/dto/create-video-resolution-profile.dto.js +33 -0
  58. package/dist/video-resolution-profile/dto/create-video-resolution-profile.dto.js.map +1 -0
  59. package/dist/video-resolution-profile/dto/update-video-resolution-profile.dto.d.ts +6 -0
  60. package/dist/video-resolution-profile/dto/update-video-resolution-profile.dto.d.ts.map +1 -0
  61. package/dist/video-resolution-profile/dto/update-video-resolution-profile.dto.js +33 -0
  62. package/dist/video-resolution-profile/dto/update-video-resolution-profile.dto.js.map +1 -0
  63. package/dist/video-resolution-profile/video-resolution-profile.controller.d.ts +38 -0
  64. package/dist/video-resolution-profile/video-resolution-profile.controller.d.ts.map +1 -0
  65. package/dist/video-resolution-profile/video-resolution-profile.controller.js +89 -0
  66. package/dist/video-resolution-profile/video-resolution-profile.controller.js.map +1 -0
  67. package/dist/video-resolution-profile/video-resolution-profile.mcp-tools.d.ts +26 -0
  68. package/dist/video-resolution-profile/video-resolution-profile.mcp-tools.d.ts.map +1 -0
  69. package/dist/video-resolution-profile/video-resolution-profile.mcp-tools.js +160 -0
  70. package/dist/video-resolution-profile/video-resolution-profile.mcp-tools.js.map +1 -0
  71. package/dist/video-resolution-profile/video-resolution-profile.module.d.ts +3 -0
  72. package/dist/video-resolution-profile/video-resolution-profile.module.d.ts.map +1 -0
  73. package/dist/video-resolution-profile/video-resolution-profile.module.js +26 -0
  74. package/dist/video-resolution-profile/video-resolution-profile.module.js.map +1 -0
  75. package/dist/video-resolution-profile/video-resolution-profile.service.d.ts +45 -0
  76. package/dist/video-resolution-profile/video-resolution-profile.service.d.ts.map +1 -0
  77. package/dist/video-resolution-profile/video-resolution-profile.service.js +117 -0
  78. package/dist/video-resolution-profile/video-resolution-profile.service.js.map +1 -0
  79. package/hedhog/data/menu.yaml +17 -0
  80. package/hedhog/data/route.yaml +133 -0
  81. package/hedhog/data/video_resolution_profile.yaml +7 -0
  82. package/hedhog/frontend/app/_components/class-form-sheet.tsx.ejs +269 -324
  83. package/hedhog/frontend/app/_components/course-form-sheet.tsx.ejs +124 -70
  84. package/hedhog/frontend/app/_components/create-lms-instructor-sheet.tsx.ejs +7 -4
  85. package/hedhog/frontend/app/_components/create-lms-person-sheet.tsx.ejs +2 -2
  86. package/hedhog/frontend/app/_components/create-lms-student-person-sheet.tsx.ejs +2 -2
  87. package/hedhog/frontend/app/_lib/editor/templateSerializer.ts.ejs +34 -4
  88. package/hedhog/frontend/app/_lib/editor/types.ts.ejs +28 -3
  89. package/hedhog/frontend/app/achievements/page.tsx.ejs +9 -3
  90. package/hedhog/frontend/app/bitcodes/page.tsx.ejs +9 -3
  91. package/hedhog/frontend/app/certificates/issued/page.tsx.ejs +7 -3
  92. package/hedhog/frontend/app/certificates/models/CanvasStage.tsx.ejs +29 -8
  93. package/hedhog/frontend/app/certificates/models/LeftPanel.tsx.ejs +14 -0
  94. package/hedhog/frontend/app/certificates/models/RightPanel.tsx.ejs +194 -9
  95. package/hedhog/frontend/app/certificates/models/page.tsx.ejs +15 -5
  96. package/hedhog/frontend/app/classes/[id]/page.tsx.ejs +9 -5
  97. package/hedhog/frontend/app/classes/page.tsx.ejs +73 -47
  98. package/hedhog/frontend/app/courses/[id]/_components/CourseCertificateCard.tsx.ejs +19 -9
  99. package/hedhog/frontend/app/courses/[id]/_components/CourseClassificationCard.tsx.ejs +24 -1
  100. package/hedhog/frontend/app/courses/[id]/_components/CourseContentCard.tsx.ejs +1 -1
  101. package/hedhog/frontend/app/courses/[id]/_components/CourseMainInfoCard.tsx.ejs +1 -1
  102. package/hedhog/frontend/app/courses/[id]/_components/CourseRelationsCard.tsx.ejs +28 -16
  103. package/hedhog/frontend/app/courses/[id]/_components/CourseSectionCard.tsx.ejs +11 -6
  104. package/hedhog/frontend/app/courses/[id]/_components/CourseSummaryCard.tsx.ejs +7 -4
  105. package/hedhog/frontend/app/courses/[id]/_components/course-edit-types.ts.ejs +1 -0
  106. package/hedhog/frontend/app/courses/[id]/page.tsx.ejs +24 -87
  107. package/hedhog/frontend/app/courses/[id]/structure/_components/editor-course.tsx.ejs +892 -411
  108. package/hedhog/frontend/app/courses/[id]/structure/_components/editor-lesson.tsx.ejs +1004 -293
  109. package/hedhog/frontend/app/courses/[id]/structure/_components/editor-session.tsx.ejs +11 -11
  110. package/hedhog/frontend/app/courses/[id]/structure/_components/shortcuts-help.tsx.ejs +62 -52
  111. package/hedhog/frontend/app/courses/[id]/structure/_components/types.ts.ejs +2 -0
  112. package/hedhog/frontend/app/courses/[id]/structure/_data/adapters/course-structure.adapter.ts.ejs +19 -6
  113. package/hedhog/frontend/app/courses/[id]/structure/_data/services/course-structure.service.ts.ejs +86 -1
  114. package/hedhog/frontend/app/courses/[id]/structure/_data/types/api-course.types.ts.ejs +3 -0
  115. package/hedhog/frontend/app/courses/[id]/structure/_data/use-course-structure-mutations.ts.ejs +1 -0
  116. package/hedhog/frontend/app/courses/page.tsx.ejs +112 -89
  117. package/hedhog/frontend/app/enterprise/[id]/page.tsx.ejs +1 -1
  118. package/hedhog/frontend/app/enterprise/_components/enterprise-admin-create-sheet.tsx.ejs +10 -3
  119. package/hedhog/frontend/app/enterprise/_components/enterprise-detail-sheet.tsx.ejs +8 -4
  120. package/hedhog/frontend/app/enterprise/_components/enterprise-person-edit-sheet.tsx.ejs +2 -2
  121. package/hedhog/frontend/app/enterprise/_components/enterprise-sheet.tsx.ejs +10 -4
  122. package/hedhog/frontend/app/enterprise/_components/enterprise-student-create-sheet.tsx.ejs +10 -3
  123. package/hedhog/frontend/app/enterprise/_components/enterprise-user-create-sheet.tsx.ejs +10 -3
  124. package/hedhog/frontend/app/evaluations/_components/evaluation-topic-form-sheet.tsx.ejs +10 -3
  125. package/hedhog/frontend/app/exams/[id]/questions/page.tsx.ejs +23 -9
  126. package/hedhog/frontend/app/exams/page.tsx.ejs +14 -6
  127. package/hedhog/frontend/app/instructor-skills/page.tsx.ejs +9 -3
  128. package/hedhog/frontend/app/instructors/_components/instructor-form-sheet.tsx.ejs +190 -17
  129. package/hedhog/frontend/app/layout.tsx.ejs +5 -1
  130. package/hedhog/frontend/app/paths/page.tsx.ejs +13 -5
  131. package/hedhog/frontend/app/reports/evaluations/page.tsx.ejs +10 -10
  132. package/hedhog/frontend/app/training/page.tsx.ejs +13 -5
  133. package/hedhog/frontend/app/video-resolution-profiles/page.tsx.ejs +607 -0
  134. package/hedhog/frontend/messages/en.json +250 -9
  135. package/hedhog/frontend/messages/pt.json +250 -9
  136. package/hedhog/table/course.yaml +4 -0
  137. package/hedhog/table/course_lesson_file.yaml +8 -0
  138. package/hedhog/table/course_video_resolution_profile.yaml +22 -0
  139. package/hedhog/table/video_resolution_profile.yaml +18 -0
  140. package/package.json +7 -6
  141. package/src/certificate/certificate.controller.ts +19 -14
  142. package/src/certificate/certificate.service.ts +106 -11
  143. package/src/course/course-structure.controller.ts +24 -2
  144. package/src/course/course-structure.service.ts +21 -4
  145. package/src/course/course-video-conversion.service.ts +415 -0
  146. package/src/course/course.controller.ts +18 -0
  147. package/src/course/course.module.ts +15 -2
  148. package/src/course/course.service.ts +72 -2
  149. package/src/course/dto/create-course-structure-lesson.dto.ts +13 -2
  150. package/src/course/dto/create-course.dto.ts +8 -0
  151. package/src/enterprise/enterprise.controller.ts +0 -1
  152. package/src/evaluation/evaluation.service.ts +9 -2
  153. package/src/index.ts +1 -0
  154. package/src/lms.module.ts +3 -0
  155. package/src/video-resolution-profile/dto/create-video-resolution-profile.dto.ts +16 -0
  156. package/src/video-resolution-profile/dto/update-video-resolution-profile.dto.ts +16 -0
  157. package/src/video-resolution-profile/video-resolution-profile.controller.ts +62 -0
  158. package/src/video-resolution-profile/video-resolution-profile.mcp-tools.ts +128 -0
  159. package/src/video-resolution-profile/video-resolution-profile.module.ts +13 -0
  160. package/src/video-resolution-profile/video-resolution-profile.service.ts +117 -0
@@ -19,7 +19,9 @@ import {
19
19
  Loader2,
20
20
  Lock,
21
21
  Pencil,
22
+ Play,
22
23
  Plus,
24
+ RefreshCw,
23
25
  Save,
24
26
  Trash2,
25
27
  Undo2,
@@ -48,6 +50,8 @@ import {
48
50
  } from '@/components/ui/form';
49
51
  import { Input } from '@/components/ui/input';
50
52
  import { Label } from '@/components/ui/label';
53
+ import { Progress } from '@/components/ui/progress';
54
+ import { ResizableSheetContent } from '@/components/ui/resizable-sheet-content';
51
55
  import { ScrollArea } from '@/components/ui/scroll-area';
52
56
  import {
53
57
  Select,
@@ -59,7 +63,6 @@ import {
59
63
  import { Separator } from '@/components/ui/separator';
60
64
  import {
61
65
  Sheet,
62
- SheetContent,
63
66
  SheetFooter,
64
67
  SheetHeader,
65
68
  SheetTitle,
@@ -86,11 +89,15 @@ import {
86
89
  import { CSS } from '@dnd-kit/utilities';
87
90
 
88
91
  import { RichTextEditor } from '@/components/rich-text-editor';
89
- import { useApp } from '@hed-hog/next-app-provider';
92
+ import { useApp, useQuery } from '@hed-hog/next-app-provider';
90
93
  import { useQueryClient } from '@tanstack/react-query';
91
94
  import {
92
95
  deleteFile,
96
+ enqueueLessonVideoConversion,
97
+ getQueueJob,
93
98
  uploadFile,
99
+ type QueueJobResponse,
100
+ type QueueJobStatus,
94
101
  } from '../_data/services/course-structure.service';
95
102
  import {
96
103
  useDeleteLessonMutation,
@@ -128,6 +135,68 @@ function formatFileSize(bytes: number): string {
128
135
  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
129
136
  }
130
137
 
138
+ function videoProfileResourceType(profileId: number): string {
139
+ return `video_profile:${profileId}`;
140
+ }
141
+
142
+ const MAX_VIDEO_UPLOAD_SIZE_BYTES = 100 * 1024 * 1024;
143
+
144
+ type LessonEditorTab =
145
+ | 'dados'
146
+ | 'conteudo'
147
+ | 'videos'
148
+ | 'transcricao'
149
+ | 'recursos';
150
+
151
+ const ACTIVE_VIDEO_JOB_STATUSES: QueueJobStatus[] = [
152
+ 'pending',
153
+ 'scheduled',
154
+ 'processing',
155
+ 'retrying',
156
+ ];
157
+
158
+ const TERMINAL_VIDEO_JOB_STATUSES: QueueJobStatus[] = [
159
+ 'completed',
160
+ 'failed',
161
+ 'canceled',
162
+ 'dead_letter',
163
+ ];
164
+
165
+ const VIDEO_JOB_STATUS_COLORS: Record<QueueJobStatus, string> = {
166
+ pending: 'bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300',
167
+ scheduled: 'bg-sky-100 text-sky-700 dark:bg-sky-900 dark:text-sky-300',
168
+ processing: 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300',
169
+ retrying:
170
+ 'bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-300',
171
+ completed:
172
+ 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900 dark:text-emerald-300',
173
+ failed: 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300',
174
+ canceled: 'bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300',
175
+ dead_letter: 'bg-rose-100 text-rose-700 dark:bg-rose-900 dark:text-rose-300',
176
+ };
177
+
178
+ function formatDateTimeLabel(value?: string | null): string | null {
179
+ if (!value) return null;
180
+
181
+ const parsed = new Date(value);
182
+ if (Number.isNaN(parsed.getTime())) return null;
183
+
184
+ return new Intl.DateTimeFormat('pt-BR', {
185
+ dateStyle: 'short',
186
+ timeStyle: 'short',
187
+ }).format(parsed);
188
+ }
189
+
190
+ function formatDurationLabel(durationMs?: number | null): string | null {
191
+ if (durationMs == null) return null;
192
+ if (durationMs < 1000) return `${durationMs} ms`;
193
+
194
+ const seconds = durationMs / 1000;
195
+ if (seconds < 60) return `${seconds.toFixed(1)} s`;
196
+
197
+ return `${(seconds / 60).toFixed(1)} min`;
198
+ }
199
+
131
200
  // ── Config maps ───────────────────────────────────────────────────────────────
132
201
 
133
202
  const TYPE_CONFIG: Record<
@@ -442,6 +511,13 @@ interface EditorLessonProps {
442
511
  lessonId: string;
443
512
  }
444
513
 
514
+ type VideoProfileOption = {
515
+ id: number;
516
+ name: string;
517
+ ffmpeg_params: string;
518
+ status: string;
519
+ };
520
+
445
521
  export function EditorLesson({ lessonId }: EditorLessonProps) {
446
522
  const t = useTranslations('lms.CoursesPage.StructurePage');
447
523
  const lesson = useStructureStore((s) =>
@@ -542,15 +618,62 @@ export function EditorLesson({ lessonId }: EditorLessonProps) {
542
618
  const [localResources, setLocalResources] = useState<Resource[]>(
543
619
  () => lesson?.resources ?? []
544
620
  );
545
- const [videoResolution, setVideoResolution] = useState('1080p');
621
+ const [activeTab, setActiveTab] = useState<LessonEditorTab>('dados');
622
+ const [resourcesDirty, setResourcesDirty] = useState(false);
623
+ const [conversionJobId, setConversionJobId] = useState<number | null>(null);
624
+ const [videoUploadError, setVideoUploadError] = useState<string | null>(null);
546
625
  const [dragOver, setDragOver] = useState(false);
547
626
  const [isUploading, setIsUploading] = useState(false);
627
+ const [originalUploadProgress, setOriginalUploadProgress] = useState<
628
+ number | null
629
+ >(null);
630
+ const [profileUploadProgress, setProfileUploadProgress] = useState<
631
+ Record<number, number>
632
+ >({});
633
+ const [isResolvingVideoPreview, setIsResolvingVideoPreview] = useState(false);
548
634
  const resourceInputRef = useRef<HTMLInputElement>(null);
549
- const videoInputRef = useRef<HTMLInputElement>(null);
635
+ const originalVideoInputRef = useRef<HTMLInputElement>(null);
636
+ const lastTerminalJobStatusRef = useRef<string | null>(null);
550
637
  const [transcriptionSegments, setTranscriptionSegments] = useState<
551
638
  TranscriptionSegment[]
552
639
  >(() => parseTranscriptionSegments(lesson?.transcription));
553
640
 
641
+ const {
642
+ data: courseVideoProfiles = [],
643
+ isFetching: isFetchingCourseVideoProfiles,
644
+ isError: hasCourseVideoProfilesError,
645
+ refetch: refetchCourseVideoProfiles,
646
+ } = useQuery<VideoProfileOption[]>({
647
+ queryKey: ['lms-course-video-resolution-profiles', courseId],
648
+ queryFn: async () => {
649
+ const response = await request<VideoProfileOption[]>({
650
+ url: `/lms/courses/${courseId}/video-resolution-profiles`,
651
+ method: 'GET',
652
+ });
653
+ return response.data ?? [];
654
+ },
655
+ enabled: Boolean(courseId),
656
+ initialData: [],
657
+ });
658
+
659
+ const {
660
+ data: conversionJob,
661
+ isFetching: isFetchingConversionJob,
662
+ isError: hasConversionJobError,
663
+ refetch: refetchConversionJob,
664
+ } = useQuery<QueueJobResponse>({
665
+ queryKey: ['queue-job', conversionJobId],
666
+ enabled: Boolean(conversionJobId),
667
+ retry: 1,
668
+ queryFn: async () => getQueueJob(request, conversionJobId!),
669
+ refetchInterval: (query) => {
670
+ const status = (query.state.data as QueueJobResponse | undefined)?.status;
671
+ if (!status) return 3000;
672
+
673
+ return ACTIVE_VIDEO_JOB_STATUSES.includes(status) ? 3000 : false;
674
+ },
675
+ });
676
+
554
677
  // ── Instructors state ────────────────────────────────────────────────────
555
678
  const [selectedInstructorIds, setSelectedInstructorIds] = useState<string[]>(
556
679
  () => lesson?.instructors?.map((i) => i.id) ?? []
@@ -578,15 +701,62 @@ export function EditorLesson({ lessonId }: EditorLessonProps) {
578
701
 
579
702
  useEffect(() => {
580
703
  setLocalResources(lesson?.resources ?? []);
704
+ setResourcesDirty(false);
705
+ setConversionJobId(lesson?.videoConversionJobId ?? null);
581
706
  setSelectedInstructorIds(lesson?.instructors?.map((i) => i.id) ?? []);
582
707
  setTranscriptionSegments(parseTranscriptionSegments(lesson?.transcription));
583
- }, [lesson?.id]); // eslint-disable-line react-hooks/exhaustive-deps
708
+ }, [lesson?.id, lesson?.resources, lesson?.videoConversionJobId]); // eslint-disable-line react-hooks/exhaustive-deps
709
+
710
+ useEffect(() => {
711
+ if (watchedType === 'video') return;
712
+ if (activeTab === 'videos' || activeTab === 'transcricao') {
713
+ setActiveTab('conteudo');
714
+ }
715
+ }, [activeTab, watchedType]);
716
+
717
+ useEffect(() => {
718
+ lastTerminalJobStatusRef.current = null;
719
+ }, [conversionJobId]);
720
+
721
+ useEffect(() => {
722
+ if (!conversionJobId || !conversionJob) return;
723
+ if (!TERMINAL_VIDEO_JOB_STATUSES.includes(conversionJob.status)) return;
724
+
725
+ const terminalKey = `${conversionJob.id}:${conversionJob.status}`;
726
+ if (lastTerminalJobStatusRef.current === terminalKey) return;
727
+
728
+ lastTerminalJobStatusRef.current = terminalKey;
729
+ void queryClient.invalidateQueries({
730
+ queryKey: courseStructureQueryKey(courseId),
731
+ });
732
+ }, [conversionJob, conversionJobId, courseId, queryClient]);
584
733
 
585
734
  if (!lesson) return null;
586
735
 
587
736
  const cfg = TYPE_CONFIG[lesson.type];
588
737
  const Icon = cfg.icon;
589
738
  const lessonTypeLabel = t(cfg.labelKey as any);
739
+ const originalVideoResource =
740
+ localResources.find((res) => res.type === 'video_original') ?? null;
741
+ const profileVideoResources = new Map(
742
+ localResources
743
+ .filter((res) => res.type.startsWith('video_profile:'))
744
+ .map((res) => [Number(res.type.replace('video_profile:', '')), res])
745
+ );
746
+ const genericResources = localResources.filter(
747
+ (res) =>
748
+ res.type !== 'video_original' && !res.type.startsWith('video_profile:')
749
+ );
750
+ const isConversionJobActive = conversionJob
751
+ ? ACTIVE_VIDEO_JOB_STATUSES.includes(conversionJob.status)
752
+ : false;
753
+ const latestConversionAttempt =
754
+ conversionJob?.queue_job_attempt.at(-1) ?? null;
755
+ const recentConversionEvents =
756
+ conversionJob?.queue_job_event.slice(-3).reverse() ?? [];
757
+ const isOriginalVideoUploadBlocked =
758
+ originalUploadProgress !== null || isConversionJobActive;
759
+ const isProfileVideoUploadBlocked = isConversionJobActive;
590
760
 
591
761
  async function handleResourceFiles(files: File[]) {
592
762
  setIsUploading(true);
@@ -594,7 +764,8 @@ export function EditorLesson({ lessonId }: EditorLessonProps) {
594
764
  const results = await Promise.allSettled(
595
765
  files.map((f) =>
596
766
  uploadFile(request, f).then<Resource>((res) => ({
597
- id: String(res.id),
767
+ id: `new-${res.id}`,
768
+ fileId: res.id,
598
769
  name: f.name,
599
770
  size: formatFileSize(f.size),
600
771
  type: f.type || f.name.split('.').pop() || 'file',
@@ -615,30 +786,32 @@ export function EditorLesson({ lessonId }: EditorLessonProps) {
615
786
  );
616
787
  if (succeeded.length > 0)
617
788
  setLocalResources((prev) => [...prev, ...succeeded]);
789
+ if (succeeded.length > 0) setResourcesDirty(true);
618
790
  } finally {
619
791
  setIsUploading(false);
620
792
  }
621
793
  }
622
794
 
623
795
  async function removeResource(id: string) {
624
- const numId = Number(id);
625
- if (Number.isInteger(numId) && numId > 0) {
796
+ const res = localResources.find((r) => r.id === id);
797
+ const fileId = res?.fileId ?? Number(id);
798
+ if (Number.isInteger(fileId) && fileId > 0) {
626
799
  try {
627
- await deleteFile(request, numId);
800
+ await deleteFile(request, fileId);
628
801
  } catch {
629
802
  toast.error(t('questionEditor.resourceRemoveError'));
630
803
  return;
631
804
  }
632
805
  } else {
633
- const res = localResources.find((r) => r.id === id);
634
806
  if (res?.url?.startsWith('blob:')) URL.revokeObjectURL(res.url);
635
807
  }
636
808
  setLocalResources((prev) => prev.filter((r) => r.id !== id));
809
+ setResourcesDirty(true);
637
810
  }
638
811
 
639
812
  async function resolveResourceUrl(res: Resource): Promise<string | null> {
640
813
  if (res.url) return res.url;
641
- const numId = Number(res.id);
814
+ const numId = res.fileId ?? Number(res.id);
642
815
  if (!Number.isInteger(numId) || numId <= 0) return null;
643
816
  try {
644
817
  const response = await request<{ url?: string }>({
@@ -672,50 +845,131 @@ export function EditorLesson({ lessonId }: EditorLessonProps) {
672
845
  a.click();
673
846
  }
674
847
 
675
- async function handleVideoVariantFiles(files: File[]) {
676
- const resolution = videoResolution.trim();
677
- if (!resolution) {
678
- toast.error(t('questionEditor.videoResolutionRequired'));
848
+ async function openVideoPreview(res: Resource) {
849
+ setIsResolvingVideoPreview(true);
850
+ try {
851
+ const resourceUrl = await resolveResourceUrl(res);
852
+ if (!resourceUrl) {
853
+ toast.error(t('questionEditor.resourceOpenError'));
854
+ return;
855
+ }
856
+ window.open(resourceUrl, '_blank', 'noopener,noreferrer');
857
+ } finally {
858
+ setIsResolvingVideoPreview(false);
859
+ }
860
+ }
861
+
862
+ async function handleVideoProfileFile(profileId: number, file: File) {
863
+ if (file.size > MAX_VIDEO_UPLOAD_SIZE_BYTES) {
864
+ const message = t('lessonForm.videoUploadMaxSizeError', {
865
+ size: '100MB',
866
+ });
867
+ setVideoUploadError(message);
868
+ toast.error(message);
679
869
  return;
680
870
  }
681
- setIsUploading(true);
682
- try {
683
- const results = await Promise.allSettled(
684
- files.map((file) =>
685
- uploadFile(request, file).then<Resource>((res) => ({
686
- id: String(res.id),
687
- name: file.name,
688
- size: formatFileSize(file.size),
689
- type: `video/${resolution}`,
690
- public: false,
691
- url: undefined,
692
- }))
693
- )
694
- );
695
871
 
696
- const succeeded = results
697
- .filter(
698
- (result): result is PromiseFulfilledResult<Resource> =>
699
- result.status === 'fulfilled'
700
- )
701
- .map((result) => result.value);
702
- const failedCount = results.filter(
703
- (result) => result.status === 'rejected'
704
- ).length;
872
+ setVideoUploadError(null);
873
+ setProfileUploadProgress((prev) => ({ ...prev, [profileId]: 0 }));
874
+ try {
875
+ const uploaded = await uploadFile(request, file, 'lms/lessons/videos', {
876
+ onUploadProgress: (event) => {
877
+ const total = event.total ?? 0;
878
+ const progress =
879
+ total > 0 ? Math.round((event.loaded / total) * 100) : 0;
880
+ setProfileUploadProgress((prev) => ({
881
+ ...prev,
882
+ [profileId]: progress,
883
+ }));
884
+ },
885
+ });
886
+ const type = videoProfileResourceType(profileId);
887
+ const resource: Resource = {
888
+ id: `new-${uploaded.id}`,
889
+ fileId: uploaded.id,
890
+ name: file.name,
891
+ size: formatFileSize(file.size),
892
+ type,
893
+ public: false,
894
+ url: undefined,
895
+ };
896
+ setLocalResources((prev) => [
897
+ ...prev.filter((item) => item.type !== type),
898
+ resource,
899
+ ]);
900
+ setResourcesDirty(true);
901
+ } catch {
902
+ toast.error(t('questionEditor.videoUploadFailed', { count: 1 }));
903
+ } finally {
904
+ setProfileUploadProgress((prev) => {
905
+ const next = { ...prev };
906
+ delete next[profileId];
907
+ return next;
908
+ });
909
+ }
910
+ }
705
911
 
706
- if (failedCount > 0) {
707
- toast.error(
708
- t('questionEditor.videoUploadFailed', {
709
- count: failedCount,
710
- })
711
- );
712
- }
912
+ async function handleOriginalVideoFile(file: File) {
913
+ if (file.size > MAX_VIDEO_UPLOAD_SIZE_BYTES) {
914
+ const message = t('lessonForm.videoUploadMaxSizeError', {
915
+ size: '100MB',
916
+ });
917
+ setVideoUploadError(message);
918
+ toast.error(message);
919
+ return;
920
+ }
713
921
 
714
- if (succeeded.length > 0) {
715
- setLocalResources((prev) => [...prev, ...succeeded]);
716
- }
922
+ setVideoUploadError(null);
923
+ setOriginalUploadProgress(0);
924
+ try {
925
+ const uploaded = await uploadFile(
926
+ request,
927
+ file,
928
+ 'lms/lessons/originals',
929
+ {
930
+ onUploadProgress: (event) => {
931
+ const total = event.total ?? 0;
932
+ const progress =
933
+ total > 0 ? Math.round((event.loaded / total) * 100) : 0;
934
+ setOriginalUploadProgress(progress);
935
+ },
936
+ }
937
+ );
938
+ const originalResource: Resource = {
939
+ id: `new-${uploaded.id}`,
940
+ fileId: uploaded.id,
941
+ name: file.name,
942
+ size: formatFileSize(file.size),
943
+ type: 'video_original',
944
+ public: false,
945
+ url: undefined,
946
+ };
947
+ setLocalResources((prev) => [
948
+ ...prev.filter((item) => item.type !== 'video_original'),
949
+ originalResource,
950
+ ]);
951
+ setResourcesDirty(true);
952
+
953
+ const queued = await enqueueLessonVideoConversion(
954
+ request,
955
+ courseId,
956
+ lesson!.sessionId,
957
+ lessonId,
958
+ uploaded.id
959
+ );
960
+ setConversionJobId(queued.queueJobId);
961
+ toast.success(
962
+ t('lessonForm.videoConversionQueued', {
963
+ id: queued.queueJobId,
964
+ })
965
+ );
966
+ void queryClient.invalidateQueries({
967
+ queryKey: courseStructureQueryKey(courseId),
968
+ });
969
+ } catch {
970
+ toast.error(t('lessonForm.videoConversionFailed'));
717
971
  } finally {
718
- setIsUploading(false);
972
+ setOriginalUploadProgress(null);
719
973
  }
720
974
  }
721
975
 
@@ -730,12 +984,18 @@ export function EditorLesson({ lessonId }: EditorLessonProps) {
730
984
  sessionId: lesson!.sessionId,
731
985
  formValues: {
732
986
  ...values,
987
+ videoUrl:
988
+ values.type === 'video' && values.videoProvider === 'file_storage'
989
+ ? ''
990
+ : values.videoUrl,
733
991
  transcription: transcriptionValue,
992
+ videoConversionJobId: conversionJobId ?? lesson?.videoConversionJobId,
734
993
  resources: localResources,
735
994
  instructorIds: selectedInstructorIds.map(Number),
736
995
  },
737
996
  });
738
997
  form.reset({ ...values, transcription: transcriptionValue });
998
+ setResourcesDirty(false);
739
999
  }
740
1000
 
741
1001
  // ── Question sheet helpers ────────────────────────────────────────────────
@@ -859,14 +1119,14 @@ export function EditorLesson({ lessonId }: EditorLessonProps) {
859
1119
  className="flex flex-col h-full min-h-0"
860
1120
  >
861
1121
  {/* ── Header ───────────────────────────────────────────────────────── */}
862
- <div className="flex items-center gap-3 px-4 py-3 border-b bg-muted/30 shrink-0">
1122
+ <div className="flex items-center gap-2 border-b bg-muted/30 px-2 py-2 shrink-0 sm:gap-3 sm:px-4 sm:py-3">
863
1123
  <div
864
1124
  className={cn(
865
- 'flex size-9 items-center justify-center rounded-lg shrink-0',
1125
+ 'flex size-8 items-center justify-center rounded-md shrink-0 sm:size-9 sm:rounded-lg',
866
1126
  cfg.bg
867
1127
  )}
868
1128
  >
869
- <Icon className={cn('size-4', cfg.color)} />
1129
+ <Icon className={cn('size-3.5 sm:size-4', cfg.color)} />
870
1130
  </div>
871
1131
  <div className="flex-1 min-w-0">
872
1132
  <div className="flex items-center gap-1.5">
@@ -910,18 +1170,44 @@ export function EditorLesson({ lessonId }: EditorLessonProps) {
910
1170
  </div>
911
1171
 
912
1172
  {/* ── Tabs ─────────────────────────────────────────────────────────── */}
913
- <Tabs defaultValue="dados" className="flex flex-col flex-1 min-h-0">
914
- <TabsList className="mx-3 mt-2 h-8 w-auto justify-start shrink-0 bg-muted/50">
915
- <TabsTrigger value="dados" className="text-xs h-7 px-2.5">
1173
+ <Tabs
1174
+ value={activeTab}
1175
+ onValueChange={(value) => setActiveTab(value as LessonEditorTab)}
1176
+ className="flex flex-col flex-1 min-h-0 min-w-0"
1177
+ >
1178
+ <TabsList className="mx-2 mt-1.5 h-auto w-[calc(100%-1rem)] justify-start shrink-0 bg-muted/50 overflow-x-auto overflow-y-hidden whitespace-nowrap sm:mx-3 sm:mt-2 sm:w-auto">
1179
+ <TabsTrigger
1180
+ value="dados"
1181
+ className="h-6 px-2 text-[11px] shrink-0 sm:h-7 sm:px-2.5 sm:text-xs"
1182
+ >
916
1183
  {t('lessonForm.tabData')}
917
1184
  </TabsTrigger>
918
- <TabsTrigger value="conteudo" className="text-xs h-7 px-2.5">
1185
+ <TabsTrigger
1186
+ value="conteudo"
1187
+ className="h-6 px-2 text-[11px] shrink-0 sm:h-7 sm:px-2.5 sm:text-xs"
1188
+ >
919
1189
  {t('lessonForm.postContent')}
920
1190
  </TabsTrigger>
921
- <TabsTrigger value="transcricao" className="text-xs h-7 px-2.5">
922
- {t('lessonForm.tabTranscription')}
923
- </TabsTrigger>
924
- <TabsTrigger value="recursos" className="text-xs h-7 px-2.5">
1191
+ {watchedType === 'video' && (
1192
+ <TabsTrigger
1193
+ value="videos"
1194
+ className="h-6 px-2 text-[11px] shrink-0 sm:h-7 sm:px-2.5 sm:text-xs"
1195
+ >
1196
+ {t('lessonForm.tabVideos')}
1197
+ </TabsTrigger>
1198
+ )}
1199
+ {watchedType === 'video' && (
1200
+ <TabsTrigger
1201
+ value="transcricao"
1202
+ className="h-6 px-2 text-[11px] shrink-0 sm:h-7 sm:px-2.5 sm:text-xs"
1203
+ >
1204
+ {t('lessonForm.tabTranscription')}
1205
+ </TabsTrigger>
1206
+ )}
1207
+ <TabsTrigger
1208
+ value="recursos"
1209
+ className="h-6 px-2 text-[11px] shrink-0 sm:h-7 sm:px-2.5 sm:text-xs"
1210
+ >
925
1211
  {t('lessonForm.tabResources')}
926
1212
  </TabsTrigger>
927
1213
  </TabsList>
@@ -929,7 +1215,7 @@ export function EditorLesson({ lessonId }: EditorLessonProps) {
929
1215
  {/* ── Tab Dados ────────────────────────────────────────────────── */}
930
1216
  <TabsContent value="dados" className="flex-1 min-h-0 mt-0">
931
1217
  <ScrollArea className="h-full">
932
- <div className="flex flex-col gap-3 p-3">
1218
+ <div className="flex flex-col gap-2 p-2 sm:gap-3 sm:p-3">
933
1219
  {/* Identificação */}
934
1220
  <Card className="bg-muted/20 py-2 gap-2">
935
1221
  <CardHeader className="px-3 pt-2 pb-0">
@@ -938,7 +1224,7 @@ export function EditorLesson({ lessonId }: EditorLessonProps) {
938
1224
  </CardTitle>
939
1225
  </CardHeader>
940
1226
  <CardContent className="px-3 pb-2 flex flex-col gap-3">
941
- <div className="grid grid-cols-3 gap-2">
1227
+ <div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
942
1228
  <FormField
943
1229
  control={form.control}
944
1230
  name="code"
@@ -1047,7 +1333,7 @@ export function EditorLesson({ lessonId }: EditorLessonProps) {
1047
1333
  </CardTitle>
1048
1334
  </CardHeader>
1049
1335
  <CardContent className="px-3 pb-2 flex flex-col gap-3">
1050
- <div className="grid grid-cols-2 gap-2">
1336
+ <div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
1051
1337
  <FormField
1052
1338
  control={form.control}
1053
1339
  name="status"
@@ -1228,7 +1514,7 @@ export function EditorLesson({ lessonId }: EditorLessonProps) {
1228
1514
  {/* ── Tab Conteúdo ─────────────────────────────────────────────── */}
1229
1515
  <TabsContent value="conteudo" className="flex-1 min-h-0 mt-0">
1230
1516
  <ScrollArea className="h-full">
1231
- <div className="flex flex-col gap-3 p-3">
1517
+ <div className="flex flex-col gap-2 p-2 sm:gap-3 sm:p-3">
1232
1518
  {/* Descrição pública */}
1233
1519
  <Card className="bg-muted/20 py-2 gap-2">
1234
1520
  <CardHeader className="px-3 pt-2 pb-1">
@@ -1285,71 +1571,6 @@ export function EditorLesson({ lessonId }: EditorLessonProps) {
1285
1571
  </Card>
1286
1572
 
1287
1573
  {/* Campos específicos por tipo */}
1288
- {watchedType === 'video' && (
1289
- <Card className="bg-muted/20 py-2 gap-2">
1290
- <CardHeader className="px-3 pt-2 pb-1">
1291
- <CardTitle className="text-xs font-medium text-muted-foreground uppercase tracking-wider flex items-center gap-1.5">
1292
- <Video className="size-3 text-blue-500" />{' '}
1293
- {t('types.video')}
1294
- </CardTitle>
1295
- </CardHeader>
1296
- <CardContent className="px-3 pb-2 flex flex-col gap-3">
1297
- <FormField
1298
- control={form.control}
1299
- name="videoProvider"
1300
- render={({ field }) => (
1301
- <FormItem>
1302
- <FormLabel className="text-xs">
1303
- {t('lessonForm.videoProvider')}
1304
- </FormLabel>
1305
- <Select
1306
- value={field.value}
1307
- onValueChange={field.onChange}
1308
- >
1309
- <FormControl>
1310
- <SelectTrigger className="h-8 text-xs w-full">
1311
- <SelectValue />
1312
- </SelectTrigger>
1313
- </FormControl>
1314
- <SelectContent>
1315
- {videoProviders.map((p) => (
1316
- <SelectItem key={p.value} value={p.value}>
1317
- {p.label}
1318
- </SelectItem>
1319
- ))}
1320
- </SelectContent>
1321
- </Select>
1322
- <FormMessage className="text-xs" />
1323
- </FormItem>
1324
- )}
1325
- />
1326
-
1327
- <FormField
1328
- control={form.control}
1329
- name="videoUrl"
1330
- render={({ field }) => (
1331
- <FormItem>
1332
- <FormLabel className="text-xs">
1333
- {t('lessonForm.videoUrl')}
1334
- </FormLabel>
1335
- <FormControl>
1336
- <Input
1337
- {...field}
1338
- value={field.value ?? ''}
1339
- className="h-8 text-xs font-mono"
1340
- placeholder={t(
1341
- 'lessonForm.videoUrlPlaceholder'
1342
- )}
1343
- />
1344
- </FormControl>
1345
- <FormMessage className="text-xs" />
1346
- </FormItem>
1347
- )}
1348
- />
1349
- </CardContent>
1350
- </Card>
1351
- )}
1352
-
1353
1574
  {watchedType === 'post' && (
1354
1575
  <Card className="bg-muted/20 py-2 gap-2">
1355
1576
  <CardHeader className="px-3 pt-2 pb-1">
@@ -1466,19 +1687,635 @@ export function EditorLesson({ lessonId }: EditorLessonProps) {
1466
1687
  </ScrollArea>
1467
1688
  </TabsContent>
1468
1689
 
1469
- {/* ── Tab Transcrição ─────────────────────────────────────────── */}
1470
- <TabsContent value="transcricao" className="flex-1 min-h-0 mt-0">
1471
- <ScrollArea className="h-full">
1472
- <div className="flex flex-col gap-3 p-3">
1473
- {watchedType !== 'video' ? (
1690
+ {watchedType === 'video' && (
1691
+ <TabsContent value="videos" className="flex-1 min-h-0 mt-0">
1692
+ <ScrollArea className="h-full">
1693
+ <div className="flex flex-col gap-2 p-2 sm:gap-3 sm:p-3">
1474
1694
  <Card className="bg-muted/20 py-2 gap-2">
1475
- <CardContent className="px-3 py-4">
1476
- <p className="text-xs text-muted-foreground">
1477
- {t('lessonForm.transcriptionVideoOnly')}
1478
- </p>
1695
+ <CardHeader className="px-3 pt-2 pb-1">
1696
+ <CardTitle className="text-xs font-medium text-muted-foreground uppercase tracking-wider flex items-center gap-1.5">
1697
+ <Video className="size-3 text-blue-500" />{' '}
1698
+ {t('lessonForm.tabVideos')}
1699
+ </CardTitle>
1700
+ </CardHeader>
1701
+ <CardContent className="px-3 pb-2 flex flex-col gap-3">
1702
+ <FormField
1703
+ control={form.control}
1704
+ name="videoProvider"
1705
+ render={({ field }) => (
1706
+ <FormItem>
1707
+ <FormLabel className="text-xs">
1708
+ {t('lessonForm.videoProvider')}
1709
+ </FormLabel>
1710
+ <Select
1711
+ value={field.value}
1712
+ onValueChange={field.onChange}
1713
+ >
1714
+ <FormControl>
1715
+ <SelectTrigger className="h-8 text-xs w-full">
1716
+ <SelectValue />
1717
+ </SelectTrigger>
1718
+ </FormControl>
1719
+ <SelectContent>
1720
+ {videoProviders.map((p) => (
1721
+ <SelectItem key={p.value} value={p.value}>
1722
+ {p.label}
1723
+ </SelectItem>
1724
+ ))}
1725
+ </SelectContent>
1726
+ </Select>
1727
+ <FormMessage className="text-xs" />
1728
+ </FormItem>
1729
+ )}
1730
+ />
1731
+
1732
+ {watchedVideoProvider !== 'file_storage' ? (
1733
+ <FormField
1734
+ control={form.control}
1735
+ name="videoUrl"
1736
+ render={({ field }) => (
1737
+ <FormItem>
1738
+ <FormLabel className="text-xs">
1739
+ {t('lessonForm.videoUrl')}
1740
+ </FormLabel>
1741
+ <FormControl>
1742
+ <Input
1743
+ {...field}
1744
+ value={field.value ?? ''}
1745
+ className="h-8 text-xs font-mono"
1746
+ placeholder={t(
1747
+ 'lessonForm.videoUrlPlaceholder'
1748
+ )}
1749
+ />
1750
+ </FormControl>
1751
+ <FormMessage className="text-xs" />
1752
+ </FormItem>
1753
+ )}
1754
+ />
1755
+ ) : (
1756
+ <p className="text-xs text-muted-foreground">
1757
+ {t('lessonForm.fileStorageVideoHint')}
1758
+ </p>
1759
+ )}
1479
1760
  </CardContent>
1480
1761
  </Card>
1481
- ) : (
1762
+
1763
+ {watchedVideoProvider === 'file_storage' && (
1764
+ <>
1765
+ {videoUploadError ? (
1766
+ <p className="text-xs text-destructive">
1767
+ {videoUploadError}
1768
+ </p>
1769
+ ) : null}
1770
+
1771
+ <Card className="bg-muted/20 py-2 gap-2">
1772
+ <CardHeader className="px-3 pt-2 pb-1">
1773
+ <CardTitle className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
1774
+ {t('lessonForm.originalVideoTitle')}
1775
+ </CardTitle>
1776
+ </CardHeader>
1777
+ <CardContent className="px-3 pb-2 flex flex-col gap-3">
1778
+ <div className="rounded-lg border bg-background/90 p-3 shadow-sm">
1779
+ <div className="flex items-start gap-3">
1780
+ <div className="flex size-8 shrink-0 items-center justify-center rounded-md bg-blue-500/10 text-blue-600">
1781
+ <Video className="size-4" />
1782
+ </div>
1783
+ <div className="min-w-0 flex-1 space-y-1">
1784
+ <div className="flex items-start justify-between gap-2">
1785
+ <div className="min-w-0">
1786
+ <p className="truncate text-sm font-medium">
1787
+ {originalVideoResource
1788
+ ? originalVideoResource.name
1789
+ : t('lessonForm.originalVideoTitle')}
1790
+ </p>
1791
+ <p className="text-xs text-muted-foreground">
1792
+ {conversionJobId
1793
+ ? t('lessonForm.videoConversionJob', {
1794
+ id: conversionJobId,
1795
+ })
1796
+ : t('lessonForm.originalVideoHint')}
1797
+ </p>
1798
+ <p className="text-[0.65rem] text-muted-foreground">
1799
+ {t('lessonForm.originalVideoPurpose')}
1800
+ </p>
1801
+ </div>
1802
+ {originalVideoResource && (
1803
+ <div className="flex shrink-0 items-center gap-1">
1804
+ <Button
1805
+ type="button"
1806
+ variant="ghost"
1807
+ size="icon"
1808
+ className="size-7 shrink-0"
1809
+ disabled={isResolvingVideoPreview}
1810
+ onClick={() =>
1811
+ void openVideoPreview(
1812
+ originalVideoResource
1813
+ )
1814
+ }
1815
+ aria-label={t(
1816
+ 'lessonForm.playVideoAria',
1817
+ {
1818
+ name: originalVideoResource.name,
1819
+ }
1820
+ )}
1821
+ >
1822
+ {isResolvingVideoPreview ? (
1823
+ <Loader2 className="size-3 animate-spin" />
1824
+ ) : (
1825
+ <Play className="size-3" />
1826
+ )}
1827
+ </Button>
1828
+ <Button
1829
+ type="button"
1830
+ variant="ghost"
1831
+ size="icon"
1832
+ className="size-7 shrink-0"
1833
+ onClick={() =>
1834
+ void handleResourceDownload(
1835
+ originalVideoResource
1836
+ )
1837
+ }
1838
+ aria-label={t(
1839
+ 'lessonForm.downloadVideoAria',
1840
+ {
1841
+ name: originalVideoResource.name,
1842
+ }
1843
+ )}
1844
+ >
1845
+ <Download className="size-3" />
1846
+ </Button>
1847
+ <Button
1848
+ type="button"
1849
+ variant="ghost"
1850
+ size="icon"
1851
+ className="size-7 shrink-0"
1852
+ onClick={() =>
1853
+ void openResource(
1854
+ originalVideoResource
1855
+ )
1856
+ }
1857
+ aria-label={t(
1858
+ 'lessonForm.openVideoAria',
1859
+ {
1860
+ name: originalVideoResource.name,
1861
+ }
1862
+ )}
1863
+ >
1864
+ <ExternalLink className="size-3" />
1865
+ </Button>
1866
+ </div>
1867
+ )}
1868
+ </div>
1869
+ {originalVideoResource?.size ? (
1870
+ <div className="inline-flex rounded-full bg-muted px-2 py-0.5 text-[0.65rem] font-medium text-muted-foreground">
1871
+ {originalVideoResource.size}
1872
+ </div>
1873
+ ) : null}
1874
+ <div className="flex flex-wrap items-center gap-2 pt-1">
1875
+ <Button
1876
+ type="button"
1877
+ variant="secondary"
1878
+ className="h-8 px-3 text-xs"
1879
+ disabled={isOriginalVideoUploadBlocked}
1880
+ onClick={() =>
1881
+ originalVideoInputRef.current?.click()
1882
+ }
1883
+ >
1884
+ <UploadCloud className="size-3.5 mr-1" />
1885
+ {t(
1886
+ 'lessonForm.uploadOriginalForConversion'
1887
+ )}
1888
+ </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
+ ) : null}
1908
+ </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
+ </CardTitle>
1949
+ </CardHeader>
1950
+ <CardContent className="px-3 pb-2 flex flex-col gap-3">
1951
+ {hasConversionJobError ? (
1952
+ <div className="flex flex-col gap-2 rounded-md border border-destructive/30 bg-destructive/5 p-3">
1953
+ <p className="text-xs text-destructive">
1954
+ {t('lessonForm.videoJobLoadError')}
1955
+ </p>
1956
+ <Button
1957
+ type="button"
1958
+ variant="outline"
1959
+ size="sm"
1960
+ className="h-7 w-fit px-2 text-xs"
1961
+ onClick={() => void refetchConversionJob()}
1962
+ >
1963
+ <RefreshCw className="size-3 mr-1" />
1964
+ {t('lessonForm.retryLoadVideoJob')}
1965
+ </Button>
1966
+ </div>
1967
+ ) : !conversionJob ? (
1968
+ <div className="flex items-center gap-2 rounded-md border bg-background/70 px-3 py-2 text-xs text-muted-foreground">
1969
+ <Loader2 className="size-3.5 animate-spin" />
1970
+ {isFetchingConversionJob
1971
+ ? t('lessonForm.videoJobLoading')
1972
+ : t('lessonForm.videoJobPendingLoad')}
1973
+ </div>
1974
+ ) : (
1975
+ <>
1976
+ <div className="grid grid-cols-1 gap-2 sm:grid-cols-2 xl:grid-cols-4">
1977
+ <div className="rounded-md border bg-background/70 p-2">
1978
+ <p className="text-[0.65rem] text-muted-foreground">
1979
+ {t('lessonForm.videoJobIdLabel')}
1980
+ </p>
1981
+ <p className="text-xs font-medium">
1982
+ #{conversionJob.id}
1983
+ </p>
1984
+ </div>
1985
+ <div className="rounded-md border bg-background/70 p-2">
1986
+ <p className="text-[0.65rem] text-muted-foreground">
1987
+ {t('lessonForm.videoJobAttemptsLabel')}
1988
+ </p>
1989
+ <p className="text-xs font-medium">
1990
+ {t('lessonForm.videoJobAttemptsValue', {
1991
+ current: conversionJob.attempts,
1992
+ total: conversionJob.max_attempts,
1993
+ })}
1994
+ </p>
1995
+ </div>
1996
+ <div className="rounded-md border bg-background/70 p-2">
1997
+ <p className="text-[0.65rem] text-muted-foreground">
1998
+ {t('lessonForm.videoJobCreatedAt')}
1999
+ </p>
2000
+ <p className="text-xs font-medium">
2001
+ {formatDateTimeLabel(
2002
+ conversionJob.created_at
2003
+ ) ?? '—'}
2004
+ </p>
2005
+ </div>
2006
+ <div className="rounded-md border bg-background/70 p-2">
2007
+ <p className="text-[0.65rem] text-muted-foreground">
2008
+ {TERMINAL_VIDEO_JOB_STATUSES.includes(
2009
+ conversionJob.status
2010
+ )
2011
+ ? t('lessonForm.videoJobFinishedAt')
2012
+ : t('lessonForm.videoJobStartedAt')}
2013
+ </p>
2014
+ <p className="text-xs font-medium">
2015
+ {formatDateTimeLabel(
2016
+ TERMINAL_VIDEO_JOB_STATUSES.includes(
2017
+ conversionJob.status
2018
+ )
2019
+ ? conversionJob.finished_at
2020
+ : conversionJob.started_at
2021
+ ) ?? '—'}
2022
+ </p>
2023
+ </div>
2024
+ </div>
2025
+
2026
+ {latestConversionAttempt ? (
2027
+ <div className="rounded-md border bg-background/70 p-3">
2028
+ <div className="flex items-center justify-between gap-2">
2029
+ <p className="text-xs font-medium">
2030
+ {t('lessonForm.videoJobLatestAttempt')}
2031
+ </p>
2032
+ <span className="text-[0.65rem] text-muted-foreground">
2033
+ {t(
2034
+ `lessonForm.videoAttemptStatuses.${latestConversionAttempt.status}` as any
2035
+ )}
2036
+ </span>
2037
+ </div>
2038
+ <p className="mt-1 text-[0.65rem] text-muted-foreground">
2039
+ {t('lessonForm.videoJobAttemptValue', {
2040
+ count:
2041
+ latestConversionAttempt.attempt_number,
2042
+ })}
2043
+ {formatDurationLabel(
2044
+ latestConversionAttempt.duration_ms
2045
+ )
2046
+ ? ` · ${formatDurationLabel(latestConversionAttempt.duration_ms)}`
2047
+ : ''}
2048
+ </p>
2049
+ {latestConversionAttempt.error_message ? (
2050
+ <p className="mt-2 text-xs text-destructive">
2051
+ {latestConversionAttempt.error_message}
2052
+ </p>
2053
+ ) : null}
2054
+ </div>
2055
+ ) : null}
2056
+
2057
+ {conversionJob.last_error ? (
2058
+ <div className="rounded-md border border-destructive/30 bg-destructive/5 p-3">
2059
+ <p className="text-[0.65rem] font-medium text-destructive">
2060
+ {t('lessonForm.videoJobLastError')}
2061
+ </p>
2062
+ <p className="mt-1 text-xs text-destructive">
2063
+ {conversionJob.last_error}
2064
+ </p>
2065
+ </div>
2066
+ ) : null}
2067
+
2068
+ <div className="rounded-md border bg-background/70 p-3">
2069
+ <p className="text-xs font-medium">
2070
+ {t('lessonForm.videoJobRecentEvents')}
2071
+ </p>
2072
+ <div className="mt-2 flex flex-col gap-2">
2073
+ {recentConversionEvents.length === 0 ? (
2074
+ <p className="text-xs text-muted-foreground">
2075
+ {t('lessonForm.videoJobNoEvents')}
2076
+ </p>
2077
+ ) : (
2078
+ recentConversionEvents.map((event) => (
2079
+ <div
2080
+ key={event.id}
2081
+ className="rounded-md border border-border/60 px-2.5 py-2"
2082
+ >
2083
+ <div className="flex items-center justify-between gap-2">
2084
+ <p className="text-xs font-medium">
2085
+ {t(
2086
+ `lessonForm.videoJobEvents.${event.event_type}` as any
2087
+ )}
2088
+ </p>
2089
+ <span className="text-[0.65rem] text-muted-foreground">
2090
+ {formatDateTimeLabel(
2091
+ event.created_at
2092
+ ) ?? '—'}
2093
+ </span>
2094
+ </div>
2095
+ {event.message ? (
2096
+ <p className="mt-1 text-[0.65rem] text-muted-foreground">
2097
+ {event.message}
2098
+ </p>
2099
+ ) : null}
2100
+ </div>
2101
+ ))
2102
+ )}
2103
+ </div>
2104
+ </div>
2105
+ </>
2106
+ )}
2107
+ </CardContent>
2108
+ </Card>
2109
+ ) : null}
2110
+
2111
+ <Card className="bg-muted/20 py-2 gap-2">
2112
+ <CardHeader className="px-3 pt-2 pb-1">
2113
+ <CardTitle className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
2114
+ {t('lessonForm.fileStorageVideosByResolution')}
2115
+ </CardTitle>
2116
+ </CardHeader>
2117
+ <CardContent className="px-3 pb-2 flex flex-col gap-3">
2118
+ {isFetchingCourseVideoProfiles ? (
2119
+ <p className="text-xs text-muted-foreground">
2120
+ {t('lessonForm.loadingVideoProfiles')}
2121
+ </p>
2122
+ ) : hasCourseVideoProfilesError ? (
2123
+ <div className="flex flex-col gap-2">
2124
+ <p className="text-xs text-destructive">
2125
+ {t('lessonForm.videoProfilesLoadError')}
2126
+ </p>
2127
+ <Button
2128
+ type="button"
2129
+ variant="outline"
2130
+ size="sm"
2131
+ className="h-7 w-fit px-2 text-xs"
2132
+ onClick={() =>
2133
+ void refetchCourseVideoProfiles()
2134
+ }
2135
+ >
2136
+ <RefreshCw className="size-3 mr-1" />
2137
+ {t('lessonForm.retryLoadVideoProfiles')}
2138
+ </Button>
2139
+ </div>
2140
+ ) : courseVideoProfiles.length === 0 ? (
2141
+ <p className="text-xs text-muted-foreground">
2142
+ {t('lessonForm.noVideoProfilesConfigured')}
2143
+ </p>
2144
+ ) : (
2145
+ <>
2146
+ {isConversionJobActive ? (
2147
+ <p className="text-xs text-muted-foreground">
2148
+ {t(
2149
+ 'lessonForm.videoProfilesLockedWhileProcessing'
2150
+ )}
2151
+ </p>
2152
+ ) : null}
2153
+ <div className="flex flex-col gap-1">
2154
+ {courseVideoProfiles.map((profile) => {
2155
+ const res = profileVideoResources.get(
2156
+ profile.id
2157
+ );
2158
+ const currentUploadProgress =
2159
+ profileUploadProgress[profile.id];
2160
+ const inputId = `lesson-video-profile-${profile.id}`;
2161
+
2162
+ return (
2163
+ <div
2164
+ key={profile.id}
2165
+ className="flex items-center gap-2 rounded-md border bg-background/80 px-2.5 py-2"
2166
+ >
2167
+ <Video className="size-3.5 shrink-0 text-blue-500" />
2168
+ <div className="flex-1 min-w-0">
2169
+ <p className="text-xs truncate font-medium">
2170
+ {profile.name}
2171
+ </p>
2172
+ <p className="text-[0.65rem] text-muted-foreground">
2173
+ {res
2174
+ ? `${res.name}${res.size ? ` · ${res.size}` : ''}`
2175
+ : t(
2176
+ 'lessonForm.videoProfileMissing'
2177
+ )}
2178
+ </p>
2179
+ {currentUploadProgress !== undefined ? (
2180
+ <div className="mt-1 space-y-1">
2181
+ <Progress
2182
+ value={currentUploadProgress}
2183
+ className="h-1.5"
2184
+ />
2185
+ <p className="text-[0.65rem] text-muted-foreground">
2186
+ {currentUploadProgress}%
2187
+ </p>
2188
+ </div>
2189
+ ) : null}
2190
+ </div>
2191
+ <input
2192
+ id={inputId}
2193
+ type="file"
2194
+ accept="video/*"
2195
+ className="hidden"
2196
+ onChange={(event) => {
2197
+ const file = event.target.files?.[0];
2198
+ if (
2199
+ file &&
2200
+ !isProfileVideoUploadBlocked
2201
+ ) {
2202
+ void handleVideoProfileFile(
2203
+ profile.id,
2204
+ file
2205
+ );
2206
+ }
2207
+ event.target.value = '';
2208
+ }}
2209
+ />
2210
+ <Button
2211
+ type="button"
2212
+ variant="outline"
2213
+ size="sm"
2214
+ className="h-7 px-2 text-xs"
2215
+ disabled={
2216
+ currentUploadProgress !== undefined ||
2217
+ isProfileVideoUploadBlocked
2218
+ }
2219
+ onClick={() =>
2220
+ document
2221
+ .getElementById(inputId)
2222
+ ?.click()
2223
+ }
2224
+ >
2225
+ <UploadCloud className="size-3 mr-1" />
2226
+ {res
2227
+ ? t('lessonForm.replaceVideo')
2228
+ : t('lessonForm.upload')}
2229
+ </Button>
2230
+ {res && (
2231
+ <>
2232
+ <Button
2233
+ type="button"
2234
+ variant="ghost"
2235
+ size="icon"
2236
+ className="size-6 shrink-0"
2237
+ disabled={isResolvingVideoPreview}
2238
+ onClick={() =>
2239
+ void openVideoPreview(res)
2240
+ }
2241
+ aria-label={t(
2242
+ 'lessonForm.playVideoAria',
2243
+ { name: res.name }
2244
+ )}
2245
+ >
2246
+ {isResolvingVideoPreview ? (
2247
+ <Loader2 className="size-3 animate-spin" />
2248
+ ) : (
2249
+ <Play className="size-3" />
2250
+ )}
2251
+ </Button>
2252
+ <Button
2253
+ type="button"
2254
+ variant="ghost"
2255
+ size="icon"
2256
+ className="size-6 shrink-0"
2257
+ onClick={() =>
2258
+ void openResource(res)
2259
+ }
2260
+ aria-label={t(
2261
+ 'lessonForm.openVideoAria',
2262
+ { name: res.name }
2263
+ )}
2264
+ >
2265
+ <ExternalLink className="size-3" />
2266
+ </Button>
2267
+ <Button
2268
+ type="button"
2269
+ variant="ghost"
2270
+ size="icon"
2271
+ className="size-6 shrink-0"
2272
+ onClick={() =>
2273
+ void handleResourceDownload(res)
2274
+ }
2275
+ aria-label={t(
2276
+ 'lessonForm.downloadVideoAria',
2277
+ { name: res.name }
2278
+ )}
2279
+ >
2280
+ <Download className="size-3" />
2281
+ </Button>
2282
+ <Button
2283
+ type="button"
2284
+ variant="ghost"
2285
+ size="icon"
2286
+ className="size-6 shrink-0 text-muted-foreground hover:text-destructive"
2287
+ onClick={() =>
2288
+ void removeResource(res.id)
2289
+ }
2290
+ aria-label={t(
2291
+ 'lessonForm.removeVideoAria',
2292
+ { name: res.name }
2293
+ )}
2294
+ >
2295
+ <X className="size-3" />
2296
+ </Button>
2297
+ </>
2298
+ )}
2299
+ </div>
2300
+ );
2301
+ })}
2302
+ </div>
2303
+ </>
2304
+ )}
2305
+ </CardContent>
2306
+ </Card>
2307
+ </>
2308
+ )}
2309
+ </div>
2310
+ </ScrollArea>
2311
+ </TabsContent>
2312
+ )}
2313
+
2314
+ {/* ── Tab Transcrição ─────────────────────────────────────────── */}
2315
+ {watchedType === 'video' && (
2316
+ <TabsContent value="transcricao" className="flex-1 min-h-0 mt-0">
2317
+ <ScrollArea className="h-full">
2318
+ <div className="flex flex-col gap-2 p-2 sm:gap-3 sm:p-3">
1482
2319
  <Card className="bg-muted/20 py-2 gap-2">
1483
2320
  <CardHeader className="px-3 pt-2 pb-1">
1484
2321
  <CardTitle className="text-xs font-medium text-muted-foreground uppercase tracking-wider flex items-center justify-between gap-2">
@@ -1602,7 +2439,9 @@ export function EditorLesson({ lessonId }: EditorLessonProps) {
1602
2439
  setTranscriptionSegments((prev) => {
1603
2440
  if (prev.length === 1) {
1604
2441
  const first = prev[0];
1605
- return first ? [{ ...first, text: '' }] : [];
2442
+ return first
2443
+ ? [{ ...first, text: '' }]
2444
+ : [];
1606
2445
  }
1607
2446
 
1608
2447
  return prev.filter(
@@ -1627,148 +2466,15 @@ export function EditorLesson({ lessonId }: EditorLessonProps) {
1627
2466
  </p>
1628
2467
  </CardContent>
1629
2468
  </Card>
1630
- )}
1631
- </div>
1632
- </ScrollArea>
1633
- </TabsContent>
2469
+ </div>
2470
+ </ScrollArea>
2471
+ </TabsContent>
2472
+ )}
1634
2473
 
1635
2474
  {/* ── Tab Recursos ─────────────────────────────────────────────── */}
1636
2475
  <TabsContent value="recursos" className="flex-1 min-h-0 mt-0">
1637
2476
  <ScrollArea className="h-full">
1638
- <div className="flex flex-col gap-3 p-3">
1639
- {watchedType === 'video' &&
1640
- watchedVideoProvider === 'file_storage' && (
1641
- <Card className="bg-muted/20 py-2 gap-2">
1642
- <CardHeader className="px-3 pt-2 pb-1">
1643
- <CardTitle className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
1644
- {t('lessonForm.fileStorageVideosByResolution')}
1645
- </CardTitle>
1646
- </CardHeader>
1647
- <CardContent className="px-3 pb-2 flex flex-col gap-3">
1648
- <div className="grid grid-cols-1 md:grid-cols-[160px_1fr] gap-2">
1649
- <Input
1650
- value={videoResolution}
1651
- onChange={(event) =>
1652
- setVideoResolution(event.target.value)
1653
- }
1654
- className="h-8 text-xs"
1655
- placeholder={t(
1656
- 'lessonForm.videoResolutionPlaceholder'
1657
- )}
1658
- />
1659
- <Button
1660
- type="button"
1661
- variant="outline"
1662
- className="h-8 text-xs"
1663
- disabled={isUploading}
1664
- onClick={() => videoInputRef.current?.click()}
1665
- >
1666
- <UploadCloud className="size-3.5 mr-1" />
1667
- {t('lessonForm.uploadVideoForResolution')}
1668
- </Button>
1669
- <input
1670
- ref={videoInputRef}
1671
- type="file"
1672
- multiple
1673
- accept="video/*"
1674
- className="hidden"
1675
- onChange={(event) => {
1676
- if (event.target.files) {
1677
- void handleVideoVariantFiles(
1678
- Array.from(event.target.files)
1679
- );
1680
- event.target.value = '';
1681
- }
1682
- }}
1683
- />
1684
- </div>
1685
-
1686
- {localResources.filter((res) =>
1687
- res.type.startsWith('video/')
1688
- ).length === 0 ? (
1689
- <p className="text-xs text-muted-foreground">
1690
- {t('lessonForm.noResolutionVideosYet')}
1691
- </p>
1692
- ) : (
1693
- <div className="flex flex-col gap-1">
1694
- {localResources
1695
- .filter((res) => res.type.startsWith('video/'))
1696
- .map((res) => {
1697
- const resolution =
1698
- res.type.replace('video/', '') || 'N/A';
1699
- return (
1700
- <div
1701
- key={res.id}
1702
- className="flex items-center gap-2 rounded-md border bg-background/80 px-2.5 py-2"
1703
- >
1704
- <Video className="size-3.5 shrink-0 text-blue-500" />
1705
- <div className="flex-1 min-w-0">
1706
- <p className="text-xs truncate font-medium">
1707
- {res.name}
1708
- </p>
1709
- <p className="text-[0.65rem] text-muted-foreground">
1710
- {resolution}
1711
- {res.size ? ` · ${res.size}` : ''}
1712
- </p>
1713
- </div>
1714
- <Button
1715
- type="button"
1716
- variant="ghost"
1717
- size="icon"
1718
- className="size-6 shrink-0"
1719
- onClick={() => void openResource(res)}
1720
- aria-label={t(
1721
- 'lessonForm.openVideoAria',
1722
- {
1723
- name: res.name,
1724
- }
1725
- )}
1726
- >
1727
- <ExternalLink className="size-3" />
1728
- </Button>
1729
- <Button
1730
- type="button"
1731
- variant="ghost"
1732
- size="icon"
1733
- className="size-6 shrink-0"
1734
- onClick={() =>
1735
- void handleResourceDownload(res)
1736
- }
1737
- aria-label={t(
1738
- 'lessonForm.downloadVideoAria',
1739
- {
1740
- name: res.name,
1741
- }
1742
- )}
1743
- >
1744
- <Download className="size-3" />
1745
- </Button>
1746
- <Button
1747
- type="button"
1748
- variant="ghost"
1749
- size="icon"
1750
- className="size-6 shrink-0 text-muted-foreground hover:text-destructive"
1751
- onClick={() =>
1752
- void removeResource(res.id)
1753
- }
1754
- aria-label={t(
1755
- 'lessonForm.removeVideoAria',
1756
- {
1757
- name: res.name,
1758
- }
1759
- )}
1760
- >
1761
- <X className="size-3" />
1762
- </Button>
1763
- </div>
1764
- );
1765
- })}
1766
- </div>
1767
- )}
1768
- </CardContent>
1769
- </Card>
1770
- )}
1771
-
2477
+ <div className="flex flex-col gap-2 p-2 sm:gap-3 sm:p-3">
1772
2478
  {/* Drop zone */}
1773
2479
  <div
1774
2480
  role="button"
@@ -1846,22 +2552,22 @@ export function EditorLesson({ lessonId }: EditorLessonProps) {
1846
2552
  </div>
1847
2553
 
1848
2554
  {/* Counter */}
1849
- {localResources.length > 0 && (
2555
+ {genericResources.length > 0 && (
1850
2556
  <p className="text-xs text-muted-foreground">
1851
2557
  {t('lessonForm.resourcesCount', {
1852
- count: localResources.length,
2558
+ count: genericResources.length,
1853
2559
  })}
1854
2560
  </p>
1855
2561
  )}
1856
2562
 
1857
2563
  {/* Resource list */}
1858
- {localResources.length === 0 ? (
2564
+ {genericResources.length === 0 ? (
1859
2565
  <p className="text-center text-xs text-muted-foreground py-1">
1860
2566
  {t('questionEditor.noLinkedResources')}
1861
2567
  </p>
1862
2568
  ) : (
1863
2569
  <div className="flex flex-col gap-1">
1864
- {localResources.map((res) => {
2570
+ {genericResources.map((res) => {
1865
2571
  const ResIcon = getResourceIcon(res.type);
1866
2572
  return (
1867
2573
  <div
@@ -1888,7 +2594,6 @@ export function EditorLesson({ lessonId }: EditorLessonProps) {
1888
2594
  aria-label={t('lessonForm.public')}
1889
2595
  />
1890
2596
  )}
1891
- {/* Abrir em nova aba */}
1892
2597
  {res.url && (
1893
2598
  <a
1894
2599
  href={res.url}
@@ -1903,7 +2608,6 @@ export function EditorLesson({ lessonId }: EditorLessonProps) {
1903
2608
  <ExternalLink className="size-3" />
1904
2609
  </a>
1905
2610
  )}
1906
- {/* Download */}
1907
2611
  <Button
1908
2612
  type="button"
1909
2613
  variant="ghost"
@@ -1916,7 +2620,6 @@ export function EditorLesson({ lessonId }: EditorLessonProps) {
1916
2620
  >
1917
2621
  <Download className="size-3" />
1918
2622
  </Button>
1919
- {/* Remover */}
1920
2623
  <Button
1921
2624
  type="button"
1922
2625
  variant="ghost"
@@ -1942,14 +2645,18 @@ export function EditorLesson({ lessonId }: EditorLessonProps) {
1942
2645
  {/* ── Footer ───────────────────────────────────────────────────────── */}
1943
2646
  <div className="shrink-0 border-t bg-background">
1944
2647
  <Separator />
1945
- <div className="flex items-center gap-2 px-3 py-2">
2648
+ <div className="flex items-center gap-1.5 px-2 py-1.5 sm:gap-2 sm:px-3 sm:py-2">
1946
2649
  <Button
1947
2650
  type="button"
1948
2651
  variant="ghost"
1949
2652
  size="sm"
1950
- className="h-7 text-xs"
1951
- disabled={!isDirty || updateLesson.isPending}
1952
- onClick={() => form.reset()}
2653
+ className="h-6 text-[11px] sm:h-7 sm:text-xs"
2654
+ disabled={(!isDirty && !resourcesDirty) || updateLesson.isPending}
2655
+ onClick={() => {
2656
+ form.reset();
2657
+ setLocalResources(lesson?.resources ?? []);
2658
+ setResourcesDirty(false);
2659
+ }}
1953
2660
  >
1954
2661
  <Undo2 className="size-3 mr-1" />
1955
2662
  {t('lessonForm.cancel')}
@@ -1958,8 +2665,8 @@ export function EditorLesson({ lessonId }: EditorLessonProps) {
1958
2665
  <Button
1959
2666
  type="submit"
1960
2667
  size="sm"
1961
- className="h-7 text-xs"
1962
- disabled={!isDirty || updateLesson.isPending}
2668
+ className="h-6 text-[11px] sm:h-7 sm:text-xs"
2669
+ disabled={(!isDirty && !resourcesDirty) || updateLesson.isPending}
1963
2670
  >
1964
2671
  {updateLesson.isPending ? (
1965
2672
  <Loader2 className="size-3 mr-1 animate-spin" />
@@ -1973,7 +2680,11 @@ export function EditorLesson({ lessonId }: EditorLessonProps) {
1973
2680
 
1974
2681
  {/* ── Question Sheet ──────────────────────────────────────────────── */}
1975
2682
  <Sheet open={questionSheetOpen} onOpenChange={setQuestionSheetOpen}>
1976
- <SheetContent
2683
+ <ResizableSheetContent
2684
+ sheetId="lms-course-structure-question-sheet"
2685
+ defaultWidth={560}
2686
+ minWidth={420}
2687
+ maxWidth={920}
1977
2688
  side="right"
1978
2689
  className="flex w-full flex-col sm:max-w-lg overflow-y-auto"
1979
2690
  >
@@ -2362,7 +3073,7 @@ export function EditorLesson({ lessonId }: EditorLessonProps) {
2362
3073
  : t('questionEditor.createQuestion')}
2363
3074
  </Button>
2364
3075
  </SheetFooter>
2365
- </SheetContent>
3076
+ </ResizableSheetContent>
2366
3077
  </Sheet>
2367
3078
  </form>
2368
3079
  </Form>