@hed-hog/lms 0.0.268 → 0.0.270
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/hedhog/data/menu.yaml +8 -1
- package/hedhog/frontend/app/classes/[id]/page.tsx.ejs +1387 -0
- package/hedhog/frontend/app/classes/page.tsx.ejs +4 -4
- package/hedhog/frontend/app/courses/[id]/page.tsx.ejs +1237 -0
- package/hedhog/frontend/app/courses/[id]/structure/page.tsx.ejs +2642 -0
- package/hedhog/frontend/app/courses/page.tsx.ejs +825 -727
- package/hedhog/frontend/app/exams/[id]/attempt/page.tsx.ejs +976 -0
- package/hedhog/frontend/app/exams/[id]/questions/page.tsx.ejs +931 -0
- package/hedhog/frontend/app/exams/page.tsx.ejs +9 -7
- package/hedhog/frontend/app/training/page.tsx.ejs +3 -3
- package/hedhog/frontend/messages/en.json +703 -14
- package/hedhog/frontend/messages/pt.json +863 -174
- package/hedhog/query/triggers.sql +0 -0
- package/hedhog/table/certificate.yaml +89 -0
- package/hedhog/table/certificate_template.yaml +24 -0
- package/hedhog/table/course.yaml +67 -1
- package/hedhog/table/course_category.yaml +22 -0
- package/hedhog/table/course_class_attendance.yaml +34 -0
- package/hedhog/table/course_class_group.yaml +58 -0
- package/hedhog/table/course_class_session.yaml +38 -0
- package/hedhog/table/course_class_session_instructor.yaml +27 -0
- package/hedhog/table/course_enrollment.yaml +45 -0
- package/hedhog/table/course_image.yaml +33 -0
- package/hedhog/table/course_lesson.yaml +35 -0
- package/hedhog/table/course_lesson_file.yaml +23 -0
- package/hedhog/table/course_lesson_instructor.yaml +27 -0
- package/hedhog/table/course_lesson_progress.yaml +40 -0
- package/hedhog/table/course_lesson_question.yaml +24 -0
- package/hedhog/table/course_module.yaml +25 -0
- package/hedhog/table/course_prerequisite.yaml +48 -0
- package/hedhog/table/evaluation_rating.yaml +30 -0
- package/hedhog/table/evaluation_topic.yaml +68 -0
- package/hedhog/table/exam.yaml +91 -0
- package/hedhog/table/exam_answer.yaml +40 -0
- package/hedhog/table/exam_attempt.yaml +51 -0
- package/hedhog/table/exam_image.yaml +33 -0
- package/hedhog/table/exam_option.yaml +25 -0
- package/hedhog/table/exam_question.yaml +24 -0
- package/hedhog/table/image_type.yaml +28 -0
- package/hedhog/table/instructor.yaml +23 -0
- package/hedhog/table/learning_path.yaml +49 -0
- package/hedhog/table/learning_path_enrollment.yaml +33 -0
- package/hedhog/table/learning_path_image.yaml +33 -0
- package/hedhog/table/learning_path_step.yaml +43 -0
- package/hedhog/table/question.yaml +15 -0
- package/package.json +9 -6
- package/src/index.ts +1 -1
- package/src/lms.module.ts +15 -15
- package/hedhog/table/classes.yaml +0 -3
- package/hedhog/table/exams.yaml +0 -3
- package/hedhog/table/reports.yaml +0 -3
- package/hedhog/table/training.yaml +0 -3
|
@@ -0,0 +1,2642 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Page, PageHeader } from '@/components/entity-list';
|
|
4
|
+
import { Badge } from '@/components/ui/badge';
|
|
5
|
+
import { Button } from '@/components/ui/button';
|
|
6
|
+
import { Card, CardContent } from '@/components/ui/card';
|
|
7
|
+
import {
|
|
8
|
+
Dialog,
|
|
9
|
+
DialogContent,
|
|
10
|
+
DialogDescription,
|
|
11
|
+
DialogFooter,
|
|
12
|
+
DialogHeader,
|
|
13
|
+
DialogTitle,
|
|
14
|
+
} from '@/components/ui/dialog';
|
|
15
|
+
import {
|
|
16
|
+
Field,
|
|
17
|
+
FieldDescription,
|
|
18
|
+
FieldError,
|
|
19
|
+
FieldLabel,
|
|
20
|
+
} from '@/components/ui/field';
|
|
21
|
+
import { Input } from '@/components/ui/input';
|
|
22
|
+
import {
|
|
23
|
+
Select,
|
|
24
|
+
SelectContent,
|
|
25
|
+
SelectItem,
|
|
26
|
+
SelectTrigger,
|
|
27
|
+
SelectValue,
|
|
28
|
+
} from '@/components/ui/select';
|
|
29
|
+
import { Separator } from '@/components/ui/separator';
|
|
30
|
+
import {
|
|
31
|
+
Sheet,
|
|
32
|
+
SheetContent,
|
|
33
|
+
SheetDescription,
|
|
34
|
+
SheetFooter,
|
|
35
|
+
SheetHeader,
|
|
36
|
+
SheetTitle,
|
|
37
|
+
} from '@/components/ui/sheet';
|
|
38
|
+
import { Skeleton } from '@/components/ui/skeleton';
|
|
39
|
+
import { Switch } from '@/components/ui/switch';
|
|
40
|
+
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
|
41
|
+
import { Textarea } from '@/components/ui/textarea';
|
|
42
|
+
import {
|
|
43
|
+
closestCorners,
|
|
44
|
+
DndContext,
|
|
45
|
+
DragOverlay,
|
|
46
|
+
KeyboardSensor,
|
|
47
|
+
PointerSensor,
|
|
48
|
+
useSensor,
|
|
49
|
+
useSensors,
|
|
50
|
+
type DragEndEvent,
|
|
51
|
+
type DragOverEvent,
|
|
52
|
+
type DragStartEvent,
|
|
53
|
+
type UniqueIdentifier,
|
|
54
|
+
} from '@dnd-kit/core';
|
|
55
|
+
import {
|
|
56
|
+
arrayMove,
|
|
57
|
+
SortableContext,
|
|
58
|
+
sortableKeyboardCoordinates,
|
|
59
|
+
useSortable,
|
|
60
|
+
verticalListSortingStrategy,
|
|
61
|
+
} from '@dnd-kit/sortable';
|
|
62
|
+
import { CSS } from '@dnd-kit/utilities';
|
|
63
|
+
import { zodResolver } from '@hookform/resolvers/zod';
|
|
64
|
+
import { AnimatePresence, motion } from 'framer-motion';
|
|
65
|
+
import {
|
|
66
|
+
AlertTriangle,
|
|
67
|
+
Archive,
|
|
68
|
+
BarChart3,
|
|
69
|
+
BookOpen,
|
|
70
|
+
CheckSquare,
|
|
71
|
+
ChevronDown,
|
|
72
|
+
ChevronRight,
|
|
73
|
+
Clock,
|
|
74
|
+
Copy,
|
|
75
|
+
Eye,
|
|
76
|
+
FileCheck,
|
|
77
|
+
FileText,
|
|
78
|
+
FileUp,
|
|
79
|
+
FolderOpen,
|
|
80
|
+
Globe,
|
|
81
|
+
GraduationCap,
|
|
82
|
+
GripVertical,
|
|
83
|
+
HelpCircle,
|
|
84
|
+
Layers,
|
|
85
|
+
LayoutDashboard,
|
|
86
|
+
Link2,
|
|
87
|
+
Loader2,
|
|
88
|
+
Lock,
|
|
89
|
+
MessageSquare,
|
|
90
|
+
Paperclip,
|
|
91
|
+
Pencil,
|
|
92
|
+
Plus,
|
|
93
|
+
Save,
|
|
94
|
+
Square,
|
|
95
|
+
Trash2,
|
|
96
|
+
Upload,
|
|
97
|
+
Users,
|
|
98
|
+
Video,
|
|
99
|
+
X,
|
|
100
|
+
type LucideIcon,
|
|
101
|
+
} from 'lucide-react';
|
|
102
|
+
import { useTranslations } from 'next-intl';
|
|
103
|
+
import { usePathname } from 'next/navigation';
|
|
104
|
+
import { use, useCallback, useEffect, useRef, useState } from 'react';
|
|
105
|
+
import { Controller, useForm } from 'react-hook-form';
|
|
106
|
+
import { toast } from 'sonner';
|
|
107
|
+
import { z } from 'zod';
|
|
108
|
+
|
|
109
|
+
// ── Navigation ──────────────────────────────────────────────────────────────
|
|
110
|
+
|
|
111
|
+
const NAV_ITEMS = [
|
|
112
|
+
{ label: 'Dashboard', href: '/', icon: LayoutDashboard },
|
|
113
|
+
{ label: 'Cursos', href: '/cursos', icon: BookOpen },
|
|
114
|
+
{ label: 'Turmas', href: '/turmas', icon: Users },
|
|
115
|
+
{ label: 'Exames', href: '/exames', icon: FileCheck },
|
|
116
|
+
{ label: 'Formacoes', href: '/formacoes', icon: GraduationCap },
|
|
117
|
+
{ label: 'Relatorios', href: '/relatorios', icon: BarChart3 },
|
|
118
|
+
];
|
|
119
|
+
|
|
120
|
+
// ── Types ───────────────────────────────────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
type AulaTipo = 'video' | 'questao' | 'post' | 'exercicio';
|
|
123
|
+
type VideoProvider = 'youtube' | 'vimeo' | 'bunny' | 'custom';
|
|
124
|
+
|
|
125
|
+
interface Recurso {
|
|
126
|
+
id: string;
|
|
127
|
+
nome: string;
|
|
128
|
+
tamanho: string;
|
|
129
|
+
tipo: string;
|
|
130
|
+
publico: boolean;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
interface Aula {
|
|
134
|
+
id: string;
|
|
135
|
+
codigo: string;
|
|
136
|
+
titulo: string;
|
|
137
|
+
descricaoPublica: string;
|
|
138
|
+
descricaoPrivada: string;
|
|
139
|
+
tipo: AulaTipo;
|
|
140
|
+
duracao: number;
|
|
141
|
+
sessaoId: string;
|
|
142
|
+
// Video fields
|
|
143
|
+
videoProvedor?: VideoProvider;
|
|
144
|
+
videoUrl?: string;
|
|
145
|
+
duracaoAutomatica?: boolean;
|
|
146
|
+
transcricao?: string;
|
|
147
|
+
// Questao fields
|
|
148
|
+
exameVinculado?: string;
|
|
149
|
+
// Post fields
|
|
150
|
+
conteudoPost?: string;
|
|
151
|
+
// Recursos
|
|
152
|
+
recursos: Recurso[];
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
interface Sessao {
|
|
156
|
+
id: string;
|
|
157
|
+
codigo: string;
|
|
158
|
+
titulo: string;
|
|
159
|
+
duracao: number;
|
|
160
|
+
collapsed: boolean;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ── Schemas ─────────────────────────────────────────────────────────────────
|
|
164
|
+
|
|
165
|
+
function getSessaoSchema(t: any) {
|
|
166
|
+
return z.object({
|
|
167
|
+
codigo: z.string().min(2, t('validation.codeMin')),
|
|
168
|
+
titulo: z.string().min(3, t('validation.titleMin')),
|
|
169
|
+
duracao: z.coerce.number().min(1, t('validation.durationMin')),
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
type SessaoForm = {
|
|
173
|
+
codigo: string;
|
|
174
|
+
titulo: string;
|
|
175
|
+
duracao: number;
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
function getAulaSchema(t: any) {
|
|
179
|
+
return z.object({
|
|
180
|
+
codigo: z.string().min(2, t('validation.codeMin')),
|
|
181
|
+
titulo: z.string().min(3, t('validation.titleMin')),
|
|
182
|
+
descricaoPublica: z.string(),
|
|
183
|
+
descricaoPrivada: z.string(),
|
|
184
|
+
tipo: z.enum(['video', 'questao', 'post', 'exercicio'], {
|
|
185
|
+
errorMap: () => ({ message: t('validation.typeRequired') }),
|
|
186
|
+
}),
|
|
187
|
+
duracao: z.coerce.number().min(1, t('validation.durationMin')),
|
|
188
|
+
// Video
|
|
189
|
+
videoProvedor: z.string().optional(),
|
|
190
|
+
videoUrl: z.string().optional(),
|
|
191
|
+
duracaoAutomatica: z.boolean().optional(),
|
|
192
|
+
// Questao
|
|
193
|
+
exameVinculado: z.string().optional(),
|
|
194
|
+
// Post
|
|
195
|
+
conteudoPost: z.string().optional(),
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
type AulaFormType = {
|
|
199
|
+
codigo: string;
|
|
200
|
+
titulo: string;
|
|
201
|
+
descricaoPublica: string;
|
|
202
|
+
descricaoPrivada: string;
|
|
203
|
+
tipo: AulaTipo;
|
|
204
|
+
duracao: number;
|
|
205
|
+
videoProvedor?: string;
|
|
206
|
+
videoUrl?: string;
|
|
207
|
+
duracaoAutomatica?: boolean;
|
|
208
|
+
exameVinculado?: string;
|
|
209
|
+
conteudoPost?: string;
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
// ── Constants ───────────────────────────────────────────────────────────────
|
|
213
|
+
|
|
214
|
+
function getTipoAulaMap(
|
|
215
|
+
t: any
|
|
216
|
+
): Record<AulaTipo, { label: string; icon: LucideIcon; color: string }> {
|
|
217
|
+
return {
|
|
218
|
+
video: {
|
|
219
|
+
label: t('types.video'),
|
|
220
|
+
icon: Video,
|
|
221
|
+
color: 'bg-blue-100 text-blue-700',
|
|
222
|
+
},
|
|
223
|
+
post: {
|
|
224
|
+
label: t('types.post'),
|
|
225
|
+
icon: FileText,
|
|
226
|
+
color: 'bg-emerald-100 text-emerald-700',
|
|
227
|
+
},
|
|
228
|
+
questao: {
|
|
229
|
+
label: t('types.questao'),
|
|
230
|
+
icon: HelpCircle,
|
|
231
|
+
color: 'bg-amber-100 text-amber-700',
|
|
232
|
+
},
|
|
233
|
+
exercicio: {
|
|
234
|
+
label: t('types.exercicio'),
|
|
235
|
+
icon: Layers,
|
|
236
|
+
color: 'bg-rose-100 text-rose-700',
|
|
237
|
+
},
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function getProvedores(t: any): { value: VideoProvider; label: string }[] {
|
|
242
|
+
return [
|
|
243
|
+
{ value: 'youtube', label: 'YouTube' },
|
|
244
|
+
{ value: 'vimeo', label: 'Vimeo' },
|
|
245
|
+
{ value: 'bunny', label: 'Bunny Stream' },
|
|
246
|
+
{ value: 'custom', label: t('providers.custom') },
|
|
247
|
+
];
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function getMockExames(t: any) {
|
|
251
|
+
return [
|
|
252
|
+
{ id: 'ex1', titulo: t('mockExams.hooksBasic') },
|
|
253
|
+
{ id: 'ex2', titulo: t('mockExams.hooksAdvanced') },
|
|
254
|
+
{ id: 'ex3', titulo: t('mockExams.reactPatterns') },
|
|
255
|
+
{ id: 'ex4', titulo: t('mockExams.performance') },
|
|
256
|
+
{ id: 'ex5', titulo: t('mockExams.finalEvaluation') },
|
|
257
|
+
];
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// ── Mock Data ───────────────────────────────────────────────────────────────
|
|
261
|
+
|
|
262
|
+
const initialSessoes: Sessao[] = [
|
|
263
|
+
{
|
|
264
|
+
id: 's1',
|
|
265
|
+
codigo: 'S01',
|
|
266
|
+
titulo: 'Introducao ao React Avancado',
|
|
267
|
+
duracao: 45,
|
|
268
|
+
collapsed: false,
|
|
269
|
+
},
|
|
270
|
+
{
|
|
271
|
+
id: 's2',
|
|
272
|
+
codigo: 'S02',
|
|
273
|
+
titulo: 'Hooks Avancados',
|
|
274
|
+
duracao: 113,
|
|
275
|
+
collapsed: false,
|
|
276
|
+
},
|
|
277
|
+
{
|
|
278
|
+
id: 's3',
|
|
279
|
+
codigo: 'S03',
|
|
280
|
+
titulo: 'Patterns de Composicao',
|
|
281
|
+
duracao: 102,
|
|
282
|
+
collapsed: false,
|
|
283
|
+
},
|
|
284
|
+
{
|
|
285
|
+
id: 's4',
|
|
286
|
+
codigo: 'S04',
|
|
287
|
+
titulo: 'Gerenciamento de Estado',
|
|
288
|
+
duracao: 62,
|
|
289
|
+
collapsed: false,
|
|
290
|
+
},
|
|
291
|
+
{
|
|
292
|
+
id: 's5',
|
|
293
|
+
codigo: 'S05',
|
|
294
|
+
titulo: 'Performance e Otimizacao',
|
|
295
|
+
duracao: 130,
|
|
296
|
+
collapsed: false,
|
|
297
|
+
},
|
|
298
|
+
];
|
|
299
|
+
|
|
300
|
+
const initialAulas: Aula[] = [
|
|
301
|
+
{
|
|
302
|
+
id: 'a1',
|
|
303
|
+
codigo: 'A01',
|
|
304
|
+
titulo: 'Bem-vindo ao curso',
|
|
305
|
+
tipo: 'video',
|
|
306
|
+
duracao: 8,
|
|
307
|
+
descricaoPublica: 'Apresentacao do instrutor e roadmap do curso',
|
|
308
|
+
descricaoPrivada: 'Gravar nova intro',
|
|
309
|
+
sessaoId: 's1',
|
|
310
|
+
videoProvedor: 'youtube',
|
|
311
|
+
videoUrl: 'https://youtube.com/watch?v=abc123',
|
|
312
|
+
duracaoAutomatica: true,
|
|
313
|
+
recursos: [
|
|
314
|
+
{
|
|
315
|
+
id: 'r1',
|
|
316
|
+
nome: 'slides-intro.pdf',
|
|
317
|
+
tamanho: '2.4 MB',
|
|
318
|
+
tipo: 'pdf',
|
|
319
|
+
publico: true,
|
|
320
|
+
},
|
|
321
|
+
],
|
|
322
|
+
},
|
|
323
|
+
{
|
|
324
|
+
id: 'a2',
|
|
325
|
+
codigo: 'A02',
|
|
326
|
+
titulo: 'Configuracao do ambiente',
|
|
327
|
+
tipo: 'post',
|
|
328
|
+
duracao: 15,
|
|
329
|
+
descricaoPublica: 'Guia passo a passo para configurar o ambiente',
|
|
330
|
+
descricaoPrivada: '',
|
|
331
|
+
sessaoId: 's1',
|
|
332
|
+
conteudoPost: '# Configuracao\n\nSiga os passos abaixo...',
|
|
333
|
+
recursos: [],
|
|
334
|
+
},
|
|
335
|
+
{
|
|
336
|
+
id: 'a3',
|
|
337
|
+
codigo: 'A03',
|
|
338
|
+
titulo: 'Revisao: Componentes e Props',
|
|
339
|
+
tipo: 'video',
|
|
340
|
+
duracao: 22,
|
|
341
|
+
descricaoPublica: 'Revisao rapida dos fundamentos',
|
|
342
|
+
descricaoPrivada: '',
|
|
343
|
+
sessaoId: 's1',
|
|
344
|
+
videoProvedor: 'vimeo',
|
|
345
|
+
videoUrl: 'https://vimeo.com/123456',
|
|
346
|
+
duracaoAutomatica: false,
|
|
347
|
+
transcricao:
|
|
348
|
+
'Ola, nesta aula vamos revisar os fundamentos de componentes React. Vamos comecar entendendo o que sao componentes, como definir props e como compor interfaces modulares...',
|
|
349
|
+
recursos: [],
|
|
350
|
+
},
|
|
351
|
+
{
|
|
352
|
+
id: 'a4',
|
|
353
|
+
codigo: 'A04',
|
|
354
|
+
titulo: 'useReducer na pratica',
|
|
355
|
+
tipo: 'video',
|
|
356
|
+
duracao: 35,
|
|
357
|
+
descricaoPublica: 'Como usar useReducer para estado complexo',
|
|
358
|
+
descricaoPrivada: 'Adicionar exemplo de todo list',
|
|
359
|
+
sessaoId: 's2',
|
|
360
|
+
videoProvedor: 'youtube',
|
|
361
|
+
videoUrl: 'https://youtube.com/watch?v=def456',
|
|
362
|
+
duracaoAutomatica: true,
|
|
363
|
+
recursos: [
|
|
364
|
+
{
|
|
365
|
+
id: 'r2',
|
|
366
|
+
nome: 'useReducer-exemplos.zip',
|
|
367
|
+
tamanho: '840 KB',
|
|
368
|
+
tipo: 'zip',
|
|
369
|
+
publico: false,
|
|
370
|
+
},
|
|
371
|
+
{
|
|
372
|
+
id: 'r3',
|
|
373
|
+
nome: 'diagrama-reducer.png',
|
|
374
|
+
tamanho: '180 KB',
|
|
375
|
+
tipo: 'png',
|
|
376
|
+
publico: true,
|
|
377
|
+
},
|
|
378
|
+
],
|
|
379
|
+
},
|
|
380
|
+
{
|
|
381
|
+
id: 'a5',
|
|
382
|
+
codigo: 'A05',
|
|
383
|
+
titulo: 'useMemo e useCallback',
|
|
384
|
+
tipo: 'video',
|
|
385
|
+
duracao: 28,
|
|
386
|
+
descricaoPublica: 'Otimizando re-renders com memoizacao',
|
|
387
|
+
descricaoPrivada: '',
|
|
388
|
+
sessaoId: 's2',
|
|
389
|
+
videoProvedor: 'bunny',
|
|
390
|
+
videoUrl: 'https://stream.bunny.net/xyz',
|
|
391
|
+
duracaoAutomatica: true,
|
|
392
|
+
recursos: [],
|
|
393
|
+
},
|
|
394
|
+
{
|
|
395
|
+
id: 'a6',
|
|
396
|
+
codigo: 'A06',
|
|
397
|
+
titulo: 'Criando hooks customizados',
|
|
398
|
+
tipo: 'video',
|
|
399
|
+
duracao: 40,
|
|
400
|
+
descricaoPublica: 'Extraindo logica reutilizavel',
|
|
401
|
+
descricaoPrivada: '',
|
|
402
|
+
sessaoId: 's2',
|
|
403
|
+
videoProvedor: 'youtube',
|
|
404
|
+
videoUrl: 'https://youtube.com/watch?v=ghi789',
|
|
405
|
+
duracaoAutomatica: false,
|
|
406
|
+
recursos: [],
|
|
407
|
+
},
|
|
408
|
+
{
|
|
409
|
+
id: 'a7',
|
|
410
|
+
codigo: 'A07',
|
|
411
|
+
titulo: 'Quiz: Hooks Avancados',
|
|
412
|
+
tipo: 'questao',
|
|
413
|
+
duracao: 10,
|
|
414
|
+
descricaoPublica: 'Teste seus conhecimentos',
|
|
415
|
+
descricaoPrivada: 'Revisar questao 5',
|
|
416
|
+
sessaoId: 's2',
|
|
417
|
+
exameVinculado: 'ex2',
|
|
418
|
+
recursos: [],
|
|
419
|
+
},
|
|
420
|
+
{
|
|
421
|
+
id: 'a8',
|
|
422
|
+
codigo: 'A08',
|
|
423
|
+
titulo: 'Compound Components',
|
|
424
|
+
tipo: 'video',
|
|
425
|
+
duracao: 32,
|
|
426
|
+
descricaoPublica: 'Pattern para componentes composiveis',
|
|
427
|
+
descricaoPrivada: '',
|
|
428
|
+
sessaoId: 's3',
|
|
429
|
+
videoProvedor: 'youtube',
|
|
430
|
+
videoUrl: 'https://youtube.com/watch?v=jkl012',
|
|
431
|
+
duracaoAutomatica: true,
|
|
432
|
+
recursos: [],
|
|
433
|
+
},
|
|
434
|
+
{
|
|
435
|
+
id: 'a9',
|
|
436
|
+
codigo: 'A09',
|
|
437
|
+
titulo: 'Render Props vs HOC',
|
|
438
|
+
tipo: 'video',
|
|
439
|
+
duracao: 25,
|
|
440
|
+
descricaoPublica: 'Comparacao pratica',
|
|
441
|
+
descricaoPrivada: '',
|
|
442
|
+
sessaoId: 's3',
|
|
443
|
+
videoProvedor: 'vimeo',
|
|
444
|
+
videoUrl: 'https://vimeo.com/789012',
|
|
445
|
+
duracaoAutomatica: true,
|
|
446
|
+
recursos: [
|
|
447
|
+
{
|
|
448
|
+
id: 'r4',
|
|
449
|
+
nome: 'comparativo-patterns.pdf',
|
|
450
|
+
tamanho: '1.1 MB',
|
|
451
|
+
tipo: 'pdf',
|
|
452
|
+
publico: true,
|
|
453
|
+
},
|
|
454
|
+
],
|
|
455
|
+
},
|
|
456
|
+
{
|
|
457
|
+
id: 'a10',
|
|
458
|
+
codigo: 'A10',
|
|
459
|
+
titulo: 'Exercicio: Refatorar componente',
|
|
460
|
+
tipo: 'exercicio',
|
|
461
|
+
duracao: 45,
|
|
462
|
+
descricaoPublica: 'Refatore um componente monolitico',
|
|
463
|
+
descricaoPrivada: 'Solucao no repo privado',
|
|
464
|
+
sessaoId: 's3',
|
|
465
|
+
recursos: [
|
|
466
|
+
{
|
|
467
|
+
id: 'r5',
|
|
468
|
+
nome: 'codigo-base.zip',
|
|
469
|
+
tamanho: '520 KB',
|
|
470
|
+
tipo: 'zip',
|
|
471
|
+
publico: true,
|
|
472
|
+
},
|
|
473
|
+
{
|
|
474
|
+
id: 'r6',
|
|
475
|
+
nome: 'solucao.zip',
|
|
476
|
+
tamanho: '580 KB',
|
|
477
|
+
tipo: 'zip',
|
|
478
|
+
publico: false,
|
|
479
|
+
},
|
|
480
|
+
],
|
|
481
|
+
},
|
|
482
|
+
{
|
|
483
|
+
id: 'a11',
|
|
484
|
+
codigo: 'A11',
|
|
485
|
+
titulo: 'Context API a fundo',
|
|
486
|
+
tipo: 'video',
|
|
487
|
+
duracao: 30,
|
|
488
|
+
descricaoPublica: 'Context sem re-renders desnecessarios',
|
|
489
|
+
descricaoPrivada: '',
|
|
490
|
+
sessaoId: 's4',
|
|
491
|
+
videoProvedor: 'youtube',
|
|
492
|
+
videoUrl: 'https://youtube.com/watch?v=mno345',
|
|
493
|
+
duracaoAutomatica: true,
|
|
494
|
+
recursos: [],
|
|
495
|
+
},
|
|
496
|
+
{
|
|
497
|
+
id: 'a12',
|
|
498
|
+
codigo: 'A12',
|
|
499
|
+
titulo: 'Introducao ao Zustand',
|
|
500
|
+
tipo: 'video',
|
|
501
|
+
duracao: 20,
|
|
502
|
+
descricaoPublica: 'Store global leve',
|
|
503
|
+
descricaoPrivada: '',
|
|
504
|
+
sessaoId: 's4',
|
|
505
|
+
videoProvedor: 'youtube',
|
|
506
|
+
videoUrl: 'https://youtube.com/watch?v=pqr678',
|
|
507
|
+
duracaoAutomatica: true,
|
|
508
|
+
recursos: [],
|
|
509
|
+
},
|
|
510
|
+
{
|
|
511
|
+
id: 'a13',
|
|
512
|
+
codigo: 'A13',
|
|
513
|
+
titulo: 'Comparativo de solucoes',
|
|
514
|
+
tipo: 'post',
|
|
515
|
+
duracao: 12,
|
|
516
|
+
descricaoPublica: 'Tabela comparativa',
|
|
517
|
+
descricaoPrivada: '',
|
|
518
|
+
sessaoId: 's4',
|
|
519
|
+
conteudoPost:
|
|
520
|
+
'# Comparacao\n\n| Feature | Context | Zustand | Jotai |\n|---|---|---|---|\n| Bundle size | 0kb | 1kb | 2kb |',
|
|
521
|
+
recursos: [],
|
|
522
|
+
},
|
|
523
|
+
{
|
|
524
|
+
id: 'a14',
|
|
525
|
+
codigo: 'A14',
|
|
526
|
+
titulo: 'React Profiler',
|
|
527
|
+
tipo: 'video',
|
|
528
|
+
duracao: 18,
|
|
529
|
+
descricaoPublica: 'Identificando gargalos',
|
|
530
|
+
descricaoPrivada: '',
|
|
531
|
+
sessaoId: 's5',
|
|
532
|
+
videoProvedor: 'youtube',
|
|
533
|
+
videoUrl: 'https://youtube.com/watch?v=stu901',
|
|
534
|
+
duracaoAutomatica: false,
|
|
535
|
+
recursos: [],
|
|
536
|
+
},
|
|
537
|
+
{
|
|
538
|
+
id: 'a15',
|
|
539
|
+
codigo: 'A15',
|
|
540
|
+
titulo: 'Code Splitting com React.lazy',
|
|
541
|
+
tipo: 'video',
|
|
542
|
+
duracao: 22,
|
|
543
|
+
descricaoPublica: 'Lazy loading de componentes',
|
|
544
|
+
descricaoPrivada: '',
|
|
545
|
+
sessaoId: 's5',
|
|
546
|
+
videoProvedor: 'bunny',
|
|
547
|
+
videoUrl: 'https://stream.bunny.net/abc',
|
|
548
|
+
duracaoAutomatica: true,
|
|
549
|
+
recursos: [],
|
|
550
|
+
},
|
|
551
|
+
{
|
|
552
|
+
id: 'a16',
|
|
553
|
+
codigo: 'A16',
|
|
554
|
+
titulo: 'Projeto Final',
|
|
555
|
+
tipo: 'exercicio',
|
|
556
|
+
duracao: 90,
|
|
557
|
+
descricaoPublica: 'Aplicacao completa com todos os conceitos',
|
|
558
|
+
descricaoPrivada: 'Avaliar com rubrica',
|
|
559
|
+
sessaoId: 's5',
|
|
560
|
+
recursos: [
|
|
561
|
+
{
|
|
562
|
+
id: 'r7',
|
|
563
|
+
nome: 'rubrica-avaliacao.pdf',
|
|
564
|
+
tamanho: '320 KB',
|
|
565
|
+
tipo: 'pdf',
|
|
566
|
+
publico: false,
|
|
567
|
+
},
|
|
568
|
+
{
|
|
569
|
+
id: 'r8',
|
|
570
|
+
nome: 'template-projeto.zip',
|
|
571
|
+
tamanho: '1.2 MB',
|
|
572
|
+
tipo: 'zip',
|
|
573
|
+
publico: true,
|
|
574
|
+
},
|
|
575
|
+
],
|
|
576
|
+
},
|
|
577
|
+
];
|
|
578
|
+
|
|
579
|
+
// ── Animations ──────────────────────────────────────────────────────────────
|
|
580
|
+
|
|
581
|
+
const stagger = { hidden: {}, show: { transition: { staggerChildren: 0.04 } } };
|
|
582
|
+
const fadeUp = {
|
|
583
|
+
hidden: { opacity: 0, y: 12 },
|
|
584
|
+
show: { opacity: 1, y: 0, transition: { duration: 0.3, ease: 'easeOut' } },
|
|
585
|
+
};
|
|
586
|
+
|
|
587
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
588
|
+
// PAGE COMPONENT
|
|
589
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
590
|
+
|
|
591
|
+
export default function EstruturaPage({
|
|
592
|
+
params,
|
|
593
|
+
}: {
|
|
594
|
+
params: Promise<{ id: string }>;
|
|
595
|
+
}) {
|
|
596
|
+
const { id } = use(params);
|
|
597
|
+
const pathname = usePathname();
|
|
598
|
+
const t = useTranslations('lms.CoursesPage.StructurePage');
|
|
599
|
+
|
|
600
|
+
// ── States ──────────────────────────────────────────────────────────────
|
|
601
|
+
const [loading, setLoading] = useState(true);
|
|
602
|
+
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
|
603
|
+
|
|
604
|
+
// Data
|
|
605
|
+
const [sessoes, setSessoes] = useState<Sessao[]>(initialSessoes);
|
|
606
|
+
const [aulas, setAulas] = useState<Aula[]>(initialAulas);
|
|
607
|
+
|
|
608
|
+
// Selection
|
|
609
|
+
const [selectedAulas, setSelectedAulas] = useState<Set<string>>(new Set());
|
|
610
|
+
const [lastSelectedAula, setLastSelectedAula] = useState<string | null>(null);
|
|
611
|
+
|
|
612
|
+
// DnD
|
|
613
|
+
const [activeId, setActiveId] = useState<UniqueIdentifier | null>(null);
|
|
614
|
+
const [activeSessaoId, setActiveSessaoId] = useState<string | null>(null);
|
|
615
|
+
|
|
616
|
+
// Sheets
|
|
617
|
+
const [sessaoSheetOpen, setSessaoSheetOpen] = useState(false);
|
|
618
|
+
const [aulaSheetOpen, setAulaSheetOpen] = useState(false);
|
|
619
|
+
const [editingSessao, setEditingSessao] = useState<Sessao | null>(null);
|
|
620
|
+
const [editingAula, setEditingAula] = useState<Aula | null>(null);
|
|
621
|
+
const [targetSessaoId, setTargetSessaoId] = useState<string | null>(null);
|
|
622
|
+
const [saving, setSaving] = useState(false);
|
|
623
|
+
const [aulaSheetTab, setAulaSheetTab] = useState('dados');
|
|
624
|
+
|
|
625
|
+
// Dialogs
|
|
626
|
+
const [deleteSessaoDialog, setDeleteSessaoDialog] = useState(false);
|
|
627
|
+
const [deleteAulaDialog, setDeleteAulaDialog] = useState(false);
|
|
628
|
+
const [sessaoToDelete, setSessaoToDelete] = useState<Sessao | null>(null);
|
|
629
|
+
const [aulaToDelete, setAulaToDelete] = useState<Aula | null>(null);
|
|
630
|
+
const [bulkDeleteDialog, setBulkDeleteDialog] = useState(false);
|
|
631
|
+
|
|
632
|
+
// Recursos management in sheet
|
|
633
|
+
const [sheetRecursos, setSheetRecursos] = useState<Recurso[]>([]);
|
|
634
|
+
const [transcricaoFile, setTranscricaoFile] = useState<string | null>(null);
|
|
635
|
+
|
|
636
|
+
// Refs for shift-select
|
|
637
|
+
const aulasOrderRef = useRef<string[]>([]);
|
|
638
|
+
|
|
639
|
+
// Translation functions
|
|
640
|
+
const sessaoSchema = getSessaoSchema(t);
|
|
641
|
+
const aulaSchema = getAulaSchema(t);
|
|
642
|
+
const TIPO_AULA_MAP = getTipoAulaMap(t);
|
|
643
|
+
const PROVEDORES = getProvedores(t);
|
|
644
|
+
const MOCK_EXAMES = getMockExames(t);
|
|
645
|
+
|
|
646
|
+
// Forms
|
|
647
|
+
const sessaoForm = useForm<SessaoForm>({
|
|
648
|
+
resolver: zodResolver(sessaoSchema),
|
|
649
|
+
defaultValues: { codigo: '', titulo: '', duracao: 30 },
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
const aulaForm = useForm<AulaFormType>({
|
|
653
|
+
resolver: zodResolver(aulaSchema),
|
|
654
|
+
defaultValues: {
|
|
655
|
+
codigo: '',
|
|
656
|
+
titulo: '',
|
|
657
|
+
descricaoPublica: '',
|
|
658
|
+
descricaoPrivada: '',
|
|
659
|
+
tipo: 'video',
|
|
660
|
+
duracao: 10,
|
|
661
|
+
videoProvedor: 'youtube',
|
|
662
|
+
videoUrl: '',
|
|
663
|
+
duracaoAutomatica: false,
|
|
664
|
+
exameVinculado: '',
|
|
665
|
+
conteudoPost: '',
|
|
666
|
+
},
|
|
667
|
+
});
|
|
668
|
+
|
|
669
|
+
const watchTipo = aulaForm.watch('tipo');
|
|
670
|
+
|
|
671
|
+
// DnD sensors
|
|
672
|
+
const sensors = useSensors(
|
|
673
|
+
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
|
674
|
+
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
|
|
675
|
+
);
|
|
676
|
+
|
|
677
|
+
useEffect(() => {
|
|
678
|
+
const t = setTimeout(() => setLoading(false), 700);
|
|
679
|
+
return () => clearTimeout(t);
|
|
680
|
+
}, []);
|
|
681
|
+
|
|
682
|
+
// Keep a flat ordered list of aulas for shift-select
|
|
683
|
+
useEffect(() => {
|
|
684
|
+
const ordered: string[] = [];
|
|
685
|
+
sessoes.forEach((s) => {
|
|
686
|
+
aulas
|
|
687
|
+
.filter((a) => a.sessaoId === s.id)
|
|
688
|
+
.forEach((a) => ordered.push(a.id));
|
|
689
|
+
});
|
|
690
|
+
aulasOrderRef.current = ordered;
|
|
691
|
+
}, [sessoes, aulas]);
|
|
692
|
+
|
|
693
|
+
// ── Helpers ─────────────────────────────────────────────────────────────
|
|
694
|
+
|
|
695
|
+
const getAulasBySessao = useCallback(
|
|
696
|
+
(sessaoId: string) => aulas.filter((a) => a.sessaoId === sessaoId),
|
|
697
|
+
[aulas]
|
|
698
|
+
);
|
|
699
|
+
|
|
700
|
+
const findSessaoOfAula = useCallback(
|
|
701
|
+
(aulaId: string) => aulas.find((a) => a.id === aulaId)?.sessaoId ?? null,
|
|
702
|
+
[aulas]
|
|
703
|
+
);
|
|
704
|
+
|
|
705
|
+
function isSessaoId(uid: UniqueIdentifier): boolean {
|
|
706
|
+
return sessoes.some((s) => s.id === String(uid));
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
function isAulaId(uid: UniqueIdentifier): boolean {
|
|
710
|
+
return aulas.some((a) => a.id === String(uid));
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
// ── Multi-select ────────────────────────────────────────────────────────
|
|
714
|
+
|
|
715
|
+
function handleAulaClick(aulaId: string, e: React.MouseEvent) {
|
|
716
|
+
if (e.ctrlKey || e.metaKey) {
|
|
717
|
+
setSelectedAulas((prev) => {
|
|
718
|
+
const next = new Set(prev);
|
|
719
|
+
if (next.has(aulaId)) next.delete(aulaId);
|
|
720
|
+
else next.add(aulaId);
|
|
721
|
+
return next;
|
|
722
|
+
});
|
|
723
|
+
setLastSelectedAula(aulaId);
|
|
724
|
+
} else if (e.shiftKey && lastSelectedAula) {
|
|
725
|
+
const order = aulasOrderRef.current;
|
|
726
|
+
const startIdx = order.indexOf(lastSelectedAula);
|
|
727
|
+
const endIdx = order.indexOf(aulaId);
|
|
728
|
+
if (startIdx !== -1 && endIdx !== -1) {
|
|
729
|
+
const [from, to] =
|
|
730
|
+
startIdx < endIdx ? [startIdx, endIdx] : [endIdx, startIdx];
|
|
731
|
+
const range = order.slice(from, to + 1);
|
|
732
|
+
setSelectedAulas((prev) => {
|
|
733
|
+
const next = new Set(prev);
|
|
734
|
+
range.forEach((rid) => next.add(rid));
|
|
735
|
+
return next;
|
|
736
|
+
});
|
|
737
|
+
}
|
|
738
|
+
} else {
|
|
739
|
+
setSelectedAulas(new Set([aulaId]));
|
|
740
|
+
setLastSelectedAula(aulaId);
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
function clearSelection() {
|
|
745
|
+
setSelectedAulas(new Set());
|
|
746
|
+
setLastSelectedAula(null);
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
function selectAllInSessao(sessaoId: string) {
|
|
750
|
+
const ids = getAulasBySessao(sessaoId).map((a) => a.id);
|
|
751
|
+
setSelectedAulas((prev) => {
|
|
752
|
+
const next = new Set(prev);
|
|
753
|
+
ids.forEach((rid) => next.add(rid));
|
|
754
|
+
return next;
|
|
755
|
+
});
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
// ── DnD Handlers ────────────────────────────────────────────────────────
|
|
759
|
+
|
|
760
|
+
function handleDragStart(event: DragStartEvent) {
|
|
761
|
+
const { active } = event;
|
|
762
|
+
setActiveId(active.id);
|
|
763
|
+
setActiveSessaoId(isSessaoId(active.id) ? String(active.id) : null);
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
function handleDragOver(event: DragOverEvent) {
|
|
767
|
+
const { active, over } = event;
|
|
768
|
+
if (!over || !isAulaId(active.id)) return;
|
|
769
|
+
|
|
770
|
+
const activeIdStr = String(active.id);
|
|
771
|
+
const overIdStr = String(over.id);
|
|
772
|
+
const activeSessao = findSessaoOfAula(activeIdStr);
|
|
773
|
+
let overSessao: string | null = null;
|
|
774
|
+
|
|
775
|
+
if (isSessaoId(over.id)) overSessao = overIdStr;
|
|
776
|
+
else if (isAulaId(over.id)) overSessao = findSessaoOfAula(overIdStr);
|
|
777
|
+
|
|
778
|
+
if (!activeSessao || !overSessao || activeSessao === overSessao) return;
|
|
779
|
+
|
|
780
|
+
setAulas((prev) =>
|
|
781
|
+
prev.map((a) =>
|
|
782
|
+
a.id === activeIdStr ? { ...a, sessaoId: overSessao } : a
|
|
783
|
+
)
|
|
784
|
+
);
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
function handleDragEnd(event: DragEndEvent) {
|
|
788
|
+
const { active, over } = event;
|
|
789
|
+
setActiveId(null);
|
|
790
|
+
setActiveSessaoId(null);
|
|
791
|
+
|
|
792
|
+
if (!over || active.id === over.id) return;
|
|
793
|
+
|
|
794
|
+
const activeIdStr = String(active.id);
|
|
795
|
+
const overIdStr = String(over.id);
|
|
796
|
+
|
|
797
|
+
if (isSessaoId(active.id) && isSessaoId(over.id)) {
|
|
798
|
+
setSessoes((prev) => {
|
|
799
|
+
const oldIndex = prev.findIndex((s) => s.id === activeIdStr);
|
|
800
|
+
const newIndex = prev.findIndex((s) => s.id === overIdStr);
|
|
801
|
+
return arrayMove(prev, oldIndex, newIndex);
|
|
802
|
+
});
|
|
803
|
+
return;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
if (isAulaId(active.id)) {
|
|
807
|
+
const activeSessao = findSessaoOfAula(activeIdStr);
|
|
808
|
+
let overSessao: string | null = null;
|
|
809
|
+
if (isSessaoId(over.id)) overSessao = overIdStr;
|
|
810
|
+
else if (isAulaId(over.id)) overSessao = findSessaoOfAula(overIdStr);
|
|
811
|
+
|
|
812
|
+
if (activeSessao && overSessao && activeSessao === overSessao) {
|
|
813
|
+
setAulas((prev) => {
|
|
814
|
+
const sessaoAulas = prev.filter((a) => a.sessaoId === activeSessao);
|
|
815
|
+
const others = prev.filter((a) => a.sessaoId !== activeSessao);
|
|
816
|
+
const oldIndex = sessaoAulas.findIndex((a) => a.id === activeIdStr);
|
|
817
|
+
const newIndex = isAulaId(over.id)
|
|
818
|
+
? sessaoAulas.findIndex((a) => a.id === overIdStr)
|
|
819
|
+
: sessaoAulas.length;
|
|
820
|
+
return [...others, ...arrayMove(sessaoAulas, oldIndex, newIndex)];
|
|
821
|
+
});
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
function mergeSessao(sourceId: string, targetId: string) {
|
|
827
|
+
setAulas((prev) =>
|
|
828
|
+
prev.map((a) =>
|
|
829
|
+
a.sessaoId === sourceId ? { ...a, sessaoId: targetId } : a
|
|
830
|
+
)
|
|
831
|
+
);
|
|
832
|
+
setSessoes((prev) => prev.filter((s) => s.id !== sourceId));
|
|
833
|
+
toast.success(t('toasts.sessionsMerged'));
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
// ── CRUD: Sessao ──────────────────────────────────────────────────────
|
|
837
|
+
|
|
838
|
+
function openCreateSessao() {
|
|
839
|
+
setEditingSessao(null);
|
|
840
|
+
sessaoForm.reset({ codigo: '', titulo: '', duracao: 30 });
|
|
841
|
+
setSessaoSheetOpen(true);
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
function openEditSessao(sessao: Sessao) {
|
|
845
|
+
setEditingSessao(sessao);
|
|
846
|
+
sessaoForm.reset({
|
|
847
|
+
codigo: sessao.codigo,
|
|
848
|
+
titulo: sessao.titulo,
|
|
849
|
+
duracao: sessao.duracao,
|
|
850
|
+
});
|
|
851
|
+
setSessaoSheetOpen(true);
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
async function onSubmitSessao(data: SessaoForm) {
|
|
855
|
+
setSaving(true);
|
|
856
|
+
await new Promise((r) => setTimeout(r, 400));
|
|
857
|
+
if (editingSessao) {
|
|
858
|
+
setSessoes((prev) =>
|
|
859
|
+
prev.map((s) => (s.id === editingSessao.id ? { ...s, ...data } : s))
|
|
860
|
+
);
|
|
861
|
+
toast.success(t('toasts.sessionUpdated'));
|
|
862
|
+
} else {
|
|
863
|
+
const newSessao: Sessao = {
|
|
864
|
+
id: `s-${Date.now()}`,
|
|
865
|
+
...data,
|
|
866
|
+
collapsed: false,
|
|
867
|
+
};
|
|
868
|
+
setSessoes((prev) => [...prev, newSessao]);
|
|
869
|
+
toast.success(t('toasts.sessionCreated'));
|
|
870
|
+
}
|
|
871
|
+
setSaving(false);
|
|
872
|
+
setSessaoSheetOpen(false);
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
function confirmDeleteSessao() {
|
|
876
|
+
if (sessaoToDelete) {
|
|
877
|
+
setAulas((prev) => prev.filter((a) => a.sessaoId !== sessaoToDelete.id));
|
|
878
|
+
setSessoes((prev) => prev.filter((s) => s.id !== sessaoToDelete.id));
|
|
879
|
+
setSelectedAulas((prev) => {
|
|
880
|
+
const next = new Set(prev);
|
|
881
|
+
aulas
|
|
882
|
+
.filter((a) => a.sessaoId === sessaoToDelete.id)
|
|
883
|
+
.forEach((a) => next.delete(a.id));
|
|
884
|
+
return next;
|
|
885
|
+
});
|
|
886
|
+
toast.success(t('toasts.sessionDeleted'));
|
|
887
|
+
setSessaoToDelete(null);
|
|
888
|
+
setDeleteSessaoDialog(false);
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
// ── CRUD: Aula ────────────────────────────────────────────────────────
|
|
893
|
+
|
|
894
|
+
function openCreateAula(sessaoId: string) {
|
|
895
|
+
setEditingAula(null);
|
|
896
|
+
setTargetSessaoId(sessaoId);
|
|
897
|
+
setAulaSheetTab('dados');
|
|
898
|
+
setSheetRecursos([]);
|
|
899
|
+
setTranscricaoFile(null);
|
|
900
|
+
aulaForm.reset({
|
|
901
|
+
codigo: '',
|
|
902
|
+
titulo: '',
|
|
903
|
+
descricaoPublica: '',
|
|
904
|
+
descricaoPrivada: '',
|
|
905
|
+
tipo: 'video',
|
|
906
|
+
duracao: 10,
|
|
907
|
+
videoProvedor: 'youtube',
|
|
908
|
+
videoUrl: '',
|
|
909
|
+
duracaoAutomatica: false,
|
|
910
|
+
exameVinculado: '',
|
|
911
|
+
conteudoPost: '',
|
|
912
|
+
});
|
|
913
|
+
setAulaSheetOpen(true);
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
function openEditAula(aula: Aula) {
|
|
917
|
+
setEditingAula(aula);
|
|
918
|
+
setTargetSessaoId(aula.sessaoId);
|
|
919
|
+
setAulaSheetTab('dados');
|
|
920
|
+
setSheetRecursos([...aula.recursos]);
|
|
921
|
+
setTranscricaoFile(aula.transcricao ? 'transcricao.txt' : null);
|
|
922
|
+
aulaForm.reset({
|
|
923
|
+
codigo: aula.codigo,
|
|
924
|
+
titulo: aula.titulo,
|
|
925
|
+
descricaoPublica: aula.descricaoPublica,
|
|
926
|
+
descricaoPrivada: aula.descricaoPrivada,
|
|
927
|
+
tipo: aula.tipo,
|
|
928
|
+
duracao: aula.duracao,
|
|
929
|
+
videoProvedor: aula.videoProvedor ?? 'youtube',
|
|
930
|
+
videoUrl: aula.videoUrl ?? '',
|
|
931
|
+
duracaoAutomatica: aula.duracaoAutomatica ?? false,
|
|
932
|
+
exameVinculado: aula.exameVinculado ?? '',
|
|
933
|
+
conteudoPost: aula.conteudoPost ?? '',
|
|
934
|
+
});
|
|
935
|
+
setAulaSheetOpen(true);
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
async function onSubmitAula(data: AulaFormType) {
|
|
939
|
+
setSaving(true);
|
|
940
|
+
await new Promise((r) => setTimeout(r, 400));
|
|
941
|
+
if (editingAula) {
|
|
942
|
+
setAulas((prev) =>
|
|
943
|
+
prev.map((a) =>
|
|
944
|
+
a.id === editingAula.id
|
|
945
|
+
? {
|
|
946
|
+
...a,
|
|
947
|
+
codigo: data.codigo,
|
|
948
|
+
titulo: data.titulo,
|
|
949
|
+
descricaoPublica: data.descricaoPublica,
|
|
950
|
+
descricaoPrivada: data.descricaoPrivada,
|
|
951
|
+
tipo: data.tipo as AulaTipo,
|
|
952
|
+
duracao: data.duracao,
|
|
953
|
+
videoProvedor: data.videoProvedor as VideoProvider | undefined,
|
|
954
|
+
videoUrl: data.videoUrl,
|
|
955
|
+
duracaoAutomatica: data.duracaoAutomatica,
|
|
956
|
+
exameVinculado: data.exameVinculado,
|
|
957
|
+
conteudoPost: data.conteudoPost,
|
|
958
|
+
recursos: sheetRecursos,
|
|
959
|
+
}
|
|
960
|
+
: a
|
|
961
|
+
)
|
|
962
|
+
);
|
|
963
|
+
toast.success(t('toasts.lessonUpdated'));
|
|
964
|
+
} else {
|
|
965
|
+
const newAula: Aula = {
|
|
966
|
+
id: `a-${Date.now()}`,
|
|
967
|
+
codigo: data.codigo,
|
|
968
|
+
titulo: data.titulo,
|
|
969
|
+
descricaoPublica: data.descricaoPublica,
|
|
970
|
+
descricaoPrivada: data.descricaoPrivada,
|
|
971
|
+
tipo: data.tipo as AulaTipo,
|
|
972
|
+
duracao: data.duracao,
|
|
973
|
+
sessaoId: targetSessaoId!,
|
|
974
|
+
videoProvedor: data.videoProvedor as VideoProvider | undefined,
|
|
975
|
+
videoUrl: data.videoUrl,
|
|
976
|
+
duracaoAutomatica: data.duracaoAutomatica,
|
|
977
|
+
exameVinculado: data.exameVinculado,
|
|
978
|
+
conteudoPost: data.conteudoPost,
|
|
979
|
+
recursos: sheetRecursos,
|
|
980
|
+
};
|
|
981
|
+
setAulas((prev) => [...prev, newAula]);
|
|
982
|
+
toast.success(t('toasts.lessonCreated'));
|
|
983
|
+
}
|
|
984
|
+
setSaving(false);
|
|
985
|
+
setAulaSheetOpen(false);
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
function confirmDeleteAula() {
|
|
989
|
+
if (aulaToDelete) {
|
|
990
|
+
setAulas((prev) => prev.filter((a) => a.id !== aulaToDelete.id));
|
|
991
|
+
setSelectedAulas((prev) => {
|
|
992
|
+
const next = new Set(prev);
|
|
993
|
+
next.delete(aulaToDelete.id);
|
|
994
|
+
return next;
|
|
995
|
+
});
|
|
996
|
+
toast.success(t('toasts.lessonDeleted'));
|
|
997
|
+
setAulaToDelete(null);
|
|
998
|
+
setDeleteAulaDialog(false);
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
// ── Recursos helpers ──────────────────────────────────────────────────
|
|
1003
|
+
|
|
1004
|
+
function addRecurso() {
|
|
1005
|
+
const newRecurso: Recurso = {
|
|
1006
|
+
id: `r-${Date.now()}`,
|
|
1007
|
+
nome: `arquivo-${sheetRecursos.length + 1}.pdf`,
|
|
1008
|
+
tamanho: `${(Math.random() * 5 + 0.1).toFixed(1)} MB`,
|
|
1009
|
+
tipo: 'pdf',
|
|
1010
|
+
publico: true,
|
|
1011
|
+
};
|
|
1012
|
+
setSheetRecursos((prev) => [...prev, newRecurso]);
|
|
1013
|
+
toast.success(t('toasts.resourceAdded'));
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
function toggleRecursoVisibilidade(recursoId: string) {
|
|
1017
|
+
setSheetRecursos((prev) =>
|
|
1018
|
+
prev.map((r) => (r.id === recursoId ? { ...r, publico: !r.publico } : r))
|
|
1019
|
+
);
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
function removeRecurso(recursoId: string) {
|
|
1023
|
+
setSheetRecursos((prev) => prev.filter((r) => r.id !== recursoId));
|
|
1024
|
+
toast.success(t('toasts.resourceRemoved'));
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
// ── Bulk operations ───────────────────────────────────────────────────
|
|
1028
|
+
|
|
1029
|
+
function confirmBulkDelete() {
|
|
1030
|
+
setAulas((prev) => prev.filter((a) => !selectedAulas.has(a.id)));
|
|
1031
|
+
toast.success(t('toasts.lessonsDeleted', { count: selectedAulas.size }));
|
|
1032
|
+
setSelectedAulas(new Set());
|
|
1033
|
+
setLastSelectedAula(null);
|
|
1034
|
+
setBulkDeleteDialog(false);
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
function bulkMoveToSessao(target: string) {
|
|
1038
|
+
setAulas((prev) =>
|
|
1039
|
+
prev.map((a) =>
|
|
1040
|
+
selectedAulas.has(a.id) ? { ...a, sessaoId: target } : a
|
|
1041
|
+
)
|
|
1042
|
+
);
|
|
1043
|
+
toast.success(t('toasts.lessonsMoved', { count: selectedAulas.size }));
|
|
1044
|
+
setSelectedAulas(new Set());
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
function bulkDuplicate() {
|
|
1048
|
+
const newAulas: Aula[] = [];
|
|
1049
|
+
aulas.forEach((a) => {
|
|
1050
|
+
if (selectedAulas.has(a.id)) {
|
|
1051
|
+
newAulas.push({
|
|
1052
|
+
...a,
|
|
1053
|
+
id: `a-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
|
1054
|
+
titulo: `${a.titulo} (copia)`,
|
|
1055
|
+
codigo: `${a.codigo}-C`,
|
|
1056
|
+
recursos: a.recursos.map((r) => ({
|
|
1057
|
+
...r,
|
|
1058
|
+
id: `r-${Date.now()}-${Math.random().toString(36).slice(2, 4)}`,
|
|
1059
|
+
})),
|
|
1060
|
+
});
|
|
1061
|
+
}
|
|
1062
|
+
});
|
|
1063
|
+
setAulas((prev) => [...prev, ...newAulas]);
|
|
1064
|
+
toast.success(t('toasts.lessonsDuplicated', { count: newAulas.length }));
|
|
1065
|
+
setSelectedAulas(new Set());
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
// ── Toggle collapse ───────────────────────────────────────────────────
|
|
1069
|
+
|
|
1070
|
+
function toggleCollapse(sessaoId: string) {
|
|
1071
|
+
setSessoes((prev) =>
|
|
1072
|
+
prev.map((s) =>
|
|
1073
|
+
s.id === sessaoId ? { ...s, collapsed: !s.collapsed } : s
|
|
1074
|
+
)
|
|
1075
|
+
);
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
// ── Stats ─────────────────────────────────────────────────────────────
|
|
1079
|
+
|
|
1080
|
+
const totalAulas = aulas.length;
|
|
1081
|
+
const totalDuracao = aulas.reduce((sum, a) => sum + a.duracao, 0);
|
|
1082
|
+
const totalHoras = Math.floor(totalDuracao / 60);
|
|
1083
|
+
const totalMin = totalDuracao % 60;
|
|
1084
|
+
|
|
1085
|
+
// ── Active drag items ─────────────────────────────────────────────────
|
|
1086
|
+
|
|
1087
|
+
const activeAula = activeId
|
|
1088
|
+
? aulas.find((a) => a.id === String(activeId))
|
|
1089
|
+
: null;
|
|
1090
|
+
const activeSessaoItem = activeId
|
|
1091
|
+
? sessoes.find((s) => s.id === String(activeId))
|
|
1092
|
+
: null;
|
|
1093
|
+
|
|
1094
|
+
// ── Render ────────────────────────────────────────────────────────────
|
|
1095
|
+
|
|
1096
|
+
return (
|
|
1097
|
+
<Page>
|
|
1098
|
+
{/* ── Header ─────────────────────────────────────────────────────────── */}
|
|
1099
|
+
<PageHeader
|
|
1100
|
+
title={t('pageHeader.title')}
|
|
1101
|
+
description={t('pageHeader.description', {
|
|
1102
|
+
sessions: sessoes.length,
|
|
1103
|
+
lessons: totalAulas,
|
|
1104
|
+
hours: totalHoras,
|
|
1105
|
+
minutes: totalMin,
|
|
1106
|
+
})}
|
|
1107
|
+
breadcrumbs={[
|
|
1108
|
+
{
|
|
1109
|
+
label: t('breadcrumbs.home'),
|
|
1110
|
+
href: '/',
|
|
1111
|
+
},
|
|
1112
|
+
{
|
|
1113
|
+
label: t('breadcrumbs.courses'),
|
|
1114
|
+
href: '/lms/courses',
|
|
1115
|
+
},
|
|
1116
|
+
{
|
|
1117
|
+
label: t('breadcrumbs.structure'),
|
|
1118
|
+
},
|
|
1119
|
+
]}
|
|
1120
|
+
actions={
|
|
1121
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
1122
|
+
<Button
|
|
1123
|
+
variant="outline"
|
|
1124
|
+
className="gap-2"
|
|
1125
|
+
onClick={() => toast.success(t('toasts.structureSaved'))}
|
|
1126
|
+
>
|
|
1127
|
+
<Save className="size-4" />
|
|
1128
|
+
{t('actions.saveOrder')}
|
|
1129
|
+
</Button>
|
|
1130
|
+
<Button className="gap-2" onClick={openCreateSessao}>
|
|
1131
|
+
<Plus className="size-4" />
|
|
1132
|
+
{t('actions.newSession')}
|
|
1133
|
+
</Button>
|
|
1134
|
+
</div>
|
|
1135
|
+
}
|
|
1136
|
+
/>
|
|
1137
|
+
|
|
1138
|
+
{/* ── Main ───────────────────────────────────────────────────────────── */}
|
|
1139
|
+
<div>
|
|
1140
|
+
{loading ? (
|
|
1141
|
+
<LoadingSkeleton />
|
|
1142
|
+
) : (
|
|
1143
|
+
<motion.div initial="hidden" animate="show" variants={stagger}>
|
|
1144
|
+
{/* Selection bar */}
|
|
1145
|
+
<AnimatePresence>
|
|
1146
|
+
{selectedAulas.size > 0 && (
|
|
1147
|
+
<motion.div
|
|
1148
|
+
initial={{ opacity: 0, y: -8 }}
|
|
1149
|
+
animate={{ opacity: 1, y: 0 }}
|
|
1150
|
+
exit={{ opacity: 0, y: -8 }}
|
|
1151
|
+
transition={{ duration: 0.2 }}
|
|
1152
|
+
className="mb-4"
|
|
1153
|
+
>
|
|
1154
|
+
<Card className="border-foreground/20 bg-muted/50">
|
|
1155
|
+
<CardContent className="flex flex-wrap items-center gap-3 p-3">
|
|
1156
|
+
<span className="text-sm font-medium">
|
|
1157
|
+
{t('selection.lessonsSelected', {
|
|
1158
|
+
count: selectedAulas.size,
|
|
1159
|
+
})}
|
|
1160
|
+
</span>
|
|
1161
|
+
<Separator orientation="vertical" className="h-5" />
|
|
1162
|
+
<Select onValueChange={(val) => bulkMoveToSessao(val)}>
|
|
1163
|
+
<SelectTrigger className="h-8 w-auto gap-2 text-xs">
|
|
1164
|
+
<SelectValue placeholder={t('selection.moveTo')} />
|
|
1165
|
+
</SelectTrigger>
|
|
1166
|
+
<SelectContent>
|
|
1167
|
+
{sessoes.map((s) => (
|
|
1168
|
+
<SelectItem
|
|
1169
|
+
key={s.id}
|
|
1170
|
+
value={s.id}
|
|
1171
|
+
className="text-xs"
|
|
1172
|
+
>
|
|
1173
|
+
{s.titulo}
|
|
1174
|
+
</SelectItem>
|
|
1175
|
+
))}
|
|
1176
|
+
</SelectContent>
|
|
1177
|
+
</Select>
|
|
1178
|
+
<Button
|
|
1179
|
+
variant="outline"
|
|
1180
|
+
size="sm"
|
|
1181
|
+
className="h-8 gap-1.5 text-xs"
|
|
1182
|
+
onClick={bulkDuplicate}
|
|
1183
|
+
>
|
|
1184
|
+
<Copy className="size-3.5" />
|
|
1185
|
+
{t('selection.duplicate')}
|
|
1186
|
+
</Button>
|
|
1187
|
+
<Button
|
|
1188
|
+
variant="outline"
|
|
1189
|
+
size="sm"
|
|
1190
|
+
className="h-8 gap-1.5 text-xs text-destructive hover:text-destructive"
|
|
1191
|
+
onClick={() => setBulkDeleteDialog(true)}
|
|
1192
|
+
>
|
|
1193
|
+
<Trash2 className="size-3.5" />
|
|
1194
|
+
{t('selection.delete')}
|
|
1195
|
+
</Button>
|
|
1196
|
+
<Button
|
|
1197
|
+
variant="ghost"
|
|
1198
|
+
size="sm"
|
|
1199
|
+
className="ml-auto h-8 text-xs"
|
|
1200
|
+
onClick={clearSelection}
|
|
1201
|
+
>
|
|
1202
|
+
{t('selection.clear')}
|
|
1203
|
+
</Button>
|
|
1204
|
+
</CardContent>
|
|
1205
|
+
</Card>
|
|
1206
|
+
</motion.div>
|
|
1207
|
+
)}
|
|
1208
|
+
</AnimatePresence>
|
|
1209
|
+
|
|
1210
|
+
<motion.p
|
|
1211
|
+
variants={fadeUp}
|
|
1212
|
+
className="mb-4 text-xs text-muted-foreground"
|
|
1213
|
+
>
|
|
1214
|
+
{t('instructions.dragToReorder')}
|
|
1215
|
+
</motion.p>
|
|
1216
|
+
|
|
1217
|
+
{/* ── DnD Area ─────────────────────────────────────────────────────── */}
|
|
1218
|
+
<motion.div variants={fadeUp}>
|
|
1219
|
+
<DndContext
|
|
1220
|
+
sensors={sensors}
|
|
1221
|
+
collisionDetection={closestCorners}
|
|
1222
|
+
onDragStart={handleDragStart}
|
|
1223
|
+
onDragOver={handleDragOver}
|
|
1224
|
+
onDragEnd={handleDragEnd}
|
|
1225
|
+
>
|
|
1226
|
+
<SortableContext
|
|
1227
|
+
items={sessoes.map((s) => s.id)}
|
|
1228
|
+
strategy={verticalListSortingStrategy}
|
|
1229
|
+
>
|
|
1230
|
+
<div className="flex flex-col gap-4">
|
|
1231
|
+
{sessoes.map((sessao, sIndex) => {
|
|
1232
|
+
const sessaoAulas = getAulasBySessao(sessao.id);
|
|
1233
|
+
return (
|
|
1234
|
+
<SortableSessao
|
|
1235
|
+
key={sessao.id}
|
|
1236
|
+
sessao={sessao}
|
|
1237
|
+
index={sIndex}
|
|
1238
|
+
aulas={sessaoAulas}
|
|
1239
|
+
allSessoes={sessoes}
|
|
1240
|
+
selectedAulas={selectedAulas}
|
|
1241
|
+
tipoAulaMap={TIPO_AULA_MAP}
|
|
1242
|
+
t={t}
|
|
1243
|
+
onToggleCollapse={() => toggleCollapse(sessao.id)}
|
|
1244
|
+
onEditSessao={() => openEditSessao(sessao)}
|
|
1245
|
+
onDeleteSessao={() => {
|
|
1246
|
+
setSessaoToDelete(sessao);
|
|
1247
|
+
setDeleteSessaoDialog(true);
|
|
1248
|
+
}}
|
|
1249
|
+
onMergeSessao={(targetIdVal) =>
|
|
1250
|
+
mergeSessao(sessao.id, targetIdVal)
|
|
1251
|
+
}
|
|
1252
|
+
onCreateAula={() => openCreateAula(sessao.id)}
|
|
1253
|
+
onEditAula={openEditAula}
|
|
1254
|
+
onDeleteAula={(aula) => {
|
|
1255
|
+
setAulaToDelete(aula);
|
|
1256
|
+
setDeleteAulaDialog(true);
|
|
1257
|
+
}}
|
|
1258
|
+
onAulaClick={handleAulaClick}
|
|
1259
|
+
onSelectAllInSessao={() =>
|
|
1260
|
+
selectAllInSessao(sessao.id)
|
|
1261
|
+
}
|
|
1262
|
+
/>
|
|
1263
|
+
);
|
|
1264
|
+
})}
|
|
1265
|
+
</div>
|
|
1266
|
+
</SortableContext>
|
|
1267
|
+
|
|
1268
|
+
<DragOverlay>
|
|
1269
|
+
{activeAula && (
|
|
1270
|
+
<div className="rounded-lg border bg-background p-3 shadow-lg opacity-90">
|
|
1271
|
+
<div className="flex items-center gap-2">
|
|
1272
|
+
<GripVertical className="size-4 text-muted-foreground" />
|
|
1273
|
+
<div
|
|
1274
|
+
className={`flex size-6 items-center justify-center rounded ${TIPO_AULA_MAP[activeAula.tipo].color}`}
|
|
1275
|
+
>
|
|
1276
|
+
{(() => {
|
|
1277
|
+
const Icon = TIPO_AULA_MAP[activeAula.tipo].icon;
|
|
1278
|
+
return <Icon className="size-3.5" />;
|
|
1279
|
+
})()}
|
|
1280
|
+
</div>
|
|
1281
|
+
<span className="text-sm font-medium">
|
|
1282
|
+
{activeAula.titulo}
|
|
1283
|
+
</span>
|
|
1284
|
+
{selectedAulas.size > 1 &&
|
|
1285
|
+
selectedAulas.has(activeAula.id) && (
|
|
1286
|
+
<Badge
|
|
1287
|
+
variant="secondary"
|
|
1288
|
+
className="ml-auto text-xs"
|
|
1289
|
+
>
|
|
1290
|
+
+{selectedAulas.size - 1}
|
|
1291
|
+
</Badge>
|
|
1292
|
+
)}
|
|
1293
|
+
</div>
|
|
1294
|
+
</div>
|
|
1295
|
+
)}
|
|
1296
|
+
{activeSessaoItem && (
|
|
1297
|
+
<div className="rounded-xl border bg-background p-4 shadow-lg opacity-90">
|
|
1298
|
+
<div className="flex items-center gap-2">
|
|
1299
|
+
<GripVertical className="size-4 text-muted-foreground" />
|
|
1300
|
+
<FolderOpen className="size-4 text-muted-foreground" />
|
|
1301
|
+
<span className="font-semibold">
|
|
1302
|
+
{activeSessaoItem.titulo}
|
|
1303
|
+
</span>
|
|
1304
|
+
</div>
|
|
1305
|
+
</div>
|
|
1306
|
+
)}
|
|
1307
|
+
</DragOverlay>
|
|
1308
|
+
</DndContext>
|
|
1309
|
+
</motion.div>
|
|
1310
|
+
|
|
1311
|
+
{/* Empty state */}
|
|
1312
|
+
{sessoes.length === 0 && (
|
|
1313
|
+
<motion.div
|
|
1314
|
+
variants={fadeUp}
|
|
1315
|
+
className="mt-8 flex flex-col items-center gap-4 py-16 text-center"
|
|
1316
|
+
>
|
|
1317
|
+
<div className="flex size-16 items-center justify-center rounded-full bg-muted">
|
|
1318
|
+
<Layers className="size-8 text-muted-foreground" />
|
|
1319
|
+
</div>
|
|
1320
|
+
<div>
|
|
1321
|
+
<h3 className="text-lg font-semibold">{t('empty.title')}</h3>
|
|
1322
|
+
<p className="text-sm text-muted-foreground">
|
|
1323
|
+
{t('empty.description')}
|
|
1324
|
+
</p>
|
|
1325
|
+
</div>
|
|
1326
|
+
<Button className="gap-2" onClick={openCreateSessao}>
|
|
1327
|
+
<Plus className="size-4" />
|
|
1328
|
+
{t('empty.action')}
|
|
1329
|
+
</Button>
|
|
1330
|
+
</motion.div>
|
|
1331
|
+
)}
|
|
1332
|
+
</motion.div>
|
|
1333
|
+
)}
|
|
1334
|
+
</div>
|
|
1335
|
+
|
|
1336
|
+
{/* ═══════════════════════════════════════════════════════════════════════
|
|
1337
|
+
SHEET: SESSAO
|
|
1338
|
+
═══════════════════════════════════════════════════════════════════════ */}
|
|
1339
|
+
<Sheet open={sessaoSheetOpen} onOpenChange={setSessaoSheetOpen}>
|
|
1340
|
+
<SheetContent className="flex flex-col overflow-y-auto sm:max-w-md">
|
|
1341
|
+
<SheetHeader>
|
|
1342
|
+
<SheetTitle>
|
|
1343
|
+
{editingSessao
|
|
1344
|
+
? t('sessionForm.titleEdit')
|
|
1345
|
+
: t('sessionForm.titleCreate')}
|
|
1346
|
+
</SheetTitle>
|
|
1347
|
+
<SheetDescription>
|
|
1348
|
+
{editingSessao
|
|
1349
|
+
? t('sessionForm.descriptionEdit')
|
|
1350
|
+
: t('sessionForm.descriptionCreate')}
|
|
1351
|
+
</SheetDescription>
|
|
1352
|
+
</SheetHeader>
|
|
1353
|
+
<form
|
|
1354
|
+
onSubmit={sessaoForm.handleSubmit(onSubmitSessao)}
|
|
1355
|
+
className="flex flex-1 flex-col gap-5 py-4 px-4"
|
|
1356
|
+
>
|
|
1357
|
+
<Field data-invalid={!!sessaoForm.formState.errors.codigo}>
|
|
1358
|
+
<FieldLabel htmlFor="sessao-codigo">
|
|
1359
|
+
{t('sessionForm.code')}
|
|
1360
|
+
</FieldLabel>
|
|
1361
|
+
<Input
|
|
1362
|
+
id="sessao-codigo"
|
|
1363
|
+
placeholder={t('sessionForm.codePlaceholder')}
|
|
1364
|
+
{...sessaoForm.register('codigo')}
|
|
1365
|
+
/>
|
|
1366
|
+
<FieldDescription>
|
|
1367
|
+
{t('sessionForm.codeDescription')}
|
|
1368
|
+
</FieldDescription>
|
|
1369
|
+
{sessaoForm.formState.errors.codigo && (
|
|
1370
|
+
<FieldError>
|
|
1371
|
+
{sessaoForm.formState.errors.codigo.message}
|
|
1372
|
+
</FieldError>
|
|
1373
|
+
)}
|
|
1374
|
+
</Field>
|
|
1375
|
+
|
|
1376
|
+
<Field data-invalid={!!sessaoForm.formState.errors.titulo}>
|
|
1377
|
+
<FieldLabel htmlFor="sessao-titulo">
|
|
1378
|
+
{t('sessionForm.title')}
|
|
1379
|
+
</FieldLabel>
|
|
1380
|
+
<Input
|
|
1381
|
+
id="sessao-titulo"
|
|
1382
|
+
placeholder={t('sessionForm.titlePlaceholder')}
|
|
1383
|
+
{...sessaoForm.register('titulo')}
|
|
1384
|
+
/>
|
|
1385
|
+
{sessaoForm.formState.errors.titulo && (
|
|
1386
|
+
<FieldError>
|
|
1387
|
+
{sessaoForm.formState.errors.titulo.message}
|
|
1388
|
+
</FieldError>
|
|
1389
|
+
)}
|
|
1390
|
+
</Field>
|
|
1391
|
+
|
|
1392
|
+
<Field data-invalid={!!sessaoForm.formState.errors.duracao}>
|
|
1393
|
+
<FieldLabel htmlFor="sessao-duracao">
|
|
1394
|
+
{t('sessionForm.duration')}
|
|
1395
|
+
</FieldLabel>
|
|
1396
|
+
<Input
|
|
1397
|
+
id="sessao-duracao"
|
|
1398
|
+
type="number"
|
|
1399
|
+
min={1}
|
|
1400
|
+
{...sessaoForm.register('duracao')}
|
|
1401
|
+
/>
|
|
1402
|
+
<FieldDescription>
|
|
1403
|
+
{t('sessionForm.durationDescription')}
|
|
1404
|
+
</FieldDescription>
|
|
1405
|
+
{sessaoForm.formState.errors.duracao && (
|
|
1406
|
+
<FieldError>
|
|
1407
|
+
{sessaoForm.formState.errors.duracao.message}
|
|
1408
|
+
</FieldError>
|
|
1409
|
+
)}
|
|
1410
|
+
</Field>
|
|
1411
|
+
|
|
1412
|
+
<SheetFooter className="mt-auto p-0">
|
|
1413
|
+
<Button type="submit" disabled={saving} className="gap-2">
|
|
1414
|
+
{saving && <Loader2 className="size-4 animate-spin" />}
|
|
1415
|
+
{editingSessao
|
|
1416
|
+
? t('sessionForm.update')
|
|
1417
|
+
: t('sessionForm.create')}
|
|
1418
|
+
</Button>
|
|
1419
|
+
</SheetFooter>
|
|
1420
|
+
</form>
|
|
1421
|
+
</SheetContent>
|
|
1422
|
+
</Sheet>
|
|
1423
|
+
|
|
1424
|
+
{/* ═══════════════════════════════════════════════════════════════════════
|
|
1425
|
+
SHEET: AULA (com abas)
|
|
1426
|
+
═══════════════════════════════════════════════════════════════════════ */}
|
|
1427
|
+
<Sheet open={aulaSheetOpen} onOpenChange={setAulaSheetOpen}>
|
|
1428
|
+
<SheetContent className="flex flex-col overflow-y-auto sm:max-w-lg">
|
|
1429
|
+
<SheetHeader>
|
|
1430
|
+
<SheetTitle>
|
|
1431
|
+
{editingAula
|
|
1432
|
+
? t('lessonForm.titleEdit')
|
|
1433
|
+
: t('lessonForm.titleCreate')}
|
|
1434
|
+
</SheetTitle>
|
|
1435
|
+
<SheetDescription>
|
|
1436
|
+
{editingAula
|
|
1437
|
+
? t('lessonForm.descriptionEdit')
|
|
1438
|
+
: t('lessonForm.descriptionCreate')}
|
|
1439
|
+
</SheetDescription>
|
|
1440
|
+
</SheetHeader>
|
|
1441
|
+
|
|
1442
|
+
<Tabs
|
|
1443
|
+
value={aulaSheetTab}
|
|
1444
|
+
onValueChange={setAulaSheetTab}
|
|
1445
|
+
className="flex flex-1 flex-col px-4"
|
|
1446
|
+
>
|
|
1447
|
+
<TabsList className="grid w-full grid-cols-3">
|
|
1448
|
+
<TabsTrigger value="dados" className="text-xs">
|
|
1449
|
+
{t('lessonForm.tabs.data')}
|
|
1450
|
+
</TabsTrigger>
|
|
1451
|
+
<TabsTrigger value="transcricao" className="text-xs">
|
|
1452
|
+
{t('lessonForm.tabs.transcription')}
|
|
1453
|
+
</TabsTrigger>
|
|
1454
|
+
<TabsTrigger value="recursos" className="text-xs">
|
|
1455
|
+
{t('lessonForm.tabs.resources')}
|
|
1456
|
+
</TabsTrigger>
|
|
1457
|
+
</TabsList>
|
|
1458
|
+
|
|
1459
|
+
{/* ── Tab: Dados ─────────────────────────────────────── */}
|
|
1460
|
+
<TabsContent value="dados" className="flex-1 mt-0">
|
|
1461
|
+
<form
|
|
1462
|
+
id="aula-form"
|
|
1463
|
+
onSubmit={aulaForm.handleSubmit(onSubmitAula)}
|
|
1464
|
+
className="flex flex-col gap-4 py-4"
|
|
1465
|
+
>
|
|
1466
|
+
<div className="grid grid-cols-2 gap-4">
|
|
1467
|
+
<Field data-invalid={!!aulaForm.formState.errors.codigo}>
|
|
1468
|
+
<FieldLabel htmlFor="aula-codigo">
|
|
1469
|
+
{t('lessonForm.code')}
|
|
1470
|
+
</FieldLabel>
|
|
1471
|
+
<Input
|
|
1472
|
+
id="aula-codigo"
|
|
1473
|
+
placeholder={t('lessonForm.codePlaceholder')}
|
|
1474
|
+
{...aulaForm.register('codigo')}
|
|
1475
|
+
/>
|
|
1476
|
+
{aulaForm.formState.errors.codigo && (
|
|
1477
|
+
<FieldError>
|
|
1478
|
+
{aulaForm.formState.errors.codigo.message}
|
|
1479
|
+
</FieldError>
|
|
1480
|
+
)}
|
|
1481
|
+
</Field>
|
|
1482
|
+
<Field data-invalid={!!aulaForm.formState.errors.duracao}>
|
|
1483
|
+
<FieldLabel htmlFor="aula-duracao">
|
|
1484
|
+
{t('lessonForm.duration')}
|
|
1485
|
+
</FieldLabel>
|
|
1486
|
+
<Input
|
|
1487
|
+
id="aula-duracao"
|
|
1488
|
+
type="number"
|
|
1489
|
+
min={1}
|
|
1490
|
+
{...aulaForm.register('duracao')}
|
|
1491
|
+
/>
|
|
1492
|
+
{aulaForm.formState.errors.duracao && (
|
|
1493
|
+
<FieldError>
|
|
1494
|
+
{aulaForm.formState.errors.duracao.message}
|
|
1495
|
+
</FieldError>
|
|
1496
|
+
)}
|
|
1497
|
+
</Field>
|
|
1498
|
+
</div>
|
|
1499
|
+
|
|
1500
|
+
<Field data-invalid={!!aulaForm.formState.errors.titulo}>
|
|
1501
|
+
<FieldLabel htmlFor="aula-titulo">
|
|
1502
|
+
{t('lessonForm.title')}
|
|
1503
|
+
</FieldLabel>
|
|
1504
|
+
<Input
|
|
1505
|
+
id="aula-titulo"
|
|
1506
|
+
placeholder={t('lessonForm.titlePlaceholder')}
|
|
1507
|
+
{...aulaForm.register('titulo')}
|
|
1508
|
+
/>
|
|
1509
|
+
{aulaForm.formState.errors.titulo && (
|
|
1510
|
+
<FieldError>
|
|
1511
|
+
{aulaForm.formState.errors.titulo.message}
|
|
1512
|
+
</FieldError>
|
|
1513
|
+
)}
|
|
1514
|
+
</Field>
|
|
1515
|
+
|
|
1516
|
+
<Field>
|
|
1517
|
+
<FieldLabel htmlFor="aula-desc-pub">
|
|
1518
|
+
{t('lessonForm.publicDescription')}
|
|
1519
|
+
</FieldLabel>
|
|
1520
|
+
<Textarea
|
|
1521
|
+
id="aula-desc-pub"
|
|
1522
|
+
placeholder={t('lessonForm.publicDescriptionPlaceholder')}
|
|
1523
|
+
rows={2}
|
|
1524
|
+
{...aulaForm.register('descricaoPublica')}
|
|
1525
|
+
/>
|
|
1526
|
+
<FieldDescription>
|
|
1527
|
+
{t('lessonForm.publicDescriptionHelp')}
|
|
1528
|
+
</FieldDescription>
|
|
1529
|
+
</Field>
|
|
1530
|
+
|
|
1531
|
+
<Field>
|
|
1532
|
+
<FieldLabel htmlFor="aula-desc-priv">
|
|
1533
|
+
{t('lessonForm.privateDescription')}
|
|
1534
|
+
</FieldLabel>
|
|
1535
|
+
<Textarea
|
|
1536
|
+
id="aula-desc-priv"
|
|
1537
|
+
placeholder={t('lessonForm.privateDescriptionPlaceholder')}
|
|
1538
|
+
rows={2}
|
|
1539
|
+
{...aulaForm.register('descricaoPrivada')}
|
|
1540
|
+
/>
|
|
1541
|
+
<FieldDescription>
|
|
1542
|
+
{t('lessonForm.privateDescriptionHelp')}
|
|
1543
|
+
</FieldDescription>
|
|
1544
|
+
</Field>
|
|
1545
|
+
|
|
1546
|
+
<Field data-invalid={!!aulaForm.formState.errors.tipo}>
|
|
1547
|
+
<FieldLabel>{t('lessonForm.type')}</FieldLabel>
|
|
1548
|
+
<Controller
|
|
1549
|
+
name="tipo"
|
|
1550
|
+
control={aulaForm.control}
|
|
1551
|
+
render={({ field }) => (
|
|
1552
|
+
<Select
|
|
1553
|
+
value={field.value}
|
|
1554
|
+
onValueChange={field.onChange}
|
|
1555
|
+
>
|
|
1556
|
+
<SelectTrigger>
|
|
1557
|
+
<SelectValue
|
|
1558
|
+
placeholder={t('lessonForm.typePlaceholder')}
|
|
1559
|
+
/>
|
|
1560
|
+
</SelectTrigger>
|
|
1561
|
+
<SelectContent>
|
|
1562
|
+
{(
|
|
1563
|
+
Object.entries(TIPO_AULA_MAP) as [
|
|
1564
|
+
AulaTipo,
|
|
1565
|
+
(typeof TIPO_AULA_MAP)[AulaTipo],
|
|
1566
|
+
][]
|
|
1567
|
+
).map(([key, val]) => (
|
|
1568
|
+
<SelectItem key={key} value={key}>
|
|
1569
|
+
<span className="flex items-center gap-2">
|
|
1570
|
+
<val.icon className="size-3.5" />
|
|
1571
|
+
{val.label}
|
|
1572
|
+
</span>
|
|
1573
|
+
</SelectItem>
|
|
1574
|
+
))}
|
|
1575
|
+
</SelectContent>
|
|
1576
|
+
</Select>
|
|
1577
|
+
)}
|
|
1578
|
+
/>
|
|
1579
|
+
{aulaForm.formState.errors.tipo && (
|
|
1580
|
+
<FieldError>
|
|
1581
|
+
{aulaForm.formState.errors.tipo.message}
|
|
1582
|
+
</FieldError>
|
|
1583
|
+
)}
|
|
1584
|
+
</Field>
|
|
1585
|
+
|
|
1586
|
+
{/* ── Conditional: VIDEO ──────────────────────────── */}
|
|
1587
|
+
<AnimatePresence mode="wait">
|
|
1588
|
+
{watchTipo === 'video' && (
|
|
1589
|
+
<motion.div
|
|
1590
|
+
key="video-fields"
|
|
1591
|
+
initial={{ opacity: 0, height: 0 }}
|
|
1592
|
+
animate={{ opacity: 1, height: 'auto' }}
|
|
1593
|
+
exit={{ opacity: 0, height: 0 }}
|
|
1594
|
+
transition={{ duration: 0.2 }}
|
|
1595
|
+
className="overflow-hidden"
|
|
1596
|
+
>
|
|
1597
|
+
<div className="flex flex-col gap-4 rounded-lg border bg-muted/30 p-4">
|
|
1598
|
+
<div className="flex items-center gap-2">
|
|
1599
|
+
<Video className="size-4 text-blue-600" />
|
|
1600
|
+
<span className="text-sm font-medium">
|
|
1601
|
+
{t('lessonForm.videoConfig')}
|
|
1602
|
+
</span>
|
|
1603
|
+
</div>
|
|
1604
|
+
|
|
1605
|
+
<Field>
|
|
1606
|
+
<FieldLabel>{t('lessonForm.provider')}</FieldLabel>
|
|
1607
|
+
<Controller
|
|
1608
|
+
name="videoProvedor"
|
|
1609
|
+
control={aulaForm.control}
|
|
1610
|
+
render={({ field }) => (
|
|
1611
|
+
<Select
|
|
1612
|
+
value={field.value ?? 'youtube'}
|
|
1613
|
+
onValueChange={field.onChange}
|
|
1614
|
+
>
|
|
1615
|
+
<SelectTrigger>
|
|
1616
|
+
<SelectValue />
|
|
1617
|
+
</SelectTrigger>
|
|
1618
|
+
<SelectContent>
|
|
1619
|
+
{PROVEDORES.map((p) => (
|
|
1620
|
+
<SelectItem key={p.value} value={p.value}>
|
|
1621
|
+
{p.label}
|
|
1622
|
+
</SelectItem>
|
|
1623
|
+
))}
|
|
1624
|
+
</SelectContent>
|
|
1625
|
+
</Select>
|
|
1626
|
+
)}
|
|
1627
|
+
/>
|
|
1628
|
+
</Field>
|
|
1629
|
+
|
|
1630
|
+
<Field>
|
|
1631
|
+
<FieldLabel htmlFor="aula-video-url">
|
|
1632
|
+
{t('lessonForm.videoUrl')}
|
|
1633
|
+
</FieldLabel>
|
|
1634
|
+
<div className="relative">
|
|
1635
|
+
<Link2 className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
|
1636
|
+
<Input
|
|
1637
|
+
id="aula-video-url"
|
|
1638
|
+
className="pl-10"
|
|
1639
|
+
placeholder={t('lessonForm.videoUrlPlaceholder')}
|
|
1640
|
+
{...aulaForm.register('videoUrl')}
|
|
1641
|
+
/>
|
|
1642
|
+
</div>
|
|
1643
|
+
</Field>
|
|
1644
|
+
|
|
1645
|
+
<Field>
|
|
1646
|
+
<div className="flex items-center justify-between">
|
|
1647
|
+
<div>
|
|
1648
|
+
<FieldLabel
|
|
1649
|
+
htmlFor="aula-duracao-auto"
|
|
1650
|
+
className="mb-0"
|
|
1651
|
+
>
|
|
1652
|
+
{t('lessonForm.autoDuration')}
|
|
1653
|
+
</FieldLabel>
|
|
1654
|
+
<FieldDescription>
|
|
1655
|
+
{t('lessonForm.autoDurationHelp')}
|
|
1656
|
+
</FieldDescription>
|
|
1657
|
+
</div>
|
|
1658
|
+
<Controller
|
|
1659
|
+
name="duracaoAutomatica"
|
|
1660
|
+
control={aulaForm.control}
|
|
1661
|
+
render={({ field }) => (
|
|
1662
|
+
<Switch
|
|
1663
|
+
id="aula-duracao-auto"
|
|
1664
|
+
checked={field.value ?? false}
|
|
1665
|
+
onCheckedChange={field.onChange}
|
|
1666
|
+
/>
|
|
1667
|
+
)}
|
|
1668
|
+
/>
|
|
1669
|
+
</div>
|
|
1670
|
+
</Field>
|
|
1671
|
+
|
|
1672
|
+
<Field>
|
|
1673
|
+
<FieldLabel>
|
|
1674
|
+
{t('lessonForm.transcriptionUpload')}
|
|
1675
|
+
</FieldLabel>
|
|
1676
|
+
{transcricaoFile ? (
|
|
1677
|
+
<div className="flex items-center justify-between rounded-md border bg-background px-3 py-2">
|
|
1678
|
+
<div className="flex items-center gap-2">
|
|
1679
|
+
<FileText className="size-4 text-muted-foreground" />
|
|
1680
|
+
<span className="text-sm">
|
|
1681
|
+
{transcricaoFile}
|
|
1682
|
+
</span>
|
|
1683
|
+
</div>
|
|
1684
|
+
<Button
|
|
1685
|
+
type="button"
|
|
1686
|
+
variant="ghost"
|
|
1687
|
+
size="icon"
|
|
1688
|
+
className="size-7"
|
|
1689
|
+
onClick={() => setTranscricaoFile(null)}
|
|
1690
|
+
>
|
|
1691
|
+
<X className="size-3.5" />
|
|
1692
|
+
</Button>
|
|
1693
|
+
</div>
|
|
1694
|
+
) : (
|
|
1695
|
+
<label className="flex cursor-pointer flex-col items-center gap-2 rounded-lg border-2 border-dashed bg-background px-4 py-6 text-center transition-colors hover:border-foreground/30 hover:bg-muted/50">
|
|
1696
|
+
<Upload className="size-6 text-muted-foreground" />
|
|
1697
|
+
<span className="text-sm text-muted-foreground">
|
|
1698
|
+
{t('lessonForm.transcriptionUploadText')}
|
|
1699
|
+
</span>
|
|
1700
|
+
<input
|
|
1701
|
+
type="file"
|
|
1702
|
+
accept=".txt,.srt,.vtt"
|
|
1703
|
+
className="hidden"
|
|
1704
|
+
onChange={(e) => {
|
|
1705
|
+
const file = e.target.files?.[0];
|
|
1706
|
+
if (file) {
|
|
1707
|
+
setTranscricaoFile(file.name);
|
|
1708
|
+
toast.success(
|
|
1709
|
+
t('toasts.transcriptionUploaded')
|
|
1710
|
+
);
|
|
1711
|
+
}
|
|
1712
|
+
}}
|
|
1713
|
+
/>
|
|
1714
|
+
</label>
|
|
1715
|
+
)}
|
|
1716
|
+
</Field>
|
|
1717
|
+
</div>
|
|
1718
|
+
</motion.div>
|
|
1719
|
+
)}
|
|
1720
|
+
</AnimatePresence>
|
|
1721
|
+
|
|
1722
|
+
{/* ── Conditional: QUESTAO ────────────────────────── */}
|
|
1723
|
+
<AnimatePresence mode="wait">
|
|
1724
|
+
{watchTipo === 'questao' && (
|
|
1725
|
+
<motion.div
|
|
1726
|
+
key="questao-fields"
|
|
1727
|
+
initial={{ opacity: 0, height: 0 }}
|
|
1728
|
+
animate={{ opacity: 1, height: 'auto' }}
|
|
1729
|
+
exit={{ opacity: 0, height: 0 }}
|
|
1730
|
+
transition={{ duration: 0.2 }}
|
|
1731
|
+
className="overflow-hidden"
|
|
1732
|
+
>
|
|
1733
|
+
<div className="flex flex-col gap-4 rounded-lg border bg-muted/30 p-4">
|
|
1734
|
+
<div className="flex items-center gap-2">
|
|
1735
|
+
<HelpCircle className="size-4 text-amber-600" />
|
|
1736
|
+
<span className="text-sm font-medium">
|
|
1737
|
+
{t('lessonForm.linkExam')}
|
|
1738
|
+
</span>
|
|
1739
|
+
</div>
|
|
1740
|
+
|
|
1741
|
+
<Field>
|
|
1742
|
+
<FieldLabel>{t('lessonForm.linkedExam')}</FieldLabel>
|
|
1743
|
+
<Controller
|
|
1744
|
+
name="exameVinculado"
|
|
1745
|
+
control={aulaForm.control}
|
|
1746
|
+
render={({ field }) => (
|
|
1747
|
+
<Select
|
|
1748
|
+
value={field.value ?? ''}
|
|
1749
|
+
onValueChange={field.onChange}
|
|
1750
|
+
>
|
|
1751
|
+
<SelectTrigger>
|
|
1752
|
+
<SelectValue
|
|
1753
|
+
placeholder={t(
|
|
1754
|
+
'lessonForm.linkedExamPlaceholder'
|
|
1755
|
+
)}
|
|
1756
|
+
/>
|
|
1757
|
+
</SelectTrigger>
|
|
1758
|
+
<SelectContent>
|
|
1759
|
+
{MOCK_EXAMES.map((ex) => (
|
|
1760
|
+
<SelectItem key={ex.id} value={ex.id}>
|
|
1761
|
+
{ex.titulo}
|
|
1762
|
+
</SelectItem>
|
|
1763
|
+
))}
|
|
1764
|
+
</SelectContent>
|
|
1765
|
+
</Select>
|
|
1766
|
+
)}
|
|
1767
|
+
/>
|
|
1768
|
+
<FieldDescription>
|
|
1769
|
+
{t('lessonForm.linkedExamHelp')}
|
|
1770
|
+
</FieldDescription>
|
|
1771
|
+
</Field>
|
|
1772
|
+
</div>
|
|
1773
|
+
</motion.div>
|
|
1774
|
+
)}
|
|
1775
|
+
</AnimatePresence>
|
|
1776
|
+
|
|
1777
|
+
{/* ── Conditional: POST ───────────────────────────── */}
|
|
1778
|
+
<AnimatePresence mode="wait">
|
|
1779
|
+
{watchTipo === 'post' && (
|
|
1780
|
+
<motion.div
|
|
1781
|
+
key="post-fields"
|
|
1782
|
+
initial={{ opacity: 0, height: 0 }}
|
|
1783
|
+
animate={{ opacity: 1, height: 'auto' }}
|
|
1784
|
+
exit={{ opacity: 0, height: 0 }}
|
|
1785
|
+
transition={{ duration: 0.2 }}
|
|
1786
|
+
className="overflow-hidden"
|
|
1787
|
+
>
|
|
1788
|
+
<div className="flex flex-col gap-4 rounded-lg border bg-muted/30 p-4">
|
|
1789
|
+
<div className="flex items-center gap-2">
|
|
1790
|
+
<MessageSquare className="size-4 text-emerald-600" />
|
|
1791
|
+
<span className="text-sm font-medium">
|
|
1792
|
+
{t('lessonForm.postContent')}
|
|
1793
|
+
</span>
|
|
1794
|
+
</div>
|
|
1795
|
+
|
|
1796
|
+
<Field>
|
|
1797
|
+
<FieldLabel htmlFor="aula-post-content">
|
|
1798
|
+
{t('lessonForm.contentEditor')}
|
|
1799
|
+
</FieldLabel>
|
|
1800
|
+
<div className="flex items-center gap-1 rounded-t-md border border-b-0 bg-muted/50 px-2 py-1.5">
|
|
1801
|
+
<Button
|
|
1802
|
+
type="button"
|
|
1803
|
+
variant="ghost"
|
|
1804
|
+
size="icon"
|
|
1805
|
+
className="size-7"
|
|
1806
|
+
title={t('lessonForm.bold')}
|
|
1807
|
+
onClick={() =>
|
|
1808
|
+
toast.info(t('toasts.boldApplied'))
|
|
1809
|
+
}
|
|
1810
|
+
>
|
|
1811
|
+
<span className="text-xs font-bold">B</span>
|
|
1812
|
+
</Button>
|
|
1813
|
+
<Button
|
|
1814
|
+
type="button"
|
|
1815
|
+
variant="ghost"
|
|
1816
|
+
size="icon"
|
|
1817
|
+
className="size-7"
|
|
1818
|
+
title={t('lessonForm.italic')}
|
|
1819
|
+
onClick={() =>
|
|
1820
|
+
toast.info(t('toasts.italicApplied'))
|
|
1821
|
+
}
|
|
1822
|
+
>
|
|
1823
|
+
<span className="text-xs italic">I</span>
|
|
1824
|
+
</Button>
|
|
1825
|
+
<Button
|
|
1826
|
+
type="button"
|
|
1827
|
+
variant="ghost"
|
|
1828
|
+
size="icon"
|
|
1829
|
+
className="size-7"
|
|
1830
|
+
title={t('lessonForm.underline')}
|
|
1831
|
+
onClick={() =>
|
|
1832
|
+
toast.info(t('toasts.underlineApplied'))
|
|
1833
|
+
}
|
|
1834
|
+
>
|
|
1835
|
+
<span className="text-xs underline">U</span>
|
|
1836
|
+
</Button>
|
|
1837
|
+
<Separator
|
|
1838
|
+
orientation="vertical"
|
|
1839
|
+
className="mx-1 h-4"
|
|
1840
|
+
/>
|
|
1841
|
+
<Button
|
|
1842
|
+
type="button"
|
|
1843
|
+
variant="ghost"
|
|
1844
|
+
size="icon"
|
|
1845
|
+
className="size-7"
|
|
1846
|
+
title={t('lessonForm.link')}
|
|
1847
|
+
onClick={() => toast.info(t('toasts.insertLink'))}
|
|
1848
|
+
>
|
|
1849
|
+
<Link2 className="size-3.5" />
|
|
1850
|
+
</Button>
|
|
1851
|
+
<Button
|
|
1852
|
+
type="button"
|
|
1853
|
+
variant="ghost"
|
|
1854
|
+
size="icon"
|
|
1855
|
+
className="size-7"
|
|
1856
|
+
title={t('lessonForm.image')}
|
|
1857
|
+
onClick={() =>
|
|
1858
|
+
toast.info(t('toasts.insertImage'))
|
|
1859
|
+
}
|
|
1860
|
+
>
|
|
1861
|
+
<FileUp className="size-3.5" />
|
|
1862
|
+
</Button>
|
|
1863
|
+
</div>
|
|
1864
|
+
<Textarea
|
|
1865
|
+
id="aula-post-content"
|
|
1866
|
+
placeholder={t('lessonForm.postContentPlaceholder')}
|
|
1867
|
+
rows={8}
|
|
1868
|
+
className="rounded-t-none font-mono text-sm"
|
|
1869
|
+
{...aulaForm.register('conteudoPost')}
|
|
1870
|
+
/>
|
|
1871
|
+
<FieldDescription>
|
|
1872
|
+
{t('lessonForm.postContentHelp')}
|
|
1873
|
+
</FieldDescription>
|
|
1874
|
+
</Field>
|
|
1875
|
+
</div>
|
|
1876
|
+
</motion.div>
|
|
1877
|
+
)}
|
|
1878
|
+
</AnimatePresence>
|
|
1879
|
+
</form>
|
|
1880
|
+
</TabsContent>
|
|
1881
|
+
|
|
1882
|
+
{/* ── Tab: Transcricao ────────────────────────────────── */}
|
|
1883
|
+
<TabsContent value="transcricao" className="flex-1 mt-0">
|
|
1884
|
+
<div className="flex flex-col gap-4 py-4">
|
|
1885
|
+
{editingAula?.transcricao ? (
|
|
1886
|
+
<>
|
|
1887
|
+
<div className="flex items-center justify-between">
|
|
1888
|
+
<div className="flex items-center gap-2">
|
|
1889
|
+
<Eye className="size-4 text-muted-foreground" />
|
|
1890
|
+
<span className="text-sm font-medium">
|
|
1891
|
+
{t('lessonForm.fullTranscription')}
|
|
1892
|
+
</span>
|
|
1893
|
+
</div>
|
|
1894
|
+
<Badge variant="secondary" className="text-xs">
|
|
1895
|
+
{t('lessonForm.wordCount', {
|
|
1896
|
+
count: editingAula.transcricao.split(' ').length,
|
|
1897
|
+
})}
|
|
1898
|
+
</Badge>
|
|
1899
|
+
</div>
|
|
1900
|
+
<div className="max-h-96 overflow-y-auto rounded-lg border bg-muted/30 p-4">
|
|
1901
|
+
<p className="whitespace-pre-wrap text-sm leading-relaxed text-muted-foreground">
|
|
1902
|
+
{editingAula.transcricao}
|
|
1903
|
+
</p>
|
|
1904
|
+
</div>
|
|
1905
|
+
<div className="flex gap-2">
|
|
1906
|
+
<Button
|
|
1907
|
+
type="button"
|
|
1908
|
+
variant="outline"
|
|
1909
|
+
size="sm"
|
|
1910
|
+
className="gap-2"
|
|
1911
|
+
onClick={() => {
|
|
1912
|
+
navigator.clipboard.writeText(
|
|
1913
|
+
editingAula.transcricao ?? ''
|
|
1914
|
+
);
|
|
1915
|
+
toast.success(t('toasts.transcriptionCopied'));
|
|
1916
|
+
}}
|
|
1917
|
+
>
|
|
1918
|
+
<Copy className="size-3.5" />
|
|
1919
|
+
{t('lessonForm.copyText')}
|
|
1920
|
+
</Button>
|
|
1921
|
+
<Button
|
|
1922
|
+
type="button"
|
|
1923
|
+
variant="outline"
|
|
1924
|
+
size="sm"
|
|
1925
|
+
className="gap-2"
|
|
1926
|
+
onClick={() => toast.info(t('toasts.downloadStarted'))}
|
|
1927
|
+
>
|
|
1928
|
+
<FileUp className="size-3.5" />
|
|
1929
|
+
{t('lessonForm.downloadTxt')}
|
|
1930
|
+
</Button>
|
|
1931
|
+
</div>
|
|
1932
|
+
</>
|
|
1933
|
+
) : (
|
|
1934
|
+
<div className="flex flex-col items-center gap-4 rounded-lg border-2 border-dashed py-12 text-center">
|
|
1935
|
+
<div className="flex size-12 items-center justify-center rounded-full bg-muted">
|
|
1936
|
+
<FileText className="size-6 text-muted-foreground" />
|
|
1937
|
+
</div>
|
|
1938
|
+
<div>
|
|
1939
|
+
<h4 className="text-sm font-medium">
|
|
1940
|
+
{t('lessonForm.noTranscription')}
|
|
1941
|
+
</h4>
|
|
1942
|
+
<p className="mt-1 text-xs text-muted-foreground">
|
|
1943
|
+
{t('lessonForm.noTranscriptionHelp')}
|
|
1944
|
+
</p>
|
|
1945
|
+
</div>
|
|
1946
|
+
</div>
|
|
1947
|
+
)}
|
|
1948
|
+
</div>
|
|
1949
|
+
</TabsContent>
|
|
1950
|
+
|
|
1951
|
+
{/* ── Tab: Recursos ───────────────────────────────────── */}
|
|
1952
|
+
<TabsContent value="recursos" className="flex-1 mt-0">
|
|
1953
|
+
<div className="flex flex-col gap-4 py-4">
|
|
1954
|
+
<div className="flex items-center justify-between">
|
|
1955
|
+
<div className="flex items-center gap-2">
|
|
1956
|
+
<Paperclip className="size-4 text-muted-foreground" />
|
|
1957
|
+
<span className="text-sm font-medium">
|
|
1958
|
+
{t('lessonForm.resources', {
|
|
1959
|
+
count: sheetRecursos.length,
|
|
1960
|
+
})}
|
|
1961
|
+
</span>
|
|
1962
|
+
</div>
|
|
1963
|
+
<Button
|
|
1964
|
+
type="button"
|
|
1965
|
+
variant="outline"
|
|
1966
|
+
size="sm"
|
|
1967
|
+
className="gap-2"
|
|
1968
|
+
onClick={addRecurso}
|
|
1969
|
+
>
|
|
1970
|
+
<Plus className="size-3.5" />
|
|
1971
|
+
{t('lessonForm.upload')}
|
|
1972
|
+
</Button>
|
|
1973
|
+
</div>
|
|
1974
|
+
|
|
1975
|
+
{/* Upload zone */}
|
|
1976
|
+
<label className="flex cursor-pointer flex-col items-center gap-2 rounded-lg border-2 border-dashed px-4 py-6 text-center transition-colors hover:border-foreground/30 hover:bg-muted/50">
|
|
1977
|
+
<Upload className="size-6 text-muted-foreground" />
|
|
1978
|
+
<span className="text-sm text-muted-foreground">
|
|
1979
|
+
{t('lessonForm.dragOrClick')}
|
|
1980
|
+
</span>
|
|
1981
|
+
<span className="text-xs text-muted-foreground/70">
|
|
1982
|
+
{t('lessonForm.fileTypes')}
|
|
1983
|
+
</span>
|
|
1984
|
+
<input
|
|
1985
|
+
type="file"
|
|
1986
|
+
multiple
|
|
1987
|
+
className="hidden"
|
|
1988
|
+
onChange={(e) => {
|
|
1989
|
+
const files = e.target.files;
|
|
1990
|
+
if (files) {
|
|
1991
|
+
const newRecursos: Recurso[] = Array.from(files).map(
|
|
1992
|
+
(f) => ({
|
|
1993
|
+
id: `r-${Date.now()}-${Math.random().toString(36).slice(2, 4)}`,
|
|
1994
|
+
nome: f.name,
|
|
1995
|
+
tamanho: `${(f.size / 1024 / 1024).toFixed(1)} MB`,
|
|
1996
|
+
tipo: f.name.split('.').pop() ?? 'file',
|
|
1997
|
+
publico: true,
|
|
1998
|
+
})
|
|
1999
|
+
);
|
|
2000
|
+
setSheetRecursos((prev) => [...prev, ...newRecursos]);
|
|
2001
|
+
toast.success(
|
|
2002
|
+
t('toasts.filesAdded', { count: files.length })
|
|
2003
|
+
);
|
|
2004
|
+
}
|
|
2005
|
+
}}
|
|
2006
|
+
/>
|
|
2007
|
+
</label>
|
|
2008
|
+
|
|
2009
|
+
{/* Resources list */}
|
|
2010
|
+
{sheetRecursos.length === 0 ? (
|
|
2011
|
+
<div className="flex flex-col items-center gap-2 py-6 text-center">
|
|
2012
|
+
<Paperclip className="size-8 text-muted-foreground/30" />
|
|
2013
|
+
<p className="text-xs text-muted-foreground">
|
|
2014
|
+
{t('lessonForm.noResources')}
|
|
2015
|
+
</p>
|
|
2016
|
+
</div>
|
|
2017
|
+
) : (
|
|
2018
|
+
<div className="flex flex-col gap-2">
|
|
2019
|
+
{sheetRecursos.map((recurso) => (
|
|
2020
|
+
<div
|
|
2021
|
+
key={recurso.id}
|
|
2022
|
+
className="flex items-center gap-3 rounded-lg border bg-background px-3 py-2.5 transition-colors hover:bg-muted/50"
|
|
2023
|
+
>
|
|
2024
|
+
<div className="flex size-8 shrink-0 items-center justify-center rounded bg-muted">
|
|
2025
|
+
<FileText className="size-4 text-muted-foreground" />
|
|
2026
|
+
</div>
|
|
2027
|
+
<div className="min-w-0 flex-1">
|
|
2028
|
+
<p className="truncate text-sm font-medium">
|
|
2029
|
+
{recurso.nome}
|
|
2030
|
+
</p>
|
|
2031
|
+
<p className="text-xs text-muted-foreground">
|
|
2032
|
+
{recurso.tamanho} · .{recurso.tipo}
|
|
2033
|
+
</p>
|
|
2034
|
+
</div>
|
|
2035
|
+
<div className="flex shrink-0 items-center gap-1">
|
|
2036
|
+
<Button
|
|
2037
|
+
type="button"
|
|
2038
|
+
variant="ghost"
|
|
2039
|
+
size="icon"
|
|
2040
|
+
className="size-7"
|
|
2041
|
+
onClick={() =>
|
|
2042
|
+
toggleRecursoVisibilidade(recurso.id)
|
|
2043
|
+
}
|
|
2044
|
+
title={
|
|
2045
|
+
recurso.publico
|
|
2046
|
+
? t('lessonForm.publicTooltip')
|
|
2047
|
+
: t('lessonForm.privateTooltip')
|
|
2048
|
+
}
|
|
2049
|
+
>
|
|
2050
|
+
{recurso.publico ? (
|
|
2051
|
+
<Globe className="size-3.5 text-emerald-600" />
|
|
2052
|
+
) : (
|
|
2053
|
+
<Lock className="size-3.5 text-amber-600" />
|
|
2054
|
+
)}
|
|
2055
|
+
</Button>
|
|
2056
|
+
<Button
|
|
2057
|
+
type="button"
|
|
2058
|
+
variant="ghost"
|
|
2059
|
+
size="icon"
|
|
2060
|
+
className="size-7 text-destructive hover:text-destructive"
|
|
2061
|
+
onClick={() => removeRecurso(recurso.id)}
|
|
2062
|
+
>
|
|
2063
|
+
<Trash2 className="size-3.5" />
|
|
2064
|
+
</Button>
|
|
2065
|
+
</div>
|
|
2066
|
+
</div>
|
|
2067
|
+
))}
|
|
2068
|
+
</div>
|
|
2069
|
+
)}
|
|
2070
|
+
</div>
|
|
2071
|
+
</TabsContent>
|
|
2072
|
+
</Tabs>
|
|
2073
|
+
|
|
2074
|
+
<SheetFooter className="mt-auto border-t pt-4">
|
|
2075
|
+
<Button
|
|
2076
|
+
type="submit"
|
|
2077
|
+
form="aula-form"
|
|
2078
|
+
disabled={saving}
|
|
2079
|
+
className="gap-2"
|
|
2080
|
+
>
|
|
2081
|
+
{saving && <Loader2 className="size-4 animate-spin" />}
|
|
2082
|
+
{editingAula ? t('lessonForm.update') : t('lessonForm.create')}
|
|
2083
|
+
</Button>
|
|
2084
|
+
</SheetFooter>
|
|
2085
|
+
</SheetContent>
|
|
2086
|
+
</Sheet>
|
|
2087
|
+
|
|
2088
|
+
{/* ═══════════════════════════════════════════════════════════════════════
|
|
2089
|
+
DIALOGS
|
|
2090
|
+
═══════════════════════════════════════════════════════════════════════ */}
|
|
2091
|
+
|
|
2092
|
+
{/* Delete Sessao */}
|
|
2093
|
+
<Dialog open={deleteSessaoDialog} onOpenChange={setDeleteSessaoDialog}>
|
|
2094
|
+
<DialogContent className="max-w-3xl">
|
|
2095
|
+
<DialogHeader>
|
|
2096
|
+
<DialogTitle className="flex items-center gap-2">
|
|
2097
|
+
<AlertTriangle className="size-5 text-destructive" />
|
|
2098
|
+
{t('deleteSession.title')}
|
|
2099
|
+
</DialogTitle>
|
|
2100
|
+
<DialogDescription asChild>
|
|
2101
|
+
<div className="flex flex-col gap-3">
|
|
2102
|
+
<p>
|
|
2103
|
+
{t('deleteSession.message')}{' '}
|
|
2104
|
+
<strong className="text-foreground">
|
|
2105
|
+
{sessaoToDelete?.titulo}
|
|
2106
|
+
</strong>
|
|
2107
|
+
?
|
|
2108
|
+
</p>
|
|
2109
|
+
{sessaoToDelete &&
|
|
2110
|
+
getAulasBySessao(sessaoToDelete.id).length > 0 && (
|
|
2111
|
+
<div className="flex items-center gap-1.5 rounded-md bg-amber-50 px-3 py-2.5 text-xs font-medium text-amber-700">
|
|
2112
|
+
<AlertTriangle className="size-3.5 shrink-0" />
|
|
2113
|
+
<span>
|
|
2114
|
+
{t('deleteSession.warning', {
|
|
2115
|
+
count: getAulasBySessao(sessaoToDelete.id).length,
|
|
2116
|
+
})}
|
|
2117
|
+
</span>
|
|
2118
|
+
</div>
|
|
2119
|
+
)}
|
|
2120
|
+
</div>
|
|
2121
|
+
</DialogDescription>
|
|
2122
|
+
</DialogHeader>
|
|
2123
|
+
<DialogFooter className="gap-2">
|
|
2124
|
+
<Button
|
|
2125
|
+
variant="outline"
|
|
2126
|
+
onClick={() => setDeleteSessaoDialog(false)}
|
|
2127
|
+
>
|
|
2128
|
+
{t('deleteSession.cancel')}
|
|
2129
|
+
</Button>
|
|
2130
|
+
<Button
|
|
2131
|
+
variant="destructive"
|
|
2132
|
+
onClick={confirmDeleteSessao}
|
|
2133
|
+
className="gap-2"
|
|
2134
|
+
>
|
|
2135
|
+
<Trash2 className="size-4" />
|
|
2136
|
+
{t('deleteSession.confirm')}
|
|
2137
|
+
</Button>
|
|
2138
|
+
</DialogFooter>
|
|
2139
|
+
</DialogContent>
|
|
2140
|
+
</Dialog>
|
|
2141
|
+
|
|
2142
|
+
{/* Delete Aula */}
|
|
2143
|
+
<Dialog open={deleteAulaDialog} onOpenChange={setDeleteAulaDialog}>
|
|
2144
|
+
<DialogContent>
|
|
2145
|
+
<DialogHeader>
|
|
2146
|
+
<DialogTitle className="flex items-center gap-2">
|
|
2147
|
+
<AlertTriangle className="size-5 text-destructive" />
|
|
2148
|
+
{t('deleteLesson.title')}
|
|
2149
|
+
</DialogTitle>
|
|
2150
|
+
<DialogDescription>
|
|
2151
|
+
{t('deleteLesson.message')}{' '}
|
|
2152
|
+
<strong className="text-foreground">
|
|
2153
|
+
{aulaToDelete?.titulo}
|
|
2154
|
+
</strong>
|
|
2155
|
+
? {t('deleteLesson.warning')}
|
|
2156
|
+
</DialogDescription>
|
|
2157
|
+
</DialogHeader>
|
|
2158
|
+
<DialogFooter className="gap-2">
|
|
2159
|
+
<Button
|
|
2160
|
+
variant="outline"
|
|
2161
|
+
onClick={() => setDeleteAulaDialog(false)}
|
|
2162
|
+
>
|
|
2163
|
+
{t('deleteLesson.cancel')}
|
|
2164
|
+
</Button>
|
|
2165
|
+
<Button
|
|
2166
|
+
variant="destructive"
|
|
2167
|
+
onClick={confirmDeleteAula}
|
|
2168
|
+
className="gap-2"
|
|
2169
|
+
>
|
|
2170
|
+
<Trash2 className="size-4" />
|
|
2171
|
+
{t('deleteLesson.confirm')}
|
|
2172
|
+
</Button>
|
|
2173
|
+
</DialogFooter>
|
|
2174
|
+
</DialogContent>
|
|
2175
|
+
</Dialog>
|
|
2176
|
+
|
|
2177
|
+
{/* Bulk Delete */}
|
|
2178
|
+
<Dialog open={bulkDeleteDialog} onOpenChange={setBulkDeleteDialog}>
|
|
2179
|
+
<DialogContent>
|
|
2180
|
+
<DialogHeader>
|
|
2181
|
+
<DialogTitle className="flex items-center gap-2">
|
|
2182
|
+
<AlertTriangle className="size-5 text-destructive" />
|
|
2183
|
+
{t('bulkDelete.title', { count: selectedAulas.size })}
|
|
2184
|
+
</DialogTitle>
|
|
2185
|
+
<DialogDescription>
|
|
2186
|
+
{t('bulkDelete.message', { count: selectedAulas.size })}
|
|
2187
|
+
</DialogDescription>
|
|
2188
|
+
</DialogHeader>
|
|
2189
|
+
<DialogFooter className="gap-2">
|
|
2190
|
+
<Button
|
|
2191
|
+
variant="outline"
|
|
2192
|
+
onClick={() => setBulkDeleteDialog(false)}
|
|
2193
|
+
>
|
|
2194
|
+
{t('bulkDelete.cancel')}
|
|
2195
|
+
</Button>
|
|
2196
|
+
<Button
|
|
2197
|
+
variant="destructive"
|
|
2198
|
+
onClick={confirmBulkDelete}
|
|
2199
|
+
className="gap-2"
|
|
2200
|
+
>
|
|
2201
|
+
<Trash2 className="size-4" />
|
|
2202
|
+
{t('bulkDelete.confirm', { count: selectedAulas.size })}
|
|
2203
|
+
</Button>
|
|
2204
|
+
</DialogFooter>
|
|
2205
|
+
</DialogContent>
|
|
2206
|
+
</Dialog>
|
|
2207
|
+
</Page>
|
|
2208
|
+
);
|
|
2209
|
+
}
|
|
2210
|
+
|
|
2211
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
2212
|
+
// SORTABLE SESSAO
|
|
2213
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
2214
|
+
|
|
2215
|
+
function SortableSessao({
|
|
2216
|
+
sessao,
|
|
2217
|
+
index,
|
|
2218
|
+
aulas,
|
|
2219
|
+
allSessoes,
|
|
2220
|
+
selectedAulas,
|
|
2221
|
+
tipoAulaMap,
|
|
2222
|
+
t,
|
|
2223
|
+
onToggleCollapse,
|
|
2224
|
+
onEditSessao,
|
|
2225
|
+
onDeleteSessao,
|
|
2226
|
+
onMergeSessao,
|
|
2227
|
+
onCreateAula,
|
|
2228
|
+
onEditAula,
|
|
2229
|
+
onDeleteAula,
|
|
2230
|
+
onAulaClick,
|
|
2231
|
+
onSelectAllInSessao,
|
|
2232
|
+
}: {
|
|
2233
|
+
sessao: Sessao;
|
|
2234
|
+
index: number;
|
|
2235
|
+
aulas: Aula[];
|
|
2236
|
+
allSessoes: Sessao[];
|
|
2237
|
+
selectedAulas: Set<string>;
|
|
2238
|
+
tipoAulaMap: Record<
|
|
2239
|
+
AulaTipo,
|
|
2240
|
+
{ label: string; icon: LucideIcon; color: string }
|
|
2241
|
+
>;
|
|
2242
|
+
t: any;
|
|
2243
|
+
onToggleCollapse: () => void;
|
|
2244
|
+
onEditSessao: () => void;
|
|
2245
|
+
onDeleteSessao: () => void;
|
|
2246
|
+
onMergeSessao: (targetId: string) => void;
|
|
2247
|
+
onCreateAula: () => void;
|
|
2248
|
+
onEditAula: (aula: Aula) => void;
|
|
2249
|
+
onDeleteAula: (aula: Aula) => void;
|
|
2250
|
+
onAulaClick: (aulaId: string, e: React.MouseEvent) => void;
|
|
2251
|
+
onSelectAllInSessao: () => void;
|
|
2252
|
+
}) {
|
|
2253
|
+
const {
|
|
2254
|
+
attributes,
|
|
2255
|
+
listeners,
|
|
2256
|
+
setNodeRef,
|
|
2257
|
+
transform,
|
|
2258
|
+
transition,
|
|
2259
|
+
isDragging,
|
|
2260
|
+
} = useSortable({ id: sessao.id });
|
|
2261
|
+
const [mergeMenuOpen, setMergeMenuOpen] = useState(false);
|
|
2262
|
+
|
|
2263
|
+
const style = {
|
|
2264
|
+
transform: CSS.Transform.toString(transform),
|
|
2265
|
+
transition,
|
|
2266
|
+
opacity: isDragging ? 0.4 : 1,
|
|
2267
|
+
};
|
|
2268
|
+
const totalDuracao = aulas.reduce((sum, a) => sum + a.duracao, 0);
|
|
2269
|
+
const selectedInSessao = aulas.filter((a) => selectedAulas.has(a.id)).length;
|
|
2270
|
+
const allSelected = aulas.length > 0 && selectedInSessao === aulas.length;
|
|
2271
|
+
|
|
2272
|
+
return (
|
|
2273
|
+
<div
|
|
2274
|
+
ref={setNodeRef}
|
|
2275
|
+
style={style}
|
|
2276
|
+
className="rounded-xl border bg-card transition-shadow hover:shadow-sm"
|
|
2277
|
+
>
|
|
2278
|
+
{/* Session header */}
|
|
2279
|
+
<div className="flex items-center gap-2 border-b px-3 py-3 sm:px-4">
|
|
2280
|
+
<button
|
|
2281
|
+
{...attributes}
|
|
2282
|
+
{...listeners}
|
|
2283
|
+
className="flex cursor-grab items-center text-muted-foreground transition-colors hover:text-foreground active:cursor-grabbing"
|
|
2284
|
+
aria-label={t('lesson.dragSession')}
|
|
2285
|
+
>
|
|
2286
|
+
<GripVertical className="size-4" />
|
|
2287
|
+
</button>
|
|
2288
|
+
|
|
2289
|
+
<button
|
|
2290
|
+
onClick={onToggleCollapse}
|
|
2291
|
+
className="flex items-center text-muted-foreground transition-colors hover:text-foreground"
|
|
2292
|
+
aria-label={
|
|
2293
|
+
sessao.collapsed ? t('session.expand') : t('session.collapse')
|
|
2294
|
+
}
|
|
2295
|
+
>
|
|
2296
|
+
{sessao.collapsed ? (
|
|
2297
|
+
<ChevronRight className="size-4" />
|
|
2298
|
+
) : (
|
|
2299
|
+
<ChevronDown className="size-4" />
|
|
2300
|
+
)}
|
|
2301
|
+
</button>
|
|
2302
|
+
|
|
2303
|
+
<div className="min-w-0 flex-1">
|
|
2304
|
+
<div className="flex items-center gap-2">
|
|
2305
|
+
<Badge variant="outline" className="shrink-0 text-[0.65rem]">
|
|
2306
|
+
{sessao.codigo}
|
|
2307
|
+
</Badge>
|
|
2308
|
+
<span className="truncate text-sm font-semibold">
|
|
2309
|
+
{sessao.titulo}
|
|
2310
|
+
</span>
|
|
2311
|
+
</div>
|
|
2312
|
+
{!sessao.collapsed && (
|
|
2313
|
+
<p className="mt-0.5 text-xs text-muted-foreground">
|
|
2314
|
+
{t('session.estimatedDuration', { minutes: sessao.duracao })}
|
|
2315
|
+
</p>
|
|
2316
|
+
)}
|
|
2317
|
+
</div>
|
|
2318
|
+
|
|
2319
|
+
<div className="flex shrink-0 items-center gap-1">
|
|
2320
|
+
<span className="hidden text-xs text-muted-foreground sm:inline">
|
|
2321
|
+
{t('session.lessonsAndDuration', {
|
|
2322
|
+
lessons: aulas.length,
|
|
2323
|
+
minutes: totalDuracao,
|
|
2324
|
+
})}
|
|
2325
|
+
</span>
|
|
2326
|
+
|
|
2327
|
+
<Button
|
|
2328
|
+
variant="ghost"
|
|
2329
|
+
size="icon"
|
|
2330
|
+
className="size-7"
|
|
2331
|
+
onClick={onSelectAllInSessao}
|
|
2332
|
+
title={t('session.selectAll')}
|
|
2333
|
+
>
|
|
2334
|
+
{allSelected ? (
|
|
2335
|
+
<CheckSquare className="size-3.5" />
|
|
2336
|
+
) : (
|
|
2337
|
+
<Square className="size-3.5" />
|
|
2338
|
+
)}
|
|
2339
|
+
</Button>
|
|
2340
|
+
|
|
2341
|
+
<Button
|
|
2342
|
+
variant="ghost"
|
|
2343
|
+
size="icon"
|
|
2344
|
+
className="size-7"
|
|
2345
|
+
onClick={onCreateAula}
|
|
2346
|
+
title={t('session.addLesson')}
|
|
2347
|
+
>
|
|
2348
|
+
<Plus className="size-3.5" />
|
|
2349
|
+
</Button>
|
|
2350
|
+
|
|
2351
|
+
<Button
|
|
2352
|
+
variant="ghost"
|
|
2353
|
+
size="icon"
|
|
2354
|
+
className="size-7"
|
|
2355
|
+
onClick={onEditSessao}
|
|
2356
|
+
title={t('session.edit')}
|
|
2357
|
+
>
|
|
2358
|
+
<Pencil className="size-3.5" />
|
|
2359
|
+
</Button>
|
|
2360
|
+
|
|
2361
|
+
{/* Merge dropdown */}
|
|
2362
|
+
<div className="relative">
|
|
2363
|
+
<Button
|
|
2364
|
+
variant="ghost"
|
|
2365
|
+
size="icon"
|
|
2366
|
+
className="size-7"
|
|
2367
|
+
onClick={() => setMergeMenuOpen(!mergeMenuOpen)}
|
|
2368
|
+
title={t('session.merge')}
|
|
2369
|
+
>
|
|
2370
|
+
<Archive className="size-3.5" />
|
|
2371
|
+
</Button>
|
|
2372
|
+
<AnimatePresence>
|
|
2373
|
+
{mergeMenuOpen && (
|
|
2374
|
+
<motion.div
|
|
2375
|
+
initial={{ opacity: 0, scale: 0.95 }}
|
|
2376
|
+
animate={{ opacity: 1, scale: 1 }}
|
|
2377
|
+
exit={{ opacity: 0, scale: 0.95 }}
|
|
2378
|
+
transition={{ duration: 0.15 }}
|
|
2379
|
+
className="absolute right-0 top-full z-30 mt-1 w-56 rounded-md border bg-popover p-1 shadow-md"
|
|
2380
|
+
>
|
|
2381
|
+
<p className="px-2 py-1.5 text-xs font-medium text-muted-foreground">
|
|
2382
|
+
{t('session.mergeWith')}
|
|
2383
|
+
</p>
|
|
2384
|
+
{allSessoes
|
|
2385
|
+
.filter((s) => s.id !== sessao.id)
|
|
2386
|
+
.map((s) => (
|
|
2387
|
+
<button
|
|
2388
|
+
key={s.id}
|
|
2389
|
+
className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm transition-colors hover:bg-accent"
|
|
2390
|
+
onClick={() => {
|
|
2391
|
+
onMergeSessao(s.id);
|
|
2392
|
+
setMergeMenuOpen(false);
|
|
2393
|
+
}}
|
|
2394
|
+
>
|
|
2395
|
+
<FolderOpen className="size-3.5 text-muted-foreground" />
|
|
2396
|
+
<span className="truncate">{s.titulo}</span>
|
|
2397
|
+
</button>
|
|
2398
|
+
))}
|
|
2399
|
+
</motion.div>
|
|
2400
|
+
)}
|
|
2401
|
+
</AnimatePresence>
|
|
2402
|
+
</div>
|
|
2403
|
+
|
|
2404
|
+
<Button
|
|
2405
|
+
variant="ghost"
|
|
2406
|
+
size="icon"
|
|
2407
|
+
className="size-7 text-destructive hover:text-destructive"
|
|
2408
|
+
onClick={onDeleteSessao}
|
|
2409
|
+
title={t('session.delete')}
|
|
2410
|
+
>
|
|
2411
|
+
<Trash2 className="size-3.5" />
|
|
2412
|
+
</Button>
|
|
2413
|
+
</div>
|
|
2414
|
+
</div>
|
|
2415
|
+
|
|
2416
|
+
{/* Aulas list */}
|
|
2417
|
+
<AnimatePresence initial={false}>
|
|
2418
|
+
{!sessao.collapsed && (
|
|
2419
|
+
<motion.div
|
|
2420
|
+
initial={{ height: 0, opacity: 0 }}
|
|
2421
|
+
animate={{ height: 'auto', opacity: 1 }}
|
|
2422
|
+
exit={{ height: 0, opacity: 0 }}
|
|
2423
|
+
transition={{ duration: 0.2 }}
|
|
2424
|
+
className="overflow-hidden"
|
|
2425
|
+
>
|
|
2426
|
+
<SortableContext
|
|
2427
|
+
items={aulas.map((a) => a.id)}
|
|
2428
|
+
strategy={verticalListSortingStrategy}
|
|
2429
|
+
>
|
|
2430
|
+
<div className="flex flex-col gap-0 p-2">
|
|
2431
|
+
{aulas.length === 0 ? (
|
|
2432
|
+
<div className="flex flex-col items-center gap-2 py-6 text-center">
|
|
2433
|
+
<p className="text-xs text-muted-foreground">
|
|
2434
|
+
{t('session.noLessons')}
|
|
2435
|
+
</p>
|
|
2436
|
+
<Button
|
|
2437
|
+
variant="outline"
|
|
2438
|
+
size="sm"
|
|
2439
|
+
className="gap-1.5 text-xs"
|
|
2440
|
+
onClick={onCreateAula}
|
|
2441
|
+
>
|
|
2442
|
+
<Plus className="size-3" />
|
|
2443
|
+
{t('session.addLesson')}
|
|
2444
|
+
</Button>
|
|
2445
|
+
</div>
|
|
2446
|
+
) : (
|
|
2447
|
+
aulas.map((aula) => (
|
|
2448
|
+
<SortableAula
|
|
2449
|
+
key={aula.id}
|
|
2450
|
+
aula={aula}
|
|
2451
|
+
t={t}
|
|
2452
|
+
tipoAulaMap={tipoAulaMap}
|
|
2453
|
+
isSelected={selectedAulas.has(aula.id)}
|
|
2454
|
+
onEdit={() => onEditAula(aula)}
|
|
2455
|
+
onDelete={() => onDeleteAula(aula)}
|
|
2456
|
+
onClick={(e) => onAulaClick(aula.id, e)}
|
|
2457
|
+
/>
|
|
2458
|
+
))
|
|
2459
|
+
)}
|
|
2460
|
+
</div>
|
|
2461
|
+
</SortableContext>
|
|
2462
|
+
</motion.div>
|
|
2463
|
+
)}
|
|
2464
|
+
</AnimatePresence>
|
|
2465
|
+
</div>
|
|
2466
|
+
);
|
|
2467
|
+
}
|
|
2468
|
+
|
|
2469
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
2470
|
+
// SORTABLE AULA
|
|
2471
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
2472
|
+
|
|
2473
|
+
function SortableAula({
|
|
2474
|
+
aula,
|
|
2475
|
+
t,
|
|
2476
|
+
tipoAulaMap,
|
|
2477
|
+
isSelected,
|
|
2478
|
+
onEdit,
|
|
2479
|
+
onDelete,
|
|
2480
|
+
onClick,
|
|
2481
|
+
}: {
|
|
2482
|
+
aula: Aula;
|
|
2483
|
+
t: any;
|
|
2484
|
+
tipoAulaMap: Record<
|
|
2485
|
+
AulaTipo,
|
|
2486
|
+
{ label: string; icon: LucideIcon; color: string }
|
|
2487
|
+
>;
|
|
2488
|
+
isSelected: boolean;
|
|
2489
|
+
onEdit: () => void;
|
|
2490
|
+
onDelete: () => void;
|
|
2491
|
+
onClick: (e: React.MouseEvent) => void;
|
|
2492
|
+
}) {
|
|
2493
|
+
const {
|
|
2494
|
+
attributes,
|
|
2495
|
+
listeners,
|
|
2496
|
+
setNodeRef,
|
|
2497
|
+
transform,
|
|
2498
|
+
transition,
|
|
2499
|
+
isDragging,
|
|
2500
|
+
} = useSortable({ id: aula.id });
|
|
2501
|
+
const style = {
|
|
2502
|
+
transform: CSS.Transform.toString(transform),
|
|
2503
|
+
transition,
|
|
2504
|
+
opacity: isDragging ? 0.3 : 1,
|
|
2505
|
+
};
|
|
2506
|
+
const tipoInfo = tipoAulaMap[aula.tipo];
|
|
2507
|
+
const TipoIcon = tipoInfo.icon;
|
|
2508
|
+
|
|
2509
|
+
return (
|
|
2510
|
+
<div
|
|
2511
|
+
ref={setNodeRef}
|
|
2512
|
+
style={style}
|
|
2513
|
+
onClick={onClick}
|
|
2514
|
+
className={`group flex items-center gap-2 rounded-lg px-2 py-2 transition-colors sm:px-3 ${
|
|
2515
|
+
isSelected
|
|
2516
|
+
? 'bg-foreground/5 ring-1 ring-foreground/20'
|
|
2517
|
+
: 'hover:bg-muted/50'
|
|
2518
|
+
} ${isDragging ? 'z-10' : ''}`}
|
|
2519
|
+
>
|
|
2520
|
+
<button
|
|
2521
|
+
{...attributes}
|
|
2522
|
+
{...listeners}
|
|
2523
|
+
className="flex shrink-0 cursor-grab items-center text-muted-foreground/50 transition-colors hover:text-muted-foreground active:cursor-grabbing"
|
|
2524
|
+
aria-label={t('lesson.dragLesson')}
|
|
2525
|
+
onClick={(e) => e.stopPropagation()}
|
|
2526
|
+
>
|
|
2527
|
+
<GripVertical className="size-4" />
|
|
2528
|
+
</button>
|
|
2529
|
+
|
|
2530
|
+
<div
|
|
2531
|
+
className={`flex size-7 shrink-0 items-center justify-center rounded ${tipoInfo.color}`}
|
|
2532
|
+
>
|
|
2533
|
+
<TipoIcon className="size-3.5" />
|
|
2534
|
+
</div>
|
|
2535
|
+
|
|
2536
|
+
<div className="min-w-0 flex-1">
|
|
2537
|
+
<div className="flex items-center gap-1.5">
|
|
2538
|
+
<Badge
|
|
2539
|
+
variant="outline"
|
|
2540
|
+
className="shrink-0 text-[0.55rem] px-1 py-0"
|
|
2541
|
+
>
|
|
2542
|
+
{aula.codigo}
|
|
2543
|
+
</Badge>
|
|
2544
|
+
<p className="truncate text-sm font-medium">{aula.titulo}</p>
|
|
2545
|
+
</div>
|
|
2546
|
+
<div className="flex items-center gap-2">
|
|
2547
|
+
<Badge variant="secondary" className="text-[0.6rem]">
|
|
2548
|
+
{tipoInfo.label}
|
|
2549
|
+
</Badge>
|
|
2550
|
+
<span className="flex items-center gap-1 text-[0.65rem] text-muted-foreground">
|
|
2551
|
+
<Clock className="size-3" />
|
|
2552
|
+
{aula.duracao}min
|
|
2553
|
+
</span>
|
|
2554
|
+
{aula.recursos.length > 0 && (
|
|
2555
|
+
<span className="flex items-center gap-1 text-[0.65rem] text-muted-foreground">
|
|
2556
|
+
<Paperclip className="size-3" />
|
|
2557
|
+
{aula.recursos.length}
|
|
2558
|
+
</span>
|
|
2559
|
+
)}
|
|
2560
|
+
</div>
|
|
2561
|
+
</div>
|
|
2562
|
+
|
|
2563
|
+
<div className="flex shrink-0 items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100">
|
|
2564
|
+
<Button
|
|
2565
|
+
variant="ghost"
|
|
2566
|
+
size="icon"
|
|
2567
|
+
className="size-7"
|
|
2568
|
+
onClick={(e) => {
|
|
2569
|
+
e.stopPropagation();
|
|
2570
|
+
onEdit();
|
|
2571
|
+
}}
|
|
2572
|
+
title={t('lesson.edit')}
|
|
2573
|
+
>
|
|
2574
|
+
<Pencil className="size-3" />
|
|
2575
|
+
</Button>
|
|
2576
|
+
<Button
|
|
2577
|
+
variant="ghost"
|
|
2578
|
+
size="icon"
|
|
2579
|
+
className="size-7 text-destructive hover:text-destructive"
|
|
2580
|
+
onClick={(e) => {
|
|
2581
|
+
e.stopPropagation();
|
|
2582
|
+
onDelete();
|
|
2583
|
+
}}
|
|
2584
|
+
title={t('lesson.delete')}
|
|
2585
|
+
>
|
|
2586
|
+
<Trash2 className="size-3" />
|
|
2587
|
+
</Button>
|
|
2588
|
+
</div>
|
|
2589
|
+
</div>
|
|
2590
|
+
);
|
|
2591
|
+
}
|
|
2592
|
+
|
|
2593
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
2594
|
+
// LOADING SKELETON
|
|
2595
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
2596
|
+
|
|
2597
|
+
function LoadingSkeleton() {
|
|
2598
|
+
return (
|
|
2599
|
+
<div className="flex flex-col gap-6">
|
|
2600
|
+
<Skeleton className="h-4 w-32" />
|
|
2601
|
+
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
|
2602
|
+
<div className="flex flex-col gap-2">
|
|
2603
|
+
<Skeleton className="h-8 w-64" />
|
|
2604
|
+
<Skeleton className="h-4 w-44" />
|
|
2605
|
+
</div>
|
|
2606
|
+
<div className="flex items-center gap-2">
|
|
2607
|
+
<Skeleton className="h-9 w-32 rounded-md" />
|
|
2608
|
+
<Skeleton className="h-9 w-32 rounded-md" />
|
|
2609
|
+
</div>
|
|
2610
|
+
</div>
|
|
2611
|
+
<Skeleton className="h-4 w-80" />
|
|
2612
|
+
{Array.from({ length: 4 }).map((_, i) => (
|
|
2613
|
+
<div key={i} className="flex flex-col gap-0 rounded-xl border">
|
|
2614
|
+
<div className="flex items-center gap-3 border-b p-4">
|
|
2615
|
+
<Skeleton className="h-4 w-4" />
|
|
2616
|
+
<Skeleton className="h-5 w-48" />
|
|
2617
|
+
<div className="ml-auto flex items-center gap-2">
|
|
2618
|
+
<Skeleton className="h-7 w-7 rounded-md" />
|
|
2619
|
+
<Skeleton className="h-7 w-7 rounded-md" />
|
|
2620
|
+
<Skeleton className="h-7 w-7 rounded-md" />
|
|
2621
|
+
</div>
|
|
2622
|
+
</div>
|
|
2623
|
+
<div className="flex flex-col gap-1 p-2">
|
|
2624
|
+
{Array.from({ length: 3 }).map((_, j) => (
|
|
2625
|
+
<div
|
|
2626
|
+
key={j}
|
|
2627
|
+
className="flex items-center gap-3 rounded-lg px-3 py-2"
|
|
2628
|
+
>
|
|
2629
|
+
<Skeleton className="h-4 w-4" />
|
|
2630
|
+
<Skeleton className="h-7 w-7 rounded" />
|
|
2631
|
+
<div className="flex flex-1 flex-col gap-1">
|
|
2632
|
+
<Skeleton className="h-4 w-40" />
|
|
2633
|
+
<Skeleton className="h-3 w-24" />
|
|
2634
|
+
</div>
|
|
2635
|
+
</div>
|
|
2636
|
+
))}
|
|
2637
|
+
</div>
|
|
2638
|
+
</div>
|
|
2639
|
+
))}
|
|
2640
|
+
</div>
|
|
2641
|
+
);
|
|
2642
|
+
}
|