@hed-hog/lms 0.0.312 → 0.0.315

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 (68) hide show
  1. package/dist/class-group/class-group.controller.d.ts +2 -2
  2. package/dist/class-group/class-group.service.d.ts +2 -2
  3. package/dist/enterprise/dto/enterprise-profile.dto.d.ts +13 -0
  4. package/dist/enterprise/dto/enterprise-profile.dto.d.ts.map +1 -0
  5. package/dist/enterprise/dto/enterprise-profile.dto.js +3 -0
  6. package/dist/enterprise/dto/enterprise-profile.dto.js.map +1 -0
  7. package/dist/enterprise/enterprise.controller.d.ts +3 -0
  8. package/dist/enterprise/enterprise.controller.d.ts.map +1 -1
  9. package/dist/enterprise/enterprise.controller.js +14 -0
  10. package/dist/enterprise/enterprise.controller.js.map +1 -1
  11. package/dist/enterprise/enterprise.service.d.ts +3 -0
  12. package/dist/enterprise/enterprise.service.d.ts.map +1 -1
  13. package/dist/enterprise/enterprise.service.js +128 -1
  14. package/dist/enterprise/enterprise.service.js.map +1 -1
  15. package/dist/instructor/instructor.controller.d.ts +23 -0
  16. package/dist/instructor/instructor.controller.d.ts.map +1 -1
  17. package/dist/instructor/instructor.controller.js +41 -0
  18. package/dist/instructor/instructor.controller.js.map +1 -1
  19. package/dist/instructor/instructor.service.d.ts +25 -0
  20. package/dist/instructor/instructor.service.d.ts.map +1 -1
  21. package/dist/instructor/instructor.service.js +126 -8
  22. package/dist/instructor/instructor.service.js.map +1 -1
  23. package/hedhog/data/menu.yaml +23 -7
  24. package/hedhog/data/role.yaml +9 -1
  25. package/hedhog/data/route.yaml +54 -0
  26. package/hedhog/frontend/app/_components/class-form-sheet.tsx.ejs +2 -1
  27. package/hedhog/frontend/app/courses/[id]/structure/_components/confirm-dialog.tsx.ejs +44 -44
  28. package/hedhog/frontend/app/courses/[id]/structure/_components/course-tree-dnd.tsx.ejs +362 -362
  29. package/hedhog/frontend/app/courses/[id]/structure/_components/course-tree-panel.tsx.ejs +111 -111
  30. package/hedhog/frontend/app/courses/[id]/structure/_components/course-tree.tsx.ejs +134 -134
  31. package/hedhog/frontend/app/courses/[id]/structure/_components/detail-course.tsx.ejs +113 -113
  32. package/hedhog/frontend/app/courses/[id]/structure/_components/detail-lesson.tsx.ejs +314 -314
  33. package/hedhog/frontend/app/courses/[id]/structure/_components/detail-panel.tsx.ejs +62 -62
  34. package/hedhog/frontend/app/courses/[id]/structure/_components/detail-session.tsx.ejs +173 -173
  35. package/hedhog/frontend/app/courses/[id]/structure/_components/drag-handle.tsx.ejs +58 -58
  36. package/hedhog/frontend/app/courses/[id]/structure/_components/drag-overlay.tsx.ejs +51 -51
  37. package/hedhog/frontend/app/courses/[id]/structure/_components/editor-bulk.tsx.ejs +276 -276
  38. package/hedhog/frontend/app/courses/[id]/structure/_components/editor-course.tsx.ejs +1216 -1216
  39. package/hedhog/frontend/app/courses/[id]/structure/_components/editor-lesson.tsx.ejs +1824 -1824
  40. package/hedhog/frontend/app/courses/[id]/structure/_components/editor-session.tsx.ejs +443 -443
  41. package/hedhog/frontend/app/courses/[id]/structure/_components/highlighted-text.tsx.ejs +40 -40
  42. package/hedhog/frontend/app/courses/[id]/structure/_components/mock-data.ts.ejs +185 -185
  43. package/hedhog/frontend/app/courses/[id]/structure/_components/multi-select-bar.tsx.ejs +264 -264
  44. package/hedhog/frontend/app/courses/[id]/structure/_components/search-filter.tsx.ejs +95 -95
  45. package/hedhog/frontend/app/courses/[id]/structure/_components/session-picker-dialog.tsx.ejs +73 -73
  46. package/hedhog/frontend/app/courses/[id]/structure/_components/shortcuts-help.tsx.ejs +136 -136
  47. package/hedhog/frontend/app/courses/[id]/structure/_components/sortable-tree-row.tsx.ejs +80 -80
  48. package/hedhog/frontend/app/courses/[id]/structure/_components/store.ts.ejs +949 -949
  49. package/hedhog/frontend/app/courses/[id]/structure/_components/tree-context-menu.tsx.ejs +525 -525
  50. package/hedhog/frontend/app/courses/[id]/structure/_components/tree-helpers.ts.ejs +181 -181
  51. package/hedhog/frontend/app/courses/[id]/structure/_components/tree-row-course.tsx.ejs +51 -51
  52. package/hedhog/frontend/app/courses/[id]/structure/_components/tree-row-lesson.tsx.ejs +271 -271
  53. package/hedhog/frontend/app/courses/[id]/structure/_components/tree-row-session.tsx.ejs +167 -167
  54. package/hedhog/frontend/app/courses/[id]/structure/_components/tree-row.tsx.ejs +108 -108
  55. package/hedhog/frontend/app/courses/[id]/structure/_components/use-course-structure-shortcuts.ts.ejs +318 -318
  56. package/hedhog/frontend/app/courses/[id]/structure/page.tsx.ejs +10 -10
  57. package/hedhog/frontend/app/enterprise/_components/enterprise-course-create-sheet.tsx.ejs +2 -1
  58. package/hedhog/frontend/app/instructors/_components/instructor-form-sheet.tsx.ejs +438 -0
  59. package/hedhog/frontend/app/instructors/_components/instructor-types.ts.ejs +40 -0
  60. package/hedhog/frontend/app/instructors/page.tsx.ejs +696 -0
  61. package/hedhog/frontend/app/training/page.tsx.ejs +2339 -0
  62. package/hedhog/table/enterprise_user.yaml +1 -1
  63. package/package.json +8 -8
  64. package/src/enterprise/dto/enterprise-profile.dto.ts +17 -0
  65. package/src/enterprise/enterprise.controller.ts +9 -1
  66. package/src/enterprise/enterprise.service.ts +147 -4
  67. package/src/instructor/instructor.controller.ts +36 -9
  68. package/src/instructor/instructor.service.ts +140 -10
@@ -1,1824 +1,1824 @@
1
- 'use client';
2
-
3
- import { zodResolver } from '@hookform/resolvers/zod';
4
- import {
5
- CircleDot,
6
- CircleOff,
7
- ClipboardList,
8
- Clock,
9
- Download,
10
- ExternalLink,
11
- Eye,
12
- EyeOff,
13
- File as FileIcon,
14
- FileImage,
15
- FileText,
16
- GripVertical,
17
- HelpCircle,
18
- ListChecks,
19
- Loader2,
20
- Lock,
21
- Pencil,
22
- Plus,
23
- Save,
24
- Trash2,
25
- Undo2,
26
- UploadCloud,
27
- Video,
28
- X,
29
- type LucideIcon,
30
- } from 'lucide-react';
31
- import { useEffect, useRef, useState } from 'react';
32
- import { useForm, useWatch } from 'react-hook-form';
33
- import { toast } from 'sonner';
34
- import { z } from 'zod';
35
-
36
- import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
37
- import { Button } from '@/components/ui/button';
38
- import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
39
- import { EntityPicker } from '@/components/ui/entity-picker';
40
- import {
41
- Form,
42
- FormControl,
43
- FormField,
44
- FormItem,
45
- FormLabel,
46
- FormMessage,
47
- } from '@/components/ui/form';
48
- import { Input } from '@/components/ui/input';
49
- import { Label } from '@/components/ui/label';
50
- import { ScrollArea } from '@/components/ui/scroll-area';
51
- import {
52
- Select,
53
- SelectContent,
54
- SelectItem,
55
- SelectTrigger,
56
- SelectValue,
57
- } from '@/components/ui/select';
58
- import { Separator } from '@/components/ui/separator';
59
- import {
60
- Sheet,
61
- SheetContent,
62
- SheetFooter,
63
- SheetHeader,
64
- SheetTitle,
65
- } from '@/components/ui/sheet';
66
- import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
67
- import { Textarea } from '@/components/ui/textarea';
68
- import { cn } from '@/lib/utils';
69
-
70
- import {
71
- closestCenter,
72
- DndContext,
73
- KeyboardSensor,
74
- PointerSensor,
75
- useSensor,
76
- useSensors,
77
- } from '@dnd-kit/core';
78
- import {
79
- arrayMove,
80
- SortableContext,
81
- sortableKeyboardCoordinates,
82
- useSortable,
83
- verticalListSortingStrategy,
84
- } from '@dnd-kit/sortable';
85
- import { CSS } from '@dnd-kit/utilities';
86
-
87
- import { RichTextEditor } from '@/components/rich-text-editor';
88
- import { useApp } from '@hed-hog/next-app-provider';
89
- import { useQueryClient } from '@tanstack/react-query';
90
- import {
91
- deleteFile,
92
- uploadFile,
93
- } from '../_data/services/course-structure.service';
94
- import {
95
- useDeleteLessonMutation,
96
- useUpdateLessonMutation,
97
- } from '../_data/use-course-structure-mutations';
98
- import {
99
- courseStructureQueryKey,
100
- type CourseStructureCacheData,
101
- } from '../_data/use-course-structure-query';
102
- import { useStructureStore } from './store';
103
- import type {
104
- LessonStatus,
105
- LessonType,
106
- Resource,
107
- VideoProvider,
108
- } from './types';
109
-
110
- // ── Resource helpers ──────────────────────────────────────────────────────────
111
-
112
- function getResourceIcon(type: string): LucideIcon {
113
- if (type === 'application/pdf' || type.endsWith('pdf')) return FileText;
114
- if (type.startsWith('image/')) return FileImage;
115
- return FileIcon;
116
- }
117
-
118
- function getResourceIconColor(type: string): string {
119
- if (type === 'application/pdf' || type.endsWith('pdf')) return 'text-red-500';
120
- if (type.startsWith('image/')) return 'text-blue-500';
121
- return 'text-muted-foreground';
122
- }
123
-
124
- function formatFileSize(bytes: number): string {
125
- if (bytes < 1024) return `${bytes} B`;
126
- if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
127
- return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
128
- }
129
-
130
- // ── Config maps ───────────────────────────────────────────────────────────────
131
-
132
- const TYPE_CONFIG: Record<
133
- LessonType,
134
- { icon: LucideIcon; color: string; bg: string; label: string }
135
- > = {
136
- video: {
137
- icon: Video,
138
- color: 'text-blue-500',
139
- bg: 'bg-blue-500/10',
140
- label: 'Vídeo',
141
- },
142
- post: {
143
- icon: FileText,
144
- color: 'text-emerald-500',
145
- bg: 'bg-emerald-500/10',
146
- label: 'Texto',
147
- },
148
- questao: {
149
- icon: HelpCircle,
150
- color: 'text-amber-500',
151
- bg: 'bg-amber-500/10',
152
- label: 'Quiz',
153
- },
154
- exercicio: {
155
- icon: ClipboardList,
156
- color: 'text-purple-500',
157
- bg: 'bg-purple-500/10',
158
- label: 'Exercício',
159
- },
160
- };
161
-
162
- const STATUS_LABELS: Record<LessonStatus, string> = {
163
- preparada: 'Preparada',
164
- gravada: 'Gravada',
165
- editada: 'Editada',
166
- finalizada: 'Finalizada',
167
- publicada: 'Publicada',
168
- };
169
-
170
- const STATUS_COLORS: Record<LessonStatus, string> = {
171
- preparada:
172
- 'bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-300',
173
- gravada:
174
- 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300',
175
- editada:
176
- 'bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300',
177
- finalizada:
178
- 'bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300',
179
- publicada:
180
- 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900 dark:text-emerald-300',
181
- };
182
-
183
- const VIDEO_PROVIDERS: { value: VideoProvider; label: string }[] = [
184
- { value: 'youtube', label: 'YouTube' },
185
- { value: 'vimeo', label: 'Vimeo' },
186
- { value: 'bunny', label: 'Bunny.net' },
187
- { value: 'custom', label: 'URL Direta' },
188
- ];
189
-
190
- // ── Question sheet types ──────────────────────────────────────────────────────
191
-
192
- type QuestionType =
193
- | 'multiple_choice'
194
- | 'true_false'
195
- | 'essay'
196
- | 'fill_blank'
197
- | 'matching';
198
-
199
- const QUESTION_TYPE_LABELS: Record<QuestionType, string> = {
200
- multiple_choice: 'Múltipla escolha',
201
- true_false: 'Verdadeiro / Falso',
202
- essay: 'Dissertativa',
203
- fill_blank: 'Lacuna',
204
- matching: 'Associação',
205
- };
206
-
207
- type QAlt = { id: string; texto: string; correta: boolean };
208
- type QFillBlank = { id: string; answer: string; alternativesText: string };
209
- type QMatchingPair = { id: string; leftText: string; rightText: string };
210
-
211
- type MockQuestion = {
212
- id: string;
213
- title: string;
214
- type: QuestionType;
215
- statement?: string;
216
- points?: number;
217
- alternatives?: QAlt[];
218
- fillBlankAnswers?: QFillBlank[];
219
- matchingPairs?: QMatchingPair[];
220
- };
221
-
222
- function generateAltId(): string {
223
- return Math.random().toString(36).slice(2, 9);
224
- }
225
-
226
- const MOCK_QUESTIONS: MockQuestion[] = [
227
- {
228
- id: 'q1',
229
- title: 'O que é React?',
230
- type: 'multiple_choice',
231
- points: 1,
232
- alternatives: [
233
- {
234
- id: 'a1',
235
- texto: 'Uma biblioteca JavaScript para interfaces',
236
- correta: true,
237
- },
238
- { id: 'a2', texto: 'Um framework CSS', correta: false },
239
- { id: 'a3', texto: 'Um banco de dados', correta: false },
240
- ],
241
- },
242
- {
243
- id: 'q2',
244
- title: 'Defina componente funcional',
245
- type: 'essay',
246
- points: 2,
247
- },
248
- {
249
- id: 'q3',
250
- title: 'TypeScript é um superset de JavaScript?',
251
- type: 'true_false',
252
- points: 1,
253
- alternatives: [
254
- { id: 'true', texto: 'Verdadeiro', correta: true },
255
- { id: 'false', texto: 'Falso', correta: false },
256
- ],
257
- },
258
- ];
259
-
260
- // ── Schema ────────────────────────────────────────────────────────────────────
261
-
262
- const schema = z.object({
263
- code: z.string().min(1, 'Código obrigatório'),
264
- title: z.string().min(1, 'Título obrigatório'),
265
- type: z.enum(['video', 'post', 'questao', 'exercicio'] as const),
266
- duration: z.coerce.number().min(0),
267
- status: z.enum([
268
- 'preparada',
269
- 'gravada',
270
- 'editada',
271
- 'finalizada',
272
- 'publicada',
273
- ] as const),
274
- visibility: z.enum(['publico', 'privado', 'restrito'] as const),
275
- publicDescription: z.string(),
276
- privateDescription: z.string(),
277
- videoProvider: z
278
- .enum(['youtube', 'vimeo', 'bunny', 'custom'] as const)
279
- .optional(),
280
- videoUrl: z.string().optional(),
281
- transcription: z.string().optional(),
282
- postContent: z.string().optional(),
283
- questionId: z.string().nullable().optional(),
284
- });
285
-
286
- type FormValues = z.infer<typeof schema>;
287
-
288
- // ── SortableAlternativa ───────────────────────────────────────────────────────
289
-
290
- function SortableAlternativa({
291
- alt,
292
- index,
293
- onToggleCorrect,
294
- onChangeTexto,
295
- onRemove,
296
- canRemove,
297
- disableText,
298
- }: {
299
- alt: QAlt;
300
- index: number;
301
- onToggleCorrect: () => void;
302
- onChangeTexto: (v: string) => void;
303
- onRemove: () => void;
304
- canRemove: boolean;
305
- disableText?: boolean;
306
- }) {
307
- const {
308
- attributes,
309
- listeners,
310
- setNodeRef,
311
- transform,
312
- transition,
313
- isDragging,
314
- } = useSortable({ id: alt.id });
315
- const style = { transform: CSS.Transform.toString(transform), transition };
316
- return (
317
- <div
318
- ref={setNodeRef}
319
- style={style}
320
- className={cn(
321
- 'flex items-center gap-2 rounded-lg border p-2 transition-colors',
322
- isDragging ? 'z-50 bg-muted shadow-lg' : '',
323
- alt.correta ? 'border-foreground/30 bg-muted/50' : 'bg-background'
324
- )}
325
- >
326
- <button
327
- type="button"
328
- className="cursor-grab touch-none text-muted-foreground hover:text-foreground"
329
- {...attributes}
330
- {...listeners}
331
- aria-label="Arrastar alternativa"
332
- >
333
- <GripVertical className="size-4" />
334
- </button>
335
- <span className="flex size-6 shrink-0 items-center justify-center rounded-full border text-xs font-medium">
336
- {String.fromCharCode(65 + index)}
337
- </span>
338
- <Input
339
- value={alt.texto}
340
- onChange={(e) => onChangeTexto(e.target.value)}
341
- className="flex-1 border-0 bg-transparent p-0 text-sm shadow-none focus-visible:ring-0"
342
- placeholder="Texto da alternativa…"
343
- disabled={disableText}
344
- />
345
- <button
346
- type="button"
347
- onClick={onToggleCorrect}
348
- className={cn(
349
- 'shrink-0 rounded-full p-1 transition-colors',
350
- alt.correta
351
- ? 'text-foreground'
352
- : 'text-muted-foreground hover:text-foreground'
353
- )}
354
- aria-label={
355
- alt.correta ? 'Marcar como incorreta' : 'Marcar como correta'
356
- }
357
- >
358
- {alt.correta ? (
359
- <CircleDot className="size-5" />
360
- ) : (
361
- <CircleOff className="size-5" />
362
- )}
363
- </button>
364
- {canRemove && (
365
- <button
366
- type="button"
367
- onClick={onRemove}
368
- className="shrink-0 rounded p-1 text-muted-foreground transition-colors hover:text-destructive"
369
- aria-label="Remover alternativa"
370
- >
371
- <X className="size-4" />
372
- </button>
373
- )}
374
- </div>
375
- );
376
- }
377
-
378
- // ── Component ─────────────────────────────────────────────────────────────────
379
-
380
- interface EditorLessonProps {
381
- lessonId: string;
382
- }
383
-
384
- export function EditorLesson({ lessonId }: EditorLessonProps) {
385
- const lesson = useStructureStore((s) =>
386
- s.lessons.find((l) => l.id === lessonId)
387
- );
388
- const updateLesson = useUpdateLessonMutation();
389
- const deleteLesson = useDeleteLessonMutation();
390
- const showConfirm = useStructureStore((s) => s.showConfirm);
391
- const courseId = useStructureStore((s) => s.courseId);
392
- const { request } = useApp();
393
- const queryClient = useQueryClient();
394
-
395
- const instructorPool =
396
- queryClient.getQueryData<CourseStructureCacheData>(
397
- courseStructureQueryKey(courseId)
398
- )?.instructors ?? [];
399
-
400
- const defaultValues = (): FormValues => ({
401
- code: lesson?.code ?? '',
402
- title: lesson?.title ?? '',
403
- type: lesson?.type ?? 'video',
404
- duration: lesson?.duration ?? 0,
405
- status: lesson?.status ?? 'preparada',
406
- visibility: lesson?.visibility ?? 'publico',
407
- publicDescription: lesson?.publicDescription ?? '',
408
- privateDescription: lesson?.privateDescription ?? '',
409
- videoProvider: lesson?.videoProvider ?? 'youtube',
410
- videoUrl: lesson?.videoUrl ?? '',
411
- transcription: lesson?.transcription ?? '',
412
- postContent: lesson?.postContent ?? '',
413
- questionId: null,
414
- });
415
-
416
- const form = useForm<FormValues>({
417
- resolver: zodResolver(schema),
418
- defaultValues: defaultValues(),
419
- });
420
-
421
- const { isDirty } = form.formState;
422
- const watchedType = useWatch({ control: form.control, name: 'type' });
423
- const watchedStatus = useWatch({ control: form.control, name: 'status' });
424
-
425
- useEffect(() => {
426
- if (!lesson) return;
427
- form.reset(defaultValues());
428
- }, [lesson?.id]); // eslint-disable-line react-hooks/exhaustive-deps
429
-
430
- // ── Local resources state ─────────────────────────────────────────────────
431
- const [localResources, setLocalResources] = useState<Resource[]>(
432
- () => lesson?.resources ?? []
433
- );
434
- const [dragOver, setDragOver] = useState(false);
435
- const [isUploading, setIsUploading] = useState(false);
436
- const resourceInputRef = useRef<HTMLInputElement>(null);
437
-
438
- // ── Instructors state ────────────────────────────────────────────────────
439
- const [selectedInstructorIds, setSelectedInstructorIds] = useState<string[]>(
440
- () => lesson?.instructors?.map((i) => i.id) ?? []
441
- );
442
-
443
- // ── Question sheet state ────────────────────────────────────────────────────
444
- const [questionSheetOpen, setQuestionSheetOpen] = useState(false);
445
- const [editingQuestion, setEditingQuestion] = useState<MockQuestion | null>(
446
- null
447
- );
448
- const [selectedQuestion, setSelectedQuestion] = useState<MockQuestion | null>(
449
- null
450
- );
451
- const [qSheetStatement, setQSheetStatement] = useState('');
452
- const [qSheetType, setQSheetType] = useState<QuestionType>('multiple_choice');
453
- const [qSheetPoints, setQSheetPoints] = useState(1);
454
- const [qSheetAlts, setQSheetAlts] = useState<QAlt[]>([]);
455
- const [qSheetFillBlanks, setQSheetFillBlanks] = useState<QFillBlank[]>([]);
456
- const [qSheetPairs, setQSheetPairs] = useState<QMatchingPair[]>([]);
457
- const [qSheetErrors, setQSheetErrors] = useState<Record<string, string>>({});
458
- const qSensors = useSensors(
459
- useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
460
- useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
461
- );
462
-
463
- useEffect(() => {
464
- setLocalResources(lesson?.resources ?? []);
465
- setSelectedInstructorIds(lesson?.instructors?.map((i) => i.id) ?? []);
466
- }, [lesson?.id]); // eslint-disable-line react-hooks/exhaustive-deps
467
-
468
- if (!lesson) return null;
469
-
470
- const cfg = TYPE_CONFIG[lesson.type];
471
- const Icon = cfg.icon;
472
-
473
- async function handleResourceFiles(files: File[]) {
474
- setIsUploading(true);
475
- try {
476
- const results = await Promise.allSettled(
477
- files.map((f) =>
478
- uploadFile(request, f).then<Resource>((res) => ({
479
- id: String(res.id),
480
- name: f.name,
481
- size: formatFileSize(f.size),
482
- type: f.type || f.name.split('.').pop() || 'file',
483
- public: false,
484
- url: undefined,
485
- }))
486
- )
487
- );
488
- const succeeded = results
489
- .filter(
490
- (r): r is PromiseFulfilledResult<Resource> => r.status === 'fulfilled'
491
- )
492
- .map((r) => r.value);
493
- const failedCount = results.filter((r) => r.status === 'rejected').length;
494
- if (failedCount > 0)
495
- toast.error(
496
- `${failedCount} arquivo${failedCount > 1 ? 's' : ''} não ${failedCount > 1 ? 'puderam' : 'pôde'} ser enviado${failedCount > 1 ? 's' : ''}`
497
- );
498
- if (succeeded.length > 0)
499
- setLocalResources((prev) => [...prev, ...succeeded]);
500
- } finally {
501
- setIsUploading(false);
502
- }
503
- }
504
-
505
- async function removeResource(id: string) {
506
- const numId = Number(id);
507
- if (Number.isInteger(numId) && numId > 0) {
508
- try {
509
- await deleteFile(request, numId);
510
- } catch {
511
- toast.error('Erro ao remover arquivo');
512
- return;
513
- }
514
- } else {
515
- const res = localResources.find((r) => r.id === id);
516
- if (res?.url?.startsWith('blob:')) URL.revokeObjectURL(res.url);
517
- }
518
- setLocalResources((prev) => prev.filter((r) => r.id !== id));
519
- }
520
-
521
- function handleResourceDownload(res: Resource) {
522
- if (!res.url) {
523
- toast(`Download de ${res.name} — em breve`);
524
- return;
525
- }
526
- const a = document.createElement('a');
527
- a.href = res.url;
528
- a.download = res.name;
529
- a.click();
530
- }
531
-
532
- function onSubmit(values: FormValues) {
533
- updateLesson.mutate({
534
- lessonId,
535
- sessionId: lesson!.sessionId,
536
- formValues: {
537
- ...values,
538
- resources: localResources,
539
- instructorIds: selectedInstructorIds.map(Number),
540
- },
541
- });
542
- form.reset(values);
543
- }
544
-
545
- // ── Question sheet helpers ────────────────────────────────────────────────
546
-
547
- function openCreateQuestion() {
548
- setEditingQuestion(null);
549
- setQSheetStatement('');
550
- setQSheetType('multiple_choice');
551
- setQSheetPoints(1);
552
- setQSheetAlts([
553
- { id: generateAltId(), texto: '', correta: false },
554
- { id: generateAltId(), texto: '', correta: false },
555
- ]);
556
- setQSheetFillBlanks([
557
- { id: generateAltId(), answer: '', alternativesText: '' },
558
- ]);
559
- setQSheetPairs([{ id: generateAltId(), leftText: '', rightText: '' }]);
560
- setQSheetErrors({});
561
- setQuestionSheetOpen(true);
562
- }
563
-
564
- function openEditQuestion(q: MockQuestion) {
565
- setEditingQuestion(q);
566
- setQSheetStatement(q.statement ?? q.title);
567
- setQSheetType(q.type);
568
- setQSheetPoints(q.points ?? 1);
569
- setQSheetAlts(
570
- q.alternatives?.length
571
- ? q.alternatives
572
- : [
573
- { id: generateAltId(), texto: '', correta: false },
574
- { id: generateAltId(), texto: '', correta: false },
575
- ]
576
- );
577
- setQSheetFillBlanks(
578
- q.fillBlankAnswers?.length
579
- ? q.fillBlankAnswers
580
- : [{ id: generateAltId(), answer: '', alternativesText: '' }]
581
- );
582
- setQSheetPairs(
583
- q.matchingPairs?.length
584
- ? q.matchingPairs
585
- : [{ id: generateAltId(), leftText: '', rightText: '' }]
586
- );
587
- setQSheetErrors({});
588
- setQuestionSheetOpen(true);
589
- }
590
-
591
- function handleQSheetTypeChange(v: QuestionType) {
592
- setQSheetType(v);
593
- setQSheetErrors({});
594
- if (v === 'true_false') {
595
- setQSheetAlts([
596
- { id: 'true', texto: 'Verdadeiro', correta: true },
597
- { id: 'false', texto: 'Falso', correta: false },
598
- ]);
599
- } else if (v === 'multiple_choice' && qSheetAlts.length === 0) {
600
- setQSheetAlts([
601
- { id: generateAltId(), texto: '', correta: false },
602
- { id: generateAltId(), texto: '', correta: false },
603
- ]);
604
- } else if (v === 'fill_blank' && qSheetFillBlanks.length === 0) {
605
- setQSheetFillBlanks([
606
- { id: generateAltId(), answer: '', alternativesText: '' },
607
- ]);
608
- } else if (v === 'matching' && qSheetPairs.length === 0) {
609
- setQSheetPairs([{ id: generateAltId(), leftText: '', rightText: '' }]);
610
- }
611
- }
612
-
613
- function saveQuestion() {
614
- const errors: Record<string, string> = {};
615
- if (!qSheetStatement.trim()) errors.statement = 'Enunciado obrigatório';
616
- if (
617
- (qSheetType === 'multiple_choice' || qSheetType === 'true_false') &&
618
- !qSheetAlts.some((a) => a.correta)
619
- )
620
- errors.alts = 'Selecione pelo menos uma resposta correta';
621
- if (Object.keys(errors).length) {
622
- setQSheetErrors(errors);
623
- return;
624
- }
625
- const saved: MockQuestion = {
626
- id: editingQuestion?.id ?? `q-${Date.now()}`,
627
- title: qSheetStatement.replace(/<[^>]+>/g, '').slice(0, 80),
628
- type: qSheetType,
629
- statement: qSheetStatement,
630
- points: qSheetPoints,
631
- alternatives:
632
- qSheetType === 'multiple_choice' || qSheetType === 'true_false'
633
- ? qSheetAlts
634
- : undefined,
635
- fillBlankAnswers:
636
- qSheetType === 'fill_blank' ? qSheetFillBlanks : undefined,
637
- matchingPairs: qSheetType === 'matching' ? qSheetPairs : undefined,
638
- };
639
- setSelectedQuestion(saved);
640
- form.setValue('questionId', saved.id, { shouldDirty: true });
641
- setQuestionSheetOpen(false);
642
- toast.success(editingQuestion ? 'Questão atualizada' : 'Questão criada');
643
- }
644
-
645
- function handleDelete() {
646
- showConfirm({
647
- title: `Excluir aula "${lesson!.title}"?`,
648
- description: 'Esta ação não pode ser desfeita.',
649
- onConfirm: () =>
650
- deleteLesson.mutate({ lessonId, sessionId: lesson!.sessionId }),
651
- });
652
- }
653
-
654
- return (
655
- <Form {...form}>
656
- <form
657
- onSubmit={form.handleSubmit(onSubmit)}
658
- className="flex flex-col h-full min-h-0"
659
- >
660
- {/* ── Header ───────────────────────────────────────────────────────── */}
661
- <div className="flex items-center gap-3 px-4 py-3 border-b bg-muted/30 shrink-0">
662
- <div
663
- className={cn(
664
- 'flex size-9 items-center justify-center rounded-lg shrink-0',
665
- cfg.bg
666
- )}
667
- >
668
- <Icon className={cn('size-4', cfg.color)} />
669
- </div>
670
- <div className="flex-1 min-w-0">
671
- <div className="flex items-center gap-1.5">
672
- <span className="text-sm font-semibold truncate">Aula</span>
673
- {isDirty && (
674
- <CircleDot className="size-3 text-amber-500 shrink-0" />
675
- )}
676
- </div>
677
- <p className="text-[0.65rem] text-muted-foreground truncate">
678
- {lesson.code} · {cfg.label}
679
- </p>
680
- </div>
681
- {watchedStatus && (
682
- <span
683
- className={cn(
684
- 'text-[0.65rem] font-medium px-2 py-0.5 rounded-full shrink-0',
685
- STATUS_COLORS[watchedStatus]
686
- )}
687
- >
688
- {STATUS_LABELS[watchedStatus]}
689
- </span>
690
- )}
691
- <Button
692
- type="button"
693
- variant="ghost"
694
- size="icon"
695
- className="size-7 text-destructive/60 hover:text-destructive shrink-0"
696
- title="Excluir aula"
697
- aria-label="Excluir aula"
698
- disabled={deleteLesson.isPending}
699
- onClick={handleDelete}
700
- >
701
- {deleteLesson.isPending ? (
702
- <Loader2 className="size-3.5 animate-spin" />
703
- ) : (
704
- <Trash2 className="size-3.5" />
705
- )}
706
- </Button>
707
- </div>
708
-
709
- {/* ── Tabs ─────────────────────────────────────────────────────────── */}
710
- <Tabs defaultValue="dados" className="flex flex-col flex-1 min-h-0">
711
- <TabsList className="mx-3 mt-2 h-8 w-auto justify-start shrink-0 bg-muted/50">
712
- <TabsTrigger value="dados" className="text-xs h-7 px-2.5">
713
- Dados
714
- </TabsTrigger>
715
- <TabsTrigger value="conteudo" className="text-xs h-7 px-2.5">
716
- Conteúdo
717
- </TabsTrigger>
718
- <TabsTrigger value="recursos" className="text-xs h-7 px-2.5">
719
- Recursos
720
- </TabsTrigger>
721
- </TabsList>
722
-
723
- {/* ── Tab Dados ────────────────────────────────────────────────── */}
724
- <TabsContent value="dados" className="flex-1 min-h-0 mt-0">
725
- <ScrollArea className="h-full">
726
- <div className="flex flex-col gap-3 p-3">
727
- {/* Identificação */}
728
- <Card className="bg-muted/20 py-2 gap-2">
729
- <CardHeader className="px-3 pt-2 pb-0">
730
- <CardTitle className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
731
- Identificação
732
- </CardTitle>
733
- </CardHeader>
734
- <CardContent className="px-3 pb-2 flex flex-col gap-3">
735
- <div className="grid grid-cols-3 gap-2">
736
- <FormField
737
- control={form.control}
738
- name="code"
739
- render={({ field }) => (
740
- <FormItem>
741
- <FormLabel className="text-xs">Código</FormLabel>
742
- <FormControl>
743
- <Input
744
- {...field}
745
- className="h-8 text-xs font-mono"
746
- />
747
- </FormControl>
748
- <FormMessage className="text-xs" />
749
- </FormItem>
750
- )}
751
- />
752
- <FormField
753
- control={form.control}
754
- name="duration"
755
- render={({ field }) => (
756
- <FormItem>
757
- <FormLabel className="text-xs">
758
- <Clock className="size-3 inline mr-1" />
759
- Duração (min)
760
- </FormLabel>
761
- <FormControl>
762
- <Input
763
- {...field}
764
- type="number"
765
- className="h-8 text-xs"
766
- />
767
- </FormControl>
768
- <FormMessage className="text-xs" />
769
- </FormItem>
770
- )}
771
- />
772
- <FormField
773
- control={form.control}
774
- name="type"
775
- render={({ field }) => (
776
- <FormItem>
777
- <FormLabel className="text-xs">Tipo</FormLabel>
778
- <Select
779
- value={field.value}
780
- onValueChange={field.onChange}
781
- >
782
- <FormControl>
783
- <SelectTrigger className="h-8 text-xs w-full">
784
- <SelectValue />
785
- </SelectTrigger>
786
- </FormControl>
787
- <SelectContent>
788
- {(
789
- Object.entries(TYPE_CONFIG) as [
790
- LessonType,
791
- (typeof TYPE_CONFIG)[LessonType],
792
- ][]
793
- ).map(([val, cfg]) => {
794
- const Ic = cfg.icon;
795
- return (
796
- <SelectItem key={val} value={val}>
797
- <span className="flex items-center gap-1.5">
798
- <Ic
799
- className={cn('size-3', cfg.color)}
800
- />
801
- {cfg.label}
802
- </span>
803
- </SelectItem>
804
- );
805
- })}
806
- </SelectContent>
807
- </Select>
808
- <FormMessage className="text-xs" />
809
- </FormItem>
810
- )}
811
- />
812
- </div>
813
-
814
- <FormField
815
- control={form.control}
816
- name="title"
817
- render={({ field }) => (
818
- <FormItem>
819
- <FormLabel className="text-xs">Título</FormLabel>
820
- <FormControl>
821
- <Input {...field} className="h-8 text-sm" />
822
- </FormControl>
823
- <FormMessage className="text-xs" />
824
- </FormItem>
825
- )}
826
- />
827
- </CardContent>
828
- </Card>
829
-
830
- {/* Publicação */}
831
- <Card className="bg-muted/20 py-2 gap-2">
832
- <CardHeader className="px-3 pt-2 pb-0">
833
- <CardTitle className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
834
- Publicação
835
- </CardTitle>
836
- </CardHeader>
837
- <CardContent className="px-3 pb-2 flex flex-col gap-3">
838
- <div className="grid grid-cols-2 gap-2">
839
- <FormField
840
- control={form.control}
841
- name="status"
842
- render={({ field }) => (
843
- <FormItem>
844
- <FormLabel className="text-xs">
845
- Status de produção
846
- </FormLabel>
847
- <Select
848
- value={field.value}
849
- onValueChange={field.onChange}
850
- >
851
- <FormControl>
852
- <SelectTrigger className="h-8 text-xs w-full">
853
- <SelectValue />
854
- </SelectTrigger>
855
- </FormControl>
856
- <SelectContent>
857
- {(
858
- Object.entries(STATUS_LABELS) as [
859
- LessonStatus,
860
- string,
861
- ][]
862
- ).map(([val, lbl]) => (
863
- <SelectItem key={val} value={val}>
864
- <span
865
- className={cn(
866
- 'text-xs px-1.5 py-0.5 rounded',
867
- STATUS_COLORS[val]
868
- )}
869
- >
870
- {lbl}
871
- </span>
872
- </SelectItem>
873
- ))}
874
- </SelectContent>
875
- </Select>
876
- <FormMessage className="text-xs" />
877
- </FormItem>
878
- )}
879
- />
880
-
881
- <FormField
882
- control={form.control}
883
- name="visibility"
884
- render={({ field }) => (
885
- <FormItem>
886
- <FormLabel className="text-xs">
887
- Visibilidade
888
- </FormLabel>
889
- <Select
890
- value={field.value}
891
- onValueChange={field.onChange}
892
- >
893
- <FormControl>
894
- <SelectTrigger className="h-8 text-xs w-full">
895
- <SelectValue />
896
- </SelectTrigger>
897
- </FormControl>
898
- <SelectContent>
899
- <SelectItem value="publico">
900
- <span className="flex items-center gap-1.5">
901
- <Eye className="size-3" /> Público
902
- </span>
903
- </SelectItem>
904
- <SelectItem value="privado">
905
- <span className="flex items-center gap-1.5">
906
- <EyeOff className="size-3" /> Privado
907
- </span>
908
- </SelectItem>
909
- <SelectItem value="restrito">
910
- <span className="flex items-center gap-1.5">
911
- <Lock className="size-3" /> Restrito
912
- </span>
913
- </SelectItem>
914
- </SelectContent>
915
- </Select>
916
- <FormMessage className="text-xs" />
917
- </FormItem>
918
- )}
919
- />
920
- </div>
921
- </CardContent>
922
- </Card>
923
-
924
- {/* Instrutores */}
925
- <Card className="bg-muted/20 py-2 gap-2">
926
- <CardHeader className="px-3 pt-2 pb-0">
927
- <CardTitle className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
928
- Instrutores
929
- </CardTitle>
930
- </CardHeader>
931
- <CardContent className="px-3 pb-2 flex flex-col gap-2">
932
- {/* Picker para adicionar */}
933
- <EntityPicker<{ id: number; name: string }>
934
- value={null}
935
- onChange={(val) => {
936
- if (val != null)
937
- setSelectedInstructorIds((prev) =>
938
- prev.includes(String(val))
939
- ? prev
940
- : [...prev, String(val)]
941
- );
942
- }}
943
- placeholder="Adicionar instrutor…"
944
- searchPlaceholder="Buscar instrutor…"
945
- emptyLabel="Nenhum instrutor disponível"
946
- entityLabel="Instrutor"
947
- options={instructorPool.filter(
948
- (i) => !selectedInstructorIds.includes(String(i.id))
949
- )}
950
- getOptionValue={(o) => o.id}
951
- getOptionLabel={(o) => o.name}
952
- />
953
- {/* Lista de instrutores selecionados */}
954
- {selectedInstructorIds.length > 0 && (
955
- <div className="flex flex-col gap-1">
956
- {selectedInstructorIds.map((sid) => {
957
- const inst = instructorPool.find(
958
- (i) => String(i.id) === sid
959
- );
960
- const displayName = inst?.name ?? sid;
961
- return (
962
- <div
963
- key={sid}
964
- className="flex items-center gap-2 rounded-md border bg-muted/20 px-2 py-1"
965
- >
966
- <Avatar className="size-6 shrink-0">
967
- <AvatarImage
968
- src={undefined}
969
- alt={displayName}
970
- />
971
- <AvatarFallback className="text-[0.6rem] font-medium">
972
- {displayName
973
- .split(' ')
974
- .slice(0, 2)
975
- .map((n: string) => n[0])
976
- .join('')
977
- .toUpperCase()}
978
- </AvatarFallback>
979
- </Avatar>
980
- <span className="text-xs flex-1 truncate">
981
- {displayName}
982
- </span>
983
- <Button
984
- type="button"
985
- variant="ghost"
986
- size="icon"
987
- className="size-5 shrink-0 text-muted-foreground hover:text-destructive"
988
- onClick={() =>
989
- setSelectedInstructorIds((prev) =>
990
- prev.filter((id) => id !== sid)
991
- )
992
- }
993
- aria-label={`Remover ${displayName}`}
994
- >
995
- <X className="size-3" />
996
- </Button>
997
- </div>
998
- );
999
- })}
1000
- </div>
1001
- )}
1002
- </CardContent>
1003
- </Card>
1004
- </div>
1005
- </ScrollArea>
1006
- </TabsContent>
1007
-
1008
- {/* ── Tab Conteúdo ─────────────────────────────────────────────── */}
1009
- <TabsContent value="conteudo" className="flex-1 min-h-0 mt-0">
1010
- <ScrollArea className="h-full">
1011
- <div className="flex flex-col gap-3 p-3">
1012
- {/* Descrição pública */}
1013
- <Card className="bg-muted/20 py-2 gap-2">
1014
- <CardHeader className="px-3 pt-2 pb-1">
1015
- <CardTitle className="text-xs font-medium text-muted-foreground uppercase tracking-wider flex items-center gap-1.5">
1016
- <Eye className="size-3" /> Descrição pública
1017
- </CardTitle>
1018
- </CardHeader>
1019
- <CardContent className="px-3 pb-2">
1020
- <FormField
1021
- control={form.control}
1022
- name="publicDescription"
1023
- render={({ field }) => (
1024
- <FormItem>
1025
- <FormControl>
1026
- <RichTextEditor
1027
- value={field.value}
1028
- onChange={field.onChange}
1029
- />
1030
- </FormControl>
1031
- <FormMessage className="text-xs" />
1032
- </FormItem>
1033
- )}
1034
- />
1035
- </CardContent>
1036
- </Card>
1037
-
1038
- {/* Notas internas */}
1039
- <Card className="bg-muted/20 py-2 gap-2">
1040
- <CardHeader className="px-3 pt-2 pb-1">
1041
- <CardTitle className="text-xs font-medium text-muted-foreground uppercase tracking-wider flex items-center gap-1.5">
1042
- <Lock className="size-3" /> Notas internas
1043
- </CardTitle>
1044
- </CardHeader>
1045
- <CardContent className="px-3 pb-2">
1046
- <FormField
1047
- control={form.control}
1048
- name="privateDescription"
1049
- render={({ field }) => (
1050
- <FormItem>
1051
- <FormControl>
1052
- <RichTextEditor
1053
- value={field.value}
1054
- onChange={field.onChange}
1055
- className="border-amber-200/60 dark:border-amber-800/40"
1056
- />
1057
- </FormControl>
1058
- <FormMessage className="text-xs" />
1059
- </FormItem>
1060
- )}
1061
- />
1062
- </CardContent>
1063
- </Card>
1064
-
1065
- {/* Campos específicos por tipo */}
1066
- {watchedType === 'video' && (
1067
- <Card className="bg-muted/20 py-2 gap-2">
1068
- <CardHeader className="px-3 pt-2 pb-1">
1069
- <CardTitle className="text-xs font-medium text-muted-foreground uppercase tracking-wider flex items-center gap-1.5">
1070
- <Video className="size-3 text-blue-500" /> Vídeo
1071
- </CardTitle>
1072
- </CardHeader>
1073
- <CardContent className="px-3 pb-2 flex flex-col gap-3">
1074
- <FormField
1075
- control={form.control}
1076
- name="videoProvider"
1077
- render={({ field }) => (
1078
- <FormItem>
1079
- <FormLabel className="text-xs">Provider</FormLabel>
1080
- <Select
1081
- value={field.value}
1082
- onValueChange={field.onChange}
1083
- >
1084
- <FormControl>
1085
- <SelectTrigger className="h-8 text-xs w-full">
1086
- <SelectValue />
1087
- </SelectTrigger>
1088
- </FormControl>
1089
- <SelectContent>
1090
- {VIDEO_PROVIDERS.map((p) => (
1091
- <SelectItem key={p.value} value={p.value}>
1092
- {p.label}
1093
- </SelectItem>
1094
- ))}
1095
- </SelectContent>
1096
- </Select>
1097
- <FormMessage className="text-xs" />
1098
- </FormItem>
1099
- )}
1100
- />
1101
-
1102
- <FormField
1103
- control={form.control}
1104
- name="videoUrl"
1105
- render={({ field }) => (
1106
- <FormItem>
1107
- <FormLabel className="text-xs">
1108
- URL do vídeo
1109
- </FormLabel>
1110
- <FormControl>
1111
- <Input
1112
- {...field}
1113
- value={field.value ?? ''}
1114
- className="h-8 text-xs font-mono"
1115
- placeholder="https://…"
1116
- />
1117
- </FormControl>
1118
- <FormMessage className="text-xs" />
1119
- </FormItem>
1120
- )}
1121
- />
1122
-
1123
- <FormField
1124
- control={form.control}
1125
- name="transcription"
1126
- render={({ field }) => (
1127
- <FormItem>
1128
- <FormLabel className="text-xs">
1129
- Transcrição
1130
- </FormLabel>
1131
- <FormControl>
1132
- <Textarea
1133
- {...field}
1134
- value={field.value ?? ''}
1135
- rows={5}
1136
- className="text-xs resize-none font-mono"
1137
- placeholder="Transcrição automática ou manual do vídeo…"
1138
- />
1139
- </FormControl>
1140
- <FormMessage className="text-xs" />
1141
- </FormItem>
1142
- )}
1143
- />
1144
- </CardContent>
1145
- </Card>
1146
- )}
1147
-
1148
- {watchedType === 'post' && (
1149
- <Card className="bg-muted/20 py-2 gap-2">
1150
- <CardHeader className="px-3 pt-2 pb-1">
1151
- <CardTitle className="text-xs font-medium text-muted-foreground uppercase tracking-wider flex items-center gap-1.5">
1152
- <FileText className="size-3 text-emerald-500" />{' '}
1153
- Conteúdo do post
1154
- </CardTitle>
1155
- </CardHeader>
1156
- <CardContent className="px-3 pb-2">
1157
- <FormField
1158
- control={form.control}
1159
- name="postContent"
1160
- render={({ field }) => (
1161
- <FormItem>
1162
- <FormControl>
1163
- <RichTextEditor
1164
- value={field.value ?? ''}
1165
- onChange={field.onChange}
1166
- />
1167
- </FormControl>
1168
- <FormMessage className="text-xs" />
1169
- </FormItem>
1170
- )}
1171
- />
1172
- </CardContent>
1173
- </Card>
1174
- )}
1175
-
1176
- {watchedType === 'questao' && (
1177
- <Card className="bg-muted/20 py-2 gap-2">
1178
- <CardHeader className="px-3 pt-2 pb-1">
1179
- <CardTitle className="text-xs font-medium text-muted-foreground uppercase tracking-wider flex items-center justify-between gap-1.5">
1180
- <span className="flex items-center gap-1.5">
1181
- <ListChecks className="size-3 text-amber-500" />{' '}
1182
- Questão vinculada
1183
- </span>
1184
- <Button
1185
- type="button"
1186
- variant="outline"
1187
- size="sm"
1188
- className="h-6 text-xs px-2 gap-1"
1189
- onClick={openCreateQuestion}
1190
- >
1191
- <Plus className="size-3" />
1192
- Nova questão
1193
- </Button>
1194
- </CardTitle>
1195
- </CardHeader>
1196
- <CardContent className="px-3 pb-2 flex flex-col gap-2">
1197
- <FormField
1198
- control={form.control}
1199
- name="questionId"
1200
- render={({ field }) => (
1201
- <FormItem>
1202
- <FormControl>
1203
- <EntityPicker<MockQuestion>
1204
- value={field.value ?? null}
1205
- onChange={(val) => {
1206
- field.onChange(val);
1207
- const found =
1208
- MOCK_QUESTIONS.find((q) => q.id === val) ??
1209
- null;
1210
- setSelectedQuestion(found);
1211
- }}
1212
- placeholder="Selecionar questão…"
1213
- searchPlaceholder="Buscar questão…"
1214
- emptyLabel="Nenhuma questão encontrada"
1215
- entityLabel="Questão"
1216
- options={MOCK_QUESTIONS}
1217
- getOptionValue={(o) => o.id}
1218
- getOptionLabel={(o) => o.title}
1219
- getOptionDescription={(o) =>
1220
- QUESTION_TYPE_LABELS[o.type]
1221
- }
1222
- />
1223
- </FormControl>
1224
- <FormMessage className="text-xs" />
1225
- </FormItem>
1226
- )}
1227
- />
1228
- {selectedQuestion && (
1229
- <div className="flex items-center gap-2 rounded-lg border bg-background px-3 py-2 text-sm">
1230
- <HelpCircle className="size-4 shrink-0 text-amber-500" />
1231
- <div className="flex-1 min-w-0">
1232
- <p className="truncate font-medium text-xs">
1233
- {selectedQuestion.title}
1234
- </p>
1235
- <p className="text-xs text-muted-foreground">
1236
- {QUESTION_TYPE_LABELS[selectedQuestion.type]}
1237
- {selectedQuestion.points != null &&
1238
- ` · ${selectedQuestion.points} pt`}
1239
- </p>
1240
- </div>
1241
- <Button
1242
- type="button"
1243
- variant="ghost"
1244
- size="icon"
1245
- className="size-7 shrink-0"
1246
- onClick={() => openEditQuestion(selectedQuestion)}
1247
- aria-label="Editar questão"
1248
- >
1249
- <Pencil className="size-3.5" />
1250
- </Button>
1251
- </div>
1252
- )}
1253
- </CardContent>
1254
- </Card>
1255
- )}
1256
- </div>
1257
- </ScrollArea>
1258
- </TabsContent>
1259
-
1260
- {/* ── Tab Recursos ─────────────────────────────────────────────── */}
1261
- <TabsContent value="recursos" className="flex-1 min-h-0 mt-0">
1262
- <ScrollArea className="h-full">
1263
- <div className="flex flex-col gap-3 p-3">
1264
- {/* Drop zone */}
1265
- <div
1266
- role="button"
1267
- tabIndex={0}
1268
- aria-label="Soltar arquivo ou clicar para selecionar"
1269
- aria-disabled={isUploading}
1270
- onDragOver={(e) => {
1271
- e.preventDefault();
1272
- if (!isUploading) setDragOver(true);
1273
- }}
1274
- onDragLeave={() => setDragOver(false)}
1275
- onDrop={(e) => {
1276
- e.preventDefault();
1277
- setDragOver(false);
1278
- if (!isUploading)
1279
- void handleResourceFiles(
1280
- Array.from(e.dataTransfer.files)
1281
- );
1282
- }}
1283
- onClick={() => {
1284
- if (!isUploading) resourceInputRef.current?.click();
1285
- }}
1286
- onKeyDown={(e) => {
1287
- if (e.key === 'Enter' || e.key === ' ') {
1288
- e.preventDefault();
1289
- if (!isUploading) resourceInputRef.current?.click();
1290
- }
1291
- }}
1292
- className={cn(
1293
- 'flex flex-col items-center justify-center gap-2 py-7 rounded-lg border-2 border-dashed transition-colors select-none',
1294
- isUploading
1295
- ? 'cursor-wait border-border opacity-60'
1296
- : 'cursor-pointer',
1297
- !isUploading && dragOver
1298
- ? 'border-primary/70 bg-primary/5'
1299
- : !isUploading
1300
- ? 'border-border hover:border-primary/40 hover:bg-muted/30'
1301
- : ''
1302
- )}
1303
- >
1304
- {isUploading ? (
1305
- <Loader2 className="size-7 animate-spin text-muted-foreground/50" />
1306
- ) : (
1307
- <UploadCloud
1308
- className={cn(
1309
- 'size-7 transition-colors',
1310
- dragOver ? 'text-primary' : 'text-muted-foreground/50'
1311
- )}
1312
- />
1313
- )}
1314
- <div className="text-center">
1315
- <p className="text-xs font-medium">
1316
- {isUploading ? 'Enviando…' : 'Solte o arquivo aqui'}
1317
- </p>
1318
- {!isUploading && (
1319
- <p className="text-xs text-muted-foreground">
1320
- ou clique para selecionar
1321
- </p>
1322
- )}
1323
- </div>
1324
- <input
1325
- ref={resourceInputRef}
1326
- type="file"
1327
- multiple
1328
- className="hidden"
1329
- onChange={(e) => {
1330
- if (e.target.files) {
1331
- void handleResourceFiles(Array.from(e.target.files));
1332
- e.target.value = '';
1333
- }
1334
- }}
1335
- />
1336
- </div>
1337
-
1338
- {/* Counter */}
1339
- {localResources.length > 0 && (
1340
- <p className="text-xs text-muted-foreground">
1341
- {localResources.length} recurso
1342
- {localResources.length !== 1 ? 's' : ''}
1343
- </p>
1344
- )}
1345
-
1346
- {/* Resource list */}
1347
- {localResources.length === 0 ? (
1348
- <p className="text-center text-xs text-muted-foreground py-1">
1349
- Nenhum recurso vinculado.
1350
- </p>
1351
- ) : (
1352
- <div className="flex flex-col gap-1">
1353
- {localResources.map((res) => {
1354
- const ResIcon = getResourceIcon(res.type);
1355
- return (
1356
- <div
1357
- key={res.id}
1358
- className="flex items-center gap-2 rounded-md border bg-muted/20 px-2.5 py-2"
1359
- >
1360
- <ResIcon
1361
- className={cn(
1362
- 'size-3.5 shrink-0',
1363
- getResourceIconColor(res.type)
1364
- )}
1365
- />
1366
- <div className="flex-1 min-w-0">
1367
- <p className="text-xs truncate font-medium">
1368
- {res.name}
1369
- </p>
1370
- <p className="text-[0.65rem] text-muted-foreground">
1371
- {res.size}
1372
- </p>
1373
- </div>
1374
- {res.public && (
1375
- <Eye
1376
- className="size-3 text-emerald-500 shrink-0"
1377
- aria-label="Público"
1378
- />
1379
- )}
1380
- {/* Abrir em nova aba */}
1381
- {res.url && (
1382
- <a
1383
- href={res.url}
1384
- target="_blank"
1385
- rel="noopener noreferrer"
1386
- aria-label={`Abrir ${res.name} em nova aba`}
1387
- onClick={(e) => e.stopPropagation()}
1388
- className="inline-flex items-center justify-center size-6 rounded-md text-muted-foreground hover:text-foreground hover:bg-accent transition-colors shrink-0"
1389
- >
1390
- <ExternalLink className="size-3" />
1391
- </a>
1392
- )}
1393
- {/* Download */}
1394
- <Button
1395
- type="button"
1396
- variant="ghost"
1397
- size="icon"
1398
- className="size-6 shrink-0 text-muted-foreground hover:text-foreground"
1399
- onClick={() => handleResourceDownload(res)}
1400
- aria-label={`Baixar ${res.name}`}
1401
- >
1402
- <Download className="size-3" />
1403
- </Button>
1404
- {/* Remover */}
1405
- <Button
1406
- type="button"
1407
- variant="ghost"
1408
- size="icon"
1409
- className="size-6 shrink-0 text-muted-foreground hover:text-destructive"
1410
- onClick={() => void removeResource(res.id)}
1411
- aria-label={`Remover ${res.name}`}
1412
- >
1413
- <X className="size-3" />
1414
- </Button>
1415
- </div>
1416
- );
1417
- })}
1418
- </div>
1419
- )}
1420
- </div>
1421
- </ScrollArea>
1422
- </TabsContent>
1423
- </Tabs>
1424
-
1425
- {/* ── Footer ───────────────────────────────────────────────────────── */}
1426
- <div className="shrink-0 border-t bg-background">
1427
- <Separator />
1428
- <div className="flex items-center gap-2 px-3 py-2">
1429
- <Button
1430
- type="button"
1431
- variant="ghost"
1432
- size="sm"
1433
- className="h-7 text-xs"
1434
- disabled={!isDirty || updateLesson.isPending}
1435
- onClick={() => form.reset()}
1436
- >
1437
- <Undo2 className="size-3 mr-1" />
1438
- Cancelar
1439
- </Button>
1440
- <div className="flex-1" />
1441
- <Button
1442
- type="submit"
1443
- size="sm"
1444
- className="h-7 text-xs"
1445
- disabled={!isDirty || updateLesson.isPending}
1446
- >
1447
- {updateLesson.isPending ? (
1448
- <Loader2 className="size-3 mr-1 animate-spin" />
1449
- ) : (
1450
- <Save className="size-3 mr-1" />
1451
- )}
1452
- Salvar aula
1453
- </Button>
1454
- </div>
1455
- </div>
1456
-
1457
- {/* ── Question Sheet ──────────────────────────────────────────────── */}
1458
- <Sheet open={questionSheetOpen} onOpenChange={setQuestionSheetOpen}>
1459
- <SheetContent
1460
- side="right"
1461
- className="flex w-full flex-col sm:max-w-lg overflow-y-auto"
1462
- >
1463
- <SheetHeader>
1464
- <SheetTitle>
1465
- {editingQuestion ? 'Editar questão' : 'Nova questão'}
1466
- </SheetTitle>
1467
- </SheetHeader>
1468
-
1469
- <div className="flex flex-1 flex-col gap-5 px-4 pb-4">
1470
- {/* Enunciado */}
1471
- <div className="flex flex-col gap-1.5">
1472
- <Label className="text-sm font-medium">Enunciado</Label>
1473
- <RichTextEditor
1474
- value={qSheetStatement}
1475
- onChange={setQSheetStatement}
1476
- />
1477
- {qSheetErrors.statement && (
1478
- <p className="text-xs text-destructive">
1479
- {qSheetErrors.statement}
1480
- </p>
1481
- )}
1482
- </div>
1483
-
1484
- {/* Tipo + Pontuação */}
1485
- <div className="grid grid-cols-2 gap-3">
1486
- <div className="flex flex-col gap-1.5">
1487
- <Label className="text-sm font-medium">Tipo</Label>
1488
- <Select
1489
- value={qSheetType}
1490
- onValueChange={(v) =>
1491
- handleQSheetTypeChange(v as QuestionType)
1492
- }
1493
- >
1494
- <SelectTrigger className="w-full">
1495
- <SelectValue />
1496
- </SelectTrigger>
1497
- <SelectContent>
1498
- {(
1499
- Object.keys(QUESTION_TYPE_LABELS) as QuestionType[]
1500
- ).map((t) => (
1501
- <SelectItem key={t} value={t}>
1502
- {QUESTION_TYPE_LABELS[t]}
1503
- </SelectItem>
1504
- ))}
1505
- </SelectContent>
1506
- </Select>
1507
- </div>
1508
- <div className="flex flex-col gap-1.5">
1509
- <Label className="text-sm font-medium">Pontuação</Label>
1510
- <Input
1511
- type="number"
1512
- min={0}
1513
- step={0.5}
1514
- value={qSheetPoints}
1515
- onChange={(e) => setQSheetPoints(Number(e.target.value))}
1516
- />
1517
- </div>
1518
- </div>
1519
-
1520
- <Separator />
1521
-
1522
- {/* Alternativas — múltipla escolha */}
1523
- {qSheetType === 'multiple_choice' && (
1524
- <div className="flex flex-col gap-2">
1525
- <div className="flex items-center justify-between">
1526
- <Label className="text-sm font-medium">Alternativas</Label>
1527
- <Button
1528
- type="button"
1529
- variant="outline"
1530
- size="sm"
1531
- className="h-7 text-xs gap-1 px-2"
1532
- onClick={() =>
1533
- setQSheetAlts((prev) => [
1534
- ...prev,
1535
- { id: generateAltId(), texto: '', correta: false },
1536
- ])
1537
- }
1538
- >
1539
- <Plus className="size-3" />
1540
- Adicionar
1541
- </Button>
1542
- </div>
1543
- {qSheetErrors.alts && (
1544
- <p className="text-xs text-destructive">
1545
- {qSheetErrors.alts}
1546
- </p>
1547
- )}
1548
- <DndContext
1549
- sensors={qSensors}
1550
- collisionDetection={closestCenter}
1551
- onDragEnd={({ active, over }) => {
1552
- if (over && active.id !== over.id) {
1553
- setQSheetAlts((prev) => {
1554
- const from = prev.findIndex(
1555
- (a) => a.id === active.id
1556
- );
1557
- const to = prev.findIndex((a) => a.id === over.id);
1558
- return arrayMove(prev, from, to);
1559
- });
1560
- }
1561
- }}
1562
- >
1563
- <SortableContext
1564
- items={qSheetAlts.map((a) => a.id)}
1565
- strategy={verticalListSortingStrategy}
1566
- >
1567
- {qSheetAlts.map((alt, idx) => (
1568
- <SortableAlternativa
1569
- key={alt.id}
1570
- alt={alt}
1571
- index={idx}
1572
- canRemove={qSheetAlts.length > 2}
1573
- onToggleCorrect={() =>
1574
- setQSheetAlts((prev) =>
1575
- prev.map((a) =>
1576
- a.id === alt.id
1577
- ? { ...a, correta: !a.correta }
1578
- : a
1579
- )
1580
- )
1581
- }
1582
- onChangeTexto={(v) =>
1583
- setQSheetAlts((prev) =>
1584
- prev.map((a) =>
1585
- a.id === alt.id ? { ...a, texto: v } : a
1586
- )
1587
- )
1588
- }
1589
- onRemove={() =>
1590
- setQSheetAlts((prev) =>
1591
- prev.filter((a) => a.id !== alt.id)
1592
- )
1593
- }
1594
- />
1595
- ))}
1596
- </SortableContext>
1597
- </DndContext>
1598
- </div>
1599
- )}
1600
-
1601
- {/* Alternativas — verdadeiro/falso */}
1602
- {qSheetType === 'true_false' && (
1603
- <div className="flex flex-col gap-2">
1604
- <Label className="text-sm font-medium">
1605
- Resposta correta
1606
- </Label>
1607
- {qSheetErrors.alts && (
1608
- <p className="text-xs text-destructive">
1609
- {qSheetErrors.alts}
1610
- </p>
1611
- )}
1612
- {qSheetAlts.map((alt) => (
1613
- <div
1614
- key={alt.id}
1615
- className={cn(
1616
- 'flex items-center gap-3 rounded-lg border p-3 cursor-pointer transition-colors',
1617
- alt.correta
1618
- ? 'border-foreground/30 bg-muted/50'
1619
- : 'bg-background hover:bg-muted/20'
1620
- )}
1621
- onClick={() =>
1622
- setQSheetAlts((prev) =>
1623
- prev.map((a) => ({
1624
- ...a,
1625
- correta: a.id === alt.id,
1626
- }))
1627
- )
1628
- }
1629
- >
1630
- {alt.correta ? (
1631
- <CircleDot className="size-4 shrink-0" />
1632
- ) : (
1633
- <CircleOff className="size-4 shrink-0 text-muted-foreground" />
1634
- )}
1635
- <span className="text-sm">{alt.texto}</span>
1636
- </div>
1637
- ))}
1638
- </div>
1639
- )}
1640
-
1641
- {/* Fill in the blank */}
1642
- {qSheetType === 'fill_blank' && (
1643
- <div className="flex flex-col gap-2">
1644
- <div className="flex items-center justify-between">
1645
- <Label className="text-sm font-medium">Lacunas</Label>
1646
- <Button
1647
- type="button"
1648
- variant="outline"
1649
- size="sm"
1650
- className="h-7 text-xs gap-1 px-2"
1651
- onClick={() =>
1652
- setQSheetFillBlanks((prev) => [
1653
- ...prev,
1654
- {
1655
- id: generateAltId(),
1656
- answer: '',
1657
- alternativesText: '',
1658
- },
1659
- ])
1660
- }
1661
- >
1662
- <Plus className="size-3" />
1663
- Adicionar
1664
- </Button>
1665
- </div>
1666
- {qSheetFillBlanks.map((fb, idx) => (
1667
- <div
1668
- key={fb.id}
1669
- className="flex flex-col gap-1.5 rounded-lg border p-3"
1670
- >
1671
- <div className="flex items-center justify-between">
1672
- <span className="text-xs font-medium text-muted-foreground">
1673
- Lacuna {idx + 1}
1674
- </span>
1675
- {qSheetFillBlanks.length > 1 && (
1676
- <button
1677
- type="button"
1678
- className="text-muted-foreground hover:text-destructive transition-colors"
1679
- onClick={() =>
1680
- setQSheetFillBlanks((prev) =>
1681
- prev.filter((f) => f.id !== fb.id)
1682
- )
1683
- }
1684
- aria-label="Remover lacuna"
1685
- >
1686
- <X className="size-3.5" />
1687
- </button>
1688
- )}
1689
- </div>
1690
- <Input
1691
- placeholder="Resposta correta"
1692
- value={fb.answer}
1693
- onChange={(e) =>
1694
- setQSheetFillBlanks((prev) =>
1695
- prev.map((f) =>
1696
- f.id === fb.id
1697
- ? { ...f, answer: e.target.value }
1698
- : f
1699
- )
1700
- )
1701
- }
1702
- />
1703
- <Input
1704
- placeholder="Alternativas separadas por vírgula (opcional)"
1705
- value={fb.alternativesText}
1706
- onChange={(e) =>
1707
- setQSheetFillBlanks((prev) =>
1708
- prev.map((f) =>
1709
- f.id === fb.id
1710
- ? { ...f, alternativesText: e.target.value }
1711
- : f
1712
- )
1713
- )
1714
- }
1715
- />
1716
- </div>
1717
- ))}
1718
- </div>
1719
- )}
1720
-
1721
- {/* Matching pairs */}
1722
- {qSheetType === 'matching' && (
1723
- <div className="flex flex-col gap-2">
1724
- <div className="flex items-center justify-between">
1725
- <Label className="text-sm font-medium">
1726
- Pares de associação
1727
- </Label>
1728
- <Button
1729
- type="button"
1730
- variant="outline"
1731
- size="sm"
1732
- className="h-7 text-xs gap-1 px-2"
1733
- onClick={() =>
1734
- setQSheetPairs((prev) => [
1735
- ...prev,
1736
- { id: generateAltId(), leftText: '', rightText: '' },
1737
- ])
1738
- }
1739
- >
1740
- <Plus className="size-3" />
1741
- Adicionar
1742
- </Button>
1743
- </div>
1744
- {qSheetPairs.map((pair, idx) => (
1745
- <div key={pair.id} className="flex items-center gap-2">
1746
- <span className="text-xs text-muted-foreground w-4 text-center">
1747
- {idx + 1}
1748
- </span>
1749
- <Input
1750
- placeholder="Esquerda"
1751
- value={pair.leftText}
1752
- onChange={(e) =>
1753
- setQSheetPairs((prev) =>
1754
- prev.map((p) =>
1755
- p.id === pair.id
1756
- ? { ...p, leftText: e.target.value }
1757
- : p
1758
- )
1759
- )
1760
- }
1761
- className="flex-1"
1762
- />
1763
- <span className="text-muted-foreground text-xs">↔</span>
1764
- <Input
1765
- placeholder="Direita"
1766
- value={pair.rightText}
1767
- onChange={(e) =>
1768
- setQSheetPairs((prev) =>
1769
- prev.map((p) =>
1770
- p.id === pair.id
1771
- ? { ...p, rightText: e.target.value }
1772
- : p
1773
- )
1774
- )
1775
- }
1776
- className="flex-1"
1777
- />
1778
- {qSheetPairs.length > 1 && (
1779
- <button
1780
- type="button"
1781
- className="text-muted-foreground hover:text-destructive transition-colors"
1782
- onClick={() =>
1783
- setQSheetPairs((prev) =>
1784
- prev.filter((p) => p.id !== pair.id)
1785
- )
1786
- }
1787
- aria-label="Remover par"
1788
- >
1789
- <X className="size-4" />
1790
- </button>
1791
- )}
1792
- </div>
1793
- ))}
1794
- </div>
1795
- )}
1796
-
1797
- {/* Dissertativa */}
1798
- {qSheetType === 'essay' && (
1799
- <div className="rounded-lg border bg-muted/30 p-4 text-center text-sm text-muted-foreground">
1800
- Questão dissertativa — o aluno responderá em texto livre.
1801
- </div>
1802
- )}
1803
- </div>
1804
-
1805
- <SheetFooter className="flex flex-row gap-2 px-4 pb-4">
1806
- <Button
1807
- type="button"
1808
- variant="outline"
1809
- className="flex-1"
1810
- onClick={() => setQuestionSheetOpen(false)}
1811
- >
1812
- Cancelar
1813
- </Button>
1814
- <Button type="button" className="flex-1" onClick={saveQuestion}>
1815
- <Save className="size-4 mr-1.5" />
1816
- {editingQuestion ? 'Salvar alterações' : 'Criar questão'}
1817
- </Button>
1818
- </SheetFooter>
1819
- </SheetContent>
1820
- </Sheet>
1821
- </form>
1822
- </Form>
1823
- );
1824
- }
1
+ 'use client';
2
+
3
+ import { zodResolver } from '@hookform/resolvers/zod';
4
+ import {
5
+ CircleDot,
6
+ CircleOff,
7
+ ClipboardList,
8
+ Clock,
9
+ Download,
10
+ ExternalLink,
11
+ Eye,
12
+ EyeOff,
13
+ File as FileIcon,
14
+ FileImage,
15
+ FileText,
16
+ GripVertical,
17
+ HelpCircle,
18
+ ListChecks,
19
+ Loader2,
20
+ Lock,
21
+ Pencil,
22
+ Plus,
23
+ Save,
24
+ Trash2,
25
+ Undo2,
26
+ UploadCloud,
27
+ Video,
28
+ X,
29
+ type LucideIcon,
30
+ } from 'lucide-react';
31
+ import { useEffect, useRef, useState } from 'react';
32
+ import { useForm, useWatch } from 'react-hook-form';
33
+ import { toast } from 'sonner';
34
+ import { z } from 'zod';
35
+
36
+ import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
37
+ import { Button } from '@/components/ui/button';
38
+ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
39
+ import { EntityPicker } from '@/components/ui/entity-picker';
40
+ import {
41
+ Form,
42
+ FormControl,
43
+ FormField,
44
+ FormItem,
45
+ FormLabel,
46
+ FormMessage,
47
+ } from '@/components/ui/form';
48
+ import { Input } from '@/components/ui/input';
49
+ import { Label } from '@/components/ui/label';
50
+ import { ScrollArea } from '@/components/ui/scroll-area';
51
+ import {
52
+ Select,
53
+ SelectContent,
54
+ SelectItem,
55
+ SelectTrigger,
56
+ SelectValue,
57
+ } from '@/components/ui/select';
58
+ import { Separator } from '@/components/ui/separator';
59
+ import {
60
+ Sheet,
61
+ SheetContent,
62
+ SheetFooter,
63
+ SheetHeader,
64
+ SheetTitle,
65
+ } from '@/components/ui/sheet';
66
+ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
67
+ import { Textarea } from '@/components/ui/textarea';
68
+ import { cn } from '@/lib/utils';
69
+
70
+ import {
71
+ closestCenter,
72
+ DndContext,
73
+ KeyboardSensor,
74
+ PointerSensor,
75
+ useSensor,
76
+ useSensors,
77
+ } from '@dnd-kit/core';
78
+ import {
79
+ arrayMove,
80
+ SortableContext,
81
+ sortableKeyboardCoordinates,
82
+ useSortable,
83
+ verticalListSortingStrategy,
84
+ } from '@dnd-kit/sortable';
85
+ import { CSS } from '@dnd-kit/utilities';
86
+
87
+ import { RichTextEditor } from '@/components/rich-text-editor';
88
+ import { useApp } from '@hed-hog/next-app-provider';
89
+ import { useQueryClient } from '@tanstack/react-query';
90
+ import {
91
+ deleteFile,
92
+ uploadFile,
93
+ } from '../_data/services/course-structure.service';
94
+ import {
95
+ useDeleteLessonMutation,
96
+ useUpdateLessonMutation,
97
+ } from '../_data/use-course-structure-mutations';
98
+ import {
99
+ courseStructureQueryKey,
100
+ type CourseStructureCacheData,
101
+ } from '../_data/use-course-structure-query';
102
+ import { useStructureStore } from './store';
103
+ import type {
104
+ LessonStatus,
105
+ LessonType,
106
+ Resource,
107
+ VideoProvider,
108
+ } from './types';
109
+
110
+ // ── Resource helpers ──────────────────────────────────────────────────────────
111
+
112
+ function getResourceIcon(type: string): LucideIcon {
113
+ if (type === 'application/pdf' || type.endsWith('pdf')) return FileText;
114
+ if (type.startsWith('image/')) return FileImage;
115
+ return FileIcon;
116
+ }
117
+
118
+ function getResourceIconColor(type: string): string {
119
+ if (type === 'application/pdf' || type.endsWith('pdf')) return 'text-red-500';
120
+ if (type.startsWith('image/')) return 'text-blue-500';
121
+ return 'text-muted-foreground';
122
+ }
123
+
124
+ function formatFileSize(bytes: number): string {
125
+ if (bytes < 1024) return `${bytes} B`;
126
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
127
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
128
+ }
129
+
130
+ // ── Config maps ───────────────────────────────────────────────────────────────
131
+
132
+ const TYPE_CONFIG: Record<
133
+ LessonType,
134
+ { icon: LucideIcon; color: string; bg: string; label: string }
135
+ > = {
136
+ video: {
137
+ icon: Video,
138
+ color: 'text-blue-500',
139
+ bg: 'bg-blue-500/10',
140
+ label: 'Vídeo',
141
+ },
142
+ post: {
143
+ icon: FileText,
144
+ color: 'text-emerald-500',
145
+ bg: 'bg-emerald-500/10',
146
+ label: 'Texto',
147
+ },
148
+ questao: {
149
+ icon: HelpCircle,
150
+ color: 'text-amber-500',
151
+ bg: 'bg-amber-500/10',
152
+ label: 'Quiz',
153
+ },
154
+ exercicio: {
155
+ icon: ClipboardList,
156
+ color: 'text-purple-500',
157
+ bg: 'bg-purple-500/10',
158
+ label: 'Exercício',
159
+ },
160
+ };
161
+
162
+ const STATUS_LABELS: Record<LessonStatus, string> = {
163
+ preparada: 'Preparada',
164
+ gravada: 'Gravada',
165
+ editada: 'Editada',
166
+ finalizada: 'Finalizada',
167
+ publicada: 'Publicada',
168
+ };
169
+
170
+ const STATUS_COLORS: Record<LessonStatus, string> = {
171
+ preparada:
172
+ 'bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-300',
173
+ gravada:
174
+ 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300',
175
+ editada:
176
+ 'bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300',
177
+ finalizada:
178
+ 'bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300',
179
+ publicada:
180
+ 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900 dark:text-emerald-300',
181
+ };
182
+
183
+ const VIDEO_PROVIDERS: { value: VideoProvider; label: string }[] = [
184
+ { value: 'youtube', label: 'YouTube' },
185
+ { value: 'vimeo', label: 'Vimeo' },
186
+ { value: 'bunny', label: 'Bunny.net' },
187
+ { value: 'custom', label: 'URL Direta' },
188
+ ];
189
+
190
+ // ── Question sheet types ──────────────────────────────────────────────────────
191
+
192
+ type QuestionType =
193
+ | 'multiple_choice'
194
+ | 'true_false'
195
+ | 'essay'
196
+ | 'fill_blank'
197
+ | 'matching';
198
+
199
+ const QUESTION_TYPE_LABELS: Record<QuestionType, string> = {
200
+ multiple_choice: 'Múltipla escolha',
201
+ true_false: 'Verdadeiro / Falso',
202
+ essay: 'Dissertativa',
203
+ fill_blank: 'Lacuna',
204
+ matching: 'Associação',
205
+ };
206
+
207
+ type QAlt = { id: string; texto: string; correta: boolean };
208
+ type QFillBlank = { id: string; answer: string; alternativesText: string };
209
+ type QMatchingPair = { id: string; leftText: string; rightText: string };
210
+
211
+ type MockQuestion = {
212
+ id: string;
213
+ title: string;
214
+ type: QuestionType;
215
+ statement?: string;
216
+ points?: number;
217
+ alternatives?: QAlt[];
218
+ fillBlankAnswers?: QFillBlank[];
219
+ matchingPairs?: QMatchingPair[];
220
+ };
221
+
222
+ function generateAltId(): string {
223
+ return Math.random().toString(36).slice(2, 9);
224
+ }
225
+
226
+ const MOCK_QUESTIONS: MockQuestion[] = [
227
+ {
228
+ id: 'q1',
229
+ title: 'O que é React?',
230
+ type: 'multiple_choice',
231
+ points: 1,
232
+ alternatives: [
233
+ {
234
+ id: 'a1',
235
+ texto: 'Uma biblioteca JavaScript para interfaces',
236
+ correta: true,
237
+ },
238
+ { id: 'a2', texto: 'Um framework CSS', correta: false },
239
+ { id: 'a3', texto: 'Um banco de dados', correta: false },
240
+ ],
241
+ },
242
+ {
243
+ id: 'q2',
244
+ title: 'Defina componente funcional',
245
+ type: 'essay',
246
+ points: 2,
247
+ },
248
+ {
249
+ id: 'q3',
250
+ title: 'TypeScript é um superset de JavaScript?',
251
+ type: 'true_false',
252
+ points: 1,
253
+ alternatives: [
254
+ { id: 'true', texto: 'Verdadeiro', correta: true },
255
+ { id: 'false', texto: 'Falso', correta: false },
256
+ ],
257
+ },
258
+ ];
259
+
260
+ // ── Schema ────────────────────────────────────────────────────────────────────
261
+
262
+ const schema = z.object({
263
+ code: z.string().min(1, 'Código obrigatório'),
264
+ title: z.string().min(1, 'Título obrigatório'),
265
+ type: z.enum(['video', 'post', 'questao', 'exercicio'] as const),
266
+ duration: z.coerce.number().min(0),
267
+ status: z.enum([
268
+ 'preparada',
269
+ 'gravada',
270
+ 'editada',
271
+ 'finalizada',
272
+ 'publicada',
273
+ ] as const),
274
+ visibility: z.enum(['publico', 'privado', 'restrito'] as const),
275
+ publicDescription: z.string(),
276
+ privateDescription: z.string(),
277
+ videoProvider: z
278
+ .enum(['youtube', 'vimeo', 'bunny', 'custom'] as const)
279
+ .optional(),
280
+ videoUrl: z.string().optional(),
281
+ transcription: z.string().optional(),
282
+ postContent: z.string().optional(),
283
+ questionId: z.string().nullable().optional(),
284
+ });
285
+
286
+ type FormValues = z.infer<typeof schema>;
287
+
288
+ // ── SortableAlternativa ───────────────────────────────────────────────────────
289
+
290
+ function SortableAlternativa({
291
+ alt,
292
+ index,
293
+ onToggleCorrect,
294
+ onChangeTexto,
295
+ onRemove,
296
+ canRemove,
297
+ disableText,
298
+ }: {
299
+ alt: QAlt;
300
+ index: number;
301
+ onToggleCorrect: () => void;
302
+ onChangeTexto: (v: string) => void;
303
+ onRemove: () => void;
304
+ canRemove: boolean;
305
+ disableText?: boolean;
306
+ }) {
307
+ const {
308
+ attributes,
309
+ listeners,
310
+ setNodeRef,
311
+ transform,
312
+ transition,
313
+ isDragging,
314
+ } = useSortable({ id: alt.id });
315
+ const style = { transform: CSS.Transform.toString(transform), transition };
316
+ return (
317
+ <div
318
+ ref={setNodeRef}
319
+ style={style}
320
+ className={cn(
321
+ 'flex items-center gap-2 rounded-lg border p-2 transition-colors',
322
+ isDragging ? 'z-50 bg-muted shadow-lg' : '',
323
+ alt.correta ? 'border-foreground/30 bg-muted/50' : 'bg-background'
324
+ )}
325
+ >
326
+ <button
327
+ type="button"
328
+ className="cursor-grab touch-none text-muted-foreground hover:text-foreground"
329
+ {...attributes}
330
+ {...listeners}
331
+ aria-label="Arrastar alternativa"
332
+ >
333
+ <GripVertical className="size-4" />
334
+ </button>
335
+ <span className="flex size-6 shrink-0 items-center justify-center rounded-full border text-xs font-medium">
336
+ {String.fromCharCode(65 + index)}
337
+ </span>
338
+ <Input
339
+ value={alt.texto}
340
+ onChange={(e) => onChangeTexto(e.target.value)}
341
+ className="flex-1 border-0 bg-transparent p-0 text-sm shadow-none focus-visible:ring-0"
342
+ placeholder="Texto da alternativa…"
343
+ disabled={disableText}
344
+ />
345
+ <button
346
+ type="button"
347
+ onClick={onToggleCorrect}
348
+ className={cn(
349
+ 'shrink-0 rounded-full p-1 transition-colors',
350
+ alt.correta
351
+ ? 'text-foreground'
352
+ : 'text-muted-foreground hover:text-foreground'
353
+ )}
354
+ aria-label={
355
+ alt.correta ? 'Marcar como incorreta' : 'Marcar como correta'
356
+ }
357
+ >
358
+ {alt.correta ? (
359
+ <CircleDot className="size-5" />
360
+ ) : (
361
+ <CircleOff className="size-5" />
362
+ )}
363
+ </button>
364
+ {canRemove && (
365
+ <button
366
+ type="button"
367
+ onClick={onRemove}
368
+ className="shrink-0 rounded p-1 text-muted-foreground transition-colors hover:text-destructive"
369
+ aria-label="Remover alternativa"
370
+ >
371
+ <X className="size-4" />
372
+ </button>
373
+ )}
374
+ </div>
375
+ );
376
+ }
377
+
378
+ // ── Component ─────────────────────────────────────────────────────────────────
379
+
380
+ interface EditorLessonProps {
381
+ lessonId: string;
382
+ }
383
+
384
+ export function EditorLesson({ lessonId }: EditorLessonProps) {
385
+ const lesson = useStructureStore((s) =>
386
+ s.lessons.find((l) => l.id === lessonId)
387
+ );
388
+ const updateLesson = useUpdateLessonMutation();
389
+ const deleteLesson = useDeleteLessonMutation();
390
+ const showConfirm = useStructureStore((s) => s.showConfirm);
391
+ const courseId = useStructureStore((s) => s.courseId);
392
+ const { request } = useApp();
393
+ const queryClient = useQueryClient();
394
+
395
+ const instructorPool =
396
+ queryClient.getQueryData<CourseStructureCacheData>(
397
+ courseStructureQueryKey(courseId)
398
+ )?.instructors ?? [];
399
+
400
+ const defaultValues = (): FormValues => ({
401
+ code: lesson?.code ?? '',
402
+ title: lesson?.title ?? '',
403
+ type: lesson?.type ?? 'video',
404
+ duration: lesson?.duration ?? 0,
405
+ status: lesson?.status ?? 'preparada',
406
+ visibility: lesson?.visibility ?? 'publico',
407
+ publicDescription: lesson?.publicDescription ?? '',
408
+ privateDescription: lesson?.privateDescription ?? '',
409
+ videoProvider: lesson?.videoProvider ?? 'youtube',
410
+ videoUrl: lesson?.videoUrl ?? '',
411
+ transcription: lesson?.transcription ?? '',
412
+ postContent: lesson?.postContent ?? '',
413
+ questionId: null,
414
+ });
415
+
416
+ const form = useForm<FormValues>({
417
+ resolver: zodResolver(schema),
418
+ defaultValues: defaultValues(),
419
+ });
420
+
421
+ const { isDirty } = form.formState;
422
+ const watchedType = useWatch({ control: form.control, name: 'type' });
423
+ const watchedStatus = useWatch({ control: form.control, name: 'status' });
424
+
425
+ useEffect(() => {
426
+ if (!lesson) return;
427
+ form.reset(defaultValues());
428
+ }, [lesson?.id]); // eslint-disable-line react-hooks/exhaustive-deps
429
+
430
+ // ── Local resources state ─────────────────────────────────────────────────
431
+ const [localResources, setLocalResources] = useState<Resource[]>(
432
+ () => lesson?.resources ?? []
433
+ );
434
+ const [dragOver, setDragOver] = useState(false);
435
+ const [isUploading, setIsUploading] = useState(false);
436
+ const resourceInputRef = useRef<HTMLInputElement>(null);
437
+
438
+ // ── Instructors state ────────────────────────────────────────────────────
439
+ const [selectedInstructorIds, setSelectedInstructorIds] = useState<string[]>(
440
+ () => lesson?.instructors?.map((i) => i.id) ?? []
441
+ );
442
+
443
+ // ── Question sheet state ────────────────────────────────────────────────────
444
+ const [questionSheetOpen, setQuestionSheetOpen] = useState(false);
445
+ const [editingQuestion, setEditingQuestion] = useState<MockQuestion | null>(
446
+ null
447
+ );
448
+ const [selectedQuestion, setSelectedQuestion] = useState<MockQuestion | null>(
449
+ null
450
+ );
451
+ const [qSheetStatement, setQSheetStatement] = useState('');
452
+ const [qSheetType, setQSheetType] = useState<QuestionType>('multiple_choice');
453
+ const [qSheetPoints, setQSheetPoints] = useState(1);
454
+ const [qSheetAlts, setQSheetAlts] = useState<QAlt[]>([]);
455
+ const [qSheetFillBlanks, setQSheetFillBlanks] = useState<QFillBlank[]>([]);
456
+ const [qSheetPairs, setQSheetPairs] = useState<QMatchingPair[]>([]);
457
+ const [qSheetErrors, setQSheetErrors] = useState<Record<string, string>>({});
458
+ const qSensors = useSensors(
459
+ useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
460
+ useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
461
+ );
462
+
463
+ useEffect(() => {
464
+ setLocalResources(lesson?.resources ?? []);
465
+ setSelectedInstructorIds(lesson?.instructors?.map((i) => i.id) ?? []);
466
+ }, [lesson?.id]); // eslint-disable-line react-hooks/exhaustive-deps
467
+
468
+ if (!lesson) return null;
469
+
470
+ const cfg = TYPE_CONFIG[lesson.type];
471
+ const Icon = cfg.icon;
472
+
473
+ async function handleResourceFiles(files: File[]) {
474
+ setIsUploading(true);
475
+ try {
476
+ const results = await Promise.allSettled(
477
+ files.map((f) =>
478
+ uploadFile(request, f).then<Resource>((res) => ({
479
+ id: String(res.id),
480
+ name: f.name,
481
+ size: formatFileSize(f.size),
482
+ type: f.type || f.name.split('.').pop() || 'file',
483
+ public: false,
484
+ url: undefined,
485
+ }))
486
+ )
487
+ );
488
+ const succeeded = results
489
+ .filter(
490
+ (r): r is PromiseFulfilledResult<Resource> => r.status === 'fulfilled'
491
+ )
492
+ .map((r) => r.value);
493
+ const failedCount = results.filter((r) => r.status === 'rejected').length;
494
+ if (failedCount > 0)
495
+ toast.error(
496
+ `${failedCount} arquivo${failedCount > 1 ? 's' : ''} não ${failedCount > 1 ? 'puderam' : 'pôde'} ser enviado${failedCount > 1 ? 's' : ''}`
497
+ );
498
+ if (succeeded.length > 0)
499
+ setLocalResources((prev) => [...prev, ...succeeded]);
500
+ } finally {
501
+ setIsUploading(false);
502
+ }
503
+ }
504
+
505
+ async function removeResource(id: string) {
506
+ const numId = Number(id);
507
+ if (Number.isInteger(numId) && numId > 0) {
508
+ try {
509
+ await deleteFile(request, numId);
510
+ } catch {
511
+ toast.error('Erro ao remover arquivo');
512
+ return;
513
+ }
514
+ } else {
515
+ const res = localResources.find((r) => r.id === id);
516
+ if (res?.url?.startsWith('blob:')) URL.revokeObjectURL(res.url);
517
+ }
518
+ setLocalResources((prev) => prev.filter((r) => r.id !== id));
519
+ }
520
+
521
+ function handleResourceDownload(res: Resource) {
522
+ if (!res.url) {
523
+ toast(`Download de ${res.name} — em breve`);
524
+ return;
525
+ }
526
+ const a = document.createElement('a');
527
+ a.href = res.url;
528
+ a.download = res.name;
529
+ a.click();
530
+ }
531
+
532
+ function onSubmit(values: FormValues) {
533
+ updateLesson.mutate({
534
+ lessonId,
535
+ sessionId: lesson!.sessionId,
536
+ formValues: {
537
+ ...values,
538
+ resources: localResources,
539
+ instructorIds: selectedInstructorIds.map(Number),
540
+ },
541
+ });
542
+ form.reset(values);
543
+ }
544
+
545
+ // ── Question sheet helpers ────────────────────────────────────────────────
546
+
547
+ function openCreateQuestion() {
548
+ setEditingQuestion(null);
549
+ setQSheetStatement('');
550
+ setQSheetType('multiple_choice');
551
+ setQSheetPoints(1);
552
+ setQSheetAlts([
553
+ { id: generateAltId(), texto: '', correta: false },
554
+ { id: generateAltId(), texto: '', correta: false },
555
+ ]);
556
+ setQSheetFillBlanks([
557
+ { id: generateAltId(), answer: '', alternativesText: '' },
558
+ ]);
559
+ setQSheetPairs([{ id: generateAltId(), leftText: '', rightText: '' }]);
560
+ setQSheetErrors({});
561
+ setQuestionSheetOpen(true);
562
+ }
563
+
564
+ function openEditQuestion(q: MockQuestion) {
565
+ setEditingQuestion(q);
566
+ setQSheetStatement(q.statement ?? q.title);
567
+ setQSheetType(q.type);
568
+ setQSheetPoints(q.points ?? 1);
569
+ setQSheetAlts(
570
+ q.alternatives?.length
571
+ ? q.alternatives
572
+ : [
573
+ { id: generateAltId(), texto: '', correta: false },
574
+ { id: generateAltId(), texto: '', correta: false },
575
+ ]
576
+ );
577
+ setQSheetFillBlanks(
578
+ q.fillBlankAnswers?.length
579
+ ? q.fillBlankAnswers
580
+ : [{ id: generateAltId(), answer: '', alternativesText: '' }]
581
+ );
582
+ setQSheetPairs(
583
+ q.matchingPairs?.length
584
+ ? q.matchingPairs
585
+ : [{ id: generateAltId(), leftText: '', rightText: '' }]
586
+ );
587
+ setQSheetErrors({});
588
+ setQuestionSheetOpen(true);
589
+ }
590
+
591
+ function handleQSheetTypeChange(v: QuestionType) {
592
+ setQSheetType(v);
593
+ setQSheetErrors({});
594
+ if (v === 'true_false') {
595
+ setQSheetAlts([
596
+ { id: 'true', texto: 'Verdadeiro', correta: true },
597
+ { id: 'false', texto: 'Falso', correta: false },
598
+ ]);
599
+ } else if (v === 'multiple_choice' && qSheetAlts.length === 0) {
600
+ setQSheetAlts([
601
+ { id: generateAltId(), texto: '', correta: false },
602
+ { id: generateAltId(), texto: '', correta: false },
603
+ ]);
604
+ } else if (v === 'fill_blank' && qSheetFillBlanks.length === 0) {
605
+ setQSheetFillBlanks([
606
+ { id: generateAltId(), answer: '', alternativesText: '' },
607
+ ]);
608
+ } else if (v === 'matching' && qSheetPairs.length === 0) {
609
+ setQSheetPairs([{ id: generateAltId(), leftText: '', rightText: '' }]);
610
+ }
611
+ }
612
+
613
+ function saveQuestion() {
614
+ const errors: Record<string, string> = {};
615
+ if (!qSheetStatement.trim()) errors.statement = 'Enunciado obrigatório';
616
+ if (
617
+ (qSheetType === 'multiple_choice' || qSheetType === 'true_false') &&
618
+ !qSheetAlts.some((a) => a.correta)
619
+ )
620
+ errors.alts = 'Selecione pelo menos uma resposta correta';
621
+ if (Object.keys(errors).length) {
622
+ setQSheetErrors(errors);
623
+ return;
624
+ }
625
+ const saved: MockQuestion = {
626
+ id: editingQuestion?.id ?? `q-${Date.now()}`,
627
+ title: qSheetStatement.replace(/<[^>]+>/g, '').slice(0, 80),
628
+ type: qSheetType,
629
+ statement: qSheetStatement,
630
+ points: qSheetPoints,
631
+ alternatives:
632
+ qSheetType === 'multiple_choice' || qSheetType === 'true_false'
633
+ ? qSheetAlts
634
+ : undefined,
635
+ fillBlankAnswers:
636
+ qSheetType === 'fill_blank' ? qSheetFillBlanks : undefined,
637
+ matchingPairs: qSheetType === 'matching' ? qSheetPairs : undefined,
638
+ };
639
+ setSelectedQuestion(saved);
640
+ form.setValue('questionId', saved.id, { shouldDirty: true });
641
+ setQuestionSheetOpen(false);
642
+ toast.success(editingQuestion ? 'Questão atualizada' : 'Questão criada');
643
+ }
644
+
645
+ function handleDelete() {
646
+ showConfirm({
647
+ title: `Excluir aula "${lesson!.title}"?`,
648
+ description: 'Esta ação não pode ser desfeita.',
649
+ onConfirm: () =>
650
+ deleteLesson.mutate({ lessonId, sessionId: lesson!.sessionId }),
651
+ });
652
+ }
653
+
654
+ return (
655
+ <Form {...form}>
656
+ <form
657
+ onSubmit={form.handleSubmit(onSubmit)}
658
+ className="flex flex-col h-full min-h-0"
659
+ >
660
+ {/* ── Header ───────────────────────────────────────────────────────── */}
661
+ <div className="flex items-center gap-3 px-4 py-3 border-b bg-muted/30 shrink-0">
662
+ <div
663
+ className={cn(
664
+ 'flex size-9 items-center justify-center rounded-lg shrink-0',
665
+ cfg.bg
666
+ )}
667
+ >
668
+ <Icon className={cn('size-4', cfg.color)} />
669
+ </div>
670
+ <div className="flex-1 min-w-0">
671
+ <div className="flex items-center gap-1.5">
672
+ <span className="text-sm font-semibold truncate">Aula</span>
673
+ {isDirty && (
674
+ <CircleDot className="size-3 text-amber-500 shrink-0" />
675
+ )}
676
+ </div>
677
+ <p className="text-[0.65rem] text-muted-foreground truncate">
678
+ {lesson.code} · {cfg.label}
679
+ </p>
680
+ </div>
681
+ {watchedStatus && (
682
+ <span
683
+ className={cn(
684
+ 'text-[0.65rem] font-medium px-2 py-0.5 rounded-full shrink-0',
685
+ STATUS_COLORS[watchedStatus]
686
+ )}
687
+ >
688
+ {STATUS_LABELS[watchedStatus]}
689
+ </span>
690
+ )}
691
+ <Button
692
+ type="button"
693
+ variant="ghost"
694
+ size="icon"
695
+ className="size-7 text-destructive/60 hover:text-destructive shrink-0"
696
+ title="Excluir aula"
697
+ aria-label="Excluir aula"
698
+ disabled={deleteLesson.isPending}
699
+ onClick={handleDelete}
700
+ >
701
+ {deleteLesson.isPending ? (
702
+ <Loader2 className="size-3.5 animate-spin" />
703
+ ) : (
704
+ <Trash2 className="size-3.5" />
705
+ )}
706
+ </Button>
707
+ </div>
708
+
709
+ {/* ── Tabs ─────────────────────────────────────────────────────────── */}
710
+ <Tabs defaultValue="dados" className="flex flex-col flex-1 min-h-0">
711
+ <TabsList className="mx-3 mt-2 h-8 w-auto justify-start shrink-0 bg-muted/50">
712
+ <TabsTrigger value="dados" className="text-xs h-7 px-2.5">
713
+ Dados
714
+ </TabsTrigger>
715
+ <TabsTrigger value="conteudo" className="text-xs h-7 px-2.5">
716
+ Conteúdo
717
+ </TabsTrigger>
718
+ <TabsTrigger value="recursos" className="text-xs h-7 px-2.5">
719
+ Recursos
720
+ </TabsTrigger>
721
+ </TabsList>
722
+
723
+ {/* ── Tab Dados ────────────────────────────────────────────────── */}
724
+ <TabsContent value="dados" className="flex-1 min-h-0 mt-0">
725
+ <ScrollArea className="h-full">
726
+ <div className="flex flex-col gap-3 p-3">
727
+ {/* Identificação */}
728
+ <Card className="bg-muted/20 py-2 gap-2">
729
+ <CardHeader className="px-3 pt-2 pb-0">
730
+ <CardTitle className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
731
+ Identificação
732
+ </CardTitle>
733
+ </CardHeader>
734
+ <CardContent className="px-3 pb-2 flex flex-col gap-3">
735
+ <div className="grid grid-cols-3 gap-2">
736
+ <FormField
737
+ control={form.control}
738
+ name="code"
739
+ render={({ field }) => (
740
+ <FormItem>
741
+ <FormLabel className="text-xs">Código</FormLabel>
742
+ <FormControl>
743
+ <Input
744
+ {...field}
745
+ className="h-8 text-xs font-mono"
746
+ />
747
+ </FormControl>
748
+ <FormMessage className="text-xs" />
749
+ </FormItem>
750
+ )}
751
+ />
752
+ <FormField
753
+ control={form.control}
754
+ name="duration"
755
+ render={({ field }) => (
756
+ <FormItem>
757
+ <FormLabel className="text-xs">
758
+ <Clock className="size-3 inline mr-1" />
759
+ Duração (min)
760
+ </FormLabel>
761
+ <FormControl>
762
+ <Input
763
+ {...field}
764
+ type="number"
765
+ className="h-8 text-xs"
766
+ />
767
+ </FormControl>
768
+ <FormMessage className="text-xs" />
769
+ </FormItem>
770
+ )}
771
+ />
772
+ <FormField
773
+ control={form.control}
774
+ name="type"
775
+ render={({ field }) => (
776
+ <FormItem>
777
+ <FormLabel className="text-xs">Tipo</FormLabel>
778
+ <Select
779
+ value={field.value}
780
+ onValueChange={field.onChange}
781
+ >
782
+ <FormControl>
783
+ <SelectTrigger className="h-8 text-xs w-full">
784
+ <SelectValue />
785
+ </SelectTrigger>
786
+ </FormControl>
787
+ <SelectContent>
788
+ {(
789
+ Object.entries(TYPE_CONFIG) as [
790
+ LessonType,
791
+ (typeof TYPE_CONFIG)[LessonType],
792
+ ][]
793
+ ).map(([val, cfg]) => {
794
+ const Ic = cfg.icon;
795
+ return (
796
+ <SelectItem key={val} value={val}>
797
+ <span className="flex items-center gap-1.5">
798
+ <Ic
799
+ className={cn('size-3', cfg.color)}
800
+ />
801
+ {cfg.label}
802
+ </span>
803
+ </SelectItem>
804
+ );
805
+ })}
806
+ </SelectContent>
807
+ </Select>
808
+ <FormMessage className="text-xs" />
809
+ </FormItem>
810
+ )}
811
+ />
812
+ </div>
813
+
814
+ <FormField
815
+ control={form.control}
816
+ name="title"
817
+ render={({ field }) => (
818
+ <FormItem>
819
+ <FormLabel className="text-xs">Título</FormLabel>
820
+ <FormControl>
821
+ <Input {...field} className="h-8 text-sm" />
822
+ </FormControl>
823
+ <FormMessage className="text-xs" />
824
+ </FormItem>
825
+ )}
826
+ />
827
+ </CardContent>
828
+ </Card>
829
+
830
+ {/* Publicação */}
831
+ <Card className="bg-muted/20 py-2 gap-2">
832
+ <CardHeader className="px-3 pt-2 pb-0">
833
+ <CardTitle className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
834
+ Publicação
835
+ </CardTitle>
836
+ </CardHeader>
837
+ <CardContent className="px-3 pb-2 flex flex-col gap-3">
838
+ <div className="grid grid-cols-2 gap-2">
839
+ <FormField
840
+ control={form.control}
841
+ name="status"
842
+ render={({ field }) => (
843
+ <FormItem>
844
+ <FormLabel className="text-xs">
845
+ Status de produção
846
+ </FormLabel>
847
+ <Select
848
+ value={field.value}
849
+ onValueChange={field.onChange}
850
+ >
851
+ <FormControl>
852
+ <SelectTrigger className="h-8 text-xs w-full">
853
+ <SelectValue />
854
+ </SelectTrigger>
855
+ </FormControl>
856
+ <SelectContent>
857
+ {(
858
+ Object.entries(STATUS_LABELS) as [
859
+ LessonStatus,
860
+ string,
861
+ ][]
862
+ ).map(([val, lbl]) => (
863
+ <SelectItem key={val} value={val}>
864
+ <span
865
+ className={cn(
866
+ 'text-xs px-1.5 py-0.5 rounded',
867
+ STATUS_COLORS[val]
868
+ )}
869
+ >
870
+ {lbl}
871
+ </span>
872
+ </SelectItem>
873
+ ))}
874
+ </SelectContent>
875
+ </Select>
876
+ <FormMessage className="text-xs" />
877
+ </FormItem>
878
+ )}
879
+ />
880
+
881
+ <FormField
882
+ control={form.control}
883
+ name="visibility"
884
+ render={({ field }) => (
885
+ <FormItem>
886
+ <FormLabel className="text-xs">
887
+ Visibilidade
888
+ </FormLabel>
889
+ <Select
890
+ value={field.value}
891
+ onValueChange={field.onChange}
892
+ >
893
+ <FormControl>
894
+ <SelectTrigger className="h-8 text-xs w-full">
895
+ <SelectValue />
896
+ </SelectTrigger>
897
+ </FormControl>
898
+ <SelectContent>
899
+ <SelectItem value="publico">
900
+ <span className="flex items-center gap-1.5">
901
+ <Eye className="size-3" /> Público
902
+ </span>
903
+ </SelectItem>
904
+ <SelectItem value="privado">
905
+ <span className="flex items-center gap-1.5">
906
+ <EyeOff className="size-3" /> Privado
907
+ </span>
908
+ </SelectItem>
909
+ <SelectItem value="restrito">
910
+ <span className="flex items-center gap-1.5">
911
+ <Lock className="size-3" /> Restrito
912
+ </span>
913
+ </SelectItem>
914
+ </SelectContent>
915
+ </Select>
916
+ <FormMessage className="text-xs" />
917
+ </FormItem>
918
+ )}
919
+ />
920
+ </div>
921
+ </CardContent>
922
+ </Card>
923
+
924
+ {/* Instrutores */}
925
+ <Card className="bg-muted/20 py-2 gap-2">
926
+ <CardHeader className="px-3 pt-2 pb-0">
927
+ <CardTitle className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
928
+ Instrutores
929
+ </CardTitle>
930
+ </CardHeader>
931
+ <CardContent className="px-3 pb-2 flex flex-col gap-2">
932
+ {/* Picker para adicionar */}
933
+ <EntityPicker<{ id: number; name: string }>
934
+ value={null}
935
+ onChange={(val) => {
936
+ if (val != null)
937
+ setSelectedInstructorIds((prev) =>
938
+ prev.includes(String(val))
939
+ ? prev
940
+ : [...prev, String(val)]
941
+ );
942
+ }}
943
+ placeholder="Adicionar instrutor…"
944
+ searchPlaceholder="Buscar instrutor…"
945
+ emptyLabel="Nenhum instrutor disponível"
946
+ entityLabel="Instrutor"
947
+ options={instructorPool.filter(
948
+ (i) => !selectedInstructorIds.includes(String(i.id))
949
+ )}
950
+ getOptionValue={(o) => o.id}
951
+ getOptionLabel={(o) => o.name}
952
+ />
953
+ {/* Lista de instrutores selecionados */}
954
+ {selectedInstructorIds.length > 0 && (
955
+ <div className="flex flex-col gap-1">
956
+ {selectedInstructorIds.map((sid) => {
957
+ const inst = instructorPool.find(
958
+ (i) => String(i.id) === sid
959
+ );
960
+ const displayName = inst?.name ?? sid;
961
+ return (
962
+ <div
963
+ key={sid}
964
+ className="flex items-center gap-2 rounded-md border bg-muted/20 px-2 py-1"
965
+ >
966
+ <Avatar className="size-6 shrink-0">
967
+ <AvatarImage
968
+ src={undefined}
969
+ alt={displayName}
970
+ />
971
+ <AvatarFallback className="text-[0.6rem] font-medium">
972
+ {displayName
973
+ .split(' ')
974
+ .slice(0, 2)
975
+ .map((n: string) => n[0])
976
+ .join('')
977
+ .toUpperCase()}
978
+ </AvatarFallback>
979
+ </Avatar>
980
+ <span className="text-xs flex-1 truncate">
981
+ {displayName}
982
+ </span>
983
+ <Button
984
+ type="button"
985
+ variant="ghost"
986
+ size="icon"
987
+ className="size-5 shrink-0 text-muted-foreground hover:text-destructive"
988
+ onClick={() =>
989
+ setSelectedInstructorIds((prev) =>
990
+ prev.filter((id) => id !== sid)
991
+ )
992
+ }
993
+ aria-label={`Remover ${displayName}`}
994
+ >
995
+ <X className="size-3" />
996
+ </Button>
997
+ </div>
998
+ );
999
+ })}
1000
+ </div>
1001
+ )}
1002
+ </CardContent>
1003
+ </Card>
1004
+ </div>
1005
+ </ScrollArea>
1006
+ </TabsContent>
1007
+
1008
+ {/* ── Tab Conteúdo ─────────────────────────────────────────────── */}
1009
+ <TabsContent value="conteudo" className="flex-1 min-h-0 mt-0">
1010
+ <ScrollArea className="h-full">
1011
+ <div className="flex flex-col gap-3 p-3">
1012
+ {/* Descrição pública */}
1013
+ <Card className="bg-muted/20 py-2 gap-2">
1014
+ <CardHeader className="px-3 pt-2 pb-1">
1015
+ <CardTitle className="text-xs font-medium text-muted-foreground uppercase tracking-wider flex items-center gap-1.5">
1016
+ <Eye className="size-3" /> Descrição pública
1017
+ </CardTitle>
1018
+ </CardHeader>
1019
+ <CardContent className="px-3 pb-2">
1020
+ <FormField
1021
+ control={form.control}
1022
+ name="publicDescription"
1023
+ render={({ field }) => (
1024
+ <FormItem>
1025
+ <FormControl>
1026
+ <RichTextEditor
1027
+ value={field.value}
1028
+ onChange={field.onChange}
1029
+ />
1030
+ </FormControl>
1031
+ <FormMessage className="text-xs" />
1032
+ </FormItem>
1033
+ )}
1034
+ />
1035
+ </CardContent>
1036
+ </Card>
1037
+
1038
+ {/* Notas internas */}
1039
+ <Card className="bg-muted/20 py-2 gap-2">
1040
+ <CardHeader className="px-3 pt-2 pb-1">
1041
+ <CardTitle className="text-xs font-medium text-muted-foreground uppercase tracking-wider flex items-center gap-1.5">
1042
+ <Lock className="size-3" /> Notas internas
1043
+ </CardTitle>
1044
+ </CardHeader>
1045
+ <CardContent className="px-3 pb-2">
1046
+ <FormField
1047
+ control={form.control}
1048
+ name="privateDescription"
1049
+ render={({ field }) => (
1050
+ <FormItem>
1051
+ <FormControl>
1052
+ <RichTextEditor
1053
+ value={field.value}
1054
+ onChange={field.onChange}
1055
+ className="border-amber-200/60 dark:border-amber-800/40"
1056
+ />
1057
+ </FormControl>
1058
+ <FormMessage className="text-xs" />
1059
+ </FormItem>
1060
+ )}
1061
+ />
1062
+ </CardContent>
1063
+ </Card>
1064
+
1065
+ {/* Campos específicos por tipo */}
1066
+ {watchedType === 'video' && (
1067
+ <Card className="bg-muted/20 py-2 gap-2">
1068
+ <CardHeader className="px-3 pt-2 pb-1">
1069
+ <CardTitle className="text-xs font-medium text-muted-foreground uppercase tracking-wider flex items-center gap-1.5">
1070
+ <Video className="size-3 text-blue-500" /> Vídeo
1071
+ </CardTitle>
1072
+ </CardHeader>
1073
+ <CardContent className="px-3 pb-2 flex flex-col gap-3">
1074
+ <FormField
1075
+ control={form.control}
1076
+ name="videoProvider"
1077
+ render={({ field }) => (
1078
+ <FormItem>
1079
+ <FormLabel className="text-xs">Provider</FormLabel>
1080
+ <Select
1081
+ value={field.value}
1082
+ onValueChange={field.onChange}
1083
+ >
1084
+ <FormControl>
1085
+ <SelectTrigger className="h-8 text-xs w-full">
1086
+ <SelectValue />
1087
+ </SelectTrigger>
1088
+ </FormControl>
1089
+ <SelectContent>
1090
+ {VIDEO_PROVIDERS.map((p) => (
1091
+ <SelectItem key={p.value} value={p.value}>
1092
+ {p.label}
1093
+ </SelectItem>
1094
+ ))}
1095
+ </SelectContent>
1096
+ </Select>
1097
+ <FormMessage className="text-xs" />
1098
+ </FormItem>
1099
+ )}
1100
+ />
1101
+
1102
+ <FormField
1103
+ control={form.control}
1104
+ name="videoUrl"
1105
+ render={({ field }) => (
1106
+ <FormItem>
1107
+ <FormLabel className="text-xs">
1108
+ URL do vídeo
1109
+ </FormLabel>
1110
+ <FormControl>
1111
+ <Input
1112
+ {...field}
1113
+ value={field.value ?? ''}
1114
+ className="h-8 text-xs font-mono"
1115
+ placeholder="https://…"
1116
+ />
1117
+ </FormControl>
1118
+ <FormMessage className="text-xs" />
1119
+ </FormItem>
1120
+ )}
1121
+ />
1122
+
1123
+ <FormField
1124
+ control={form.control}
1125
+ name="transcription"
1126
+ render={({ field }) => (
1127
+ <FormItem>
1128
+ <FormLabel className="text-xs">
1129
+ Transcrição
1130
+ </FormLabel>
1131
+ <FormControl>
1132
+ <Textarea
1133
+ {...field}
1134
+ value={field.value ?? ''}
1135
+ rows={5}
1136
+ className="text-xs resize-none font-mono"
1137
+ placeholder="Transcrição automática ou manual do vídeo…"
1138
+ />
1139
+ </FormControl>
1140
+ <FormMessage className="text-xs" />
1141
+ </FormItem>
1142
+ )}
1143
+ />
1144
+ </CardContent>
1145
+ </Card>
1146
+ )}
1147
+
1148
+ {watchedType === 'post' && (
1149
+ <Card className="bg-muted/20 py-2 gap-2">
1150
+ <CardHeader className="px-3 pt-2 pb-1">
1151
+ <CardTitle className="text-xs font-medium text-muted-foreground uppercase tracking-wider flex items-center gap-1.5">
1152
+ <FileText className="size-3 text-emerald-500" />{' '}
1153
+ Conteúdo do post
1154
+ </CardTitle>
1155
+ </CardHeader>
1156
+ <CardContent className="px-3 pb-2">
1157
+ <FormField
1158
+ control={form.control}
1159
+ name="postContent"
1160
+ render={({ field }) => (
1161
+ <FormItem>
1162
+ <FormControl>
1163
+ <RichTextEditor
1164
+ value={field.value ?? ''}
1165
+ onChange={field.onChange}
1166
+ />
1167
+ </FormControl>
1168
+ <FormMessage className="text-xs" />
1169
+ </FormItem>
1170
+ )}
1171
+ />
1172
+ </CardContent>
1173
+ </Card>
1174
+ )}
1175
+
1176
+ {watchedType === 'questao' && (
1177
+ <Card className="bg-muted/20 py-2 gap-2">
1178
+ <CardHeader className="px-3 pt-2 pb-1">
1179
+ <CardTitle className="text-xs font-medium text-muted-foreground uppercase tracking-wider flex items-center justify-between gap-1.5">
1180
+ <span className="flex items-center gap-1.5">
1181
+ <ListChecks className="size-3 text-amber-500" />{' '}
1182
+ Questão vinculada
1183
+ </span>
1184
+ <Button
1185
+ type="button"
1186
+ variant="outline"
1187
+ size="sm"
1188
+ className="h-6 text-xs px-2 gap-1"
1189
+ onClick={openCreateQuestion}
1190
+ >
1191
+ <Plus className="size-3" />
1192
+ Nova questão
1193
+ </Button>
1194
+ </CardTitle>
1195
+ </CardHeader>
1196
+ <CardContent className="px-3 pb-2 flex flex-col gap-2">
1197
+ <FormField
1198
+ control={form.control}
1199
+ name="questionId"
1200
+ render={({ field }) => (
1201
+ <FormItem>
1202
+ <FormControl>
1203
+ <EntityPicker<MockQuestion>
1204
+ value={field.value ?? null}
1205
+ onChange={(val) => {
1206
+ field.onChange(val);
1207
+ const found =
1208
+ MOCK_QUESTIONS.find((q) => q.id === val) ??
1209
+ null;
1210
+ setSelectedQuestion(found);
1211
+ }}
1212
+ placeholder="Selecionar questão…"
1213
+ searchPlaceholder="Buscar questão…"
1214
+ emptyLabel="Nenhuma questão encontrada"
1215
+ entityLabel="Questão"
1216
+ options={MOCK_QUESTIONS}
1217
+ getOptionValue={(o) => o.id}
1218
+ getOptionLabel={(o) => o.title}
1219
+ getOptionDescription={(o) =>
1220
+ QUESTION_TYPE_LABELS[o.type]
1221
+ }
1222
+ />
1223
+ </FormControl>
1224
+ <FormMessage className="text-xs" />
1225
+ </FormItem>
1226
+ )}
1227
+ />
1228
+ {selectedQuestion && (
1229
+ <div className="flex items-center gap-2 rounded-lg border bg-background px-3 py-2 text-sm">
1230
+ <HelpCircle className="size-4 shrink-0 text-amber-500" />
1231
+ <div className="flex-1 min-w-0">
1232
+ <p className="truncate font-medium text-xs">
1233
+ {selectedQuestion.title}
1234
+ </p>
1235
+ <p className="text-xs text-muted-foreground">
1236
+ {QUESTION_TYPE_LABELS[selectedQuestion.type]}
1237
+ {selectedQuestion.points != null &&
1238
+ ` · ${selectedQuestion.points} pt`}
1239
+ </p>
1240
+ </div>
1241
+ <Button
1242
+ type="button"
1243
+ variant="ghost"
1244
+ size="icon"
1245
+ className="size-7 shrink-0"
1246
+ onClick={() => openEditQuestion(selectedQuestion)}
1247
+ aria-label="Editar questão"
1248
+ >
1249
+ <Pencil className="size-3.5" />
1250
+ </Button>
1251
+ </div>
1252
+ )}
1253
+ </CardContent>
1254
+ </Card>
1255
+ )}
1256
+ </div>
1257
+ </ScrollArea>
1258
+ </TabsContent>
1259
+
1260
+ {/* ── Tab Recursos ─────────────────────────────────────────────── */}
1261
+ <TabsContent value="recursos" className="flex-1 min-h-0 mt-0">
1262
+ <ScrollArea className="h-full">
1263
+ <div className="flex flex-col gap-3 p-3">
1264
+ {/* Drop zone */}
1265
+ <div
1266
+ role="button"
1267
+ tabIndex={0}
1268
+ aria-label="Soltar arquivo ou clicar para selecionar"
1269
+ aria-disabled={isUploading}
1270
+ onDragOver={(e) => {
1271
+ e.preventDefault();
1272
+ if (!isUploading) setDragOver(true);
1273
+ }}
1274
+ onDragLeave={() => setDragOver(false)}
1275
+ onDrop={(e) => {
1276
+ e.preventDefault();
1277
+ setDragOver(false);
1278
+ if (!isUploading)
1279
+ void handleResourceFiles(
1280
+ Array.from(e.dataTransfer.files)
1281
+ );
1282
+ }}
1283
+ onClick={() => {
1284
+ if (!isUploading) resourceInputRef.current?.click();
1285
+ }}
1286
+ onKeyDown={(e) => {
1287
+ if (e.key === 'Enter' || e.key === ' ') {
1288
+ e.preventDefault();
1289
+ if (!isUploading) resourceInputRef.current?.click();
1290
+ }
1291
+ }}
1292
+ className={cn(
1293
+ 'flex flex-col items-center justify-center gap-2 py-7 rounded-lg border-2 border-dashed transition-colors select-none',
1294
+ isUploading
1295
+ ? 'cursor-wait border-border opacity-60'
1296
+ : 'cursor-pointer',
1297
+ !isUploading && dragOver
1298
+ ? 'border-primary/70 bg-primary/5'
1299
+ : !isUploading
1300
+ ? 'border-border hover:border-primary/40 hover:bg-muted/30'
1301
+ : ''
1302
+ )}
1303
+ >
1304
+ {isUploading ? (
1305
+ <Loader2 className="size-7 animate-spin text-muted-foreground/50" />
1306
+ ) : (
1307
+ <UploadCloud
1308
+ className={cn(
1309
+ 'size-7 transition-colors',
1310
+ dragOver ? 'text-primary' : 'text-muted-foreground/50'
1311
+ )}
1312
+ />
1313
+ )}
1314
+ <div className="text-center">
1315
+ <p className="text-xs font-medium">
1316
+ {isUploading ? 'Enviando…' : 'Solte o arquivo aqui'}
1317
+ </p>
1318
+ {!isUploading && (
1319
+ <p className="text-xs text-muted-foreground">
1320
+ ou clique para selecionar
1321
+ </p>
1322
+ )}
1323
+ </div>
1324
+ <input
1325
+ ref={resourceInputRef}
1326
+ type="file"
1327
+ multiple
1328
+ className="hidden"
1329
+ onChange={(e) => {
1330
+ if (e.target.files) {
1331
+ void handleResourceFiles(Array.from(e.target.files));
1332
+ e.target.value = '';
1333
+ }
1334
+ }}
1335
+ />
1336
+ </div>
1337
+
1338
+ {/* Counter */}
1339
+ {localResources.length > 0 && (
1340
+ <p className="text-xs text-muted-foreground">
1341
+ {localResources.length} recurso
1342
+ {localResources.length !== 1 ? 's' : ''}
1343
+ </p>
1344
+ )}
1345
+
1346
+ {/* Resource list */}
1347
+ {localResources.length === 0 ? (
1348
+ <p className="text-center text-xs text-muted-foreground py-1">
1349
+ Nenhum recurso vinculado.
1350
+ </p>
1351
+ ) : (
1352
+ <div className="flex flex-col gap-1">
1353
+ {localResources.map((res) => {
1354
+ const ResIcon = getResourceIcon(res.type);
1355
+ return (
1356
+ <div
1357
+ key={res.id}
1358
+ className="flex items-center gap-2 rounded-md border bg-muted/20 px-2.5 py-2"
1359
+ >
1360
+ <ResIcon
1361
+ className={cn(
1362
+ 'size-3.5 shrink-0',
1363
+ getResourceIconColor(res.type)
1364
+ )}
1365
+ />
1366
+ <div className="flex-1 min-w-0">
1367
+ <p className="text-xs truncate font-medium">
1368
+ {res.name}
1369
+ </p>
1370
+ <p className="text-[0.65rem] text-muted-foreground">
1371
+ {res.size}
1372
+ </p>
1373
+ </div>
1374
+ {res.public && (
1375
+ <Eye
1376
+ className="size-3 text-emerald-500 shrink-0"
1377
+ aria-label="Público"
1378
+ />
1379
+ )}
1380
+ {/* Abrir em nova aba */}
1381
+ {res.url && (
1382
+ <a
1383
+ href={res.url}
1384
+ target="_blank"
1385
+ rel="noopener noreferrer"
1386
+ aria-label={`Abrir ${res.name} em nova aba`}
1387
+ onClick={(e) => e.stopPropagation()}
1388
+ className="inline-flex items-center justify-center size-6 rounded-md text-muted-foreground hover:text-foreground hover:bg-accent transition-colors shrink-0"
1389
+ >
1390
+ <ExternalLink className="size-3" />
1391
+ </a>
1392
+ )}
1393
+ {/* Download */}
1394
+ <Button
1395
+ type="button"
1396
+ variant="ghost"
1397
+ size="icon"
1398
+ className="size-6 shrink-0 text-muted-foreground hover:text-foreground"
1399
+ onClick={() => handleResourceDownload(res)}
1400
+ aria-label={`Baixar ${res.name}`}
1401
+ >
1402
+ <Download className="size-3" />
1403
+ </Button>
1404
+ {/* Remover */}
1405
+ <Button
1406
+ type="button"
1407
+ variant="ghost"
1408
+ size="icon"
1409
+ className="size-6 shrink-0 text-muted-foreground hover:text-destructive"
1410
+ onClick={() => void removeResource(res.id)}
1411
+ aria-label={`Remover ${res.name}`}
1412
+ >
1413
+ <X className="size-3" />
1414
+ </Button>
1415
+ </div>
1416
+ );
1417
+ })}
1418
+ </div>
1419
+ )}
1420
+ </div>
1421
+ </ScrollArea>
1422
+ </TabsContent>
1423
+ </Tabs>
1424
+
1425
+ {/* ── Footer ───────────────────────────────────────────────────────── */}
1426
+ <div className="shrink-0 border-t bg-background">
1427
+ <Separator />
1428
+ <div className="flex items-center gap-2 px-3 py-2">
1429
+ <Button
1430
+ type="button"
1431
+ variant="ghost"
1432
+ size="sm"
1433
+ className="h-7 text-xs"
1434
+ disabled={!isDirty || updateLesson.isPending}
1435
+ onClick={() => form.reset()}
1436
+ >
1437
+ <Undo2 className="size-3 mr-1" />
1438
+ Cancelar
1439
+ </Button>
1440
+ <div className="flex-1" />
1441
+ <Button
1442
+ type="submit"
1443
+ size="sm"
1444
+ className="h-7 text-xs"
1445
+ disabled={!isDirty || updateLesson.isPending}
1446
+ >
1447
+ {updateLesson.isPending ? (
1448
+ <Loader2 className="size-3 mr-1 animate-spin" />
1449
+ ) : (
1450
+ <Save className="size-3 mr-1" />
1451
+ )}
1452
+ Salvar aula
1453
+ </Button>
1454
+ </div>
1455
+ </div>
1456
+
1457
+ {/* ── Question Sheet ──────────────────────────────────────────────── */}
1458
+ <Sheet open={questionSheetOpen} onOpenChange={setQuestionSheetOpen}>
1459
+ <SheetContent
1460
+ side="right"
1461
+ className="flex w-full flex-col sm:max-w-lg overflow-y-auto"
1462
+ >
1463
+ <SheetHeader>
1464
+ <SheetTitle>
1465
+ {editingQuestion ? 'Editar questão' : 'Nova questão'}
1466
+ </SheetTitle>
1467
+ </SheetHeader>
1468
+
1469
+ <div className="flex flex-1 flex-col gap-5 px-4 pb-4">
1470
+ {/* Enunciado */}
1471
+ <div className="flex flex-col gap-1.5">
1472
+ <Label className="text-sm font-medium">Enunciado</Label>
1473
+ <RichTextEditor
1474
+ value={qSheetStatement}
1475
+ onChange={setQSheetStatement}
1476
+ />
1477
+ {qSheetErrors.statement && (
1478
+ <p className="text-xs text-destructive">
1479
+ {qSheetErrors.statement}
1480
+ </p>
1481
+ )}
1482
+ </div>
1483
+
1484
+ {/* Tipo + Pontuação */}
1485
+ <div className="grid grid-cols-2 gap-3">
1486
+ <div className="flex flex-col gap-1.5">
1487
+ <Label className="text-sm font-medium">Tipo</Label>
1488
+ <Select
1489
+ value={qSheetType}
1490
+ onValueChange={(v) =>
1491
+ handleQSheetTypeChange(v as QuestionType)
1492
+ }
1493
+ >
1494
+ <SelectTrigger className="w-full">
1495
+ <SelectValue />
1496
+ </SelectTrigger>
1497
+ <SelectContent>
1498
+ {(
1499
+ Object.keys(QUESTION_TYPE_LABELS) as QuestionType[]
1500
+ ).map((t) => (
1501
+ <SelectItem key={t} value={t}>
1502
+ {QUESTION_TYPE_LABELS[t]}
1503
+ </SelectItem>
1504
+ ))}
1505
+ </SelectContent>
1506
+ </Select>
1507
+ </div>
1508
+ <div className="flex flex-col gap-1.5">
1509
+ <Label className="text-sm font-medium">Pontuação</Label>
1510
+ <Input
1511
+ type="number"
1512
+ min={0}
1513
+ step={0.5}
1514
+ value={qSheetPoints}
1515
+ onChange={(e) => setQSheetPoints(Number(e.target.value))}
1516
+ />
1517
+ </div>
1518
+ </div>
1519
+
1520
+ <Separator />
1521
+
1522
+ {/* Alternativas — múltipla escolha */}
1523
+ {qSheetType === 'multiple_choice' && (
1524
+ <div className="flex flex-col gap-2">
1525
+ <div className="flex items-center justify-between">
1526
+ <Label className="text-sm font-medium">Alternativas</Label>
1527
+ <Button
1528
+ type="button"
1529
+ variant="outline"
1530
+ size="sm"
1531
+ className="h-7 text-xs gap-1 px-2"
1532
+ onClick={() =>
1533
+ setQSheetAlts((prev) => [
1534
+ ...prev,
1535
+ { id: generateAltId(), texto: '', correta: false },
1536
+ ])
1537
+ }
1538
+ >
1539
+ <Plus className="size-3" />
1540
+ Adicionar
1541
+ </Button>
1542
+ </div>
1543
+ {qSheetErrors.alts && (
1544
+ <p className="text-xs text-destructive">
1545
+ {qSheetErrors.alts}
1546
+ </p>
1547
+ )}
1548
+ <DndContext
1549
+ sensors={qSensors}
1550
+ collisionDetection={closestCenter}
1551
+ onDragEnd={({ active, over }) => {
1552
+ if (over && active.id !== over.id) {
1553
+ setQSheetAlts((prev) => {
1554
+ const from = prev.findIndex(
1555
+ (a) => a.id === active.id
1556
+ );
1557
+ const to = prev.findIndex((a) => a.id === over.id);
1558
+ return arrayMove(prev, from, to);
1559
+ });
1560
+ }
1561
+ }}
1562
+ >
1563
+ <SortableContext
1564
+ items={qSheetAlts.map((a) => a.id)}
1565
+ strategy={verticalListSortingStrategy}
1566
+ >
1567
+ {qSheetAlts.map((alt, idx) => (
1568
+ <SortableAlternativa
1569
+ key={alt.id}
1570
+ alt={alt}
1571
+ index={idx}
1572
+ canRemove={qSheetAlts.length > 2}
1573
+ onToggleCorrect={() =>
1574
+ setQSheetAlts((prev) =>
1575
+ prev.map((a) =>
1576
+ a.id === alt.id
1577
+ ? { ...a, correta: !a.correta }
1578
+ : a
1579
+ )
1580
+ )
1581
+ }
1582
+ onChangeTexto={(v) =>
1583
+ setQSheetAlts((prev) =>
1584
+ prev.map((a) =>
1585
+ a.id === alt.id ? { ...a, texto: v } : a
1586
+ )
1587
+ )
1588
+ }
1589
+ onRemove={() =>
1590
+ setQSheetAlts((prev) =>
1591
+ prev.filter((a) => a.id !== alt.id)
1592
+ )
1593
+ }
1594
+ />
1595
+ ))}
1596
+ </SortableContext>
1597
+ </DndContext>
1598
+ </div>
1599
+ )}
1600
+
1601
+ {/* Alternativas — verdadeiro/falso */}
1602
+ {qSheetType === 'true_false' && (
1603
+ <div className="flex flex-col gap-2">
1604
+ <Label className="text-sm font-medium">
1605
+ Resposta correta
1606
+ </Label>
1607
+ {qSheetErrors.alts && (
1608
+ <p className="text-xs text-destructive">
1609
+ {qSheetErrors.alts}
1610
+ </p>
1611
+ )}
1612
+ {qSheetAlts.map((alt) => (
1613
+ <div
1614
+ key={alt.id}
1615
+ className={cn(
1616
+ 'flex items-center gap-3 rounded-lg border p-3 cursor-pointer transition-colors',
1617
+ alt.correta
1618
+ ? 'border-foreground/30 bg-muted/50'
1619
+ : 'bg-background hover:bg-muted/20'
1620
+ )}
1621
+ onClick={() =>
1622
+ setQSheetAlts((prev) =>
1623
+ prev.map((a) => ({
1624
+ ...a,
1625
+ correta: a.id === alt.id,
1626
+ }))
1627
+ )
1628
+ }
1629
+ >
1630
+ {alt.correta ? (
1631
+ <CircleDot className="size-4 shrink-0" />
1632
+ ) : (
1633
+ <CircleOff className="size-4 shrink-0 text-muted-foreground" />
1634
+ )}
1635
+ <span className="text-sm">{alt.texto}</span>
1636
+ </div>
1637
+ ))}
1638
+ </div>
1639
+ )}
1640
+
1641
+ {/* Fill in the blank */}
1642
+ {qSheetType === 'fill_blank' && (
1643
+ <div className="flex flex-col gap-2">
1644
+ <div className="flex items-center justify-between">
1645
+ <Label className="text-sm font-medium">Lacunas</Label>
1646
+ <Button
1647
+ type="button"
1648
+ variant="outline"
1649
+ size="sm"
1650
+ className="h-7 text-xs gap-1 px-2"
1651
+ onClick={() =>
1652
+ setQSheetFillBlanks((prev) => [
1653
+ ...prev,
1654
+ {
1655
+ id: generateAltId(),
1656
+ answer: '',
1657
+ alternativesText: '',
1658
+ },
1659
+ ])
1660
+ }
1661
+ >
1662
+ <Plus className="size-3" />
1663
+ Adicionar
1664
+ </Button>
1665
+ </div>
1666
+ {qSheetFillBlanks.map((fb, idx) => (
1667
+ <div
1668
+ key={fb.id}
1669
+ className="flex flex-col gap-1.5 rounded-lg border p-3"
1670
+ >
1671
+ <div className="flex items-center justify-between">
1672
+ <span className="text-xs font-medium text-muted-foreground">
1673
+ Lacuna {idx + 1}
1674
+ </span>
1675
+ {qSheetFillBlanks.length > 1 && (
1676
+ <button
1677
+ type="button"
1678
+ className="text-muted-foreground hover:text-destructive transition-colors"
1679
+ onClick={() =>
1680
+ setQSheetFillBlanks((prev) =>
1681
+ prev.filter((f) => f.id !== fb.id)
1682
+ )
1683
+ }
1684
+ aria-label="Remover lacuna"
1685
+ >
1686
+ <X className="size-3.5" />
1687
+ </button>
1688
+ )}
1689
+ </div>
1690
+ <Input
1691
+ placeholder="Resposta correta"
1692
+ value={fb.answer}
1693
+ onChange={(e) =>
1694
+ setQSheetFillBlanks((prev) =>
1695
+ prev.map((f) =>
1696
+ f.id === fb.id
1697
+ ? { ...f, answer: e.target.value }
1698
+ : f
1699
+ )
1700
+ )
1701
+ }
1702
+ />
1703
+ <Input
1704
+ placeholder="Alternativas separadas por vírgula (opcional)"
1705
+ value={fb.alternativesText}
1706
+ onChange={(e) =>
1707
+ setQSheetFillBlanks((prev) =>
1708
+ prev.map((f) =>
1709
+ f.id === fb.id
1710
+ ? { ...f, alternativesText: e.target.value }
1711
+ : f
1712
+ )
1713
+ )
1714
+ }
1715
+ />
1716
+ </div>
1717
+ ))}
1718
+ </div>
1719
+ )}
1720
+
1721
+ {/* Matching pairs */}
1722
+ {qSheetType === 'matching' && (
1723
+ <div className="flex flex-col gap-2">
1724
+ <div className="flex items-center justify-between">
1725
+ <Label className="text-sm font-medium">
1726
+ Pares de associação
1727
+ </Label>
1728
+ <Button
1729
+ type="button"
1730
+ variant="outline"
1731
+ size="sm"
1732
+ className="h-7 text-xs gap-1 px-2"
1733
+ onClick={() =>
1734
+ setQSheetPairs((prev) => [
1735
+ ...prev,
1736
+ { id: generateAltId(), leftText: '', rightText: '' },
1737
+ ])
1738
+ }
1739
+ >
1740
+ <Plus className="size-3" />
1741
+ Adicionar
1742
+ </Button>
1743
+ </div>
1744
+ {qSheetPairs.map((pair, idx) => (
1745
+ <div key={pair.id} className="flex items-center gap-2">
1746
+ <span className="text-xs text-muted-foreground w-4 text-center">
1747
+ {idx + 1}
1748
+ </span>
1749
+ <Input
1750
+ placeholder="Esquerda"
1751
+ value={pair.leftText}
1752
+ onChange={(e) =>
1753
+ setQSheetPairs((prev) =>
1754
+ prev.map((p) =>
1755
+ p.id === pair.id
1756
+ ? { ...p, leftText: e.target.value }
1757
+ : p
1758
+ )
1759
+ )
1760
+ }
1761
+ className="flex-1"
1762
+ />
1763
+ <span className="text-muted-foreground text-xs">↔</span>
1764
+ <Input
1765
+ placeholder="Direita"
1766
+ value={pair.rightText}
1767
+ onChange={(e) =>
1768
+ setQSheetPairs((prev) =>
1769
+ prev.map((p) =>
1770
+ p.id === pair.id
1771
+ ? { ...p, rightText: e.target.value }
1772
+ : p
1773
+ )
1774
+ )
1775
+ }
1776
+ className="flex-1"
1777
+ />
1778
+ {qSheetPairs.length > 1 && (
1779
+ <button
1780
+ type="button"
1781
+ className="text-muted-foreground hover:text-destructive transition-colors"
1782
+ onClick={() =>
1783
+ setQSheetPairs((prev) =>
1784
+ prev.filter((p) => p.id !== pair.id)
1785
+ )
1786
+ }
1787
+ aria-label="Remover par"
1788
+ >
1789
+ <X className="size-4" />
1790
+ </button>
1791
+ )}
1792
+ </div>
1793
+ ))}
1794
+ </div>
1795
+ )}
1796
+
1797
+ {/* Dissertativa */}
1798
+ {qSheetType === 'essay' && (
1799
+ <div className="rounded-lg border bg-muted/30 p-4 text-center text-sm text-muted-foreground">
1800
+ Questão dissertativa — o aluno responderá em texto livre.
1801
+ </div>
1802
+ )}
1803
+ </div>
1804
+
1805
+ <SheetFooter className="flex flex-row gap-2 px-4 pb-4">
1806
+ <Button
1807
+ type="button"
1808
+ variant="outline"
1809
+ className="flex-1"
1810
+ onClick={() => setQuestionSheetOpen(false)}
1811
+ >
1812
+ Cancelar
1813
+ </Button>
1814
+ <Button type="button" className="flex-1" onClick={saveQuestion}>
1815
+ <Save className="size-4 mr-1.5" />
1816
+ {editingQuestion ? 'Salvar alterações' : 'Criar questão'}
1817
+ </Button>
1818
+ </SheetFooter>
1819
+ </SheetContent>
1820
+ </Sheet>
1821
+ </form>
1822
+ </Form>
1823
+ );
1824
+ }