@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.
Files changed (52) hide show
  1. package/hedhog/data/menu.yaml +8 -1
  2. package/hedhog/frontend/app/classes/[id]/page.tsx.ejs +1387 -0
  3. package/hedhog/frontend/app/classes/page.tsx.ejs +4 -4
  4. package/hedhog/frontend/app/courses/[id]/page.tsx.ejs +1237 -0
  5. package/hedhog/frontend/app/courses/[id]/structure/page.tsx.ejs +2642 -0
  6. package/hedhog/frontend/app/courses/page.tsx.ejs +825 -727
  7. package/hedhog/frontend/app/exams/[id]/attempt/page.tsx.ejs +976 -0
  8. package/hedhog/frontend/app/exams/[id]/questions/page.tsx.ejs +931 -0
  9. package/hedhog/frontend/app/exams/page.tsx.ejs +9 -7
  10. package/hedhog/frontend/app/training/page.tsx.ejs +3 -3
  11. package/hedhog/frontend/messages/en.json +703 -14
  12. package/hedhog/frontend/messages/pt.json +863 -174
  13. package/hedhog/query/triggers.sql +0 -0
  14. package/hedhog/table/certificate.yaml +89 -0
  15. package/hedhog/table/certificate_template.yaml +24 -0
  16. package/hedhog/table/course.yaml +67 -1
  17. package/hedhog/table/course_category.yaml +22 -0
  18. package/hedhog/table/course_class_attendance.yaml +34 -0
  19. package/hedhog/table/course_class_group.yaml +58 -0
  20. package/hedhog/table/course_class_session.yaml +38 -0
  21. package/hedhog/table/course_class_session_instructor.yaml +27 -0
  22. package/hedhog/table/course_enrollment.yaml +45 -0
  23. package/hedhog/table/course_image.yaml +33 -0
  24. package/hedhog/table/course_lesson.yaml +35 -0
  25. package/hedhog/table/course_lesson_file.yaml +23 -0
  26. package/hedhog/table/course_lesson_instructor.yaml +27 -0
  27. package/hedhog/table/course_lesson_progress.yaml +40 -0
  28. package/hedhog/table/course_lesson_question.yaml +24 -0
  29. package/hedhog/table/course_module.yaml +25 -0
  30. package/hedhog/table/course_prerequisite.yaml +48 -0
  31. package/hedhog/table/evaluation_rating.yaml +30 -0
  32. package/hedhog/table/evaluation_topic.yaml +68 -0
  33. package/hedhog/table/exam.yaml +91 -0
  34. package/hedhog/table/exam_answer.yaml +40 -0
  35. package/hedhog/table/exam_attempt.yaml +51 -0
  36. package/hedhog/table/exam_image.yaml +33 -0
  37. package/hedhog/table/exam_option.yaml +25 -0
  38. package/hedhog/table/exam_question.yaml +24 -0
  39. package/hedhog/table/image_type.yaml +28 -0
  40. package/hedhog/table/instructor.yaml +23 -0
  41. package/hedhog/table/learning_path.yaml +49 -0
  42. package/hedhog/table/learning_path_enrollment.yaml +33 -0
  43. package/hedhog/table/learning_path_image.yaml +33 -0
  44. package/hedhog/table/learning_path_step.yaml +43 -0
  45. package/hedhog/table/question.yaml +15 -0
  46. package/package.json +9 -6
  47. package/src/index.ts +1 -1
  48. package/src/lms.module.ts +15 -15
  49. package/hedhog/table/classes.yaml +0 -3
  50. package/hedhog/table/exams.yaml +0 -3
  51. package/hedhog/table/reports.yaml +0 -3
  52. 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} &middot; .{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
+ }