@hed-hog/lms 0.0.312 → 0.0.315

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/dist/class-group/class-group.controller.d.ts +2 -2
  2. package/dist/class-group/class-group.service.d.ts +2 -2
  3. package/dist/enterprise/dto/enterprise-profile.dto.d.ts +13 -0
  4. package/dist/enterprise/dto/enterprise-profile.dto.d.ts.map +1 -0
  5. package/dist/enterprise/dto/enterprise-profile.dto.js +3 -0
  6. package/dist/enterprise/dto/enterprise-profile.dto.js.map +1 -0
  7. package/dist/enterprise/enterprise.controller.d.ts +3 -0
  8. package/dist/enterprise/enterprise.controller.d.ts.map +1 -1
  9. package/dist/enterprise/enterprise.controller.js +14 -0
  10. package/dist/enterprise/enterprise.controller.js.map +1 -1
  11. package/dist/enterprise/enterprise.service.d.ts +3 -0
  12. package/dist/enterprise/enterprise.service.d.ts.map +1 -1
  13. package/dist/enterprise/enterprise.service.js +128 -1
  14. package/dist/enterprise/enterprise.service.js.map +1 -1
  15. package/dist/instructor/instructor.controller.d.ts +23 -0
  16. package/dist/instructor/instructor.controller.d.ts.map +1 -1
  17. package/dist/instructor/instructor.controller.js +41 -0
  18. package/dist/instructor/instructor.controller.js.map +1 -1
  19. package/dist/instructor/instructor.service.d.ts +25 -0
  20. package/dist/instructor/instructor.service.d.ts.map +1 -1
  21. package/dist/instructor/instructor.service.js +126 -8
  22. package/dist/instructor/instructor.service.js.map +1 -1
  23. package/hedhog/data/menu.yaml +23 -7
  24. package/hedhog/data/role.yaml +9 -1
  25. package/hedhog/data/route.yaml +54 -0
  26. package/hedhog/frontend/app/_components/class-form-sheet.tsx.ejs +2 -1
  27. package/hedhog/frontend/app/courses/[id]/structure/_components/confirm-dialog.tsx.ejs +44 -44
  28. package/hedhog/frontend/app/courses/[id]/structure/_components/course-tree-dnd.tsx.ejs +362 -362
  29. package/hedhog/frontend/app/courses/[id]/structure/_components/course-tree-panel.tsx.ejs +111 -111
  30. package/hedhog/frontend/app/courses/[id]/structure/_components/course-tree.tsx.ejs +134 -134
  31. package/hedhog/frontend/app/courses/[id]/structure/_components/detail-course.tsx.ejs +113 -113
  32. package/hedhog/frontend/app/courses/[id]/structure/_components/detail-lesson.tsx.ejs +314 -314
  33. package/hedhog/frontend/app/courses/[id]/structure/_components/detail-panel.tsx.ejs +62 -62
  34. package/hedhog/frontend/app/courses/[id]/structure/_components/detail-session.tsx.ejs +173 -173
  35. package/hedhog/frontend/app/courses/[id]/structure/_components/drag-handle.tsx.ejs +58 -58
  36. package/hedhog/frontend/app/courses/[id]/structure/_components/drag-overlay.tsx.ejs +51 -51
  37. package/hedhog/frontend/app/courses/[id]/structure/_components/editor-bulk.tsx.ejs +276 -276
  38. package/hedhog/frontend/app/courses/[id]/structure/_components/editor-course.tsx.ejs +1216 -1216
  39. package/hedhog/frontend/app/courses/[id]/structure/_components/editor-lesson.tsx.ejs +1824 -1824
  40. package/hedhog/frontend/app/courses/[id]/structure/_components/editor-session.tsx.ejs +443 -443
  41. package/hedhog/frontend/app/courses/[id]/structure/_components/highlighted-text.tsx.ejs +40 -40
  42. package/hedhog/frontend/app/courses/[id]/structure/_components/mock-data.ts.ejs +185 -185
  43. package/hedhog/frontend/app/courses/[id]/structure/_components/multi-select-bar.tsx.ejs +264 -264
  44. package/hedhog/frontend/app/courses/[id]/structure/_components/search-filter.tsx.ejs +95 -95
  45. package/hedhog/frontend/app/courses/[id]/structure/_components/session-picker-dialog.tsx.ejs +73 -73
  46. package/hedhog/frontend/app/courses/[id]/structure/_components/shortcuts-help.tsx.ejs +136 -136
  47. package/hedhog/frontend/app/courses/[id]/structure/_components/sortable-tree-row.tsx.ejs +80 -80
  48. package/hedhog/frontend/app/courses/[id]/structure/_components/store.ts.ejs +949 -949
  49. package/hedhog/frontend/app/courses/[id]/structure/_components/tree-context-menu.tsx.ejs +525 -525
  50. package/hedhog/frontend/app/courses/[id]/structure/_components/tree-helpers.ts.ejs +181 -181
  51. package/hedhog/frontend/app/courses/[id]/structure/_components/tree-row-course.tsx.ejs +51 -51
  52. package/hedhog/frontend/app/courses/[id]/structure/_components/tree-row-lesson.tsx.ejs +271 -271
  53. package/hedhog/frontend/app/courses/[id]/structure/_components/tree-row-session.tsx.ejs +167 -167
  54. package/hedhog/frontend/app/courses/[id]/structure/_components/tree-row.tsx.ejs +108 -108
  55. package/hedhog/frontend/app/courses/[id]/structure/_components/use-course-structure-shortcuts.ts.ejs +318 -318
  56. package/hedhog/frontend/app/courses/[id]/structure/page.tsx.ejs +10 -10
  57. package/hedhog/frontend/app/enterprise/_components/enterprise-course-create-sheet.tsx.ejs +2 -1
  58. package/hedhog/frontend/app/instructors/_components/instructor-form-sheet.tsx.ejs +438 -0
  59. package/hedhog/frontend/app/instructors/_components/instructor-types.ts.ejs +40 -0
  60. package/hedhog/frontend/app/instructors/page.tsx.ejs +696 -0
  61. package/hedhog/frontend/app/training/page.tsx.ejs +2339 -0
  62. package/hedhog/table/enterprise_user.yaml +1 -1
  63. package/package.json +8 -8
  64. package/src/enterprise/dto/enterprise-profile.dto.ts +17 -0
  65. package/src/enterprise/enterprise.controller.ts +9 -1
  66. package/src/enterprise/enterprise.service.ts +147 -4
  67. package/src/instructor/instructor.controller.ts +36 -9
  68. package/src/instructor/instructor.service.ts +140 -10
@@ -1,525 +1,525 @@
1
- 'use client';
2
-
3
- import {
4
- ContextMenu,
5
- ContextMenuContent,
6
- ContextMenuItem,
7
- ContextMenuLabel,
8
- ContextMenuSeparator,
9
- ContextMenuTrigger,
10
- } from '@/components/ui/context-menu';
11
- import {
12
- ArrowDown,
13
- ArrowUp,
14
- BookOpen,
15
- Clipboard,
16
- Copy,
17
- FolderOpen,
18
- Layers,
19
- Loader2,
20
- Pencil,
21
- Plus,
22
- Trash2,
23
- } from 'lucide-react';
24
- import { toast } from 'sonner';
25
-
26
- import {
27
- useBulkDeleteMutation,
28
- useCreateLessonMutation,
29
- useCreateSessionMutation,
30
- useDeleteLessonMutation,
31
- useDeleteSessionMutation,
32
- useDuplicateLessonMutation,
33
- useDuplicateSessionMutation,
34
- useMoveLessonsMutation,
35
- usePasteLessonsMutation,
36
- usePasteSessionsMutation,
37
- useReorderSessionsMutation,
38
- } from '../_data/use-course-structure-mutations';
39
- import { useStructureStore } from './store';
40
- import type { Course, FlatItem, Lesson, Session } from './types';
41
-
42
- // ─────────────────────────────────────────────────────────────────────────────
43
- // Types
44
- // ─────────────────────────────────────────────────────────────────────────────
45
-
46
- interface TreeContextMenuProps {
47
- item: FlatItem;
48
- children: React.ReactNode;
49
- }
50
-
51
- // ─────────────────────────────────────────────────────────────────────────────
52
- // Sub-menus by item type
53
- // ─────────────────────────────────────────────────────────────────────────────
54
-
55
- function CourseMenu({ data }: { data: Course }) {
56
- const expandAll = useStructureStore((s) => s.expandAll);
57
- const collapseAll = useStructureStore((s) => s.collapseAll);
58
- const copiedType = useStructureStore((s) => s.copiedType);
59
- const copiedIds = useStructureStore((s) => s.copiedIds);
60
-
61
- const createSessionMutation = useCreateSessionMutation();
62
- const pasteSessionsMutation = usePasteSessionsMutation();
63
- const hasSessionClipboard = copiedType === 'session' && copiedIds.length > 0;
64
-
65
- return (
66
- <>
67
- <ContextMenuLabel className="text-[0.65rem] text-muted-foreground px-2 py-1">
68
- {data.title}
69
- </ContextMenuLabel>
70
- <ContextMenuSeparator />
71
-
72
- <ContextMenuItem
73
- disabled={createSessionMutation.isPending}
74
- onSelect={() => {
75
- createSessionMutation.mutate();
76
- }}
77
- >
78
- {createSessionMutation.isPending ? (
79
- <Loader2 className="size-3.5 mr-2 animate-spin" />
80
- ) : (
81
- <Plus className="size-3.5 mr-2 text-muted-foreground" />
82
- )}
83
- Nova sessão
84
- </ContextMenuItem>
85
-
86
- <ContextMenuSeparator />
87
-
88
- <ContextMenuItem
89
- onSelect={() => {
90
- expandAll();
91
- toast('Tudo expandido');
92
- }}
93
- >
94
- <BookOpen className="size-3.5 mr-2 text-muted-foreground" />
95
- Expandir tudo
96
- </ContextMenuItem>
97
-
98
- <ContextMenuItem
99
- onSelect={() => {
100
- collapseAll();
101
- toast('Tudo recolhido');
102
- }}
103
- >
104
- <Layers className="size-3.5 mr-2 text-muted-foreground" />
105
- Recolher tudo
106
- </ContextMenuItem>
107
-
108
- {hasSessionClipboard && (
109
- <>
110
- <ContextMenuSeparator />
111
- <ContextMenuItem
112
- disabled={pasteSessionsMutation.isPending}
113
- onSelect={() => {
114
- pasteSessionsMutation.mutate({ sessionIds: copiedIds });
115
- }}
116
- >
117
- {pasteSessionsMutation.isPending ? (
118
- <Loader2 className="size-3.5 mr-2 animate-spin" />
119
- ) : (
120
- <Clipboard className="size-3.5 mr-2 text-muted-foreground" />
121
- )}
122
- Colar sessão{copiedIds.length > 1 ? ` (${copiedIds.length})` : ''}
123
- </ContextMenuItem>
124
- </>
125
- )}
126
- </>
127
- );
128
- }
129
-
130
- function SessionMenu({ data }: { data: Session }) {
131
- const sessions = useStructureStore((s) => s.sessions);
132
- const selectedIds = useStructureStore((s) => s.selectedIds);
133
- const copiedType = useStructureStore((s) => s.copiedType);
134
- const copiedIds = useStructureStore((s) => s.copiedIds);
135
- const copyItems = useStructureStore((s) => s.copyItems);
136
- const showConfirm = useStructureStore((s) => s.showConfirm);
137
- const startRename = useStructureStore((s) => s.startRename);
138
-
139
- const createLessonMutation = useCreateLessonMutation();
140
- const deleteSessionMutation = useDeleteSessionMutation();
141
- const duplicateSessionMutation = useDuplicateSessionMutation();
142
- const pasteLessonsMutation = usePasteLessonsMutation();
143
- const reorderSessionsMutation = useReorderSessionsMutation();
144
- const reorderSessionsInStore = useStructureStore((s) => s.reorderSessions);
145
-
146
- const sorted = [...sessions].sort((a, b) => a.order - b.order);
147
- const idx = sorted.findIndex((s) => s.id === data.id);
148
- const isFirst = idx === 0;
149
- const isLast = idx === sorted.length - 1;
150
-
151
- // Count selected sessions
152
- const selectedSessionIds = sessions
153
- .filter((s) => selectedIds.has(`session:${s.id}`))
154
- .map((s) => s.id);
155
- const multiSessions = selectedSessionIds.length > 1;
156
-
157
- const hasLessonClipboard = copiedType === 'lesson' && copiedIds.length > 0;
158
-
159
- return (
160
- <>
161
- <ContextMenuLabel className="text-[0.65rem] text-muted-foreground px-2 py-1">
162
- {data.title}
163
- </ContextMenuLabel>
164
- <ContextMenuSeparator />
165
-
166
- <ContextMenuItem
167
- disabled={createLessonMutation.isPending}
168
- onSelect={() => {
169
- createLessonMutation.mutate({ sessionId: data.id });
170
- }}
171
- >
172
- {createLessonMutation.isPending ? (
173
- <Loader2 className="size-3.5 mr-2 animate-spin" />
174
- ) : (
175
- <Plus className="size-3.5 mr-2 text-muted-foreground" />
176
- )}
177
- Nova aula nesta sessão
178
- </ContextMenuItem>
179
-
180
- <ContextMenuSeparator />
181
-
182
- <ContextMenuItem onSelect={() => startRename(data.id)}>
183
- <Pencil className="size-3.5 mr-2 text-muted-foreground" />
184
- Renomear
185
- </ContextMenuItem>
186
-
187
- <ContextMenuItem
188
- disabled={duplicateSessionMutation.isPending}
189
- onSelect={() => {
190
- duplicateSessionMutation.mutate({ sessionId: data.id });
191
- }}
192
- >
193
- {duplicateSessionMutation.isPending ? (
194
- <Loader2 className="size-3.5 mr-2 animate-spin" />
195
- ) : (
196
- <Copy className="size-3.5 mr-2 text-muted-foreground" />
197
- )}
198
- Duplicar sessão
199
- </ContextMenuItem>
200
-
201
- <ContextMenuItem
202
- onSelect={() => {
203
- copyItems([data.id], 'session');
204
- toast.success('Sessão copiada');
205
- }}
206
- >
207
- <Copy className="size-3.5 mr-2 text-muted-foreground" />
208
- Copiar sessão
209
- </ContextMenuItem>
210
-
211
- {multiSessions && (
212
- <ContextMenuItem
213
- onSelect={() => {
214
- copyItems(selectedSessionIds, 'session');
215
- toast.success(`${selectedSessionIds.length} sessões copiadas`);
216
- }}
217
- >
218
- <Copy className="size-3.5 mr-2 text-muted-foreground" />
219
- Copiar selecionadas ({selectedSessionIds.length})
220
- </ContextMenuItem>
221
- )}
222
-
223
- {hasLessonClipboard && (
224
- <ContextMenuItem
225
- disabled={pasteLessonsMutation.isPending}
226
- onSelect={() => {
227
- pasteLessonsMutation.mutate({
228
- targetSessionId: data.id,
229
- lessonIds: copiedIds,
230
- });
231
- }}
232
- >
233
- {pasteLessonsMutation.isPending ? (
234
- <Loader2 className="size-3.5 mr-2 animate-spin" />
235
- ) : (
236
- <Clipboard className="size-3.5 mr-2 text-muted-foreground" />
237
- )}
238
- Colar aulas nesta sessão
239
- {copiedIds.length > 1 ? ` (${copiedIds.length})` : ''}
240
- </ContextMenuItem>
241
- )}
242
-
243
- <ContextMenuSeparator />
244
-
245
- <ContextMenuItem
246
- disabled={isFirst || reorderSessionsMutation.isPending}
247
- onSelect={() => {
248
- const previousSessions = [...sorted];
249
- reorderSessionsInStore(idx, idx - 1);
250
- const newSorted = [...sorted];
251
- const [moved] = newSorted.splice(idx, 1);
252
- newSorted.splice(idx - 1, 0, moved!);
253
- reorderSessionsMutation.mutate({
254
- orderedIds: newSorted.map((s) => s.id),
255
- previousSessions,
256
- });
257
- }}
258
- >
259
- <ArrowUp className="size-3.5 mr-2 text-muted-foreground" />
260
- Mover para cima
261
- </ContextMenuItem>
262
-
263
- <ContextMenuItem
264
- disabled={isLast || reorderSessionsMutation.isPending}
265
- onSelect={() => {
266
- const previousSessions = [...sorted];
267
- reorderSessionsInStore(idx, idx + 1);
268
- const newSorted = [...sorted];
269
- const [moved] = newSorted.splice(idx, 1);
270
- newSorted.splice(idx + 1, 0, moved!);
271
- reorderSessionsMutation.mutate({
272
- orderedIds: newSorted.map((s) => s.id),
273
- previousSessions,
274
- });
275
- }}
276
- >
277
- <ArrowDown className="size-3.5 mr-2 text-muted-foreground" />
278
- Mover para baixo
279
- </ContextMenuItem>
280
-
281
- <ContextMenuSeparator />
282
-
283
- <ContextMenuItem
284
- className="text-destructive focus:text-destructive"
285
- onSelect={() => {
286
- showConfirm({
287
- title: `Excluir "${data.title}"?`,
288
- description:
289
- 'A sessão e todas as suas aulas serão excluídas permanentemente.',
290
- onConfirm: () => {
291
- deleteSessionMutation.mutate({ sessionId: data.id });
292
- },
293
- });
294
- }}
295
- >
296
- <Trash2 className="size-3.5 mr-2" />
297
- Excluir sessão
298
- </ContextMenuItem>
299
- </>
300
- );
301
- }
302
-
303
- function LessonMenu({ data }: { data: Lesson }) {
304
- const lessons = useStructureStore((s) => s.lessons);
305
- const sessions = useStructureStore((s) => s.sessions);
306
- const selectedIds = useStructureStore((s) => s.selectedIds);
307
- const copiedType = useStructureStore((s) => s.copiedType);
308
- const copiedIds = useStructureStore((s) => s.copiedIds);
309
- const copyItems = useStructureStore((s) => s.copyItems);
310
- const showSessionPicker = useStructureStore((s) => s.showSessionPicker);
311
- const showConfirm = useStructureStore((s) => s.showConfirm);
312
- const startRename = useStructureStore((s) => s.startRename);
313
-
314
- const deleteLessonMutation = useDeleteLessonMutation();
315
- const bulkDeleteMutation = useBulkDeleteMutation();
316
- const duplicateLessonMutation = useDuplicateLessonMutation();
317
- const pasteLessonsMutation = usePasteLessonsMutation();
318
- const moveLessonsMutation = useMoveLessonsMutation();
319
-
320
- // Count selected lessons
321
- const selectedLessonIds = lessons
322
- .filter((l) => selectedIds.has(l.id))
323
- .map((l) => l.id);
324
- const multiLessons = selectedLessonIds.length > 1;
325
-
326
- const hasLessonClipboard = copiedType === 'lesson' && copiedIds.length > 0;
327
- const hasOtherSessions = sessions.some((ss) => ss.id !== data.sessionId);
328
-
329
- // Ids to move when "Mover" is triggered
330
- const idsToMove = multiLessons ? selectedLessonIds : [data.id];
331
-
332
- return (
333
- <>
334
- <ContextMenuLabel className="text-[0.65rem] text-muted-foreground px-2 py-1">
335
- {data.title}
336
- </ContextMenuLabel>
337
- <ContextMenuSeparator />
338
-
339
- <ContextMenuItem onSelect={() => startRename(data.id)}>
340
- <Pencil className="size-3.5 mr-2 text-muted-foreground" />
341
- Renomear
342
- </ContextMenuItem>
343
-
344
- <ContextMenuSeparator />
345
-
346
- <ContextMenuItem
347
- disabled={duplicateLessonMutation.isPending}
348
- onSelect={() => {
349
- duplicateLessonMutation.mutate({
350
- sessionId: data.sessionId,
351
- lessonId: data.id,
352
- });
353
- }}
354
- >
355
- {duplicateLessonMutation.isPending ? (
356
- <Loader2 className="size-3.5 mr-2 animate-spin" />
357
- ) : (
358
- <Copy className="size-3.5 mr-2 text-muted-foreground" />
359
- )}
360
- Duplicar aula
361
- </ContextMenuItem>
362
-
363
- <ContextMenuItem
364
- onSelect={() => {
365
- copyItems([data.id], 'lesson');
366
- toast.success('Aula copiada');
367
- }}
368
- >
369
- <Copy className="size-3.5 mr-2 text-muted-foreground" />
370
- Copiar aula
371
- </ContextMenuItem>
372
-
373
- {multiLessons && (
374
- <ContextMenuItem
375
- onSelect={() => {
376
- copyItems(selectedLessonIds, 'lesson');
377
- toast.success(`${selectedLessonIds.length} aulas copiadas`);
378
- }}
379
- >
380
- <Copy className="size-3.5 mr-2 text-muted-foreground" />
381
- Copiar selecionadas ({selectedLessonIds.length})
382
- </ContextMenuItem>
383
- )}
384
-
385
- {hasLessonClipboard && (
386
- <ContextMenuItem
387
- disabled={pasteLessonsMutation.isPending}
388
- onSelect={() => {
389
- pasteLessonsMutation.mutate({
390
- targetSessionId: data.sessionId,
391
- lessonIds: copiedIds,
392
- });
393
- }}
394
- >
395
- {pasteLessonsMutation.isPending ? (
396
- <Loader2 className="size-3.5 mr-2 animate-spin" />
397
- ) : (
398
- <Clipboard className="size-3.5 mr-2 text-muted-foreground" />
399
- )}
400
- Colar nesta sessão
401
- </ContextMenuItem>
402
- )}
403
-
404
- {hasOtherSessions && (
405
- <>
406
- <ContextMenuSeparator />
407
- <ContextMenuItem
408
- disabled={moveLessonsMutation.isPending}
409
- onSelect={() => {
410
- showSessionPicker({
411
- title:
412
- idsToMove.length > 1
413
- ? `Mover ${idsToMove.length} aulas para`
414
- : 'Mover aula para',
415
- excludeSessionId: data.sessionId,
416
- onPick: (targetId) => {
417
- // Snapshot for rollback
418
- const previousLessons = [...lessons];
419
- // Compute moves with sequential toIndex
420
- const movingLessons = idsToMove
421
- .map((id) => lessons.find((l) => l.id === id))
422
- .filter(Boolean) as Lesson[];
423
- const moves = movingLessons.map((l, i) => ({
424
- lessonId: l.id,
425
- fromSessionId: l.sessionId,
426
- toSessionId: targetId,
427
- toIndex: i,
428
- }));
429
- // Optimistic: update store immediately
430
- useStructureStore.getState().moveLessons(idsToMove, targetId);
431
- moveLessonsMutation.mutate({ moves, previousLessons });
432
- toast.success(
433
- idsToMove.length > 1
434
- ? `${idsToMove.length} aulas movidas`
435
- : 'Aula movida'
436
- );
437
- },
438
- });
439
- }}
440
- >
441
- {moveLessonsMutation.isPending ? (
442
- <Loader2 className="size-3.5 mr-2 animate-spin" />
443
- ) : (
444
- <FolderOpen className="size-3.5 mr-2 text-muted-foreground" />
445
- )}
446
- {multiLessons
447
- ? `Mover ${idsToMove.length} aulas para...`
448
- : 'Mover para outra sessão...'}
449
- </ContextMenuItem>
450
- </>
451
- )}
452
-
453
- <ContextMenuSeparator />
454
-
455
- <ContextMenuItem
456
- className="text-destructive focus:text-destructive"
457
- onSelect={() => {
458
- if (multiLessons) {
459
- showConfirm({
460
- title: `Excluir ${selectedLessonIds.length} aulas?`,
461
- description: 'Esta ação não pode ser desfeita.',
462
- onConfirm: () => {
463
- const lessonPairs = selectedLessonIds
464
- .map((id) => lessons.find((l) => l.id === id))
465
- .filter(Boolean)
466
- .map((l) => ({ lessonId: l!.id, sessionId: l!.sessionId }));
467
- bulkDeleteMutation.mutate({
468
- sessionIds: [],
469
- lessons: lessonPairs,
470
- });
471
- },
472
- });
473
- } else {
474
- showConfirm({
475
- title: `Excluir "${data.title}"?`,
476
- description: 'Esta ação não pode ser desfeita.',
477
- onConfirm: () => {
478
- deleteLessonMutation.mutate({
479
- lessonId: data.id,
480
- sessionId: data.sessionId,
481
- });
482
- },
483
- });
484
- }
485
- }}
486
- >
487
- <Trash2 className="size-3.5 mr-2" />
488
- {multiLessons
489
- ? `Excluir ${selectedLessonIds.length} aulas`
490
- : 'Excluir aula'}
491
- </ContextMenuItem>
492
- </>
493
- );
494
- }
495
-
496
- // ─────────────────────────────────────────────────────────────────────────────
497
- // Root export
498
- // ─────────────────────────────────────────────────────────────────────────────
499
-
500
- export function TreeContextMenu({ item, children }: TreeContextMenuProps) {
501
- const selectedIds = useStructureStore((s) => s.selectedIds);
502
- const selectItem = useStructureStore((s) => s.selectItem);
503
-
504
- function handleOpenChange(open: boolean) {
505
- if (!open) return;
506
- // On right-click: if item is not in the current selection, select it alone
507
- if (!selectedIds.has(item.id)) {
508
- selectItem(item.id, item.type);
509
- }
510
- }
511
-
512
- return (
513
- <ContextMenu onOpenChange={handleOpenChange}>
514
- <ContextMenuTrigger className="block w-full h-full">
515
- {children}
516
- </ContextMenuTrigger>
517
-
518
- <ContextMenuContent className="w-56">
519
- {item.type === 'course' && <CourseMenu data={item.data as Course} />}
520
- {item.type === 'session' && <SessionMenu data={item.data as Session} />}
521
- {item.type === 'lesson' && <LessonMenu data={item.data as Lesson} />}
522
- </ContextMenuContent>
523
- </ContextMenu>
524
- );
525
- }
1
+ 'use client';
2
+
3
+ import {
4
+ ContextMenu,
5
+ ContextMenuContent,
6
+ ContextMenuItem,
7
+ ContextMenuLabel,
8
+ ContextMenuSeparator,
9
+ ContextMenuTrigger,
10
+ } from '@/components/ui/context-menu';
11
+ import {
12
+ ArrowDown,
13
+ ArrowUp,
14
+ BookOpen,
15
+ Clipboard,
16
+ Copy,
17
+ FolderOpen,
18
+ Layers,
19
+ Loader2,
20
+ Pencil,
21
+ Plus,
22
+ Trash2,
23
+ } from 'lucide-react';
24
+ import { toast } from 'sonner';
25
+
26
+ import {
27
+ useBulkDeleteMutation,
28
+ useCreateLessonMutation,
29
+ useCreateSessionMutation,
30
+ useDeleteLessonMutation,
31
+ useDeleteSessionMutation,
32
+ useDuplicateLessonMutation,
33
+ useDuplicateSessionMutation,
34
+ useMoveLessonsMutation,
35
+ usePasteLessonsMutation,
36
+ usePasteSessionsMutation,
37
+ useReorderSessionsMutation,
38
+ } from '../_data/use-course-structure-mutations';
39
+ import { useStructureStore } from './store';
40
+ import type { Course, FlatItem, Lesson, Session } from './types';
41
+
42
+ // ─────────────────────────────────────────────────────────────────────────────
43
+ // Types
44
+ // ─────────────────────────────────────────────────────────────────────────────
45
+
46
+ interface TreeContextMenuProps {
47
+ item: FlatItem;
48
+ children: React.ReactNode;
49
+ }
50
+
51
+ // ─────────────────────────────────────────────────────────────────────────────
52
+ // Sub-menus by item type
53
+ // ─────────────────────────────────────────────────────────────────────────────
54
+
55
+ function CourseMenu({ data }: { data: Course }) {
56
+ const expandAll = useStructureStore((s) => s.expandAll);
57
+ const collapseAll = useStructureStore((s) => s.collapseAll);
58
+ const copiedType = useStructureStore((s) => s.copiedType);
59
+ const copiedIds = useStructureStore((s) => s.copiedIds);
60
+
61
+ const createSessionMutation = useCreateSessionMutation();
62
+ const pasteSessionsMutation = usePasteSessionsMutation();
63
+ const hasSessionClipboard = copiedType === 'session' && copiedIds.length > 0;
64
+
65
+ return (
66
+ <>
67
+ <ContextMenuLabel className="text-[0.65rem] text-muted-foreground px-2 py-1">
68
+ {data.title}
69
+ </ContextMenuLabel>
70
+ <ContextMenuSeparator />
71
+
72
+ <ContextMenuItem
73
+ disabled={createSessionMutation.isPending}
74
+ onSelect={() => {
75
+ createSessionMutation.mutate();
76
+ }}
77
+ >
78
+ {createSessionMutation.isPending ? (
79
+ <Loader2 className="size-3.5 mr-2 animate-spin" />
80
+ ) : (
81
+ <Plus className="size-3.5 mr-2 text-muted-foreground" />
82
+ )}
83
+ Nova sessão
84
+ </ContextMenuItem>
85
+
86
+ <ContextMenuSeparator />
87
+
88
+ <ContextMenuItem
89
+ onSelect={() => {
90
+ expandAll();
91
+ toast('Tudo expandido');
92
+ }}
93
+ >
94
+ <BookOpen className="size-3.5 mr-2 text-muted-foreground" />
95
+ Expandir tudo
96
+ </ContextMenuItem>
97
+
98
+ <ContextMenuItem
99
+ onSelect={() => {
100
+ collapseAll();
101
+ toast('Tudo recolhido');
102
+ }}
103
+ >
104
+ <Layers className="size-3.5 mr-2 text-muted-foreground" />
105
+ Recolher tudo
106
+ </ContextMenuItem>
107
+
108
+ {hasSessionClipboard && (
109
+ <>
110
+ <ContextMenuSeparator />
111
+ <ContextMenuItem
112
+ disabled={pasteSessionsMutation.isPending}
113
+ onSelect={() => {
114
+ pasteSessionsMutation.mutate({ sessionIds: copiedIds });
115
+ }}
116
+ >
117
+ {pasteSessionsMutation.isPending ? (
118
+ <Loader2 className="size-3.5 mr-2 animate-spin" />
119
+ ) : (
120
+ <Clipboard className="size-3.5 mr-2 text-muted-foreground" />
121
+ )}
122
+ Colar sessão{copiedIds.length > 1 ? ` (${copiedIds.length})` : ''}
123
+ </ContextMenuItem>
124
+ </>
125
+ )}
126
+ </>
127
+ );
128
+ }
129
+
130
+ function SessionMenu({ data }: { data: Session }) {
131
+ const sessions = useStructureStore((s) => s.sessions);
132
+ const selectedIds = useStructureStore((s) => s.selectedIds);
133
+ const copiedType = useStructureStore((s) => s.copiedType);
134
+ const copiedIds = useStructureStore((s) => s.copiedIds);
135
+ const copyItems = useStructureStore((s) => s.copyItems);
136
+ const showConfirm = useStructureStore((s) => s.showConfirm);
137
+ const startRename = useStructureStore((s) => s.startRename);
138
+
139
+ const createLessonMutation = useCreateLessonMutation();
140
+ const deleteSessionMutation = useDeleteSessionMutation();
141
+ const duplicateSessionMutation = useDuplicateSessionMutation();
142
+ const pasteLessonsMutation = usePasteLessonsMutation();
143
+ const reorderSessionsMutation = useReorderSessionsMutation();
144
+ const reorderSessionsInStore = useStructureStore((s) => s.reorderSessions);
145
+
146
+ const sorted = [...sessions].sort((a, b) => a.order - b.order);
147
+ const idx = sorted.findIndex((s) => s.id === data.id);
148
+ const isFirst = idx === 0;
149
+ const isLast = idx === sorted.length - 1;
150
+
151
+ // Count selected sessions
152
+ const selectedSessionIds = sessions
153
+ .filter((s) => selectedIds.has(`session:${s.id}`))
154
+ .map((s) => s.id);
155
+ const multiSessions = selectedSessionIds.length > 1;
156
+
157
+ const hasLessonClipboard = copiedType === 'lesson' && copiedIds.length > 0;
158
+
159
+ return (
160
+ <>
161
+ <ContextMenuLabel className="text-[0.65rem] text-muted-foreground px-2 py-1">
162
+ {data.title}
163
+ </ContextMenuLabel>
164
+ <ContextMenuSeparator />
165
+
166
+ <ContextMenuItem
167
+ disabled={createLessonMutation.isPending}
168
+ onSelect={() => {
169
+ createLessonMutation.mutate({ sessionId: data.id });
170
+ }}
171
+ >
172
+ {createLessonMutation.isPending ? (
173
+ <Loader2 className="size-3.5 mr-2 animate-spin" />
174
+ ) : (
175
+ <Plus className="size-3.5 mr-2 text-muted-foreground" />
176
+ )}
177
+ Nova aula nesta sessão
178
+ </ContextMenuItem>
179
+
180
+ <ContextMenuSeparator />
181
+
182
+ <ContextMenuItem onSelect={() => startRename(data.id)}>
183
+ <Pencil className="size-3.5 mr-2 text-muted-foreground" />
184
+ Renomear
185
+ </ContextMenuItem>
186
+
187
+ <ContextMenuItem
188
+ disabled={duplicateSessionMutation.isPending}
189
+ onSelect={() => {
190
+ duplicateSessionMutation.mutate({ sessionId: data.id });
191
+ }}
192
+ >
193
+ {duplicateSessionMutation.isPending ? (
194
+ <Loader2 className="size-3.5 mr-2 animate-spin" />
195
+ ) : (
196
+ <Copy className="size-3.5 mr-2 text-muted-foreground" />
197
+ )}
198
+ Duplicar sessão
199
+ </ContextMenuItem>
200
+
201
+ <ContextMenuItem
202
+ onSelect={() => {
203
+ copyItems([data.id], 'session');
204
+ toast.success('Sessão copiada');
205
+ }}
206
+ >
207
+ <Copy className="size-3.5 mr-2 text-muted-foreground" />
208
+ Copiar sessão
209
+ </ContextMenuItem>
210
+
211
+ {multiSessions && (
212
+ <ContextMenuItem
213
+ onSelect={() => {
214
+ copyItems(selectedSessionIds, 'session');
215
+ toast.success(`${selectedSessionIds.length} sessões copiadas`);
216
+ }}
217
+ >
218
+ <Copy className="size-3.5 mr-2 text-muted-foreground" />
219
+ Copiar selecionadas ({selectedSessionIds.length})
220
+ </ContextMenuItem>
221
+ )}
222
+
223
+ {hasLessonClipboard && (
224
+ <ContextMenuItem
225
+ disabled={pasteLessonsMutation.isPending}
226
+ onSelect={() => {
227
+ pasteLessonsMutation.mutate({
228
+ targetSessionId: data.id,
229
+ lessonIds: copiedIds,
230
+ });
231
+ }}
232
+ >
233
+ {pasteLessonsMutation.isPending ? (
234
+ <Loader2 className="size-3.5 mr-2 animate-spin" />
235
+ ) : (
236
+ <Clipboard className="size-3.5 mr-2 text-muted-foreground" />
237
+ )}
238
+ Colar aulas nesta sessão
239
+ {copiedIds.length > 1 ? ` (${copiedIds.length})` : ''}
240
+ </ContextMenuItem>
241
+ )}
242
+
243
+ <ContextMenuSeparator />
244
+
245
+ <ContextMenuItem
246
+ disabled={isFirst || reorderSessionsMutation.isPending}
247
+ onSelect={() => {
248
+ const previousSessions = [...sorted];
249
+ reorderSessionsInStore(idx, idx - 1);
250
+ const newSorted = [...sorted];
251
+ const [moved] = newSorted.splice(idx, 1);
252
+ newSorted.splice(idx - 1, 0, moved!);
253
+ reorderSessionsMutation.mutate({
254
+ orderedIds: newSorted.map((s) => s.id),
255
+ previousSessions,
256
+ });
257
+ }}
258
+ >
259
+ <ArrowUp className="size-3.5 mr-2 text-muted-foreground" />
260
+ Mover para cima
261
+ </ContextMenuItem>
262
+
263
+ <ContextMenuItem
264
+ disabled={isLast || reorderSessionsMutation.isPending}
265
+ onSelect={() => {
266
+ const previousSessions = [...sorted];
267
+ reorderSessionsInStore(idx, idx + 1);
268
+ const newSorted = [...sorted];
269
+ const [moved] = newSorted.splice(idx, 1);
270
+ newSorted.splice(idx + 1, 0, moved!);
271
+ reorderSessionsMutation.mutate({
272
+ orderedIds: newSorted.map((s) => s.id),
273
+ previousSessions,
274
+ });
275
+ }}
276
+ >
277
+ <ArrowDown className="size-3.5 mr-2 text-muted-foreground" />
278
+ Mover para baixo
279
+ </ContextMenuItem>
280
+
281
+ <ContextMenuSeparator />
282
+
283
+ <ContextMenuItem
284
+ className="text-destructive focus:text-destructive"
285
+ onSelect={() => {
286
+ showConfirm({
287
+ title: `Excluir "${data.title}"?`,
288
+ description:
289
+ 'A sessão e todas as suas aulas serão excluídas permanentemente.',
290
+ onConfirm: () => {
291
+ deleteSessionMutation.mutate({ sessionId: data.id });
292
+ },
293
+ });
294
+ }}
295
+ >
296
+ <Trash2 className="size-3.5 mr-2" />
297
+ Excluir sessão
298
+ </ContextMenuItem>
299
+ </>
300
+ );
301
+ }
302
+
303
+ function LessonMenu({ data }: { data: Lesson }) {
304
+ const lessons = useStructureStore((s) => s.lessons);
305
+ const sessions = useStructureStore((s) => s.sessions);
306
+ const selectedIds = useStructureStore((s) => s.selectedIds);
307
+ const copiedType = useStructureStore((s) => s.copiedType);
308
+ const copiedIds = useStructureStore((s) => s.copiedIds);
309
+ const copyItems = useStructureStore((s) => s.copyItems);
310
+ const showSessionPicker = useStructureStore((s) => s.showSessionPicker);
311
+ const showConfirm = useStructureStore((s) => s.showConfirm);
312
+ const startRename = useStructureStore((s) => s.startRename);
313
+
314
+ const deleteLessonMutation = useDeleteLessonMutation();
315
+ const bulkDeleteMutation = useBulkDeleteMutation();
316
+ const duplicateLessonMutation = useDuplicateLessonMutation();
317
+ const pasteLessonsMutation = usePasteLessonsMutation();
318
+ const moveLessonsMutation = useMoveLessonsMutation();
319
+
320
+ // Count selected lessons
321
+ const selectedLessonIds = lessons
322
+ .filter((l) => selectedIds.has(l.id))
323
+ .map((l) => l.id);
324
+ const multiLessons = selectedLessonIds.length > 1;
325
+
326
+ const hasLessonClipboard = copiedType === 'lesson' && copiedIds.length > 0;
327
+ const hasOtherSessions = sessions.some((ss) => ss.id !== data.sessionId);
328
+
329
+ // Ids to move when "Mover" is triggered
330
+ const idsToMove = multiLessons ? selectedLessonIds : [data.id];
331
+
332
+ return (
333
+ <>
334
+ <ContextMenuLabel className="text-[0.65rem] text-muted-foreground px-2 py-1">
335
+ {data.title}
336
+ </ContextMenuLabel>
337
+ <ContextMenuSeparator />
338
+
339
+ <ContextMenuItem onSelect={() => startRename(data.id)}>
340
+ <Pencil className="size-3.5 mr-2 text-muted-foreground" />
341
+ Renomear
342
+ </ContextMenuItem>
343
+
344
+ <ContextMenuSeparator />
345
+
346
+ <ContextMenuItem
347
+ disabled={duplicateLessonMutation.isPending}
348
+ onSelect={() => {
349
+ duplicateLessonMutation.mutate({
350
+ sessionId: data.sessionId,
351
+ lessonId: data.id,
352
+ });
353
+ }}
354
+ >
355
+ {duplicateLessonMutation.isPending ? (
356
+ <Loader2 className="size-3.5 mr-2 animate-spin" />
357
+ ) : (
358
+ <Copy className="size-3.5 mr-2 text-muted-foreground" />
359
+ )}
360
+ Duplicar aula
361
+ </ContextMenuItem>
362
+
363
+ <ContextMenuItem
364
+ onSelect={() => {
365
+ copyItems([data.id], 'lesson');
366
+ toast.success('Aula copiada');
367
+ }}
368
+ >
369
+ <Copy className="size-3.5 mr-2 text-muted-foreground" />
370
+ Copiar aula
371
+ </ContextMenuItem>
372
+
373
+ {multiLessons && (
374
+ <ContextMenuItem
375
+ onSelect={() => {
376
+ copyItems(selectedLessonIds, 'lesson');
377
+ toast.success(`${selectedLessonIds.length} aulas copiadas`);
378
+ }}
379
+ >
380
+ <Copy className="size-3.5 mr-2 text-muted-foreground" />
381
+ Copiar selecionadas ({selectedLessonIds.length})
382
+ </ContextMenuItem>
383
+ )}
384
+
385
+ {hasLessonClipboard && (
386
+ <ContextMenuItem
387
+ disabled={pasteLessonsMutation.isPending}
388
+ onSelect={() => {
389
+ pasteLessonsMutation.mutate({
390
+ targetSessionId: data.sessionId,
391
+ lessonIds: copiedIds,
392
+ });
393
+ }}
394
+ >
395
+ {pasteLessonsMutation.isPending ? (
396
+ <Loader2 className="size-3.5 mr-2 animate-spin" />
397
+ ) : (
398
+ <Clipboard className="size-3.5 mr-2 text-muted-foreground" />
399
+ )}
400
+ Colar nesta sessão
401
+ </ContextMenuItem>
402
+ )}
403
+
404
+ {hasOtherSessions && (
405
+ <>
406
+ <ContextMenuSeparator />
407
+ <ContextMenuItem
408
+ disabled={moveLessonsMutation.isPending}
409
+ onSelect={() => {
410
+ showSessionPicker({
411
+ title:
412
+ idsToMove.length > 1
413
+ ? `Mover ${idsToMove.length} aulas para`
414
+ : 'Mover aula para',
415
+ excludeSessionId: data.sessionId,
416
+ onPick: (targetId) => {
417
+ // Snapshot for rollback
418
+ const previousLessons = [...lessons];
419
+ // Compute moves with sequential toIndex
420
+ const movingLessons = idsToMove
421
+ .map((id) => lessons.find((l) => l.id === id))
422
+ .filter(Boolean) as Lesson[];
423
+ const moves = movingLessons.map((l, i) => ({
424
+ lessonId: l.id,
425
+ fromSessionId: l.sessionId,
426
+ toSessionId: targetId,
427
+ toIndex: i,
428
+ }));
429
+ // Optimistic: update store immediately
430
+ useStructureStore.getState().moveLessons(idsToMove, targetId);
431
+ moveLessonsMutation.mutate({ moves, previousLessons });
432
+ toast.success(
433
+ idsToMove.length > 1
434
+ ? `${idsToMove.length} aulas movidas`
435
+ : 'Aula movida'
436
+ );
437
+ },
438
+ });
439
+ }}
440
+ >
441
+ {moveLessonsMutation.isPending ? (
442
+ <Loader2 className="size-3.5 mr-2 animate-spin" />
443
+ ) : (
444
+ <FolderOpen className="size-3.5 mr-2 text-muted-foreground" />
445
+ )}
446
+ {multiLessons
447
+ ? `Mover ${idsToMove.length} aulas para...`
448
+ : 'Mover para outra sessão...'}
449
+ </ContextMenuItem>
450
+ </>
451
+ )}
452
+
453
+ <ContextMenuSeparator />
454
+
455
+ <ContextMenuItem
456
+ className="text-destructive focus:text-destructive"
457
+ onSelect={() => {
458
+ if (multiLessons) {
459
+ showConfirm({
460
+ title: `Excluir ${selectedLessonIds.length} aulas?`,
461
+ description: 'Esta ação não pode ser desfeita.',
462
+ onConfirm: () => {
463
+ const lessonPairs = selectedLessonIds
464
+ .map((id) => lessons.find((l) => l.id === id))
465
+ .filter(Boolean)
466
+ .map((l) => ({ lessonId: l!.id, sessionId: l!.sessionId }));
467
+ bulkDeleteMutation.mutate({
468
+ sessionIds: [],
469
+ lessons: lessonPairs,
470
+ });
471
+ },
472
+ });
473
+ } else {
474
+ showConfirm({
475
+ title: `Excluir "${data.title}"?`,
476
+ description: 'Esta ação não pode ser desfeita.',
477
+ onConfirm: () => {
478
+ deleteLessonMutation.mutate({
479
+ lessonId: data.id,
480
+ sessionId: data.sessionId,
481
+ });
482
+ },
483
+ });
484
+ }
485
+ }}
486
+ >
487
+ <Trash2 className="size-3.5 mr-2" />
488
+ {multiLessons
489
+ ? `Excluir ${selectedLessonIds.length} aulas`
490
+ : 'Excluir aula'}
491
+ </ContextMenuItem>
492
+ </>
493
+ );
494
+ }
495
+
496
+ // ─────────────────────────────────────────────────────────────────────────────
497
+ // Root export
498
+ // ─────────────────────────────────────────────────────────────────────────────
499
+
500
+ export function TreeContextMenu({ item, children }: TreeContextMenuProps) {
501
+ const selectedIds = useStructureStore((s) => s.selectedIds);
502
+ const selectItem = useStructureStore((s) => s.selectItem);
503
+
504
+ function handleOpenChange(open: boolean) {
505
+ if (!open) return;
506
+ // On right-click: if item is not in the current selection, select it alone
507
+ if (!selectedIds.has(item.id)) {
508
+ selectItem(item.id, item.type);
509
+ }
510
+ }
511
+
512
+ return (
513
+ <ContextMenu onOpenChange={handleOpenChange}>
514
+ <ContextMenuTrigger className="block w-full h-full">
515
+ {children}
516
+ </ContextMenuTrigger>
517
+
518
+ <ContextMenuContent className="w-56">
519
+ {item.type === 'course' && <CourseMenu data={item.data as Course} />}
520
+ {item.type === 'session' && <SessionMenu data={item.data as Session} />}
521
+ {item.type === 'lesson' && <LessonMenu data={item.data as Lesson} />}
522
+ </ContextMenuContent>
523
+ </ContextMenu>
524
+ );
525
+ }