@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.
- package/dist/class-group/class-group.controller.d.ts +2 -2
- package/dist/class-group/class-group.service.d.ts +2 -2
- package/dist/enterprise/dto/enterprise-profile.dto.d.ts +13 -0
- package/dist/enterprise/dto/enterprise-profile.dto.d.ts.map +1 -0
- package/dist/enterprise/dto/enterprise-profile.dto.js +3 -0
- package/dist/enterprise/dto/enterprise-profile.dto.js.map +1 -0
- package/dist/enterprise/enterprise.controller.d.ts +3 -0
- package/dist/enterprise/enterprise.controller.d.ts.map +1 -1
- package/dist/enterprise/enterprise.controller.js +14 -0
- package/dist/enterprise/enterprise.controller.js.map +1 -1
- package/dist/enterprise/enterprise.service.d.ts +3 -0
- package/dist/enterprise/enterprise.service.d.ts.map +1 -1
- package/dist/enterprise/enterprise.service.js +128 -1
- package/dist/enterprise/enterprise.service.js.map +1 -1
- package/dist/instructor/instructor.controller.d.ts +23 -0
- package/dist/instructor/instructor.controller.d.ts.map +1 -1
- package/dist/instructor/instructor.controller.js +41 -0
- package/dist/instructor/instructor.controller.js.map +1 -1
- package/dist/instructor/instructor.service.d.ts +25 -0
- package/dist/instructor/instructor.service.d.ts.map +1 -1
- package/dist/instructor/instructor.service.js +126 -8
- package/dist/instructor/instructor.service.js.map +1 -1
- package/hedhog/data/menu.yaml +23 -7
- package/hedhog/data/role.yaml +9 -1
- package/hedhog/data/route.yaml +54 -0
- package/hedhog/frontend/app/_components/class-form-sheet.tsx.ejs +2 -1
- package/hedhog/frontend/app/courses/[id]/structure/_components/confirm-dialog.tsx.ejs +44 -44
- package/hedhog/frontend/app/courses/[id]/structure/_components/course-tree-dnd.tsx.ejs +362 -362
- package/hedhog/frontend/app/courses/[id]/structure/_components/course-tree-panel.tsx.ejs +111 -111
- package/hedhog/frontend/app/courses/[id]/structure/_components/course-tree.tsx.ejs +134 -134
- package/hedhog/frontend/app/courses/[id]/structure/_components/detail-course.tsx.ejs +113 -113
- package/hedhog/frontend/app/courses/[id]/structure/_components/detail-lesson.tsx.ejs +314 -314
- package/hedhog/frontend/app/courses/[id]/structure/_components/detail-panel.tsx.ejs +62 -62
- package/hedhog/frontend/app/courses/[id]/structure/_components/detail-session.tsx.ejs +173 -173
- package/hedhog/frontend/app/courses/[id]/structure/_components/drag-handle.tsx.ejs +58 -58
- package/hedhog/frontend/app/courses/[id]/structure/_components/drag-overlay.tsx.ejs +51 -51
- package/hedhog/frontend/app/courses/[id]/structure/_components/editor-bulk.tsx.ejs +276 -276
- package/hedhog/frontend/app/courses/[id]/structure/_components/editor-course.tsx.ejs +1216 -1216
- package/hedhog/frontend/app/courses/[id]/structure/_components/editor-lesson.tsx.ejs +1824 -1824
- package/hedhog/frontend/app/courses/[id]/structure/_components/editor-session.tsx.ejs +443 -443
- package/hedhog/frontend/app/courses/[id]/structure/_components/highlighted-text.tsx.ejs +40 -40
- package/hedhog/frontend/app/courses/[id]/structure/_components/mock-data.ts.ejs +185 -185
- package/hedhog/frontend/app/courses/[id]/structure/_components/multi-select-bar.tsx.ejs +264 -264
- package/hedhog/frontend/app/courses/[id]/structure/_components/search-filter.tsx.ejs +95 -95
- package/hedhog/frontend/app/courses/[id]/structure/_components/session-picker-dialog.tsx.ejs +73 -73
- package/hedhog/frontend/app/courses/[id]/structure/_components/shortcuts-help.tsx.ejs +136 -136
- package/hedhog/frontend/app/courses/[id]/structure/_components/sortable-tree-row.tsx.ejs +80 -80
- package/hedhog/frontend/app/courses/[id]/structure/_components/store.ts.ejs +949 -949
- package/hedhog/frontend/app/courses/[id]/structure/_components/tree-context-menu.tsx.ejs +525 -525
- package/hedhog/frontend/app/courses/[id]/structure/_components/tree-helpers.ts.ejs +181 -181
- package/hedhog/frontend/app/courses/[id]/structure/_components/tree-row-course.tsx.ejs +51 -51
- package/hedhog/frontend/app/courses/[id]/structure/_components/tree-row-lesson.tsx.ejs +271 -271
- package/hedhog/frontend/app/courses/[id]/structure/_components/tree-row-session.tsx.ejs +167 -167
- package/hedhog/frontend/app/courses/[id]/structure/_components/tree-row.tsx.ejs +108 -108
- package/hedhog/frontend/app/courses/[id]/structure/_components/use-course-structure-shortcuts.ts.ejs +318 -318
- package/hedhog/frontend/app/courses/[id]/structure/page.tsx.ejs +10 -10
- package/hedhog/frontend/app/enterprise/_components/enterprise-course-create-sheet.tsx.ejs +2 -1
- package/hedhog/frontend/app/instructors/_components/instructor-form-sheet.tsx.ejs +438 -0
- package/hedhog/frontend/app/instructors/_components/instructor-types.ts.ejs +40 -0
- package/hedhog/frontend/app/instructors/page.tsx.ejs +696 -0
- package/hedhog/frontend/app/training/page.tsx.ejs +2339 -0
- package/hedhog/table/enterprise_user.yaml +1 -1
- package/package.json +8 -8
- package/src/enterprise/dto/enterprise-profile.dto.ts +17 -0
- package/src/enterprise/enterprise.controller.ts +9 -1
- package/src/enterprise/enterprise.service.ts +147 -4
- package/src/instructor/instructor.controller.ts +36 -9
- 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
|
+
}
|