@hed-hog/lms 0.0.306 → 0.0.310

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