@hed-hog/lms 0.0.350 → 0.0.353
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/certificate/certificate.controller.d.ts +2 -2
- package/dist/certificate/certificate.controller.d.ts.map +1 -1
- package/dist/certificate/certificate.controller.js +8 -6
- package/dist/certificate/certificate.controller.js.map +1 -1
- package/dist/certificate/certificate.service.d.ts +5 -2
- package/dist/certificate/certificate.service.d.ts.map +1 -1
- package/dist/certificate/certificate.service.js +70 -6
- package/dist/certificate/certificate.service.js.map +1 -1
- package/dist/course/course-structure.controller.d.ts +24 -10
- package/dist/course/course-structure.controller.d.ts.map +1 -1
- package/dist/course/course-structure.controller.js +23 -2
- package/dist/course/course-structure.controller.js.map +1 -1
- package/dist/course/course-structure.service.d.ts +16 -8
- package/dist/course/course-structure.service.d.ts.map +1 -1
- package/dist/course/course-structure.service.js +61 -30
- package/dist/course/course-structure.service.js.map +1 -1
- package/dist/course/course-video-conversion.service.d.ts +37 -0
- package/dist/course/course-video-conversion.service.d.ts.map +1 -0
- package/dist/course/course-video-conversion.service.js +308 -0
- package/dist/course/course-video-conversion.service.js.map +1 -0
- package/dist/course/course.controller.d.ts +17 -0
- package/dist/course/course.controller.d.ts.map +1 -1
- package/dist/course/course.controller.js +23 -0
- package/dist/course/course.controller.js.map +1 -1
- package/dist/course/course.module.d.ts.map +1 -1
- package/dist/course/course.module.js +15 -2
- package/dist/course/course.module.js.map +1 -1
- package/dist/course/course.service.d.ts +15 -0
- package/dist/course/course.service.d.ts.map +1 -1
- package/dist/course/course.service.js +103 -49
- package/dist/course/course.service.js.map +1 -1
- package/dist/course/dto/create-course-structure-lesson.dto.d.ts +5 -1
- package/dist/course/dto/create-course-structure-lesson.dto.d.ts.map +1 -1
- package/dist/course/dto/create-course-structure-lesson.dto.js +16 -2
- package/dist/course/dto/create-course-structure-lesson.dto.js.map +1 -1
- package/dist/course/dto/create-course.dto.d.ts +1 -0
- package/dist/course/dto/create-course.dto.d.ts.map +1 -1
- package/dist/course/dto/create-course.dto.js +9 -0
- package/dist/course/dto/create-course.dto.js.map +1 -1
- package/dist/enterprise/enterprise.controller.d.ts +3 -3
- package/dist/enterprise/enterprise.controller.d.ts.map +1 -1
- package/dist/enterprise/enterprise.controller.js +0 -1
- package/dist/enterprise/enterprise.controller.js.map +1 -1
- package/dist/enterprise/enterprise.service.d.ts +3 -3
- package/dist/evaluation/evaluation.service.d.ts.map +1 -1
- package/dist/evaluation/evaluation.service.js +9 -2
- package/dist/evaluation/evaluation.service.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/lms.module.d.ts.map +1 -1
- package/dist/lms.module.js +3 -0
- package/dist/lms.module.js.map +1 -1
- package/dist/video-resolution-profile/dto/create-video-resolution-profile.dto.d.ts +6 -0
- package/dist/video-resolution-profile/dto/create-video-resolution-profile.dto.d.ts.map +1 -0
- package/dist/video-resolution-profile/dto/create-video-resolution-profile.dto.js +33 -0
- package/dist/video-resolution-profile/dto/create-video-resolution-profile.dto.js.map +1 -0
- package/dist/video-resolution-profile/dto/update-video-resolution-profile.dto.d.ts +6 -0
- package/dist/video-resolution-profile/dto/update-video-resolution-profile.dto.d.ts.map +1 -0
- package/dist/video-resolution-profile/dto/update-video-resolution-profile.dto.js +33 -0
- package/dist/video-resolution-profile/dto/update-video-resolution-profile.dto.js.map +1 -0
- package/dist/video-resolution-profile/video-resolution-profile.controller.d.ts +38 -0
- package/dist/video-resolution-profile/video-resolution-profile.controller.d.ts.map +1 -0
- package/dist/video-resolution-profile/video-resolution-profile.controller.js +89 -0
- package/dist/video-resolution-profile/video-resolution-profile.controller.js.map +1 -0
- package/dist/video-resolution-profile/video-resolution-profile.mcp-tools.d.ts +26 -0
- package/dist/video-resolution-profile/video-resolution-profile.mcp-tools.d.ts.map +1 -0
- package/dist/video-resolution-profile/video-resolution-profile.mcp-tools.js +160 -0
- package/dist/video-resolution-profile/video-resolution-profile.mcp-tools.js.map +1 -0
- package/dist/video-resolution-profile/video-resolution-profile.module.d.ts +3 -0
- package/dist/video-resolution-profile/video-resolution-profile.module.d.ts.map +1 -0
- package/dist/video-resolution-profile/video-resolution-profile.module.js +26 -0
- package/dist/video-resolution-profile/video-resolution-profile.module.js.map +1 -0
- package/dist/video-resolution-profile/video-resolution-profile.service.d.ts +45 -0
- package/dist/video-resolution-profile/video-resolution-profile.service.d.ts.map +1 -0
- package/dist/video-resolution-profile/video-resolution-profile.service.js +117 -0
- package/dist/video-resolution-profile/video-resolution-profile.service.js.map +1 -0
- package/hedhog/data/menu.yaml +17 -0
- package/hedhog/data/route.yaml +133 -0
- package/hedhog/data/video_resolution_profile.yaml +7 -0
- package/hedhog/frontend/app/_components/class-form-sheet.tsx.ejs +269 -324
- package/hedhog/frontend/app/_components/course-form-sheet.tsx.ejs +124 -70
- package/hedhog/frontend/app/_components/create-lms-instructor-sheet.tsx.ejs +7 -4
- package/hedhog/frontend/app/_components/create-lms-person-sheet.tsx.ejs +2 -2
- package/hedhog/frontend/app/_components/create-lms-student-person-sheet.tsx.ejs +2 -2
- package/hedhog/frontend/app/_lib/editor/templateSerializer.ts.ejs +34 -4
- package/hedhog/frontend/app/_lib/editor/types.ts.ejs +28 -3
- package/hedhog/frontend/app/achievements/page.tsx.ejs +9 -3
- package/hedhog/frontend/app/bitcodes/page.tsx.ejs +9 -3
- package/hedhog/frontend/app/certificates/issued/page.tsx.ejs +7 -3
- package/hedhog/frontend/app/certificates/models/CanvasStage.tsx.ejs +29 -8
- package/hedhog/frontend/app/certificates/models/LeftPanel.tsx.ejs +14 -0
- package/hedhog/frontend/app/certificates/models/RightPanel.tsx.ejs +194 -9
- package/hedhog/frontend/app/certificates/models/page.tsx.ejs +15 -5
- package/hedhog/frontend/app/classes/[id]/page.tsx.ejs +9 -5
- package/hedhog/frontend/app/classes/page.tsx.ejs +73 -47
- package/hedhog/frontend/app/courses/[id]/_components/CourseCertificateCard.tsx.ejs +19 -9
- package/hedhog/frontend/app/courses/[id]/_components/CourseClassificationCard.tsx.ejs +24 -1
- package/hedhog/frontend/app/courses/[id]/_components/CourseContentCard.tsx.ejs +1 -1
- package/hedhog/frontend/app/courses/[id]/_components/CourseMainInfoCard.tsx.ejs +1 -1
- package/hedhog/frontend/app/courses/[id]/_components/CourseRelationsCard.tsx.ejs +28 -16
- package/hedhog/frontend/app/courses/[id]/_components/CourseSectionCard.tsx.ejs +11 -6
- package/hedhog/frontend/app/courses/[id]/_components/CourseSummaryCard.tsx.ejs +7 -4
- package/hedhog/frontend/app/courses/[id]/_components/course-edit-types.ts.ejs +1 -0
- package/hedhog/frontend/app/courses/[id]/page.tsx.ejs +24 -87
- package/hedhog/frontend/app/courses/[id]/structure/_components/editor-course.tsx.ejs +892 -411
- package/hedhog/frontend/app/courses/[id]/structure/_components/editor-lesson.tsx.ejs +1004 -293
- package/hedhog/frontend/app/courses/[id]/structure/_components/editor-session.tsx.ejs +11 -11
- package/hedhog/frontend/app/courses/[id]/structure/_components/shortcuts-help.tsx.ejs +62 -52
- package/hedhog/frontend/app/courses/[id]/structure/_components/types.ts.ejs +2 -0
- package/hedhog/frontend/app/courses/[id]/structure/_data/adapters/course-structure.adapter.ts.ejs +19 -6
- package/hedhog/frontend/app/courses/[id]/structure/_data/services/course-structure.service.ts.ejs +86 -1
- package/hedhog/frontend/app/courses/[id]/structure/_data/types/api-course.types.ts.ejs +3 -0
- package/hedhog/frontend/app/courses/[id]/structure/_data/use-course-structure-mutations.ts.ejs +1 -0
- package/hedhog/frontend/app/courses/page.tsx.ejs +112 -89
- package/hedhog/frontend/app/enterprise/[id]/page.tsx.ejs +1 -1
- package/hedhog/frontend/app/enterprise/_components/enterprise-admin-create-sheet.tsx.ejs +10 -3
- package/hedhog/frontend/app/enterprise/_components/enterprise-detail-sheet.tsx.ejs +8 -4
- package/hedhog/frontend/app/enterprise/_components/enterprise-person-edit-sheet.tsx.ejs +2 -2
- package/hedhog/frontend/app/enterprise/_components/enterprise-sheet.tsx.ejs +10 -4
- package/hedhog/frontend/app/enterprise/_components/enterprise-student-create-sheet.tsx.ejs +10 -3
- package/hedhog/frontend/app/enterprise/_components/enterprise-user-create-sheet.tsx.ejs +10 -3
- package/hedhog/frontend/app/evaluations/_components/evaluation-topic-form-sheet.tsx.ejs +10 -3
- package/hedhog/frontend/app/exams/[id]/questions/page.tsx.ejs +23 -9
- package/hedhog/frontend/app/exams/page.tsx.ejs +14 -6
- package/hedhog/frontend/app/instructor-skills/page.tsx.ejs +9 -3
- package/hedhog/frontend/app/instructors/_components/instructor-form-sheet.tsx.ejs +190 -17
- package/hedhog/frontend/app/layout.tsx.ejs +5 -1
- package/hedhog/frontend/app/paths/page.tsx.ejs +13 -5
- package/hedhog/frontend/app/reports/evaluations/page.tsx.ejs +10 -10
- package/hedhog/frontend/app/training/page.tsx.ejs +13 -5
- package/hedhog/frontend/app/video-resolution-profiles/page.tsx.ejs +607 -0
- package/hedhog/frontend/messages/en.json +250 -9
- package/hedhog/frontend/messages/pt.json +250 -9
- package/hedhog/table/course.yaml +4 -0
- package/hedhog/table/course_lesson_file.yaml +8 -0
- package/hedhog/table/course_video_resolution_profile.yaml +22 -0
- package/hedhog/table/video_resolution_profile.yaml +18 -0
- package/package.json +9 -8
- package/src/certificate/certificate.controller.ts +19 -14
- package/src/certificate/certificate.service.ts +106 -11
- package/src/course/course-structure.controller.ts +24 -2
- package/src/course/course-structure.service.ts +21 -4
- package/src/course/course-video-conversion.service.ts +415 -0
- package/src/course/course.controller.ts +18 -0
- package/src/course/course.module.ts +15 -2
- package/src/course/course.service.ts +72 -2
- package/src/course/dto/create-course-structure-lesson.dto.ts +13 -2
- package/src/course/dto/create-course.dto.ts +8 -0
- package/src/enterprise/enterprise.controller.ts +0 -1
- package/src/evaluation/evaluation.service.ts +9 -2
- package/src/index.ts +1 -0
- package/src/lms.module.ts +3 -0
- package/src/video-resolution-profile/dto/create-video-resolution-profile.dto.ts +16 -0
- package/src/video-resolution-profile/dto/update-video-resolution-profile.dto.ts +16 -0
- package/src/video-resolution-profile/video-resolution-profile.controller.ts +62 -0
- package/src/video-resolution-profile/video-resolution-profile.mcp-tools.ts +128 -0
- package/src/video-resolution-profile/video-resolution-profile.module.ts +13 -0
- 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 [
|
|
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
|
|
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:
|
|
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
|
|
625
|
-
|
|
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,
|
|
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
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
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
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
)
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
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
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
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
|
-
|
|
715
|
-
|
|
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
|
-
|
|
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-
|
|
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-
|
|
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
|
|
914
|
-
|
|
915
|
-
|
|
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
|
|
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
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
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-
|
|
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-
|
|
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
|
-
{
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
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
|
-
<
|
|
1476
|
-
<
|
|
1477
|
-
{
|
|
1478
|
-
|
|
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
|
|
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
|
-
</
|
|
1632
|
-
</
|
|
1633
|
-
|
|
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
|
-
{
|
|
2555
|
+
{genericResources.length > 0 && (
|
|
1850
2556
|
<p className="text-xs text-muted-foreground">
|
|
1851
2557
|
{t('lessonForm.resourcesCount', {
|
|
1852
|
-
count:
|
|
2558
|
+
count: genericResources.length,
|
|
1853
2559
|
})}
|
|
1854
2560
|
</p>
|
|
1855
2561
|
)}
|
|
1856
2562
|
|
|
1857
2563
|
{/* Resource list */}
|
|
1858
|
-
{
|
|
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
|
-
{
|
|
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={() =>
|
|
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
|
-
<
|
|
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
|
-
</
|
|
3076
|
+
</ResizableSheetContent>
|
|
2366
3077
|
</Sheet>
|
|
2367
3078
|
</form>
|
|
2368
3079
|
</Form>
|