@hed-hog/lms 0.0.306 → 0.0.310

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 (120) hide show
  1. package/dist/course/course-structure.controller.d.ts +60 -0
  2. package/dist/course/course-structure.controller.d.ts.map +1 -1
  3. package/dist/course/course-structure.controller.js +79 -0
  4. package/dist/course/course-structure.controller.js.map +1 -1
  5. package/dist/course/course-structure.service.d.ts +61 -1
  6. package/dist/course/course-structure.service.d.ts.map +1 -1
  7. package/dist/course/course-structure.service.js +326 -1
  8. package/dist/course/course-structure.service.js.map +1 -1
  9. package/dist/course/course.controller.d.ts +52 -4
  10. package/dist/course/course.controller.d.ts.map +1 -1
  11. package/dist/course/course.service.d.ts +52 -5
  12. package/dist/course/course.service.d.ts.map +1 -1
  13. package/dist/course/course.service.js +78 -57
  14. package/dist/course/course.service.js.map +1 -1
  15. package/dist/course/dto/create-course-structure-lesson.dto.d.ts.map +1 -1
  16. package/dist/course/dto/create-course-structure-lesson.dto.js +5 -1
  17. package/dist/course/dto/create-course-structure-lesson.dto.js.map +1 -1
  18. package/dist/course/dto/create-course.dto.d.ts +1 -1
  19. package/dist/course/dto/create-course.dto.d.ts.map +1 -1
  20. package/dist/course/dto/create-course.dto.js +4 -1
  21. package/dist/course/dto/create-course.dto.js.map +1 -1
  22. package/dist/course/dto/move-lesson.dto.d.ts +10 -0
  23. package/dist/course/dto/move-lesson.dto.d.ts.map +1 -0
  24. package/dist/course/dto/move-lesson.dto.js +28 -0
  25. package/dist/course/dto/move-lesson.dto.js.map +1 -0
  26. package/dist/course/dto/paste-lessons.dto.d.ts +4 -0
  27. package/dist/course/dto/paste-lessons.dto.d.ts.map +1 -0
  28. package/dist/course/dto/paste-lessons.dto.js +24 -0
  29. package/dist/course/dto/paste-lessons.dto.js.map +1 -0
  30. package/dist/course/dto/reorder-lessons.dto.d.ts +5 -0
  31. package/dist/course/dto/reorder-lessons.dto.d.ts.map +1 -0
  32. package/dist/course/dto/reorder-lessons.dto.js +24 -0
  33. package/dist/course/dto/reorder-lessons.dto.js.map +1 -0
  34. package/dist/course/dto/reorder-sessions.dto.d.ts +5 -0
  35. package/dist/course/dto/reorder-sessions.dto.d.ts.map +1 -0
  36. package/dist/course/dto/reorder-sessions.dto.js +24 -0
  37. package/dist/course/dto/reorder-sessions.dto.js.map +1 -0
  38. package/dist/training/training.controller.js +1 -1
  39. package/dist/training/training.controller.js.map +1 -1
  40. package/hedhog/data/image_type.yaml +20 -0
  41. package/hedhog/data/menu.yaml +2 -2
  42. package/hedhog/data/route.yaml +60 -6
  43. package/hedhog/frontend/app/_components/class-form-sheet.tsx.ejs +146 -165
  44. package/hedhog/frontend/app/_components/course-avatar.tsx.ejs +70 -0
  45. package/hedhog/frontend/app/_components/course-form-sheet.tsx.ejs +372 -22
  46. package/hedhog/frontend/app/certificates/models/CanvasStage.tsx.ejs +10 -1
  47. package/hedhog/frontend/app/certificates/models/TopBar.tsx.ejs +44 -3
  48. package/hedhog/frontend/app/certificates/models/page.tsx.ejs +32 -0
  49. package/hedhog/frontend/app/classes/[id]/page.tsx.ejs +437 -77
  50. package/hedhog/frontend/app/classes/page.tsx.ejs +311 -289
  51. package/hedhog/frontend/app/courses/[id]/_components/CourseCertificateCard.tsx.ejs +10 -7
  52. package/hedhog/frontend/app/courses/[id]/_components/CourseClassificationCard.tsx.ejs +23 -32
  53. package/hedhog/frontend/app/courses/[id]/_components/CourseContentCard.tsx.ejs +3 -9
  54. package/hedhog/frontend/app/courses/[id]/_components/CourseDangerZoneCard.tsx.ejs +26 -16
  55. package/hedhog/frontend/app/courses/[id]/_components/CourseFlagsCard.tsx.ejs +19 -5
  56. package/hedhog/frontend/app/courses/[id]/_components/CourseMainInfoCard.tsx.ejs +10 -14
  57. package/hedhog/frontend/app/courses/[id]/_components/CourseMediaCard.tsx.ejs +131 -107
  58. package/hedhog/frontend/app/courses/[id]/_components/CourseRelationsCard.tsx.ejs +10 -7
  59. package/hedhog/frontend/app/courses/[id]/_components/CourseSectionCard.tsx.ejs +38 -19
  60. package/hedhog/frontend/app/courses/[id]/_components/CourseSummaryCard.tsx.ejs +1 -1
  61. package/hedhog/frontend/app/courses/[id]/_components/course-edit-types.ts.ejs +1 -1
  62. package/hedhog/frontend/app/courses/[id]/page.tsx.ejs +336 -1057
  63. package/hedhog/frontend/app/courses/[id]/structure/_components/confirm-dialog.tsx.ejs +45 -0
  64. package/hedhog/frontend/app/courses/[id]/structure/_components/course-tree-dnd.tsx.ejs +362 -0
  65. package/hedhog/frontend/app/courses/[id]/structure/_components/course-tree-panel.tsx.ejs +111 -0
  66. package/hedhog/frontend/app/courses/[id]/structure/_components/course-tree-skeleton.tsx.ejs +64 -0
  67. package/hedhog/frontend/app/courses/[id]/structure/_components/course-tree.tsx.ejs +134 -0
  68. package/hedhog/frontend/app/courses/[id]/structure/_components/detail-course.tsx.ejs +113 -0
  69. package/hedhog/frontend/app/courses/[id]/structure/_components/detail-lesson.tsx.ejs +314 -0
  70. package/hedhog/frontend/app/courses/[id]/structure/_components/detail-panel.tsx.ejs +62 -0
  71. package/hedhog/frontend/app/courses/[id]/structure/_components/detail-session.tsx.ejs +174 -0
  72. package/hedhog/frontend/app/courses/[id]/structure/_components/drag-handle.tsx.ejs +58 -0
  73. package/hedhog/frontend/app/courses/[id]/structure/_components/drag-overlay.tsx.ejs +52 -0
  74. package/hedhog/frontend/app/courses/[id]/structure/_components/editor-bulk.tsx.ejs +276 -0
  75. package/hedhog/frontend/app/courses/[id]/structure/_components/editor-course.tsx.ejs +1216 -0
  76. package/hedhog/frontend/app/courses/[id]/structure/_components/editor-lesson.tsx.ejs +1827 -0
  77. package/hedhog/frontend/app/courses/[id]/structure/_components/editor-session.tsx.ejs +443 -0
  78. package/hedhog/frontend/app/courses/[id]/structure/_components/highlighted-text.tsx.ejs +41 -0
  79. package/hedhog/frontend/app/courses/[id]/structure/_components/mock-data.ts.ejs +184 -0
  80. package/hedhog/frontend/app/courses/[id]/structure/_components/multi-select-bar.tsx.ejs +264 -0
  81. package/hedhog/frontend/app/courses/[id]/structure/_components/search-filter.tsx.ejs +96 -0
  82. package/hedhog/frontend/app/courses/[id]/structure/_components/session-picker-dialog.tsx.ejs +74 -0
  83. package/hedhog/frontend/app/courses/[id]/structure/_components/shortcuts-help.tsx.ejs +136 -0
  84. package/hedhog/frontend/app/courses/[id]/structure/_components/sortable-tree-row.tsx.ejs +80 -0
  85. package/hedhog/frontend/app/courses/[id]/structure/_components/store.ts.ejs +948 -0
  86. package/hedhog/frontend/app/courses/[id]/structure/_components/tree-context-menu.tsx.ejs +525 -0
  87. package/hedhog/frontend/app/courses/[id]/structure/_components/tree-display-settings-popover.tsx.ejs +150 -0
  88. package/hedhog/frontend/app/courses/[id]/structure/_components/tree-helpers.ts.ejs +182 -0
  89. package/hedhog/frontend/app/courses/[id]/structure/_components/tree-row-course.tsx.ejs +52 -0
  90. package/hedhog/frontend/app/courses/[id]/structure/_components/tree-row-lesson.tsx.ejs +271 -0
  91. package/hedhog/frontend/app/courses/[id]/structure/_components/tree-row-session.tsx.ejs +167 -0
  92. package/hedhog/frontend/app/courses/[id]/structure/_components/tree-row.tsx.ejs +108 -0
  93. package/hedhog/frontend/app/courses/[id]/structure/_components/types.ts.ejs +122 -0
  94. package/hedhog/frontend/app/courses/[id]/structure/_components/use-course-structure-shortcuts.ts.ejs +318 -0
  95. package/hedhog/frontend/app/courses/[id]/structure/_components/use-tree-display-settings.ts.ejs +97 -0
  96. package/hedhog/frontend/app/courses/[id]/structure/_data/adapters/course-structure.adapter.ts.ejs +347 -0
  97. package/hedhog/frontend/app/courses/[id]/structure/_data/course-structure-contract.ts.ejs +195 -0
  98. package/hedhog/frontend/app/courses/[id]/structure/_data/services/course-structure.service.ts.ejs +420 -0
  99. package/hedhog/frontend/app/courses/[id]/structure/_data/types/api-course.types.ts.ejs +254 -0
  100. package/hedhog/frontend/app/courses/[id]/structure/_data/use-course-structure-mutations.ts.ejs +987 -0
  101. package/hedhog/frontend/app/courses/[id]/structure/_data/use-course-structure-query.ts.ejs +86 -0
  102. package/hedhog/frontend/app/courses/[id]/structure/_data/use-course-structure.ts.ejs +160 -0
  103. package/hedhog/frontend/app/courses/[id]/structure/page.tsx.ejs +10 -3212
  104. package/hedhog/frontend/app/courses/page.tsx.ejs +45 -26
  105. package/hedhog/frontend/app/{training → paths}/page.tsx.ejs +29 -7
  106. package/hedhog/frontend/messages/en.json +91 -11
  107. package/hedhog/frontend/messages/pt.json +91 -11
  108. package/hedhog/table/course.yaml +1 -1
  109. package/hedhog/table/image_type.yaml +14 -0
  110. package/package.json +7 -7
  111. package/src/course/course-structure.controller.ts +63 -0
  112. package/src/course/course-structure.service.ts +390 -3
  113. package/src/course/course.service.ts +59 -27
  114. package/src/course/dto/create-course-structure-lesson.dto.ts +3 -2
  115. package/src/course/dto/create-course.dto.ts +4 -1
  116. package/src/course/dto/move-lesson.dto.ts +17 -0
  117. package/src/course/dto/paste-lessons.dto.ts +9 -0
  118. package/src/course/dto/reorder-lessons.dto.ts +10 -0
  119. package/src/course/dto/reorder-sessions.dto.ts +10 -0
  120. package/src/training/training.controller.ts +1 -1
@@ -0,0 +1,1827 @@
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(
479
+ (res) =>
480
+ ({
481
+ id: String(res.id),
482
+ name: f.name,
483
+ size: formatFileSize(f.size),
484
+ type: f.type || f.name.split('.').pop() || 'file',
485
+ public: false,
486
+ url: undefined,
487
+ }) satisfies Resource
488
+ )
489
+ )
490
+ );
491
+ const succeeded = results
492
+ .filter(
493
+ (r): r is PromiseFulfilledResult<Resource> => r.status === 'fulfilled'
494
+ )
495
+ .map((r) => r.value);
496
+ const failedCount = results.filter((r) => r.status === 'rejected').length;
497
+ if (failedCount > 0)
498
+ toast.error(
499
+ `${failedCount} arquivo${failedCount > 1 ? 's' : ''} não ${failedCount > 1 ? 'puderam' : 'pôde'} ser enviado${failedCount > 1 ? 's' : ''}`
500
+ );
501
+ if (succeeded.length > 0)
502
+ setLocalResources((prev) => [...prev, ...succeeded]);
503
+ } finally {
504
+ setIsUploading(false);
505
+ }
506
+ }
507
+
508
+ async function removeResource(id: string) {
509
+ const numId = Number(id);
510
+ if (Number.isInteger(numId) && numId > 0) {
511
+ try {
512
+ await deleteFile(request, numId);
513
+ } catch {
514
+ toast.error('Erro ao remover arquivo');
515
+ return;
516
+ }
517
+ } else {
518
+ const res = localResources.find((r) => r.id === id);
519
+ if (res?.url?.startsWith('blob:')) URL.revokeObjectURL(res.url);
520
+ }
521
+ setLocalResources((prev) => prev.filter((r) => r.id !== id));
522
+ }
523
+
524
+ function handleResourceDownload(res: Resource) {
525
+ if (!res.url) {
526
+ toast(`Download de ${res.name} — em breve`);
527
+ return;
528
+ }
529
+ const a = document.createElement('a');
530
+ a.href = res.url;
531
+ a.download = res.name;
532
+ a.click();
533
+ }
534
+
535
+ function onSubmit(values: FormValues) {
536
+ updateLesson.mutate({
537
+ lessonId,
538
+ sessionId: lesson!.sessionId,
539
+ formValues: {
540
+ ...values,
541
+ resources: localResources,
542
+ instructorIds: selectedInstructorIds.map(Number),
543
+ },
544
+ });
545
+ form.reset(values);
546
+ }
547
+
548
+ // ── Question sheet helpers ────────────────────────────────────────────────
549
+
550
+ function openCreateQuestion() {
551
+ setEditingQuestion(null);
552
+ setQSheetStatement('');
553
+ setQSheetType('multiple_choice');
554
+ setQSheetPoints(1);
555
+ setQSheetAlts([
556
+ { id: generateAltId(), texto: '', correta: false },
557
+ { id: generateAltId(), texto: '', correta: false },
558
+ ]);
559
+ setQSheetFillBlanks([
560
+ { id: generateAltId(), answer: '', alternativesText: '' },
561
+ ]);
562
+ setQSheetPairs([{ id: generateAltId(), leftText: '', rightText: '' }]);
563
+ setQSheetErrors({});
564
+ setQuestionSheetOpen(true);
565
+ }
566
+
567
+ function openEditQuestion(q: MockQuestion) {
568
+ setEditingQuestion(q);
569
+ setQSheetStatement(q.statement ?? q.title);
570
+ setQSheetType(q.type);
571
+ setQSheetPoints(q.points ?? 1);
572
+ setQSheetAlts(
573
+ q.alternatives?.length
574
+ ? q.alternatives
575
+ : [
576
+ { id: generateAltId(), texto: '', correta: false },
577
+ { id: generateAltId(), texto: '', correta: false },
578
+ ]
579
+ );
580
+ setQSheetFillBlanks(
581
+ q.fillBlankAnswers?.length
582
+ ? q.fillBlankAnswers
583
+ : [{ id: generateAltId(), answer: '', alternativesText: '' }]
584
+ );
585
+ setQSheetPairs(
586
+ q.matchingPairs?.length
587
+ ? q.matchingPairs
588
+ : [{ id: generateAltId(), leftText: '', rightText: '' }]
589
+ );
590
+ setQSheetErrors({});
591
+ setQuestionSheetOpen(true);
592
+ }
593
+
594
+ function handleQSheetTypeChange(v: QuestionType) {
595
+ setQSheetType(v);
596
+ setQSheetErrors({});
597
+ if (v === 'true_false') {
598
+ setQSheetAlts([
599
+ { id: 'true', texto: 'Verdadeiro', correta: true },
600
+ { id: 'false', texto: 'Falso', correta: false },
601
+ ]);
602
+ } else if (v === 'multiple_choice' && qSheetAlts.length === 0) {
603
+ setQSheetAlts([
604
+ { id: generateAltId(), texto: '', correta: false },
605
+ { id: generateAltId(), texto: '', correta: false },
606
+ ]);
607
+ } else if (v === 'fill_blank' && qSheetFillBlanks.length === 0) {
608
+ setQSheetFillBlanks([
609
+ { id: generateAltId(), answer: '', alternativesText: '' },
610
+ ]);
611
+ } else if (v === 'matching' && qSheetPairs.length === 0) {
612
+ setQSheetPairs([{ id: generateAltId(), leftText: '', rightText: '' }]);
613
+ }
614
+ }
615
+
616
+ function saveQuestion() {
617
+ const errors: Record<string, string> = {};
618
+ if (!qSheetStatement.trim()) errors.statement = 'Enunciado obrigatório';
619
+ if (
620
+ (qSheetType === 'multiple_choice' || qSheetType === 'true_false') &&
621
+ !qSheetAlts.some((a) => a.correta)
622
+ )
623
+ errors.alts = 'Selecione pelo menos uma resposta correta';
624
+ if (Object.keys(errors).length) {
625
+ setQSheetErrors(errors);
626
+ return;
627
+ }
628
+ const saved: MockQuestion = {
629
+ id: editingQuestion?.id ?? `q-${Date.now()}`,
630
+ title: qSheetStatement.replace(/<[^>]+>/g, '').slice(0, 80),
631
+ type: qSheetType,
632
+ statement: qSheetStatement,
633
+ points: qSheetPoints,
634
+ alternatives:
635
+ qSheetType === 'multiple_choice' || qSheetType === 'true_false'
636
+ ? qSheetAlts
637
+ : undefined,
638
+ fillBlankAnswers:
639
+ qSheetType === 'fill_blank' ? qSheetFillBlanks : undefined,
640
+ matchingPairs: qSheetType === 'matching' ? qSheetPairs : undefined,
641
+ };
642
+ setSelectedQuestion(saved);
643
+ form.setValue('questionId', saved.id, { shouldDirty: true });
644
+ setQuestionSheetOpen(false);
645
+ toast.success(editingQuestion ? 'Questão atualizada' : 'Questão criada');
646
+ }
647
+
648
+ function handleDelete() {
649
+ showConfirm({
650
+ title: `Excluir aula "${lesson!.title}"?`,
651
+ description: 'Esta ação não pode ser desfeita.',
652
+ onConfirm: () =>
653
+ deleteLesson.mutate({ lessonId, sessionId: lesson!.sessionId }),
654
+ });
655
+ }
656
+
657
+ return (
658
+ <Form {...form}>
659
+ <form
660
+ onSubmit={form.handleSubmit(onSubmit)}
661
+ className="flex flex-col h-full min-h-0"
662
+ >
663
+ {/* ── Header ───────────────────────────────────────────────────────── */}
664
+ <div className="flex items-center gap-3 px-4 py-3 border-b bg-muted/30 shrink-0">
665
+ <div
666
+ className={cn(
667
+ 'flex size-9 items-center justify-center rounded-lg shrink-0',
668
+ cfg.bg
669
+ )}
670
+ >
671
+ <Icon className={cn('size-4', cfg.color)} />
672
+ </div>
673
+ <div className="flex-1 min-w-0">
674
+ <div className="flex items-center gap-1.5">
675
+ <span className="text-sm font-semibold truncate">Aula</span>
676
+ {isDirty && (
677
+ <CircleDot className="size-3 text-amber-500 shrink-0" />
678
+ )}
679
+ </div>
680
+ <p className="text-[0.65rem] text-muted-foreground truncate">
681
+ {lesson.code} · {cfg.label}
682
+ </p>
683
+ </div>
684
+ {watchedStatus && (
685
+ <span
686
+ className={cn(
687
+ 'text-[0.65rem] font-medium px-2 py-0.5 rounded-full shrink-0',
688
+ STATUS_COLORS[watchedStatus]
689
+ )}
690
+ >
691
+ {STATUS_LABELS[watchedStatus]}
692
+ </span>
693
+ )}
694
+ <Button
695
+ type="button"
696
+ variant="ghost"
697
+ size="icon"
698
+ className="size-7 text-destructive/60 hover:text-destructive shrink-0"
699
+ title="Excluir aula"
700
+ aria-label="Excluir aula"
701
+ disabled={deleteLesson.isPending}
702
+ onClick={handleDelete}
703
+ >
704
+ {deleteLesson.isPending ? (
705
+ <Loader2 className="size-3.5 animate-spin" />
706
+ ) : (
707
+ <Trash2 className="size-3.5" />
708
+ )}
709
+ </Button>
710
+ </div>
711
+
712
+ {/* ── Tabs ─────────────────────────────────────────────────────────── */}
713
+ <Tabs defaultValue="dados" className="flex flex-col flex-1 min-h-0">
714
+ <TabsList className="mx-3 mt-2 h-8 w-auto justify-start shrink-0 bg-muted/50">
715
+ <TabsTrigger value="dados" className="text-xs h-7 px-2.5">
716
+ Dados
717
+ </TabsTrigger>
718
+ <TabsTrigger value="conteudo" className="text-xs h-7 px-2.5">
719
+ Conteúdo
720
+ </TabsTrigger>
721
+ <TabsTrigger value="recursos" className="text-xs h-7 px-2.5">
722
+ Recursos
723
+ </TabsTrigger>
724
+ </TabsList>
725
+
726
+ {/* ── Tab Dados ────────────────────────────────────────────────── */}
727
+ <TabsContent value="dados" className="flex-1 min-h-0 mt-0">
728
+ <ScrollArea className="h-full">
729
+ <div className="flex flex-col gap-3 p-3">
730
+ {/* Identificação */}
731
+ <Card className="bg-muted/20 py-2 gap-2">
732
+ <CardHeader className="px-3 pt-2 pb-0">
733
+ <CardTitle className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
734
+ Identificação
735
+ </CardTitle>
736
+ </CardHeader>
737
+ <CardContent className="px-3 pb-2 flex flex-col gap-3">
738
+ <div className="grid grid-cols-3 gap-2">
739
+ <FormField
740
+ control={form.control}
741
+ name="code"
742
+ render={({ field }) => (
743
+ <FormItem>
744
+ <FormLabel className="text-xs">Código</FormLabel>
745
+ <FormControl>
746
+ <Input
747
+ {...field}
748
+ className="h-8 text-xs font-mono"
749
+ />
750
+ </FormControl>
751
+ <FormMessage className="text-xs" />
752
+ </FormItem>
753
+ )}
754
+ />
755
+ <FormField
756
+ control={form.control}
757
+ name="duration"
758
+ render={({ field }) => (
759
+ <FormItem>
760
+ <FormLabel className="text-xs">
761
+ <Clock className="size-3 inline mr-1" />
762
+ Duração (min)
763
+ </FormLabel>
764
+ <FormControl>
765
+ <Input
766
+ {...field}
767
+ type="number"
768
+ className="h-8 text-xs"
769
+ />
770
+ </FormControl>
771
+ <FormMessage className="text-xs" />
772
+ </FormItem>
773
+ )}
774
+ />
775
+ <FormField
776
+ control={form.control}
777
+ name="type"
778
+ render={({ field }) => (
779
+ <FormItem>
780
+ <FormLabel className="text-xs">Tipo</FormLabel>
781
+ <Select
782
+ value={field.value}
783
+ onValueChange={field.onChange}
784
+ >
785
+ <FormControl>
786
+ <SelectTrigger className="h-8 text-xs w-full">
787
+ <SelectValue />
788
+ </SelectTrigger>
789
+ </FormControl>
790
+ <SelectContent>
791
+ {(
792
+ Object.entries(TYPE_CONFIG) as [
793
+ LessonType,
794
+ (typeof TYPE_CONFIG)[LessonType],
795
+ ][]
796
+ ).map(([val, cfg]) => {
797
+ const Ic = cfg.icon;
798
+ return (
799
+ <SelectItem key={val} value={val}>
800
+ <span className="flex items-center gap-1.5">
801
+ <Ic
802
+ className={cn('size-3', cfg.color)}
803
+ />
804
+ {cfg.label}
805
+ </span>
806
+ </SelectItem>
807
+ );
808
+ })}
809
+ </SelectContent>
810
+ </Select>
811
+ <FormMessage className="text-xs" />
812
+ </FormItem>
813
+ )}
814
+ />
815
+ </div>
816
+
817
+ <FormField
818
+ control={form.control}
819
+ name="title"
820
+ render={({ field }) => (
821
+ <FormItem>
822
+ <FormLabel className="text-xs">Título</FormLabel>
823
+ <FormControl>
824
+ <Input {...field} className="h-8 text-sm" />
825
+ </FormControl>
826
+ <FormMessage className="text-xs" />
827
+ </FormItem>
828
+ )}
829
+ />
830
+ </CardContent>
831
+ </Card>
832
+
833
+ {/* Publicação */}
834
+ <Card className="bg-muted/20 py-2 gap-2">
835
+ <CardHeader className="px-3 pt-2 pb-0">
836
+ <CardTitle className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
837
+ Publicação
838
+ </CardTitle>
839
+ </CardHeader>
840
+ <CardContent className="px-3 pb-2 flex flex-col gap-3">
841
+ <div className="grid grid-cols-2 gap-2">
842
+ <FormField
843
+ control={form.control}
844
+ name="status"
845
+ render={({ field }) => (
846
+ <FormItem>
847
+ <FormLabel className="text-xs">
848
+ Status de produção
849
+ </FormLabel>
850
+ <Select
851
+ value={field.value}
852
+ onValueChange={field.onChange}
853
+ >
854
+ <FormControl>
855
+ <SelectTrigger className="h-8 text-xs w-full">
856
+ <SelectValue />
857
+ </SelectTrigger>
858
+ </FormControl>
859
+ <SelectContent>
860
+ {(
861
+ Object.entries(STATUS_LABELS) as [
862
+ LessonStatus,
863
+ string,
864
+ ][]
865
+ ).map(([val, lbl]) => (
866
+ <SelectItem key={val} value={val}>
867
+ <span
868
+ className={cn(
869
+ 'text-xs px-1.5 py-0.5 rounded',
870
+ STATUS_COLORS[val]
871
+ )}
872
+ >
873
+ {lbl}
874
+ </span>
875
+ </SelectItem>
876
+ ))}
877
+ </SelectContent>
878
+ </Select>
879
+ <FormMessage className="text-xs" />
880
+ </FormItem>
881
+ )}
882
+ />
883
+
884
+ <FormField
885
+ control={form.control}
886
+ name="visibility"
887
+ render={({ field }) => (
888
+ <FormItem>
889
+ <FormLabel className="text-xs">
890
+ Visibilidade
891
+ </FormLabel>
892
+ <Select
893
+ value={field.value}
894
+ onValueChange={field.onChange}
895
+ >
896
+ <FormControl>
897
+ <SelectTrigger className="h-8 text-xs w-full">
898
+ <SelectValue />
899
+ </SelectTrigger>
900
+ </FormControl>
901
+ <SelectContent>
902
+ <SelectItem value="publico">
903
+ <span className="flex items-center gap-1.5">
904
+ <Eye className="size-3" /> Público
905
+ </span>
906
+ </SelectItem>
907
+ <SelectItem value="privado">
908
+ <span className="flex items-center gap-1.5">
909
+ <EyeOff className="size-3" /> Privado
910
+ </span>
911
+ </SelectItem>
912
+ <SelectItem value="restrito">
913
+ <span className="flex items-center gap-1.5">
914
+ <Lock className="size-3" /> Restrito
915
+ </span>
916
+ </SelectItem>
917
+ </SelectContent>
918
+ </Select>
919
+ <FormMessage className="text-xs" />
920
+ </FormItem>
921
+ )}
922
+ />
923
+ </div>
924
+ </CardContent>
925
+ </Card>
926
+
927
+ {/* Instrutores */}
928
+ <Card className="bg-muted/20 py-2 gap-2">
929
+ <CardHeader className="px-3 pt-2 pb-0">
930
+ <CardTitle className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
931
+ Instrutores
932
+ </CardTitle>
933
+ </CardHeader>
934
+ <CardContent className="px-3 pb-2 flex flex-col gap-2">
935
+ {/* Picker para adicionar */}
936
+ <EntityPicker<{ id: number; name: string }>
937
+ value={null}
938
+ onChange={(val) => {
939
+ if (val != null)
940
+ setSelectedInstructorIds((prev) =>
941
+ prev.includes(String(val))
942
+ ? prev
943
+ : [...prev, String(val)]
944
+ );
945
+ }}
946
+ placeholder="Adicionar instrutor…"
947
+ searchPlaceholder="Buscar instrutor…"
948
+ emptyLabel="Nenhum instrutor disponível"
949
+ entityLabel="Instrutor"
950
+ options={instructorPool.filter(
951
+ (i) => !selectedInstructorIds.includes(String(i.id))
952
+ )}
953
+ getOptionValue={(o) => o.id}
954
+ getOptionLabel={(o) => o.name}
955
+ />
956
+ {/* Lista de instrutores selecionados */}
957
+ {selectedInstructorIds.length > 0 && (
958
+ <div className="flex flex-col gap-1">
959
+ {selectedInstructorIds.map((sid) => {
960
+ const inst = instructorPool.find(
961
+ (i) => String(i.id) === sid
962
+ );
963
+ const displayName = inst?.name ?? sid;
964
+ return (
965
+ <div
966
+ key={sid}
967
+ className="flex items-center gap-2 rounded-md border bg-muted/20 px-2 py-1"
968
+ >
969
+ <Avatar className="size-6 shrink-0">
970
+ <AvatarImage
971
+ src={undefined}
972
+ alt={displayName}
973
+ />
974
+ <AvatarFallback className="text-[0.6rem] font-medium">
975
+ {displayName
976
+ .split(' ')
977
+ .slice(0, 2)
978
+ .map((n: string) => n[0])
979
+ .join('')
980
+ .toUpperCase()}
981
+ </AvatarFallback>
982
+ </Avatar>
983
+ <span className="text-xs flex-1 truncate">
984
+ {displayName}
985
+ </span>
986
+ <Button
987
+ type="button"
988
+ variant="ghost"
989
+ size="icon"
990
+ className="size-5 shrink-0 text-muted-foreground hover:text-destructive"
991
+ onClick={() =>
992
+ setSelectedInstructorIds((prev) =>
993
+ prev.filter((id) => id !== sid)
994
+ )
995
+ }
996
+ aria-label={`Remover ${displayName}`}
997
+ >
998
+ <X className="size-3" />
999
+ </Button>
1000
+ </div>
1001
+ );
1002
+ })}
1003
+ </div>
1004
+ )}
1005
+ </CardContent>
1006
+ </Card>
1007
+ </div>
1008
+ </ScrollArea>
1009
+ </TabsContent>
1010
+
1011
+ {/* ── Tab Conteúdo ─────────────────────────────────────────────── */}
1012
+ <TabsContent value="conteudo" className="flex-1 min-h-0 mt-0">
1013
+ <ScrollArea className="h-full">
1014
+ <div className="flex flex-col gap-3 p-3">
1015
+ {/* Descrição pública */}
1016
+ <Card className="bg-muted/20 py-2 gap-2">
1017
+ <CardHeader className="px-3 pt-2 pb-1">
1018
+ <CardTitle className="text-xs font-medium text-muted-foreground uppercase tracking-wider flex items-center gap-1.5">
1019
+ <Eye className="size-3" /> Descrição pública
1020
+ </CardTitle>
1021
+ </CardHeader>
1022
+ <CardContent className="px-3 pb-2">
1023
+ <FormField
1024
+ control={form.control}
1025
+ name="publicDescription"
1026
+ render={({ field }) => (
1027
+ <FormItem>
1028
+ <FormControl>
1029
+ <RichTextEditor
1030
+ value={field.value}
1031
+ onChange={field.onChange}
1032
+ />
1033
+ </FormControl>
1034
+ <FormMessage className="text-xs" />
1035
+ </FormItem>
1036
+ )}
1037
+ />
1038
+ </CardContent>
1039
+ </Card>
1040
+
1041
+ {/* Notas internas */}
1042
+ <Card className="bg-muted/20 py-2 gap-2">
1043
+ <CardHeader className="px-3 pt-2 pb-1">
1044
+ <CardTitle className="text-xs font-medium text-muted-foreground uppercase tracking-wider flex items-center gap-1.5">
1045
+ <Lock className="size-3" /> Notas internas
1046
+ </CardTitle>
1047
+ </CardHeader>
1048
+ <CardContent className="px-3 pb-2">
1049
+ <FormField
1050
+ control={form.control}
1051
+ name="privateDescription"
1052
+ render={({ field }) => (
1053
+ <FormItem>
1054
+ <FormControl>
1055
+ <RichTextEditor
1056
+ value={field.value}
1057
+ onChange={field.onChange}
1058
+ className="border-amber-200/60 dark:border-amber-800/40"
1059
+ />
1060
+ </FormControl>
1061
+ <FormMessage className="text-xs" />
1062
+ </FormItem>
1063
+ )}
1064
+ />
1065
+ </CardContent>
1066
+ </Card>
1067
+
1068
+ {/* Campos específicos por tipo */}
1069
+ {watchedType === 'video' && (
1070
+ <Card className="bg-muted/20 py-2 gap-2">
1071
+ <CardHeader className="px-3 pt-2 pb-1">
1072
+ <CardTitle className="text-xs font-medium text-muted-foreground uppercase tracking-wider flex items-center gap-1.5">
1073
+ <Video className="size-3 text-blue-500" /> Vídeo
1074
+ </CardTitle>
1075
+ </CardHeader>
1076
+ <CardContent className="px-3 pb-2 flex flex-col gap-3">
1077
+ <FormField
1078
+ control={form.control}
1079
+ name="videoProvider"
1080
+ render={({ field }) => (
1081
+ <FormItem>
1082
+ <FormLabel className="text-xs">Provider</FormLabel>
1083
+ <Select
1084
+ value={field.value}
1085
+ onValueChange={field.onChange}
1086
+ >
1087
+ <FormControl>
1088
+ <SelectTrigger className="h-8 text-xs w-full">
1089
+ <SelectValue />
1090
+ </SelectTrigger>
1091
+ </FormControl>
1092
+ <SelectContent>
1093
+ {VIDEO_PROVIDERS.map((p) => (
1094
+ <SelectItem key={p.value} value={p.value}>
1095
+ {p.label}
1096
+ </SelectItem>
1097
+ ))}
1098
+ </SelectContent>
1099
+ </Select>
1100
+ <FormMessage className="text-xs" />
1101
+ </FormItem>
1102
+ )}
1103
+ />
1104
+
1105
+ <FormField
1106
+ control={form.control}
1107
+ name="videoUrl"
1108
+ render={({ field }) => (
1109
+ <FormItem>
1110
+ <FormLabel className="text-xs">
1111
+ URL do vídeo
1112
+ </FormLabel>
1113
+ <FormControl>
1114
+ <Input
1115
+ {...field}
1116
+ value={field.value ?? ''}
1117
+ className="h-8 text-xs font-mono"
1118
+ placeholder="https://…"
1119
+ />
1120
+ </FormControl>
1121
+ <FormMessage className="text-xs" />
1122
+ </FormItem>
1123
+ )}
1124
+ />
1125
+
1126
+ <FormField
1127
+ control={form.control}
1128
+ name="transcription"
1129
+ render={({ field }) => (
1130
+ <FormItem>
1131
+ <FormLabel className="text-xs">
1132
+ Transcrição
1133
+ </FormLabel>
1134
+ <FormControl>
1135
+ <Textarea
1136
+ {...field}
1137
+ value={field.value ?? ''}
1138
+ rows={5}
1139
+ className="text-xs resize-none font-mono"
1140
+ placeholder="Transcrição automática ou manual do vídeo…"
1141
+ />
1142
+ </FormControl>
1143
+ <FormMessage className="text-xs" />
1144
+ </FormItem>
1145
+ )}
1146
+ />
1147
+ </CardContent>
1148
+ </Card>
1149
+ )}
1150
+
1151
+ {watchedType === 'post' && (
1152
+ <Card className="bg-muted/20 py-2 gap-2">
1153
+ <CardHeader className="px-3 pt-2 pb-1">
1154
+ <CardTitle className="text-xs font-medium text-muted-foreground uppercase tracking-wider flex items-center gap-1.5">
1155
+ <FileText className="size-3 text-emerald-500" />{' '}
1156
+ Conteúdo do post
1157
+ </CardTitle>
1158
+ </CardHeader>
1159
+ <CardContent className="px-3 pb-2">
1160
+ <FormField
1161
+ control={form.control}
1162
+ name="postContent"
1163
+ render={({ field }) => (
1164
+ <FormItem>
1165
+ <FormControl>
1166
+ <RichTextEditor
1167
+ value={field.value ?? ''}
1168
+ onChange={field.onChange}
1169
+ />
1170
+ </FormControl>
1171
+ <FormMessage className="text-xs" />
1172
+ </FormItem>
1173
+ )}
1174
+ />
1175
+ </CardContent>
1176
+ </Card>
1177
+ )}
1178
+
1179
+ {watchedType === 'questao' && (
1180
+ <Card className="bg-muted/20 py-2 gap-2">
1181
+ <CardHeader className="px-3 pt-2 pb-1">
1182
+ <CardTitle className="text-xs font-medium text-muted-foreground uppercase tracking-wider flex items-center justify-between gap-1.5">
1183
+ <span className="flex items-center gap-1.5">
1184
+ <ListChecks className="size-3 text-amber-500" />{' '}
1185
+ Questão vinculada
1186
+ </span>
1187
+ <Button
1188
+ type="button"
1189
+ variant="outline"
1190
+ size="sm"
1191
+ className="h-6 text-xs px-2 gap-1"
1192
+ onClick={openCreateQuestion}
1193
+ >
1194
+ <Plus className="size-3" />
1195
+ Nova questão
1196
+ </Button>
1197
+ </CardTitle>
1198
+ </CardHeader>
1199
+ <CardContent className="px-3 pb-2 flex flex-col gap-2">
1200
+ <FormField
1201
+ control={form.control}
1202
+ name="questionId"
1203
+ render={({ field }) => (
1204
+ <FormItem>
1205
+ <FormControl>
1206
+ <EntityPicker<MockQuestion>
1207
+ value={field.value ?? null}
1208
+ onChange={(val) => {
1209
+ field.onChange(val);
1210
+ const found =
1211
+ MOCK_QUESTIONS.find((q) => q.id === val) ??
1212
+ null;
1213
+ setSelectedQuestion(found);
1214
+ }}
1215
+ placeholder="Selecionar questão…"
1216
+ searchPlaceholder="Buscar questão…"
1217
+ emptyLabel="Nenhuma questão encontrada"
1218
+ entityLabel="Questão"
1219
+ options={MOCK_QUESTIONS}
1220
+ getOptionValue={(o) => o.id}
1221
+ getOptionLabel={(o) => o.title}
1222
+ getOptionDescription={(o) =>
1223
+ QUESTION_TYPE_LABELS[o.type]
1224
+ }
1225
+ />
1226
+ </FormControl>
1227
+ <FormMessage className="text-xs" />
1228
+ </FormItem>
1229
+ )}
1230
+ />
1231
+ {selectedQuestion && (
1232
+ <div className="flex items-center gap-2 rounded-lg border bg-background px-3 py-2 text-sm">
1233
+ <HelpCircle className="size-4 shrink-0 text-amber-500" />
1234
+ <div className="flex-1 min-w-0">
1235
+ <p className="truncate font-medium text-xs">
1236
+ {selectedQuestion.title}
1237
+ </p>
1238
+ <p className="text-xs text-muted-foreground">
1239
+ {QUESTION_TYPE_LABELS[selectedQuestion.type]}
1240
+ {selectedQuestion.points != null &&
1241
+ ` · ${selectedQuestion.points} pt`}
1242
+ </p>
1243
+ </div>
1244
+ <Button
1245
+ type="button"
1246
+ variant="ghost"
1247
+ size="icon"
1248
+ className="size-7 shrink-0"
1249
+ onClick={() => openEditQuestion(selectedQuestion)}
1250
+ aria-label="Editar questão"
1251
+ >
1252
+ <Pencil className="size-3.5" />
1253
+ </Button>
1254
+ </div>
1255
+ )}
1256
+ </CardContent>
1257
+ </Card>
1258
+ )}
1259
+ </div>
1260
+ </ScrollArea>
1261
+ </TabsContent>
1262
+
1263
+ {/* ── Tab Recursos ─────────────────────────────────────────────── */}
1264
+ <TabsContent value="recursos" className="flex-1 min-h-0 mt-0">
1265
+ <ScrollArea className="h-full">
1266
+ <div className="flex flex-col gap-3 p-3">
1267
+ {/* Drop zone */}
1268
+ <div
1269
+ role="button"
1270
+ tabIndex={0}
1271
+ aria-label="Soltar arquivo ou clicar para selecionar"
1272
+ aria-disabled={isUploading}
1273
+ onDragOver={(e) => {
1274
+ e.preventDefault();
1275
+ if (!isUploading) setDragOver(true);
1276
+ }}
1277
+ onDragLeave={() => setDragOver(false)}
1278
+ onDrop={(e) => {
1279
+ e.preventDefault();
1280
+ setDragOver(false);
1281
+ if (!isUploading)
1282
+ void handleResourceFiles(
1283
+ Array.from(e.dataTransfer.files)
1284
+ );
1285
+ }}
1286
+ onClick={() => {
1287
+ if (!isUploading) resourceInputRef.current?.click();
1288
+ }}
1289
+ onKeyDown={(e) => {
1290
+ if (e.key === 'Enter' || e.key === ' ') {
1291
+ e.preventDefault();
1292
+ if (!isUploading) resourceInputRef.current?.click();
1293
+ }
1294
+ }}
1295
+ className={cn(
1296
+ 'flex flex-col items-center justify-center gap-2 py-7 rounded-lg border-2 border-dashed transition-colors select-none',
1297
+ isUploading
1298
+ ? 'cursor-wait border-border opacity-60'
1299
+ : 'cursor-pointer',
1300
+ !isUploading && dragOver
1301
+ ? 'border-primary/70 bg-primary/5'
1302
+ : !isUploading
1303
+ ? 'border-border hover:border-primary/40 hover:bg-muted/30'
1304
+ : ''
1305
+ )}
1306
+ >
1307
+ {isUploading ? (
1308
+ <Loader2 className="size-7 animate-spin text-muted-foreground/50" />
1309
+ ) : (
1310
+ <UploadCloud
1311
+ className={cn(
1312
+ 'size-7 transition-colors',
1313
+ dragOver ? 'text-primary' : 'text-muted-foreground/50'
1314
+ )}
1315
+ />
1316
+ )}
1317
+ <div className="text-center">
1318
+ <p className="text-xs font-medium">
1319
+ {isUploading ? 'Enviando…' : 'Solte o arquivo aqui'}
1320
+ </p>
1321
+ {!isUploading && (
1322
+ <p className="text-xs text-muted-foreground">
1323
+ ou clique para selecionar
1324
+ </p>
1325
+ )}
1326
+ </div>
1327
+ <input
1328
+ ref={resourceInputRef}
1329
+ type="file"
1330
+ multiple
1331
+ className="hidden"
1332
+ onChange={(e) => {
1333
+ if (e.target.files) {
1334
+ void handleResourceFiles(Array.from(e.target.files));
1335
+ e.target.value = '';
1336
+ }
1337
+ }}
1338
+ />
1339
+ </div>
1340
+
1341
+ {/* Counter */}
1342
+ {localResources.length > 0 && (
1343
+ <p className="text-xs text-muted-foreground">
1344
+ {localResources.length} recurso
1345
+ {localResources.length !== 1 ? 's' : ''}
1346
+ </p>
1347
+ )}
1348
+
1349
+ {/* Resource list */}
1350
+ {localResources.length === 0 ? (
1351
+ <p className="text-center text-xs text-muted-foreground py-1">
1352
+ Nenhum recurso vinculado.
1353
+ </p>
1354
+ ) : (
1355
+ <div className="flex flex-col gap-1">
1356
+ {localResources.map((res) => {
1357
+ const ResIcon = getResourceIcon(res.type);
1358
+ return (
1359
+ <div
1360
+ key={res.id}
1361
+ className="flex items-center gap-2 rounded-md border bg-muted/20 px-2.5 py-2"
1362
+ >
1363
+ <ResIcon
1364
+ className={cn(
1365
+ 'size-3.5 shrink-0',
1366
+ getResourceIconColor(res.type)
1367
+ )}
1368
+ />
1369
+ <div className="flex-1 min-w-0">
1370
+ <p className="text-xs truncate font-medium">
1371
+ {res.name}
1372
+ </p>
1373
+ <p className="text-[0.65rem] text-muted-foreground">
1374
+ {res.size}
1375
+ </p>
1376
+ </div>
1377
+ {res.public && (
1378
+ <Eye
1379
+ className="size-3 text-emerald-500 shrink-0"
1380
+ aria-label="Público"
1381
+ />
1382
+ )}
1383
+ {/* Abrir em nova aba */}
1384
+ {res.url && (
1385
+ <a
1386
+ href={res.url}
1387
+ target="_blank"
1388
+ rel="noopener noreferrer"
1389
+ aria-label={`Abrir ${res.name} em nova aba`}
1390
+ onClick={(e) => e.stopPropagation()}
1391
+ className="inline-flex items-center justify-center size-6 rounded-md text-muted-foreground hover:text-foreground hover:bg-accent transition-colors shrink-0"
1392
+ >
1393
+ <ExternalLink className="size-3" />
1394
+ </a>
1395
+ )}
1396
+ {/* Download */}
1397
+ <Button
1398
+ type="button"
1399
+ variant="ghost"
1400
+ size="icon"
1401
+ className="size-6 shrink-0 text-muted-foreground hover:text-foreground"
1402
+ onClick={() => handleResourceDownload(res)}
1403
+ aria-label={`Baixar ${res.name}`}
1404
+ >
1405
+ <Download className="size-3" />
1406
+ </Button>
1407
+ {/* Remover */}
1408
+ <Button
1409
+ type="button"
1410
+ variant="ghost"
1411
+ size="icon"
1412
+ className="size-6 shrink-0 text-muted-foreground hover:text-destructive"
1413
+ onClick={() => void removeResource(res.id)}
1414
+ aria-label={`Remover ${res.name}`}
1415
+ >
1416
+ <X className="size-3" />
1417
+ </Button>
1418
+ </div>
1419
+ );
1420
+ })}
1421
+ </div>
1422
+ )}
1423
+ </div>
1424
+ </ScrollArea>
1425
+ </TabsContent>
1426
+ </Tabs>
1427
+
1428
+ {/* ── Footer ───────────────────────────────────────────────────────── */}
1429
+ <div className="shrink-0 border-t bg-background">
1430
+ <Separator />
1431
+ <div className="flex items-center gap-2 px-3 py-2">
1432
+ <Button
1433
+ type="button"
1434
+ variant="ghost"
1435
+ size="sm"
1436
+ className="h-7 text-xs"
1437
+ disabled={!isDirty || updateLesson.isPending}
1438
+ onClick={() => form.reset()}
1439
+ >
1440
+ <Undo2 className="size-3 mr-1" />
1441
+ Cancelar
1442
+ </Button>
1443
+ <div className="flex-1" />
1444
+ <Button
1445
+ type="submit"
1446
+ size="sm"
1447
+ className="h-7 text-xs"
1448
+ disabled={!isDirty || updateLesson.isPending}
1449
+ >
1450
+ {updateLesson.isPending ? (
1451
+ <Loader2 className="size-3 mr-1 animate-spin" />
1452
+ ) : (
1453
+ <Save className="size-3 mr-1" />
1454
+ )}
1455
+ Salvar aula
1456
+ </Button>
1457
+ </div>
1458
+ </div>
1459
+
1460
+ {/* ── Question Sheet ──────────────────────────────────────────────── */}
1461
+ <Sheet open={questionSheetOpen} onOpenChange={setQuestionSheetOpen}>
1462
+ <SheetContent
1463
+ side="right"
1464
+ className="flex w-full flex-col sm:max-w-lg overflow-y-auto"
1465
+ >
1466
+ <SheetHeader>
1467
+ <SheetTitle>
1468
+ {editingQuestion ? 'Editar questão' : 'Nova questão'}
1469
+ </SheetTitle>
1470
+ </SheetHeader>
1471
+
1472
+ <div className="flex flex-1 flex-col gap-5 px-4 pb-4">
1473
+ {/* Enunciado */}
1474
+ <div className="flex flex-col gap-1.5">
1475
+ <Label className="text-sm font-medium">Enunciado</Label>
1476
+ <RichTextEditor
1477
+ value={qSheetStatement}
1478
+ onChange={setQSheetStatement}
1479
+ />
1480
+ {qSheetErrors.statement && (
1481
+ <p className="text-xs text-destructive">
1482
+ {qSheetErrors.statement}
1483
+ </p>
1484
+ )}
1485
+ </div>
1486
+
1487
+ {/* Tipo + Pontuação */}
1488
+ <div className="grid grid-cols-2 gap-3">
1489
+ <div className="flex flex-col gap-1.5">
1490
+ <Label className="text-sm font-medium">Tipo</Label>
1491
+ <Select
1492
+ value={qSheetType}
1493
+ onValueChange={(v) =>
1494
+ handleQSheetTypeChange(v as QuestionType)
1495
+ }
1496
+ >
1497
+ <SelectTrigger className="w-full">
1498
+ <SelectValue />
1499
+ </SelectTrigger>
1500
+ <SelectContent>
1501
+ {(
1502
+ Object.keys(QUESTION_TYPE_LABELS) as QuestionType[]
1503
+ ).map((t) => (
1504
+ <SelectItem key={t} value={t}>
1505
+ {QUESTION_TYPE_LABELS[t]}
1506
+ </SelectItem>
1507
+ ))}
1508
+ </SelectContent>
1509
+ </Select>
1510
+ </div>
1511
+ <div className="flex flex-col gap-1.5">
1512
+ <Label className="text-sm font-medium">Pontuação</Label>
1513
+ <Input
1514
+ type="number"
1515
+ min={0}
1516
+ step={0.5}
1517
+ value={qSheetPoints}
1518
+ onChange={(e) => setQSheetPoints(Number(e.target.value))}
1519
+ />
1520
+ </div>
1521
+ </div>
1522
+
1523
+ <Separator />
1524
+
1525
+ {/* Alternativas — múltipla escolha */}
1526
+ {qSheetType === 'multiple_choice' && (
1527
+ <div className="flex flex-col gap-2">
1528
+ <div className="flex items-center justify-between">
1529
+ <Label className="text-sm font-medium">Alternativas</Label>
1530
+ <Button
1531
+ type="button"
1532
+ variant="outline"
1533
+ size="sm"
1534
+ className="h-7 text-xs gap-1 px-2"
1535
+ onClick={() =>
1536
+ setQSheetAlts((prev) => [
1537
+ ...prev,
1538
+ { id: generateAltId(), texto: '', correta: false },
1539
+ ])
1540
+ }
1541
+ >
1542
+ <Plus className="size-3" />
1543
+ Adicionar
1544
+ </Button>
1545
+ </div>
1546
+ {qSheetErrors.alts && (
1547
+ <p className="text-xs text-destructive">
1548
+ {qSheetErrors.alts}
1549
+ </p>
1550
+ )}
1551
+ <DndContext
1552
+ sensors={qSensors}
1553
+ collisionDetection={closestCenter}
1554
+ onDragEnd={({ active, over }) => {
1555
+ if (over && active.id !== over.id) {
1556
+ setQSheetAlts((prev) => {
1557
+ const from = prev.findIndex(
1558
+ (a) => a.id === active.id
1559
+ );
1560
+ const to = prev.findIndex((a) => a.id === over.id);
1561
+ return arrayMove(prev, from, to);
1562
+ });
1563
+ }
1564
+ }}
1565
+ >
1566
+ <SortableContext
1567
+ items={qSheetAlts.map((a) => a.id)}
1568
+ strategy={verticalListSortingStrategy}
1569
+ >
1570
+ {qSheetAlts.map((alt, idx) => (
1571
+ <SortableAlternativa
1572
+ key={alt.id}
1573
+ alt={alt}
1574
+ index={idx}
1575
+ canRemove={qSheetAlts.length > 2}
1576
+ onToggleCorrect={() =>
1577
+ setQSheetAlts((prev) =>
1578
+ prev.map((a) =>
1579
+ a.id === alt.id
1580
+ ? { ...a, correta: !a.correta }
1581
+ : a
1582
+ )
1583
+ )
1584
+ }
1585
+ onChangeTexto={(v) =>
1586
+ setQSheetAlts((prev) =>
1587
+ prev.map((a) =>
1588
+ a.id === alt.id ? { ...a, texto: v } : a
1589
+ )
1590
+ )
1591
+ }
1592
+ onRemove={() =>
1593
+ setQSheetAlts((prev) =>
1594
+ prev.filter((a) => a.id !== alt.id)
1595
+ )
1596
+ }
1597
+ />
1598
+ ))}
1599
+ </SortableContext>
1600
+ </DndContext>
1601
+ </div>
1602
+ )}
1603
+
1604
+ {/* Alternativas — verdadeiro/falso */}
1605
+ {qSheetType === 'true_false' && (
1606
+ <div className="flex flex-col gap-2">
1607
+ <Label className="text-sm font-medium">
1608
+ Resposta correta
1609
+ </Label>
1610
+ {qSheetErrors.alts && (
1611
+ <p className="text-xs text-destructive">
1612
+ {qSheetErrors.alts}
1613
+ </p>
1614
+ )}
1615
+ {qSheetAlts.map((alt) => (
1616
+ <div
1617
+ key={alt.id}
1618
+ className={cn(
1619
+ 'flex items-center gap-3 rounded-lg border p-3 cursor-pointer transition-colors',
1620
+ alt.correta
1621
+ ? 'border-foreground/30 bg-muted/50'
1622
+ : 'bg-background hover:bg-muted/20'
1623
+ )}
1624
+ onClick={() =>
1625
+ setQSheetAlts((prev) =>
1626
+ prev.map((a) => ({
1627
+ ...a,
1628
+ correta: a.id === alt.id,
1629
+ }))
1630
+ )
1631
+ }
1632
+ >
1633
+ {alt.correta ? (
1634
+ <CircleDot className="size-4 shrink-0" />
1635
+ ) : (
1636
+ <CircleOff className="size-4 shrink-0 text-muted-foreground" />
1637
+ )}
1638
+ <span className="text-sm">{alt.texto}</span>
1639
+ </div>
1640
+ ))}
1641
+ </div>
1642
+ )}
1643
+
1644
+ {/* Fill in the blank */}
1645
+ {qSheetType === 'fill_blank' && (
1646
+ <div className="flex flex-col gap-2">
1647
+ <div className="flex items-center justify-between">
1648
+ <Label className="text-sm font-medium">Lacunas</Label>
1649
+ <Button
1650
+ type="button"
1651
+ variant="outline"
1652
+ size="sm"
1653
+ className="h-7 text-xs gap-1 px-2"
1654
+ onClick={() =>
1655
+ setQSheetFillBlanks((prev) => [
1656
+ ...prev,
1657
+ {
1658
+ id: generateAltId(),
1659
+ answer: '',
1660
+ alternativesText: '',
1661
+ },
1662
+ ])
1663
+ }
1664
+ >
1665
+ <Plus className="size-3" />
1666
+ Adicionar
1667
+ </Button>
1668
+ </div>
1669
+ {qSheetFillBlanks.map((fb, idx) => (
1670
+ <div
1671
+ key={fb.id}
1672
+ className="flex flex-col gap-1.5 rounded-lg border p-3"
1673
+ >
1674
+ <div className="flex items-center justify-between">
1675
+ <span className="text-xs font-medium text-muted-foreground">
1676
+ Lacuna {idx + 1}
1677
+ </span>
1678
+ {qSheetFillBlanks.length > 1 && (
1679
+ <button
1680
+ type="button"
1681
+ className="text-muted-foreground hover:text-destructive transition-colors"
1682
+ onClick={() =>
1683
+ setQSheetFillBlanks((prev) =>
1684
+ prev.filter((f) => f.id !== fb.id)
1685
+ )
1686
+ }
1687
+ aria-label="Remover lacuna"
1688
+ >
1689
+ <X className="size-3.5" />
1690
+ </button>
1691
+ )}
1692
+ </div>
1693
+ <Input
1694
+ placeholder="Resposta correta"
1695
+ value={fb.answer}
1696
+ onChange={(e) =>
1697
+ setQSheetFillBlanks((prev) =>
1698
+ prev.map((f) =>
1699
+ f.id === fb.id
1700
+ ? { ...f, answer: e.target.value }
1701
+ : f
1702
+ )
1703
+ )
1704
+ }
1705
+ />
1706
+ <Input
1707
+ placeholder="Alternativas separadas por vírgula (opcional)"
1708
+ value={fb.alternativesText}
1709
+ onChange={(e) =>
1710
+ setQSheetFillBlanks((prev) =>
1711
+ prev.map((f) =>
1712
+ f.id === fb.id
1713
+ ? { ...f, alternativesText: e.target.value }
1714
+ : f
1715
+ )
1716
+ )
1717
+ }
1718
+ />
1719
+ </div>
1720
+ ))}
1721
+ </div>
1722
+ )}
1723
+
1724
+ {/* Matching pairs */}
1725
+ {qSheetType === 'matching' && (
1726
+ <div className="flex flex-col gap-2">
1727
+ <div className="flex items-center justify-between">
1728
+ <Label className="text-sm font-medium">
1729
+ Pares de associação
1730
+ </Label>
1731
+ <Button
1732
+ type="button"
1733
+ variant="outline"
1734
+ size="sm"
1735
+ className="h-7 text-xs gap-1 px-2"
1736
+ onClick={() =>
1737
+ setQSheetPairs((prev) => [
1738
+ ...prev,
1739
+ { id: generateAltId(), leftText: '', rightText: '' },
1740
+ ])
1741
+ }
1742
+ >
1743
+ <Plus className="size-3" />
1744
+ Adicionar
1745
+ </Button>
1746
+ </div>
1747
+ {qSheetPairs.map((pair, idx) => (
1748
+ <div key={pair.id} className="flex items-center gap-2">
1749
+ <span className="text-xs text-muted-foreground w-4 text-center">
1750
+ {idx + 1}
1751
+ </span>
1752
+ <Input
1753
+ placeholder="Esquerda"
1754
+ value={pair.leftText}
1755
+ onChange={(e) =>
1756
+ setQSheetPairs((prev) =>
1757
+ prev.map((p) =>
1758
+ p.id === pair.id
1759
+ ? { ...p, leftText: e.target.value }
1760
+ : p
1761
+ )
1762
+ )
1763
+ }
1764
+ className="flex-1"
1765
+ />
1766
+ <span className="text-muted-foreground text-xs">↔</span>
1767
+ <Input
1768
+ placeholder="Direita"
1769
+ value={pair.rightText}
1770
+ onChange={(e) =>
1771
+ setQSheetPairs((prev) =>
1772
+ prev.map((p) =>
1773
+ p.id === pair.id
1774
+ ? { ...p, rightText: e.target.value }
1775
+ : p
1776
+ )
1777
+ )
1778
+ }
1779
+ className="flex-1"
1780
+ />
1781
+ {qSheetPairs.length > 1 && (
1782
+ <button
1783
+ type="button"
1784
+ className="text-muted-foreground hover:text-destructive transition-colors"
1785
+ onClick={() =>
1786
+ setQSheetPairs((prev) =>
1787
+ prev.filter((p) => p.id !== pair.id)
1788
+ )
1789
+ }
1790
+ aria-label="Remover par"
1791
+ >
1792
+ <X className="size-4" />
1793
+ </button>
1794
+ )}
1795
+ </div>
1796
+ ))}
1797
+ </div>
1798
+ )}
1799
+
1800
+ {/* Dissertativa */}
1801
+ {qSheetType === 'essay' && (
1802
+ <div className="rounded-lg border bg-muted/30 p-4 text-center text-sm text-muted-foreground">
1803
+ Questão dissertativa — o aluno responderá em texto livre.
1804
+ </div>
1805
+ )}
1806
+ </div>
1807
+
1808
+ <SheetFooter className="flex flex-row gap-2 px-4 pb-4">
1809
+ <Button
1810
+ type="button"
1811
+ variant="outline"
1812
+ className="flex-1"
1813
+ onClick={() => setQuestionSheetOpen(false)}
1814
+ >
1815
+ Cancelar
1816
+ </Button>
1817
+ <Button type="button" className="flex-1" onClick={saveQuestion}>
1818
+ <Save className="size-4 mr-1.5" />
1819
+ {editingQuestion ? 'Salvar alterações' : 'Criar questão'}
1820
+ </Button>
1821
+ </SheetFooter>
1822
+ </SheetContent>
1823
+ </Sheet>
1824
+ </form>
1825
+ </Form>
1826
+ );
1827
+ }