@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
@@ -15,6 +15,7 @@ import {
15
15
  FileText,
16
16
  Layers,
17
17
  Loader2,
18
+ Pencil,
18
19
  Plus,
19
20
  Save,
20
21
  Undo2,
@@ -30,6 +31,7 @@ import { toast } from 'sonner';
30
31
  import { z } from 'zod';
31
32
 
32
33
  import { createDefaultTemplate } from '@/app/(app)/(libraries)/lms/_lib/editor/types';
34
+ import { FfmpegParamsEditor } from '@/components/ffmpeg-params-editor';
33
35
  import { RichTextEditor } from '@/components/rich-text-editor';
34
36
  import { Badge } from '@/components/ui/badge';
35
37
  import { Button } from '@/components/ui/button';
@@ -42,6 +44,7 @@ import {
42
44
  DialogHeader,
43
45
  DialogTitle,
44
46
  } from '@/components/ui/dialog';
47
+ import { EntityPicker } from '@/components/ui/entity-picker';
45
48
  import {
46
49
  Form,
47
50
  FormControl,
@@ -51,17 +54,24 @@ import {
51
54
  FormMessage,
52
55
  } from '@/components/ui/form';
53
56
  import { Input } from '@/components/ui/input';
54
- import { ScrollArea } from '@/components/ui/scroll-area';
55
57
  import { Separator } from '@/components/ui/separator';
56
58
  import {
57
59
  Sheet,
58
- SheetContent,
59
60
  SheetDescription,
60
61
  SheetFooter,
61
62
  SheetHeader,
62
63
  SheetTitle,
63
64
  } from '@/components/ui/sheet';
65
+ import { ResizableSheetContent } from '@/components/ui/resizable-sheet-content';
66
+ import { Skeleton } from '@/components/ui/skeleton';
64
67
  import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
68
+ import {
69
+ Tooltip,
70
+ TooltipContent,
71
+ TooltipProvider,
72
+ TooltipTrigger,
73
+ } from '@/components/ui/tooltip';
74
+ import { useIsMobile } from '@/hooks/use-mobile';
65
75
  import { cn } from '@/lib/utils';
66
76
  import { useApp, useQuery } from '@hed-hog/next-app-provider';
67
77
  import { useMutation, useQueryClient } from '@tanstack/react-query';
@@ -95,13 +105,14 @@ type ApiCourseDetail = {
95
105
  id: number;
96
106
  name: string;
97
107
  slug: string;
108
+ code?: string | null;
98
109
  title: string;
99
110
  description: string;
100
111
  primaryColor?: string | null;
101
112
  secondaryColor?: string | null;
102
113
  level: 'beginner' | 'intermediate' | 'advanced';
103
114
  status: 'draft' | 'published' | 'archived';
104
- offeringType: 'scheduled' | 'on_demand' | 'blended';
115
+ offeringType?: 'scheduled' | 'on_demand' | 'blended' | null;
105
116
  categories: string[];
106
117
  isFeatured: boolean;
107
118
  hasCertificate: boolean;
@@ -138,6 +149,13 @@ type ApiCourseDetail = {
138
149
  operationsProjectName?: string | null;
139
150
  };
140
151
 
152
+ type VideoProfileOption = {
153
+ id: number;
154
+ name: string;
155
+ ffmpeg_params: string;
156
+ status: string;
157
+ };
158
+
141
159
  type ApiCategory = { id: number; slug: string; name: string };
142
160
  type ApiCategoryDetail = {
143
161
  id: number;
@@ -246,12 +264,23 @@ function toApiStatus(status: CourseEditFormValues['status']) {
246
264
  }
247
265
 
248
266
  function toPtOfferingType(
249
- value: ApiCourseDetail['offeringType']
267
+ value?: ApiCourseDetail['offeringType'] | string | null
250
268
  ): CourseEditFormValues['tipoOferta'] {
251
269
  const n = normalizeEnumValue(value);
252
270
  if (n === 'scheduled' || n === 'agendado') return 'agendado';
253
271
  if (n === 'blended' || n === 'hibrido') return 'hibrido';
254
- return 'sob_demanda';
272
+ if (
273
+ n === 'on_demand' ||
274
+ n === 'ondemand' ||
275
+ n === 'on-demand' ||
276
+ n === 'sob_demanda' ||
277
+ n === 'sob demanda'
278
+ ) {
279
+ return 'sob_demanda';
280
+ }
281
+
282
+ // Safe fallback: keep video profiles tab hidden when backend sends invalid data.
283
+ return 'agendado';
255
284
  }
256
285
 
257
286
  function toApiOfferingType(value: CourseEditFormValues['tipoOferta']) {
@@ -309,7 +338,7 @@ function mapApiCourseResourceToLocal(
309
338
  ): CourseResourceItem {
310
339
  return {
311
340
  id: String(item.id),
312
- fileId: item.fileId ?? null,
341
+ fileId: item.fileId ?? undefined,
313
342
  name: item.nome,
314
343
  size: '',
315
344
  type: item.tipo ?? 'file',
@@ -326,10 +355,12 @@ function buildSchema(t: (key: string) => string) {
326
355
  .trim()
327
356
  .min(2, t('validation.codeMin'))
328
357
  .max(32, t('validation.codeMax'))
329
- .regex(
330
- /^[a-z0-9]+(?:-[a-z0-9]+)*$/,
331
- 'Código: apenas letras minúsculas, números e hífens'
332
- ),
358
+ .regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, t('validation.codeFormat')),
359
+ code: z
360
+ .string()
361
+ .regex(/^[A-Z0-9]{2,}$/, t('validation.editionCodeFormat'))
362
+ .or(z.literal(''))
363
+ .optional(),
333
364
  nomeInterno: z.string().trim().min(3, t('validation.internalNameMin')),
334
365
  tituloComercial: z.string().trim().min(3, t('validation.titleMin')),
335
366
  descricaoPublica: z.string().trim(),
@@ -367,6 +398,7 @@ export function EditorCourse() {
367
398
  const { request, currentLocaleCode, locales } = useApp();
368
399
  const t = useTranslations('lms.CursoEditPage');
369
400
  const router = useRouter();
401
+ const isMobile = useIsMobile();
370
402
  const queryClient = useQueryClient();
371
403
  const createSessionMutation = useCreateSessionMutation();
372
404
 
@@ -414,6 +446,18 @@ export function EditorCourse() {
414
446
  const [isUploadingResources, setIsUploadingResources] = useState(false);
415
447
  const [isSavingResources, setIsSavingResources] = useState(false);
416
448
  const resourcesInputRef = useRef<HTMLInputElement>(null);
449
+ const [linkedProfileIds, setLinkedProfileIds] = useState<number[]>([]);
450
+ const [videoProfilePickerResetKey, setVideoProfilePickerResetKey] =
451
+ useState(0);
452
+ const [videoProfileEditSheetOpen, setVideoProfileEditSheetOpen] =
453
+ useState(false);
454
+ const [editingVideoProfileId, setEditingVideoProfileId] = useState<
455
+ number | null
456
+ >(null);
457
+ const [editingVideoProfileName, setEditingVideoProfileName] = useState('');
458
+ const [editingVideoProfileParams, setEditingVideoProfileParams] =
459
+ useState('');
460
+ const [savingVideoProfileEdit, setSavingVideoProfileEdit] = useState(false);
417
461
 
418
462
  // ── Queries ─────────────────────────────────────────────────────────────────
419
463
  const { data: apiCourse, refetch: refetchCourse } = useQuery<ApiCourseDetail>(
@@ -525,6 +569,22 @@ export function EditorCourse() {
525
569
  initialData: { data: [] },
526
570
  });
527
571
 
572
+ const {
573
+ data: allVideoProfiles = [],
574
+ refetch: refetchVideoProfiles,
575
+ isFetching: isFetchingVideoProfiles,
576
+ } = useQuery<VideoProfileOption[]>({
577
+ queryKey: ['lms-video-resolution-profiles-all'],
578
+ queryFn: async () => {
579
+ const response = await request<VideoProfileOption[]>({
580
+ url: '/lms/video-resolution-profiles/all',
581
+ method: 'GET',
582
+ });
583
+ return response.data;
584
+ },
585
+ initialData: [],
586
+ });
587
+
528
588
  // ── Save mutation ───────────────────────────────────────────────────────────
529
589
  const { mutate: saveCourse, isPending: saving } = useMutation({
530
590
  mutationFn: async (data: CourseEditFormValues) => {
@@ -534,6 +594,7 @@ export function EditorCourse() {
534
594
  data: {
535
595
  name: data.nomeInterno.trim(),
536
596
  slug: data.slug.trim().toLowerCase(),
597
+ code: data.code?.trim() || undefined,
537
598
  title: data.tituloComercial.trim(),
538
599
  description: data.descricaoPublica.trim(),
539
600
  requirements: data.preRequisitos?.trim() || undefined,
@@ -560,6 +621,15 @@ export function EditorCourse() {
560
621
  return data;
561
622
  },
562
623
  onSuccess: (data) => {
624
+ if (courseId) {
625
+ void request({
626
+ url: `/lms/courses/${courseId}/video-resolution-profiles/sync`,
627
+ method: 'POST',
628
+ data: { profileIds: linkedProfileIds },
629
+ }).catch(() => {
630
+ toast.error('Erro ao sincronizar perfis de vídeo.');
631
+ });
632
+ }
563
633
  setPersistedCertificateModel(data.modeloCertificado || '');
564
634
  updateCourseInStore({
565
635
  title: data.tituloComercial,
@@ -586,6 +656,7 @@ export function EditorCourse() {
586
656
  resolver: zodResolver(schema),
587
657
  defaultValues: {
588
658
  slug: '',
659
+ code: '',
589
660
  nomeInterno: '',
590
661
  tituloComercial: '',
591
662
  descricaoPublica: '',
@@ -716,15 +787,27 @@ export function EditorCourse() {
716
787
  );
717
788
  }, [certificateTemplateData, createdTemplateOptions]);
718
789
 
790
+ const availableVideoProfiles = useMemo(
791
+ () =>
792
+ allVideoProfiles.filter(
793
+ (profile) => !linkedProfileIds.includes(profile.id)
794
+ ),
795
+ [allVideoProfiles, linkedProfileIds]
796
+ );
797
+
719
798
  // ── Structural stats ─────────────────────────────────────────────────────────
720
799
  const totalMinutes = lessons.reduce((sum, l) => sum + l.duration, 0);
721
800
  const hours = Math.floor(totalMinutes / 60);
722
801
  const minutes = totalMinutes % 60;
802
+ const durationLabel = hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
723
803
  const publishedCount = lessons.filter(
724
804
  (l) => l.visibility === 'publico' || l.status === 'publicada'
725
805
  ).length;
726
806
 
727
807
  const formValues = form.watch();
808
+ const showVideoTab =
809
+ formValues.tipoOferta === 'sob_demanda' ||
810
+ formValues.tipoOferta === 'hibrido';
728
811
  const descriptionText = String(formValues.descricaoPublica ?? '')
729
812
  .replace(/<[^>]+>/g, '')
730
813
  .trim();
@@ -789,6 +872,7 @@ export function EditorCourse() {
789
872
  apiCourse.certificateModel ?? persistedCertificateModel ?? '';
790
873
  form.reset({
791
874
  slug: apiCourse.slug,
875
+ code: apiCourse.code ?? '',
792
876
  nomeInterno: apiCourse.name,
793
877
  tituloComercial: apiCourse.title,
794
878
  descricaoPublica: apiCourse.description ?? '',
@@ -855,6 +939,32 @@ export function EditorCourse() {
855
939
  };
856
940
  }, [courseId, request]);
857
941
 
942
+ useEffect(() => {
943
+ if (!showVideoTab && activeTab === 'videos') {
944
+ setActiveTab('estrutura');
945
+ }
946
+ }, [showVideoTab, activeTab]);
947
+
948
+ useEffect(() => {
949
+ if (!courseId) return;
950
+ let active = true;
951
+ void request<VideoProfileOption[]>({
952
+ url: `/lms/courses/${courseId}/video-resolution-profiles`,
953
+ method: 'GET',
954
+ })
955
+ .then((res) => {
956
+ if (!active) return;
957
+ setLinkedProfileIds((res.data ?? []).map((p) => p.id));
958
+ })
959
+ .catch(() => {
960
+ if (!active) return;
961
+ setLinkedProfileIds([]);
962
+ });
963
+ return () => {
964
+ active = false;
965
+ };
966
+ }, [courseId, request]);
967
+
858
968
  async function syncCourseResources(nextResources: CourseResourceItem[]) {
859
969
  setIsSavingResources(true);
860
970
  try {
@@ -1140,6 +1250,38 @@ export function EditorCourse() {
1140
1250
  setInstructorEditSheetOpen(true);
1141
1251
  }
1142
1252
 
1253
+ function handleEditVideoProfile(profileId: number) {
1254
+ const profile = allVideoProfiles.find((p) => p.id === profileId);
1255
+ if (!profile) return;
1256
+ setEditingVideoProfileId(profileId);
1257
+ setEditingVideoProfileName(profile.name);
1258
+ setEditingVideoProfileParams(profile.ffmpeg_params);
1259
+ setVideoProfileEditSheetOpen(true);
1260
+ }
1261
+
1262
+ async function handleSaveVideoProfileEdit() {
1263
+ if (!editingVideoProfileId) return;
1264
+ try {
1265
+ setSavingVideoProfileEdit(true);
1266
+ await request({
1267
+ url: `/lms/video-resolution-profiles/${editingVideoProfileId}`,
1268
+ method: 'PATCH',
1269
+ data: {
1270
+ name: editingVideoProfileName,
1271
+ ffmpeg_params: editingVideoProfileParams,
1272
+ },
1273
+ });
1274
+ await refetchVideoProfiles();
1275
+ setVideoProfileEditSheetOpen(false);
1276
+ setEditingVideoProfileId(null);
1277
+ toast.success('Perfil de vídeo atualizado com sucesso.');
1278
+ } catch {
1279
+ toast.error('Erro ao atualizar o perfil de vídeo.');
1280
+ } finally {
1281
+ setSavingVideoProfileEdit(false);
1282
+ }
1283
+ }
1284
+
1143
1285
  async function handleEditCategory(categorySlug: string) {
1144
1286
  const category = (categoryListData?.data ?? []).find(
1145
1287
  (item) => item.slug === categorySlug
@@ -1245,6 +1387,33 @@ export function EditorCourse() {
1245
1387
  }
1246
1388
 
1247
1389
  function onSubmit(data: CourseEditFormValues) {
1390
+ if (data.status === 'ativo') {
1391
+ const requiredProfileIds = new Set(linkedProfileIds);
1392
+ const invalidLesson = lessons.find((lesson) => {
1393
+ if (lesson.type !== 'video' || lesson.videoProvider !== 'file_storage') {
1394
+ return false;
1395
+ }
1396
+
1397
+ if (requiredProfileIds.size === 0) {
1398
+ return true;
1399
+ }
1400
+
1401
+ const resourceTypes = new Set(lesson.resources.map((res) => res.type));
1402
+ return [...requiredProfileIds].some(
1403
+ (profileId) => !resourceTypes.has(`video_profile:${profileId}`)
1404
+ );
1405
+ });
1406
+
1407
+ if (invalidLesson) {
1408
+ toast.error(
1409
+ linkedProfileIds.length === 0
1410
+ ? 'Configure ao menos um perfil de vídeo antes de publicar o curso.'
1411
+ : `A aula "${invalidLesson.title}" precisa de um vídeo para cada perfil antes da publicação.`
1412
+ );
1413
+ return;
1414
+ }
1415
+ }
1416
+
1248
1417
  saveCourse(data);
1249
1418
  }
1250
1419
 
@@ -1276,21 +1445,123 @@ export function EditorCourse() {
1276
1445
  {course.slug}
1277
1446
  </p>
1278
1447
  </div>
1279
- <Badge
1280
- variant={course.published ? 'default' : 'secondary'}
1281
- className="shrink-0 text-xs"
1282
- >
1283
- {course.published ? 'Publicado' : 'Rascunho'}
1284
- </Badge>
1448
+ <div className="flex min-w-0 max-w-[56%] flex-wrap justify-end gap-1 sm:max-w-none sm:flex-nowrap sm:items-center sm:gap-1.5 sm:shrink-0">
1449
+ <TooltipProvider>
1450
+ <Tooltip>
1451
+ <TooltipTrigger asChild>
1452
+ <Badge
1453
+ variant="outline"
1454
+ className={cn(
1455
+ 'h-5 gap-1 font-normal bg-sky-500/15 text-sky-700 dark:text-sky-400 border-sky-500/30 cursor-help',
1456
+ isMobile ? 'px-1 text-[0.6rem]' : 'px-1.5 text-[0.65rem]'
1457
+ )}
1458
+ >
1459
+ <Layers className="size-3" />
1460
+ {sessions.length}
1461
+ {!isMobile && (
1462
+ <> {sessions.length === 1 ? 'sessão' : 'sessões'}</>
1463
+ )}
1464
+ </Badge>
1465
+ </TooltipTrigger>
1466
+ <TooltipContent side="bottom">
1467
+ Total de sessões cadastradas neste curso.
1468
+ </TooltipContent>
1469
+ </Tooltip>
1470
+
1471
+ <Tooltip>
1472
+ <TooltipTrigger asChild>
1473
+ <Badge
1474
+ variant="outline"
1475
+ className={cn(
1476
+ 'h-5 gap-1 font-normal bg-violet-500/15 text-violet-700 dark:text-violet-400 border-violet-500/30 cursor-help',
1477
+ isMobile ? 'px-1 text-[0.6rem]' : 'px-1.5 text-[0.65rem]'
1478
+ )}
1479
+ >
1480
+ <Video className="size-3" />
1481
+ {lessons.length}
1482
+ {!isMobile && (
1483
+ <> {lessons.length === 1 ? 'aula' : 'aulas'}</>
1484
+ )}
1485
+ </Badge>
1486
+ </TooltipTrigger>
1487
+ <TooltipContent side="bottom">
1488
+ Quantidade total de aulas do curso.
1489
+ </TooltipContent>
1490
+ </Tooltip>
1491
+
1492
+ <Tooltip>
1493
+ <TooltipTrigger asChild>
1494
+ <Badge
1495
+ variant="outline"
1496
+ className={cn(
1497
+ 'h-5 gap-1 font-normal bg-amber-500/15 text-amber-700 dark:text-amber-400 border-amber-500/30 cursor-help',
1498
+ isMobile ? 'px-1 text-[0.6rem]' : 'px-1.5 text-[0.65rem]'
1499
+ )}
1500
+ >
1501
+ <Clock className="size-3" />
1502
+ {durationLabel}
1503
+ </Badge>
1504
+ </TooltipTrigger>
1505
+ <TooltipContent side="bottom">
1506
+ Duração somada de todas as aulas.
1507
+ </TooltipContent>
1508
+ </Tooltip>
1509
+
1510
+ <Tooltip>
1511
+ <TooltipTrigger asChild>
1512
+ <Badge
1513
+ className={cn(
1514
+ 'h-5 gap-1 font-normal bg-emerald-500/15 text-emerald-700 dark:text-emerald-400 border-0 hover:bg-emerald-500/20 cursor-help',
1515
+ isMobile ? 'px-1 text-[0.6rem]' : 'px-1.5 text-[0.65rem]'
1516
+ )}
1517
+ >
1518
+ <span className="size-1.5 rounded-full bg-emerald-500 inline-block" />
1519
+ {publishedCount}
1520
+ {!isMobile && ' publicadas'}
1521
+ </Badge>
1522
+ </TooltipTrigger>
1523
+ <TooltipContent side="bottom">
1524
+ Quantidade de aulas visiveis para os alunos.
1525
+ </TooltipContent>
1526
+ </Tooltip>
1527
+
1528
+ <Tooltip>
1529
+ <TooltipTrigger asChild>
1530
+ <Badge
1531
+ variant="outline"
1532
+ className={cn(
1533
+ 'text-xs border-0 cursor-help',
1534
+ course.published
1535
+ ? 'bg-cyan-500/15 text-cyan-700 dark:text-cyan-400 hover:bg-cyan-500/20'
1536
+ : 'bg-slate-500/15 text-slate-700 dark:text-slate-400 hover:bg-slate-500/20'
1537
+ )}
1538
+ >
1539
+ {course.published ? 'Publicado' : 'Rascunho'}
1540
+ </Badge>
1541
+ </TooltipTrigger>
1542
+ <TooltipContent side="bottom">
1543
+ Status atual do curso.
1544
+ </TooltipContent>
1545
+ </Tooltip>
1546
+ </TooltipProvider>
1547
+ </div>
1285
1548
  </div>
1286
1549
 
1287
1550
  {/* ── Tabs ─────────────────────────────────────────────────────────── */}
1288
1551
  <Tabs
1289
1552
  value={activeTab}
1290
1553
  onValueChange={setActiveTab}
1291
- className="flex flex-col flex-1 min-h-0"
1554
+ className="flex flex-1 min-h-0 min-w-0 flex-col"
1292
1555
  >
1293
- <TabsList className="mx-3 mt-3 h-auto shrink-0 grid w-[calc(100%-1.5rem)] grid-cols-6 rounded-lg bg-muted/80 p-1">
1556
+ <TabsList
1557
+ className={cn(
1558
+ 'mx-3 mt-3 h-auto shrink-0 w-[calc(100%-1.5rem)] rounded-lg bg-muted/80 p-1',
1559
+ 'flex items-center gap-1 overflow-x-auto overflow-y-hidden whitespace-nowrap',
1560
+ isMobile
1561
+ ? 'touch-pan-x snap-x snap-mandatory [scrollbar-width:thin] [&::-webkit-scrollbar]:h-1.5 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-muted-foreground/35'
1562
+ : '[scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden'
1563
+ )}
1564
+ >
1294
1565
  {(
1295
1566
  [
1296
1567
  'estrutura',
@@ -1298,13 +1569,19 @@ export function EditorCourse() {
1298
1569
  'midia',
1299
1570
  'recursos',
1300
1571
  'extra',
1572
+ ...(showVideoTab ? ['videos'] : []),
1301
1573
  'publicacao',
1302
1574
  ] as const
1303
1575
  ).map((tab) => (
1304
1576
  <TabsTrigger
1305
1577
  key={tab}
1306
1578
  value={tab}
1307
- className="h-8 px-2 text-xs font-medium"
1579
+ className={cn(
1580
+ 'shrink-0 font-medium',
1581
+ isMobile
1582
+ ? 'h-7 snap-start px-2.5 text-[11px]'
1583
+ : 'h-8 px-3 text-xs'
1584
+ )}
1308
1585
  >
1309
1586
  {tab === 'estrutura'
1310
1587
  ? t('structureEditor.tabs.structure')
@@ -1316,349 +1593,498 @@ export function EditorCourse() {
1316
1593
  ? t('structureEditor.tabs.resources')
1317
1594
  : tab === 'extra'
1318
1595
  ? t('structureEditor.tabs.extra')
1319
- : t('structureEditor.tabs.publish')}
1596
+ : tab === 'videos'
1597
+ ? t('structureEditor.tabs.videoProfiles')
1598
+ : t('structureEditor.tabs.publish')}
1320
1599
  </TabsTrigger>
1321
1600
  ))}
1322
1601
  </TabsList>
1323
1602
 
1324
- <div className="flex-1 min-h-0 overflow-hidden">
1325
- <ScrollArea className="h-full">
1326
- <div className="p-3 flex flex-col gap-3">
1327
- {/* ── Tab: Estrutura ──────────────────────────────────────── */}
1328
- <TabsContent
1329
- value="estrutura"
1330
- className="mt-0 flex flex-col gap-3"
1331
- >
1332
- {/* Stat chips */}
1333
- <Card className="bg-muted/20 py-2 gap-2">
1334
- <CardHeader className="px-3 pt-2 pb-0">
1335
- <CardTitle className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
1336
- Resumo do conteúdo
1337
- </CardTitle>
1338
- </CardHeader>
1339
- <CardContent className="px-3 pb-2">
1340
- <div className="grid grid-cols-2 gap-2">
1341
- <StatChip
1342
- icon={<Layers className="size-3 text-blue-500" />}
1343
- label="Sessões"
1344
- value={sessions.length}
1345
- />
1346
- <StatChip
1347
- icon={<Video className="size-3 text-violet-500" />}
1348
- label="Aulas"
1349
- value={lessons.length}
1350
- />
1351
- <StatChip
1352
- icon={<Clock className="size-3 text-amber-500" />}
1353
- label="Duração"
1354
- value={
1355
- hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`
1356
- }
1357
- />
1358
- <StatChip
1359
- icon={
1360
- <CheckCircle2 className="size-3 text-emerald-500" />
1361
- }
1362
- label="Publicadas"
1363
- value={publishedCount}
1364
- />
1365
- </div>
1366
- </CardContent>
1367
- </Card>
1368
-
1369
- {/* Dados principais */}
1370
- <Card className="bg-muted/20 py-2 gap-2">
1371
- <CardHeader className="px-3 pt-2 pb-0">
1372
- <CardTitle className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
1373
- Dados principais
1374
- </CardTitle>
1375
- </CardHeader>
1376
- <CardContent className="px-3 pb-2 flex flex-col gap-3">
1603
+ <div className="flex-1 min-h-0 min-w-0 overflow-x-hidden overflow-y-auto overscroll-contain">
1604
+ <div className="flex min-w-0 flex-col gap-3 p-2 sm:p-3">
1605
+ {/* ── Tab: Estrutura ──────────────────────────────────────── */}
1606
+ <TabsContent
1607
+ value="estrutura"
1608
+ className="mt-0 flex min-w-0 flex-col gap-3"
1609
+ >
1610
+ {/* Dados principais */}
1611
+ <Card className="bg-muted/20 py-2 gap-2">
1612
+ <CardHeader className="px-3 pt-2 pb-0">
1613
+ <CardTitle className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
1614
+ {t('structureEditor.structureTab.mainInfo.title')}
1615
+ </CardTitle>
1616
+ </CardHeader>
1617
+ <CardContent className="px-3 pb-2 flex flex-col gap-3">
1618
+ <FormField
1619
+ control={form.control}
1620
+ name="tituloComercial"
1621
+ render={({ field }) => (
1622
+ <FormItem>
1623
+ <FormLabel className="text-xs">
1624
+ {t(
1625
+ 'structureEditor.structureTab.mainInfo.fields.title'
1626
+ )}
1627
+ </FormLabel>
1628
+ <FormControl>
1629
+ <Input {...field} className="h-8 text-sm" />
1630
+ </FormControl>
1631
+ <FormMessage className="text-xs" />
1632
+ </FormItem>
1633
+ )}
1634
+ />
1635
+ <div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
1377
1636
  <FormField
1378
1637
  control={form.control}
1379
- name="tituloComercial"
1638
+ name="nomeInterno"
1380
1639
  render={({ field }) => (
1381
1640
  <FormItem>
1382
- <FormLabel className="text-xs">Título</FormLabel>
1641
+ <FormLabel className="text-xs">
1642
+ {t(
1643
+ 'structureEditor.structureTab.mainInfo.fields.internalName'
1644
+ )}
1645
+ </FormLabel>
1383
1646
  <FormControl>
1384
- <Input {...field} className="h-8 text-sm" />
1647
+ <Input {...field} className="h-8 text-xs" />
1385
1648
  </FormControl>
1386
1649
  <FormMessage className="text-xs" />
1387
1650
  </FormItem>
1388
1651
  )}
1389
1652
  />
1390
- <div className="grid grid-cols-2 gap-2">
1391
- <FormField
1392
- control={form.control}
1393
- name="nomeInterno"
1394
- render={({ field }) => (
1395
- <FormItem>
1396
- <FormLabel className="text-xs">
1397
- Nome Interno
1398
- </FormLabel>
1399
- <FormControl>
1400
- <Input {...field} className="h-8 text-xs" />
1401
- </FormControl>
1402
- <FormMessage className="text-xs" />
1403
- </FormItem>
1404
- )}
1405
- />
1406
- <FormField
1407
- control={form.control}
1408
- name="slug"
1409
- render={({ field }) => (
1410
- <FormItem>
1411
- <FormLabel className="text-xs">Slug</FormLabel>
1412
- <FormControl>
1413
- <Input
1414
- {...field}
1415
- className="h-8 text-xs font-mono"
1416
- onChange={(e) =>
1417
- field.onChange(e.target.value.toLowerCase())
1418
- }
1419
- />
1420
- </FormControl>
1421
- <FormMessage className="text-xs" />
1422
- </FormItem>
1423
- )}
1424
- />
1425
- </div>
1426
- </CardContent>
1427
- </Card>
1428
-
1429
- {/* Descrição */}
1430
- <Card className="bg-muted/20 py-2 gap-2">
1431
- <CardHeader className="px-3 pt-2 pb-0">
1432
- <CardTitle className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
1433
- Descrição
1434
- </CardTitle>
1435
- </CardHeader>
1436
- <CardContent className="px-3 pb-2">
1437
1653
  <FormField
1438
1654
  control={form.control}
1439
- name="descricaoPublica"
1655
+ name="slug"
1440
1656
  render={({ field }) => (
1441
1657
  <FormItem>
1658
+ <FormLabel className="text-xs">
1659
+ {t(
1660
+ 'structureEditor.structureTab.mainInfo.fields.slug'
1661
+ )}
1662
+ </FormLabel>
1442
1663
  <FormControl>
1443
- <RichTextEditor
1444
- value={field.value}
1445
- onChange={field.onChange}
1664
+ <Input
1665
+ {...field}
1666
+ className="h-8 text-xs font-mono"
1667
+ onChange={(e) =>
1668
+ field.onChange(e.target.value.toLowerCase())
1669
+ }
1446
1670
  />
1447
1671
  </FormControl>
1448
1672
  <FormMessage className="text-xs" />
1449
1673
  </FormItem>
1450
1674
  )}
1451
1675
  />
1452
- </CardContent>
1453
- </Card>
1454
- </TabsContent>
1455
-
1456
- {/* ── Tab: Sobre ──────────────────────────────────────────── */}
1457
- <TabsContent value="sobre" className="mt-0 flex flex-col gap-3">
1458
- <CourseClassificationCard
1459
- form={form}
1460
- t={t}
1461
- levels={NIVEIS}
1462
- statuses={STATUS_OPTIONS}
1463
- offeringTypes={OFFERING_TYPE_OPTIONS}
1464
- />
1465
- <CourseRelationsCard
1466
- form={form}
1467
- t={t}
1468
- categoryOptions={categoryOptions}
1469
- projectOptions={projectOptions}
1470
- instructorOptions={instructorOptions}
1471
- onCreateCategory={handleCreateCategory}
1472
- onCreateInstructor={handleCreateInstructor}
1473
- onEditCategory={handleEditCategory}
1474
- onEditInstructor={handleEditInstructor}
1475
- />
1476
- <CourseContentCard form={form} />
1477
- </TabsContent>
1478
-
1479
- {/* ── Tab: Mídia ──────────────────────────────────────────── */}
1480
- <TabsContent value="midia" className="mt-0">
1481
- <CourseMediaCard
1482
- logoPreview={logoPreview}
1483
- bannerPreview={bannerPreview}
1484
- uploadingLogo={uploadingLogo}
1485
- uploadingBanner={uploadingBanner}
1486
- onLogoSelect={(e: React.ChangeEvent<HTMLInputElement>) =>
1487
- handleFileSelect(e, setLogoPreview, 'logo')
1488
- }
1489
- onBannerSelect={(e: React.ChangeEvent<HTMLInputElement>) =>
1490
- handleFileSelect(e, setBannerPreview, 'banner')
1491
- }
1492
- logoFile={
1493
- apiCourse?.logoFileId
1494
- ? {
1495
- id: apiCourse.logoFileId,
1496
- name:
1497
- apiCourse.logoFilename ||
1498
- `#${apiCourse.logoFileId}`,
1676
+ </div>
1677
+ </CardContent>
1678
+ </Card>
1679
+
1680
+ {/* Descrição */}
1681
+ <Card className="bg-muted/20 py-2 gap-2">
1682
+ <CardHeader className="px-3 pt-2 pb-0">
1683
+ <CardTitle className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
1684
+ {t('structureEditor.structureTab.description.title')}
1685
+ </CardTitle>
1686
+ </CardHeader>
1687
+ <CardContent className="px-3 pb-2">
1688
+ <FormField
1689
+ control={form.control}
1690
+ name="descricaoPublica"
1691
+ render={({ field }) => (
1692
+ <FormItem>
1693
+ <FormControl>
1694
+ <RichTextEditor
1695
+ value={field.value}
1696
+ onChange={field.onChange}
1697
+ />
1698
+ </FormControl>
1699
+ <FormMessage className="text-xs" />
1700
+ </FormItem>
1701
+ )}
1702
+ />
1703
+ </CardContent>
1704
+ </Card>
1705
+ </TabsContent>
1706
+
1707
+ {/* ── Tab: Sobre ──────────────────────────────────────────── */}
1708
+ <TabsContent
1709
+ value="sobre"
1710
+ className="mt-0 flex min-w-0 flex-col gap-3"
1711
+ >
1712
+ <CourseClassificationCard
1713
+ form={form}
1714
+ t={t}
1715
+ levels={NIVEIS}
1716
+ statuses={STATUS_OPTIONS}
1717
+ offeringTypes={OFFERING_TYPE_OPTIONS}
1718
+ />
1719
+ <CourseRelationsCard
1720
+ form={form}
1721
+ t={t}
1722
+ categoryOptions={categoryOptions}
1723
+ projectOptions={projectOptions}
1724
+ instructorOptions={instructorOptions}
1725
+ onCreateCategory={handleCreateCategory}
1726
+ onCreateInstructor={handleCreateInstructor}
1727
+ onEditCategory={handleEditCategory}
1728
+ onEditInstructor={handleEditInstructor}
1729
+ />
1730
+ <CourseContentCard form={form} />
1731
+ </TabsContent>
1732
+
1733
+ {/* ── Tab: Mídia ──────────────────────────────────────────── */}
1734
+ <TabsContent value="midia" className="mt-0 min-w-0">
1735
+ <CourseMediaCard
1736
+ logoPreview={logoPreview}
1737
+ bannerPreview={bannerPreview}
1738
+ uploadingLogo={uploadingLogo}
1739
+ uploadingBanner={uploadingBanner}
1740
+ onLogoSelect={(e: React.ChangeEvent<HTMLInputElement>) =>
1741
+ handleFileSelect(e, setLogoPreview, 'logo')
1742
+ }
1743
+ onBannerSelect={(e: React.ChangeEvent<HTMLInputElement>) =>
1744
+ handleFileSelect(e, setBannerPreview, 'banner')
1745
+ }
1746
+ logoFile={
1747
+ apiCourse?.logoFileId
1748
+ ? {
1749
+ id: apiCourse.logoFileId,
1750
+ name:
1751
+ apiCourse.logoFilename ||
1752
+ `#${apiCourse.logoFileId}`,
1753
+ }
1754
+ : undefined
1755
+ }
1756
+ bannerFile={
1757
+ apiCourse?.bannerFileId
1758
+ ? {
1759
+ id: apiCourse.bannerFileId,
1760
+ name:
1761
+ apiCourse.bannerFilename ||
1762
+ `#${apiCourse.bannerFileId}`,
1763
+ }
1764
+ : undefined
1765
+ }
1766
+ onOpenLogoFile={() => openUploadedFile(apiCourse?.logoFileId)}
1767
+ onOpenBannerFile={() =>
1768
+ openUploadedFile(apiCourse?.bannerFileId)
1769
+ }
1770
+ onRemoveLogoFile={
1771
+ apiCourse?.logoFileId
1772
+ ? () => handleRemoveFile('logo')
1773
+ : undefined
1774
+ }
1775
+ onRemoveBannerFile={
1776
+ apiCourse?.bannerFileId
1777
+ ? () => handleRemoveFile('banner')
1778
+ : undefined
1779
+ }
1780
+ logoImageType={apiCourse?.logoImageType}
1781
+ bannerImageType={apiCourse?.bannerImageType}
1782
+ t={t}
1783
+ />
1784
+ </TabsContent>
1785
+
1786
+ {/* ── Tab: Recursos ─────────────────────────────────────── */}
1787
+ <TabsContent
1788
+ value="recursos"
1789
+ className="mt-0 flex min-w-0 flex-col gap-3"
1790
+ >
1791
+ <Card className="bg-muted/20 py-2 gap-2">
1792
+ <CardHeader className="px-3 pt-2 pb-0">
1793
+ <CardTitle className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
1794
+ {t('structureEditor.resources.title')}
1795
+ </CardTitle>
1796
+ </CardHeader>
1797
+ <CardContent className="px-3 pb-2 flex flex-col gap-3">
1798
+ <div
1799
+ role="button"
1800
+ tabIndex={0}
1801
+ aria-label={t(
1802
+ 'structureEditor.resources.dropzoneAriaLabel'
1803
+ )}
1804
+ aria-disabled={isUploadingResources || isSavingResources}
1805
+ onDragOver={(event) => {
1806
+ event.preventDefault();
1807
+ if (!isUploadingResources && !isSavingResources)
1808
+ setResourcesDragOver(true);
1809
+ }}
1810
+ onDragLeave={() => setResourcesDragOver(false)}
1811
+ onDrop={(event) => {
1812
+ event.preventDefault();
1813
+ setResourcesDragOver(false);
1814
+ if (!isUploadingResources && !isSavingResources) {
1815
+ void handleCourseResourceFiles(
1816
+ Array.from(event.dataTransfer.files)
1817
+ );
1818
+ }
1819
+ }}
1820
+ onClick={() => {
1821
+ if (!isUploadingResources && !isSavingResources) {
1822
+ resourcesInputRef.current?.click();
1823
+ }
1824
+ }}
1825
+ onKeyDown={(event) => {
1826
+ if (event.key === 'Enter' || event.key === ' ') {
1827
+ event.preventDefault();
1828
+ if (!isUploadingResources && !isSavingResources) {
1829
+ resourcesInputRef.current?.click();
1499
1830
  }
1500
- : undefined
1501
- }
1502
- bannerFile={
1503
- apiCourse?.bannerFileId
1504
- ? {
1505
- id: apiCourse.bannerFileId,
1506
- name:
1507
- apiCourse.bannerFilename ||
1508
- `#${apiCourse.bannerFileId}`,
1831
+ }
1832
+ }}
1833
+ className={cn(
1834
+ 'flex flex-col items-center justify-center gap-2 py-7 rounded-lg border-2 border-dashed transition-colors select-none',
1835
+ isUploadingResources || isSavingResources
1836
+ ? 'cursor-wait border-border opacity-60'
1837
+ : 'cursor-pointer',
1838
+ !isUploadingResources &&
1839
+ !isSavingResources &&
1840
+ resourcesDragOver
1841
+ ? 'border-primary/70 bg-primary/5'
1842
+ : !isUploadingResources && !isSavingResources
1843
+ ? 'border-border hover:border-primary/40 hover:bg-muted/30'
1844
+ : ''
1845
+ )}
1846
+ >
1847
+ {isUploadingResources || isSavingResources ? (
1848
+ <Loader2 className="size-7 animate-spin text-muted-foreground/50" />
1849
+ ) : (
1850
+ <UploadCloud
1851
+ className={cn(
1852
+ 'size-7 transition-colors',
1853
+ resourcesDragOver
1854
+ ? 'text-primary'
1855
+ : 'text-muted-foreground/50'
1856
+ )}
1857
+ />
1858
+ )}
1859
+ <div className="text-center">
1860
+ <p className="text-xs font-medium">
1861
+ {isUploadingResources
1862
+ ? t('structureEditor.resources.uploading')
1863
+ : isSavingResources
1864
+ ? t('structureEditor.resources.saving')
1865
+ : t('structureEditor.resources.idle')}
1866
+ </p>
1867
+ {!isUploadingResources && !isSavingResources && (
1868
+ <p className="text-xs text-muted-foreground">
1869
+ {t('structureEditor.resources.helper')}
1870
+ </p>
1871
+ )}
1872
+ </div>
1873
+ <input
1874
+ ref={resourcesInputRef}
1875
+ type="file"
1876
+ multiple
1877
+ className="hidden"
1878
+ onChange={(event) => {
1879
+ if (event.target.files) {
1880
+ void handleCourseResourceFiles(
1881
+ Array.from(event.target.files)
1882
+ );
1883
+ event.target.value = '';
1509
1884
  }
1510
- : undefined
1511
- }
1512
- onOpenLogoFile={() =>
1513
- openUploadedFile(apiCourse?.logoFileId)
1514
- }
1515
- onOpenBannerFile={() =>
1516
- openUploadedFile(apiCourse?.bannerFileId)
1517
- }
1518
- onRemoveLogoFile={
1519
- apiCourse?.logoFileId
1520
- ? () => handleRemoveFile('logo')
1521
- : undefined
1522
- }
1523
- onRemoveBannerFile={
1524
- apiCourse?.bannerFileId
1525
- ? () => handleRemoveFile('banner')
1526
- : undefined
1527
- }
1528
- logoImageType={apiCourse?.logoImageType}
1529
- bannerImageType={apiCourse?.bannerImageType}
1530
- t={t}
1531
- />
1532
- </TabsContent>
1885
+ }}
1886
+ />
1887
+ </div>
1533
1888
 
1534
- {/* ── Tab: Recursos ─────────────────────────────────────── */}
1889
+ {courseResources.length === 0 ? (
1890
+ <p className="text-center text-xs text-muted-foreground py-1">
1891
+ {t('structureEditor.resources.empty')}
1892
+ </p>
1893
+ ) : (
1894
+ <div className="flex flex-col gap-1">
1895
+ {courseResources.map((item) => {
1896
+ const ItemIcon = getResourceIcon(item.type);
1897
+ return (
1898
+ <div
1899
+ key={item.id}
1900
+ className="flex items-center gap-2 rounded-md border bg-muted/20 px-2.5 py-2"
1901
+ >
1902
+ <ItemIcon
1903
+ className={cn(
1904
+ 'size-3.5 shrink-0',
1905
+ getResourceIconColor(item.type)
1906
+ )}
1907
+ />
1908
+ <div className="flex-1 min-w-0">
1909
+ <p className="text-xs truncate font-medium">
1910
+ {item.name}
1911
+ </p>
1912
+ <p className="text-[0.65rem] text-muted-foreground">
1913
+ {item.size ||
1914
+ t('structureEditor.resources.attachedFile')}
1915
+ </p>
1916
+ </div>
1917
+ <Button
1918
+ type="button"
1919
+ variant="ghost"
1920
+ size="icon"
1921
+ className="size-6 shrink-0 text-muted-foreground hover:text-foreground"
1922
+ onClick={() =>
1923
+ void handleDownloadCourseResource(item)
1924
+ }
1925
+ aria-label={t(
1926
+ 'structureEditor.resources.downloadAria',
1927
+ { name: item.name }
1928
+ )}
1929
+ >
1930
+ <Download className="size-3" />
1931
+ </Button>
1932
+ <Button
1933
+ type="button"
1934
+ variant="ghost"
1935
+ size="icon"
1936
+ className="size-6 shrink-0 text-muted-foreground hover:text-destructive"
1937
+ onClick={() =>
1938
+ void handleRemoveCourseResource(item)
1939
+ }
1940
+ aria-label={t(
1941
+ 'structureEditor.resources.removeAria',
1942
+ { name: item.name }
1943
+ )}
1944
+ >
1945
+ <X className="size-3" />
1946
+ </Button>
1947
+ </div>
1948
+ );
1949
+ })}
1950
+ </div>
1951
+ )}
1952
+ </CardContent>
1953
+ </Card>
1954
+ </TabsContent>
1955
+
1956
+ {/* ── Tab: Extra ──────────────────────────────────────────── */}
1957
+ <TabsContent
1958
+ value="extra"
1959
+ className="mt-0 flex min-w-0 flex-col gap-3"
1960
+ >
1961
+ <CourseCertificateCard
1962
+ form={form}
1963
+ t={t}
1964
+ options={certificateOptions}
1965
+ onCreateTemplate={handleCreateCertificateTemplate}
1966
+ />
1967
+ <CourseFlagsCard form={form} t={t} />
1968
+ <CourseDangerZoneCard
1969
+ t={t}
1970
+ onDelete={() => setDeleteDialogOpen(true)}
1971
+ />
1972
+ </TabsContent>
1973
+
1974
+ {/* ── Tab: Vídeos (on_demand / blended only) ─────────────── */}
1975
+ {showVideoTab && (
1535
1976
  <TabsContent
1536
- value="recursos"
1537
- className="mt-0 flex flex-col gap-3"
1977
+ value="videos"
1978
+ className="mt-0 flex min-w-0 flex-col gap-3"
1538
1979
  >
1539
1980
  <Card className="bg-muted/20 py-2 gap-2">
1540
1981
  <CardHeader className="px-3 pt-2 pb-0">
1541
1982
  <CardTitle className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
1542
- Recursos para download do curso
1983
+ {t('structureEditor.videoProfiles.title')}
1543
1984
  </CardTitle>
1544
1985
  </CardHeader>
1545
- <CardContent className="px-3 pb-2 flex flex-col gap-3">
1546
- <div
1547
- role="button"
1548
- tabIndex={0}
1549
- aria-label="Soltar arquivo ou clicar para selecionar"
1550
- aria-disabled={
1551
- isUploadingResources || isSavingResources
1552
- }
1553
- onDragOver={(event) => {
1554
- event.preventDefault();
1555
- if (!isUploadingResources && !isSavingResources)
1556
- setResourcesDragOver(true);
1557
- }}
1558
- onDragLeave={() => setResourcesDragOver(false)}
1559
- onDrop={(event) => {
1560
- event.preventDefault();
1561
- setResourcesDragOver(false);
1562
- if (!isUploadingResources && !isSavingResources) {
1563
- void handleCourseResourceFiles(
1564
- Array.from(event.dataTransfer.files)
1986
+ <CardContent className="px-3 pb-2 flex flex-col gap-2">
1987
+ <EntityPicker
1988
+ key={videoProfilePickerResetKey}
1989
+ placeholder={t(
1990
+ 'structureEditor.videoProfiles.placeholder'
1991
+ )}
1992
+ options={availableVideoProfiles}
1993
+ getOptionValue={(opt) => opt.id}
1994
+ getOptionLabel={(opt) => opt.name}
1995
+ onChange={(_val, opt) => {
1996
+ if (opt && !linkedProfileIds.includes(opt.id)) {
1997
+ setLinkedProfileIds((prev) => [...prev, opt.id]);
1998
+ setVideoProfilePickerResetKey(
1999
+ (current) => current + 1
1565
2000
  );
1566
2001
  }
1567
2002
  }}
1568
- onClick={() => {
1569
- if (!isUploadingResources && !isSavingResources) {
1570
- resourcesInputRef.current?.click();
1571
- }
1572
- }}
1573
- onKeyDown={(event) => {
1574
- if (event.key === 'Enter' || event.key === ' ') {
1575
- event.preventDefault();
1576
- if (!isUploadingResources && !isSavingResources) {
1577
- resourcesInputRef.current?.click();
1578
- }
2003
+ showCreateButton
2004
+ createTitle={t(
2005
+ 'structureEditor.videoProfiles.createTitle'
2006
+ )}
2007
+ createFields={[
2008
+ {
2009
+ name: 'name',
2010
+ label: t(
2011
+ 'structureEditor.videoProfiles.createFields.name.label'
2012
+ ),
2013
+ placeholder: t(
2014
+ 'structureEditor.videoProfiles.createFields.name.placeholder'
2015
+ ),
2016
+ required: true,
2017
+ },
2018
+ {
2019
+ name: 'ffmpeg_params',
2020
+ label: t(
2021
+ 'structureEditor.videoProfiles.createFields.ffmpegParams.label'
2022
+ ),
2023
+ placeholder: t(
2024
+ 'structureEditor.videoProfiles.createFields.ffmpegParams.placeholder'
2025
+ ),
2026
+ required: true,
2027
+ },
2028
+ ]}
2029
+ onCreate={async (values) => {
2030
+ try {
2031
+ const response = await request<VideoProfileOption>({
2032
+ url: '/lms/video-resolution-profiles',
2033
+ method: 'POST',
2034
+ data: {
2035
+ name: values.name,
2036
+ ffmpeg_params: values.ffmpeg_params,
2037
+ },
2038
+ });
2039
+ await refetchVideoProfiles();
2040
+ return response.data;
2041
+ } catch {
2042
+ toast.error(
2043
+ t('structureEditor.videoProfiles.createError')
2044
+ );
2045
+ return null;
1579
2046
  }
1580
2047
  }}
1581
- className={cn(
1582
- 'flex flex-col items-center justify-center gap-2 py-7 rounded-lg border-2 border-dashed transition-colors select-none',
1583
- isUploadingResources || isSavingResources
1584
- ? 'cursor-wait border-border opacity-60'
1585
- : 'cursor-pointer',
1586
- !isUploadingResources &&
1587
- !isSavingResources &&
1588
- resourcesDragOver
1589
- ? 'border-primary/70 bg-primary/5'
1590
- : !isUploadingResources && !isSavingResources
1591
- ? 'border-border hover:border-primary/40 hover:bg-muted/30'
1592
- : ''
1593
- )}
1594
- >
1595
- {isUploadingResources || isSavingResources ? (
1596
- <Loader2 className="size-7 animate-spin text-muted-foreground/50" />
1597
- ) : (
1598
- <UploadCloud
1599
- className={cn(
1600
- 'size-7 transition-colors',
1601
- resourcesDragOver
1602
- ? 'text-primary'
1603
- : 'text-muted-foreground/50'
1604
- )}
1605
- />
1606
- )}
1607
- <div className="text-center">
1608
- <p className="text-xs font-medium">
1609
- {isUploadingResources
1610
- ? 'Enviando arquivos...'
1611
- : isSavingResources
1612
- ? 'Salvando recursos...'
1613
- : 'Arraste arquivos para adicionar'}
1614
- </p>
1615
- {!isUploadingResources && !isSavingResources && (
1616
- <p className="text-xs text-muted-foreground">
1617
- Clique para selecionar arquivos
1618
- </p>
1619
- )}
1620
- </div>
1621
- <input
1622
- ref={resourcesInputRef}
1623
- type="file"
1624
- multiple
1625
- className="hidden"
1626
- onChange={(event) => {
1627
- if (event.target.files) {
1628
- void handleCourseResourceFiles(
1629
- Array.from(event.target.files)
1630
- );
1631
- event.target.value = '';
1632
- }
1633
- }}
1634
- />
1635
- </div>
2048
+ />
1636
2049
 
1637
- {courseResources.length === 0 ? (
2050
+ {isFetchingVideoProfiles ? (
2051
+ <div className="flex flex-col gap-1">
2052
+ {Array.from({ length: 3 }).map((_, index) => (
2053
+ <div
2054
+ key={`video-profile-skeleton-${index}`}
2055
+ className="flex items-center gap-2 rounded-md border bg-muted/20 px-2.5 py-2"
2056
+ >
2057
+ <Skeleton className="size-3.5 rounded-full shrink-0" />
2058
+ <Skeleton className="h-3 w-full max-w-56" />
2059
+ </div>
2060
+ ))}
2061
+ </div>
2062
+ ) : linkedProfileIds.length === 0 ? (
1638
2063
  <p className="text-center text-xs text-muted-foreground py-1">
1639
- Nenhum recurso de curso cadastrado ainda.
2064
+ {t('structureEditor.videoProfiles.empty')}
1640
2065
  </p>
1641
2066
  ) : (
1642
2067
  <div className="flex flex-col gap-1">
1643
- {courseResources.map((item) => {
1644
- const ItemIcon = getResourceIcon(item.type);
2068
+ {linkedProfileIds.map((profileId) => {
2069
+ const profile = allVideoProfiles.find(
2070
+ (p) => p.id === profileId
2071
+ );
1645
2072
  return (
1646
2073
  <div
1647
- key={item.id}
2074
+ key={profileId}
1648
2075
  className="flex items-center gap-2 rounded-md border bg-muted/20 px-2.5 py-2"
1649
2076
  >
1650
- <ItemIcon
1651
- className={cn(
1652
- 'size-3.5 shrink-0',
1653
- getResourceIconColor(item.type)
1654
- )}
1655
- />
2077
+ <Video className="size-3.5 shrink-0 text-violet-500" />
1656
2078
  <div className="flex-1 min-w-0">
1657
- <p className="text-xs truncate font-medium">
1658
- {item.name}
1659
- </p>
1660
- <p className="text-[0.65rem] text-muted-foreground">
1661
- {item.size || 'Arquivo anexado'}
2079
+ <p className="text-xs font-medium truncate">
2080
+ {profile?.name?.trim()
2081
+ ? profile.name
2082
+ : t(
2083
+ 'structureEditor.videoProfiles.fallbackName',
2084
+ {
2085
+ id: profileId,
2086
+ }
2087
+ )}
1662
2088
  </p>
1663
2089
  </div>
1664
2090
  <Button
@@ -1667,11 +2093,13 @@ export function EditorCourse() {
1667
2093
  size="icon"
1668
2094
  className="size-6 shrink-0 text-muted-foreground hover:text-foreground"
1669
2095
  onClick={() =>
1670
- void handleDownloadCourseResource(item)
2096
+ handleEditVideoProfile(profileId)
1671
2097
  }
1672
- aria-label={`Baixar ${item.name}`}
2098
+ aria-label={t(
2099
+ 'structureEditor.videoProfiles.editAria'
2100
+ )}
1673
2101
  >
1674
- <Download className="size-3" />
2102
+ <Pencil className="size-3" />
1675
2103
  </Button>
1676
2104
  <Button
1677
2105
  type="button"
@@ -1679,9 +2107,13 @@ export function EditorCourse() {
1679
2107
  size="icon"
1680
2108
  className="size-6 shrink-0 text-muted-foreground hover:text-destructive"
1681
2109
  onClick={() =>
1682
- void handleRemoveCourseResource(item)
2110
+ setLinkedProfileIds((prev) =>
2111
+ prev.filter((id) => id !== profileId)
2112
+ )
1683
2113
  }
1684
- aria-label={`Remover ${item.name}`}
2114
+ aria-label={t(
2115
+ 'structureEditor.videoProfiles.removeAria'
2116
+ )}
1685
2117
  >
1686
2118
  <X className="size-3" />
1687
2119
  </Button>
@@ -1693,98 +2125,79 @@ export function EditorCourse() {
1693
2125
  </CardContent>
1694
2126
  </Card>
1695
2127
  </TabsContent>
2128
+ )}
1696
2129
 
1697
- {/* ── Tab: Extra ──────────────────────────────────────────── */}
1698
- <TabsContent value="extra" className="mt-0 flex flex-col gap-3">
1699
- <CourseCertificateCard
1700
- form={form}
1701
- t={t}
1702
- options={certificateOptions}
1703
- onCreateTemplate={handleCreateCertificateTemplate}
1704
- />
1705
- <CourseFlagsCard form={form} t={t} />
1706
- <CourseDangerZoneCard
1707
- t={t}
1708
- onDelete={() => setDeleteDialogOpen(true)}
1709
- />
1710
- </TabsContent>
1711
-
1712
- {/* ── Tab: Publicação ─────────────────────────────────────── */}
1713
- <TabsContent
1714
- value="publicacao"
1715
- className="mt-0 flex flex-col gap-3"
1716
- >
1717
- <Card className="bg-muted/20 py-2 gap-2">
1718
- <CardHeader className="px-3 pt-2 pb-0">
1719
- <CardTitle className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
1720
- {t('structureEditor.publishChecklist.title')}
1721
- </CardTitle>
1722
- </CardHeader>
1723
- <CardContent className="px-3 pb-2 flex flex-col gap-2">
1724
- {checklistItems.map((item) => (
1725
- <div
1726
- key={item.id}
1727
- className="flex items-center gap-2 rounded-md border bg-background/80 px-2.5 py-2"
1728
- >
1729
- {item.done ? (
1730
- <CheckCircle2 className="size-4 shrink-0 text-emerald-500" />
1731
- ) : (
1732
- <AlertTriangle className="size-4 shrink-0 text-amber-500" />
1733
- )}
1734
- <div className="flex-1 min-w-0">
1735
- <p className="text-xs font-medium truncate">
1736
- {item.label}
2130
+ {/* ── Tab: Publicação ─────────────────────────────────────── */}
2131
+ <TabsContent
2132
+ value="publicacao"
2133
+ className="mt-0 flex min-w-0 flex-col gap-3"
2134
+ >
2135
+ <Card className="bg-muted/20 py-2 gap-2">
2136
+ <CardHeader className="px-3 pt-2 pb-0">
2137
+ <CardTitle className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
2138
+ {t('structureEditor.publishChecklist.title')}
2139
+ </CardTitle>
2140
+ </CardHeader>
2141
+ <CardContent className="px-3 pb-2 flex flex-col gap-2">
2142
+ {checklistItems.map((item) => (
2143
+ <div
2144
+ key={item.id}
2145
+ className="flex items-center gap-2 rounded-md border bg-background/80 px-2.5 py-2"
2146
+ >
2147
+ {item.done ? (
2148
+ <CheckCircle2 className="size-4 shrink-0 text-emerald-500" />
2149
+ ) : (
2150
+ <AlertTriangle className="size-4 shrink-0 text-amber-500" />
2151
+ )}
2152
+ <div className="flex-1 min-w-0">
2153
+ <p className="text-xs font-medium truncate">
2154
+ {item.label}
2155
+ </p>
2156
+ {!item.done && (
2157
+ <p className="text-[0.65rem] text-muted-foreground">
2158
+ {item.required
2159
+ ? t('structureEditor.publishChecklist.required')
2160
+ : t(
2161
+ 'structureEditor.publishChecklist.recommended'
2162
+ )}
1737
2163
  </p>
1738
- {!item.done && (
1739
- <p className="text-[0.65rem] text-muted-foreground">
1740
- {item.required
1741
- ? t(
1742
- 'structureEditor.publishChecklist.required'
1743
- )
1744
- : t(
1745
- 'structureEditor.publishChecklist.recommended'
1746
- )}
1747
- </p>
1748
- )}
1749
- </div>
1750
- <Badge
1751
- variant={item.done ? 'default' : 'secondary'}
1752
- className="text-[0.65rem]"
1753
- >
1754
- {item.done
1755
- ? t('structureEditor.publishChecklist.done')
1756
- : t('structureEditor.publishChecklist.missing')}
1757
- </Badge>
2164
+ )}
1758
2165
  </div>
1759
- ))}
1760
- </CardContent>
1761
- </Card>
1762
-
1763
- <Card className="bg-muted/20 py-2 gap-2">
1764
- <CardHeader className="px-3 pt-2 pb-0">
1765
- <CardTitle className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
1766
- {t('structureEditor.publishChecklist.statusTitle')}
1767
- </CardTitle>
1768
- </CardHeader>
1769
- <CardContent className="px-3 pb-2 flex items-center justify-between gap-2">
1770
- <p className="text-xs text-muted-foreground">
1771
- {t('structureEditor.publishChecklist.statusProgress', {
1772
- completed: completedRequired,
1773
- total: requiredChecklist.length,
1774
- })}
1775
- </p>
1776
- <Badge
1777
- variant={isReadyToPublish ? 'default' : 'secondary'}
1778
- >
1779
- {isReadyToPublish
1780
- ? t('structureEditor.publishChecklist.ready')
1781
- : t('structureEditor.publishChecklist.notReady')}
1782
- </Badge>
1783
- </CardContent>
1784
- </Card>
1785
- </TabsContent>
1786
- </div>
1787
- </ScrollArea>
2166
+ <Badge
2167
+ variant={item.done ? 'default' : 'secondary'}
2168
+ className="text-[0.65rem]"
2169
+ >
2170
+ {item.done
2171
+ ? t('structureEditor.publishChecklist.done')
2172
+ : t('structureEditor.publishChecklist.missing')}
2173
+ </Badge>
2174
+ </div>
2175
+ ))}
2176
+ </CardContent>
2177
+ </Card>
2178
+
2179
+ <Card className="bg-muted/20 py-2 gap-2">
2180
+ <CardHeader className="px-3 pt-2 pb-0">
2181
+ <CardTitle className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
2182
+ {t('structureEditor.publishChecklist.statusTitle')}
2183
+ </CardTitle>
2184
+ </CardHeader>
2185
+ <CardContent className="px-3 pb-2 flex items-center justify-between gap-2">
2186
+ <p className="text-xs text-muted-foreground">
2187
+ {t('structureEditor.publishChecklist.statusProgress', {
2188
+ completed: completedRequired,
2189
+ total: requiredChecklist.length,
2190
+ })}
2191
+ </p>
2192
+ <Badge variant={isReadyToPublish ? 'default' : 'secondary'}>
2193
+ {isReadyToPublish
2194
+ ? t('structureEditor.publishChecklist.ready')
2195
+ : t('structureEditor.publishChecklist.notReady')}
2196
+ </Badge>
2197
+ </CardContent>
2198
+ </Card>
2199
+ </TabsContent>
2200
+ </div>
1788
2201
  </div>
1789
2202
  </Tabs>
1790
2203
 
@@ -1801,7 +2214,7 @@ export function EditorCourse() {
1801
2214
  onClick={() => form.reset()}
1802
2215
  >
1803
2216
  <Undo2 className="size-3 mr-1" />
1804
- Cancelar
2217
+ {t('structureEditor.footer.cancel')}
1805
2218
  </Button>
1806
2219
  <div className="flex-1" />
1807
2220
  <Button
@@ -1813,7 +2226,7 @@ export function EditorCourse() {
1813
2226
  onClick={() => createSessionMutation.mutate()}
1814
2227
  >
1815
2228
  <Plus className="size-3 mr-1" />
1816
- Nova sessão
2229
+ {t('structureEditor.footer.newSession')}
1817
2230
  </Button>
1818
2231
  <Button
1819
2232
  type="submit"
@@ -1826,7 +2239,7 @@ export function EditorCourse() {
1826
2239
  ) : (
1827
2240
  <Save className="size-3 mr-1" />
1828
2241
  )}
1829
- Salvar
2242
+ {t('structureEditor.footer.save')}
1830
2243
  </Button>
1831
2244
  </div>
1832
2245
  </div>
@@ -1859,6 +2272,68 @@ export function EditorCourse() {
1859
2272
  }}
1860
2273
  />
1861
2274
 
2275
+ <Sheet
2276
+ open={videoProfileEditSheetOpen}
2277
+ onOpenChange={(open) => {
2278
+ setVideoProfileEditSheetOpen(open);
2279
+ if (!open) setEditingVideoProfileId(null);
2280
+ }}
2281
+ >
2282
+ <ResizableSheetContent
2283
+ sheetId="lms-course-structure-video-profile-edit-sheet"
2284
+ defaultWidth={560}
2285
+ minWidth={420}
2286
+ maxWidth={920}
2287
+ className="sm:max-w-lg"
2288
+ >
2289
+ <SheetHeader>
2290
+ <SheetTitle>
2291
+ {t('structureEditor.videoProfiles.sheet.title')}
2292
+ </SheetTitle>
2293
+ <SheetDescription>
2294
+ {t('structureEditor.videoProfiles.sheet.description')}
2295
+ </SheetDescription>
2296
+ </SheetHeader>
2297
+ <div className="space-y-3 px-4 pb-4 pt-2">
2298
+ <div className="space-y-1.5">
2299
+ <FormLabel>
2300
+ {t('structureEditor.videoProfiles.createFields.name.label')}
2301
+ </FormLabel>
2302
+ <Input
2303
+ value={editingVideoProfileName}
2304
+ onChange={(e) => setEditingVideoProfileName(e.target.value)}
2305
+ placeholder={t(
2306
+ 'structureEditor.videoProfiles.createFields.name.placeholder'
2307
+ )}
2308
+ />
2309
+ </div>
2310
+ <FfmpegParamsEditor
2311
+ value={editingVideoProfileParams}
2312
+ onChange={setEditingVideoProfileParams}
2313
+ />
2314
+ <div className="flex justify-end gap-2 pt-2">
2315
+ <Button
2316
+ type="button"
2317
+ variant="outline"
2318
+ onClick={() => setVideoProfileEditSheetOpen(false)}
2319
+ disabled={savingVideoProfileEdit}
2320
+ >
2321
+ {t('structureEditor.videoProfiles.sheet.actions.cancel')}
2322
+ </Button>
2323
+ <Button
2324
+ type="button"
2325
+ disabled={savingVideoProfileEdit}
2326
+ onClick={() => void handleSaveVideoProfileEdit()}
2327
+ >
2328
+ {savingVideoProfileEdit
2329
+ ? t('structureEditor.videoProfiles.sheet.actions.saving')
2330
+ : t('structureEditor.videoProfiles.sheet.actions.save')}
2331
+ </Button>
2332
+ </div>
2333
+ </div>
2334
+ </ResizableSheetContent>
2335
+ </Sheet>
2336
+
1862
2337
  <Sheet
1863
2338
  open={categoryEditSheetOpen}
1864
2339
  onOpenChange={(open) => {
@@ -1875,7 +2350,13 @@ export function EditorCourse() {
1875
2350
  }
1876
2351
  }}
1877
2352
  >
1878
- <SheetContent className="sm:max-w-lg">
2353
+ <ResizableSheetContent
2354
+ sheetId="lms-course-structure-category-edit-sheet"
2355
+ defaultWidth={560}
2356
+ minWidth={420}
2357
+ maxWidth={920}
2358
+ className="sm:max-w-lg"
2359
+ >
1879
2360
  <SheetHeader>
1880
2361
  <SheetTitle>Editar categoria</SheetTitle>
1881
2362
  <SheetDescription>
@@ -1925,7 +2406,7 @@ export function EditorCourse() {
1925
2406
  Salvar
1926
2407
  </Button>
1927
2408
  </SheetFooter>
1928
- </SheetContent>
2409
+ </ResizableSheetContent>
1929
2410
  </Sheet>
1930
2411
 
1931
2412
  {/* ── Delete dialog ────────────────────────────────────────────────── */}