@hed-hog/lms 0.0.306 → 0.0.309

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 (117) 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/classes/[id]/page.tsx.ejs +437 -77
  47. package/hedhog/frontend/app/classes/page.tsx.ejs +311 -289
  48. package/hedhog/frontend/app/courses/[id]/_components/CourseCertificateCard.tsx.ejs +10 -7
  49. package/hedhog/frontend/app/courses/[id]/_components/CourseClassificationCard.tsx.ejs +23 -32
  50. package/hedhog/frontend/app/courses/[id]/_components/CourseContentCard.tsx.ejs +3 -9
  51. package/hedhog/frontend/app/courses/[id]/_components/CourseDangerZoneCard.tsx.ejs +26 -16
  52. package/hedhog/frontend/app/courses/[id]/_components/CourseFlagsCard.tsx.ejs +19 -5
  53. package/hedhog/frontend/app/courses/[id]/_components/CourseMainInfoCard.tsx.ejs +10 -14
  54. package/hedhog/frontend/app/courses/[id]/_components/CourseMediaCard.tsx.ejs +131 -107
  55. package/hedhog/frontend/app/courses/[id]/_components/CourseRelationsCard.tsx.ejs +10 -7
  56. package/hedhog/frontend/app/courses/[id]/_components/CourseSectionCard.tsx.ejs +38 -19
  57. package/hedhog/frontend/app/courses/[id]/_components/CourseSummaryCard.tsx.ejs +1 -1
  58. package/hedhog/frontend/app/courses/[id]/_components/course-edit-types.ts.ejs +1 -1
  59. package/hedhog/frontend/app/courses/[id]/page.tsx.ejs +336 -1057
  60. package/hedhog/frontend/app/courses/[id]/structure/_components/confirm-dialog.tsx.ejs +45 -0
  61. package/hedhog/frontend/app/courses/[id]/structure/_components/course-tree-dnd.tsx.ejs +362 -0
  62. package/hedhog/frontend/app/courses/[id]/structure/_components/course-tree-panel.tsx.ejs +111 -0
  63. package/hedhog/frontend/app/courses/[id]/structure/_components/course-tree-skeleton.tsx.ejs +64 -0
  64. package/hedhog/frontend/app/courses/[id]/structure/_components/course-tree.tsx.ejs +134 -0
  65. package/hedhog/frontend/app/courses/[id]/structure/_components/detail-course.tsx.ejs +113 -0
  66. package/hedhog/frontend/app/courses/[id]/structure/_components/detail-lesson.tsx.ejs +314 -0
  67. package/hedhog/frontend/app/courses/[id]/structure/_components/detail-panel.tsx.ejs +62 -0
  68. package/hedhog/frontend/app/courses/[id]/structure/_components/detail-session.tsx.ejs +174 -0
  69. package/hedhog/frontend/app/courses/[id]/structure/_components/drag-handle.tsx.ejs +58 -0
  70. package/hedhog/frontend/app/courses/[id]/structure/_components/drag-overlay.tsx.ejs +52 -0
  71. package/hedhog/frontend/app/courses/[id]/structure/_components/editor-bulk.tsx.ejs +276 -0
  72. package/hedhog/frontend/app/courses/[id]/structure/_components/editor-course.tsx.ejs +1216 -0
  73. package/hedhog/frontend/app/courses/[id]/structure/_components/editor-lesson.tsx.ejs +1827 -0
  74. package/hedhog/frontend/app/courses/[id]/structure/_components/editor-session.tsx.ejs +443 -0
  75. package/hedhog/frontend/app/courses/[id]/structure/_components/highlighted-text.tsx.ejs +41 -0
  76. package/hedhog/frontend/app/courses/[id]/structure/_components/mock-data.ts.ejs +184 -0
  77. package/hedhog/frontend/app/courses/[id]/structure/_components/multi-select-bar.tsx.ejs +264 -0
  78. package/hedhog/frontend/app/courses/[id]/structure/_components/search-filter.tsx.ejs +96 -0
  79. package/hedhog/frontend/app/courses/[id]/structure/_components/session-picker-dialog.tsx.ejs +74 -0
  80. package/hedhog/frontend/app/courses/[id]/structure/_components/shortcuts-help.tsx.ejs +136 -0
  81. package/hedhog/frontend/app/courses/[id]/structure/_components/sortable-tree-row.tsx.ejs +80 -0
  82. package/hedhog/frontend/app/courses/[id]/structure/_components/store.ts.ejs +948 -0
  83. package/hedhog/frontend/app/courses/[id]/structure/_components/tree-context-menu.tsx.ejs +525 -0
  84. package/hedhog/frontend/app/courses/[id]/structure/_components/tree-display-settings-popover.tsx.ejs +150 -0
  85. package/hedhog/frontend/app/courses/[id]/structure/_components/tree-helpers.ts.ejs +182 -0
  86. package/hedhog/frontend/app/courses/[id]/structure/_components/tree-row-course.tsx.ejs +52 -0
  87. package/hedhog/frontend/app/courses/[id]/structure/_components/tree-row-lesson.tsx.ejs +271 -0
  88. package/hedhog/frontend/app/courses/[id]/structure/_components/tree-row-session.tsx.ejs +167 -0
  89. package/hedhog/frontend/app/courses/[id]/structure/_components/tree-row.tsx.ejs +108 -0
  90. package/hedhog/frontend/app/courses/[id]/structure/_components/types.ts.ejs +122 -0
  91. package/hedhog/frontend/app/courses/[id]/structure/_components/use-course-structure-shortcuts.ts.ejs +318 -0
  92. package/hedhog/frontend/app/courses/[id]/structure/_components/use-tree-display-settings.ts.ejs +97 -0
  93. package/hedhog/frontend/app/courses/[id]/structure/_data/adapters/course-structure.adapter.ts.ejs +347 -0
  94. package/hedhog/frontend/app/courses/[id]/structure/_data/course-structure-contract.ts.ejs +195 -0
  95. package/hedhog/frontend/app/courses/[id]/structure/_data/services/course-structure.service.ts.ejs +420 -0
  96. package/hedhog/frontend/app/courses/[id]/structure/_data/types/api-course.types.ts.ejs +254 -0
  97. package/hedhog/frontend/app/courses/[id]/structure/_data/use-course-structure-mutations.ts.ejs +987 -0
  98. package/hedhog/frontend/app/courses/[id]/structure/_data/use-course-structure-query.ts.ejs +86 -0
  99. package/hedhog/frontend/app/courses/[id]/structure/_data/use-course-structure.ts.ejs +160 -0
  100. package/hedhog/frontend/app/courses/[id]/structure/page.tsx.ejs +10 -3212
  101. package/hedhog/frontend/app/courses/page.tsx.ejs +45 -26
  102. package/hedhog/frontend/app/{training → paths}/page.tsx.ejs +29 -7
  103. package/hedhog/frontend/messages/en.json +88 -10
  104. package/hedhog/frontend/messages/pt.json +88 -10
  105. package/hedhog/table/course.yaml +1 -1
  106. package/hedhog/table/image_type.yaml +14 -0
  107. package/package.json +7 -7
  108. package/src/course/course-structure.controller.ts +63 -0
  109. package/src/course/course-structure.service.ts +390 -3
  110. package/src/course/course.service.ts +59 -27
  111. package/src/course/dto/create-course-structure-lesson.dto.ts +3 -2
  112. package/src/course/dto/create-course.dto.ts +4 -1
  113. package/src/course/dto/move-lesson.dto.ts +17 -0
  114. package/src/course/dto/paste-lessons.dto.ts +9 -0
  115. package/src/course/dto/reorder-lessons.dto.ts +10 -0
  116. package/src/course/dto/reorder-sessions.dto.ts +10 -0
  117. package/src/training/training.controller.ts +1 -1
@@ -0,0 +1,45 @@
1
+ 'use client';
2
+
3
+ import {
4
+ AlertDialog,
5
+ AlertDialogAction,
6
+ AlertDialogCancel,
7
+ AlertDialogContent,
8
+ AlertDialogDescription,
9
+ AlertDialogFooter,
10
+ AlertDialogHeader,
11
+ AlertDialogTitle,
12
+ } from '@/components/ui/alert-dialog';
13
+ import { useStructureStore } from './store';
14
+
15
+ export function ConfirmDialog() {
16
+ const confirmDialog = useStructureStore((s) => s.confirmDialog);
17
+ const closeConfirm = useStructureStore((s) => s.closeConfirm);
18
+
19
+ function handleConfirm() {
20
+ confirmDialog.onConfirm?.();
21
+ closeConfirm();
22
+ }
23
+
24
+ return (
25
+ <AlertDialog open={confirmDialog.open} onOpenChange={(open) => !open && closeConfirm()}>
26
+ <AlertDialogContent>
27
+ <AlertDialogHeader>
28
+ <AlertDialogTitle>{confirmDialog.title}</AlertDialogTitle>
29
+ {confirmDialog.description && (
30
+ <AlertDialogDescription>{confirmDialog.description}</AlertDialogDescription>
31
+ )}
32
+ </AlertDialogHeader>
33
+ <AlertDialogFooter>
34
+ <AlertDialogCancel onClick={closeConfirm}>Cancelar</AlertDialogCancel>
35
+ <AlertDialogAction
36
+ onClick={handleConfirm}
37
+ className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
38
+ >
39
+ Excluir
40
+ </AlertDialogAction>
41
+ </AlertDialogFooter>
42
+ </AlertDialogContent>
43
+ </AlertDialog>
44
+ );
45
+ }
@@ -0,0 +1,362 @@
1
+ 'use client';
2
+
3
+ import {
4
+ closestCenter,
5
+ DndContext,
6
+ KeyboardSensor,
7
+ PointerSensor,
8
+ useSensor,
9
+ useSensors,
10
+ type DragEndEvent,
11
+ type DragStartEvent,
12
+ } from '@dnd-kit/core';
13
+ import {
14
+ arrayMove,
15
+ SortableContext,
16
+ sortableKeyboardCoordinates,
17
+ verticalListSortingStrategy,
18
+ } from '@dnd-kit/sortable';
19
+ import { useCallback, useMemo, useState } from 'react';
20
+ import { toast } from 'sonner';
21
+
22
+ import { Button } from '@/components/ui/button';
23
+ import { Layers, Loader2, Plus, SearchX } from 'lucide-react';
24
+ import {
25
+ useCreateSessionMutation,
26
+ useMoveLessonMutation,
27
+ useReorderLessonsMutation,
28
+ useReorderSessionsMutation,
29
+ } from '../_data/use-course-structure-mutations';
30
+ import { TreeDragOverlay } from './drag-overlay';
31
+ import { SortableTreeRow } from './sortable-tree-row';
32
+ import { useStructureStore } from './store';
33
+ import { buildLessonCountMap, buildVisibleItems } from './tree-helpers';
34
+ import type { FlatItem } from './types';
35
+
36
+ const ROW_HEIGHT: Record<FlatItem['type'], number> = {
37
+ course: 34,
38
+ session: 32,
39
+ lesson: 28,
40
+ };
41
+
42
+ /**
43
+ * DnD-aware tree that wraps the virtualised list with dnd-kit context.
44
+ * NOTE: Because dnd-kit and react-virtual don't share a single scroll ref,
45
+ * we use a standard overflow list here (the virtual approach is kept intact
46
+ * in the non-DnD path inside CourseTree).
47
+ * For typical course sizes (< 500 items) this renders fine.
48
+ */
49
+ export function CourseTreeDnd() {
50
+ const course = useStructureStore((s) => s.course);
51
+ const sessions = useStructureStore((s) => s.sessions);
52
+ const lessons = useStructureStore((s) => s.lessons);
53
+ const expandedIds = useStructureStore((s) => s.expandedIds);
54
+ const filterQuery = useStructureStore((s) => s.filterQuery);
55
+ const activeItemId = useStructureStore((s) => s.activeItemId);
56
+ const activeItemType = useStructureStore((s) => s.activeItemType);
57
+ const selectedIds = useStructureStore((s) => s.selectedIds);
58
+
59
+ const reorderSessions = useStructureStore((s) => s.reorderSessions);
60
+ const moveLesson = useStructureStore((s) => s.moveLesson);
61
+
62
+ const reorderSessionsMutation = useReorderSessionsMutation();
63
+ const reorderLessonsMutation = useReorderLessonsMutation();
64
+ const moveLessonMutation = useMoveLessonMutation();
65
+ const createSession = useCreateSessionMutation();
66
+
67
+ const [draggingItem, setDraggingItem] = useState<FlatItem | null>(null);
68
+
69
+ const { items, matchedIds, expandedBySearch, resultCount } = useMemo(
70
+ () =>
71
+ buildVisibleItems(course, sessions, lessons, expandedIds, filterQuery),
72
+ [course, sessions, lessons, expandedIds, filterQuery]
73
+ );
74
+
75
+ const lessonCountMap = useMemo(() => buildLessonCountMap(lessons), [lessons]);
76
+
77
+ const sortedSessions = useMemo(
78
+ () => [...sessions].sort((a, b) => a.order - b.order),
79
+ [sessions]
80
+ );
81
+
82
+ const isSearchActive = filterQuery.trim().length > 0;
83
+ const dragDisabled = isSearchActive;
84
+
85
+ // ── Sensors ──────────────────────────────────────────────────────────────
86
+ const sensors = useSensors(
87
+ useSensor(PointerSensor, {
88
+ activationConstraint: { distance: 6 },
89
+ }),
90
+ useSensor(KeyboardSensor, {
91
+ coordinateGetter: sortableKeyboardCoordinates,
92
+ })
93
+ );
94
+
95
+ // ── Drag handlers ────────────────────────────────────────────────────────
96
+ const handleDragStart = useCallback(
97
+ (event: DragStartEvent) => {
98
+ const item = items.find(
99
+ (i) => `${i.type}:${i.id}` === String(event.active.id)
100
+ );
101
+ setDraggingItem(item ?? null);
102
+ },
103
+ [items]
104
+ );
105
+
106
+ const handleDragEnd = useCallback(
107
+ (event: DragEndEvent) => {
108
+ setDraggingItem(null);
109
+ const { active, over } = event;
110
+ if (!over || active.id === over.id) return;
111
+
112
+ const activeItem = items.find(
113
+ (i) => `${i.type}:${i.id}` === String(active.id)
114
+ );
115
+ const overItem = items.find(
116
+ (i) => `${i.type}:${i.id}` === String(over.id)
117
+ );
118
+ if (!activeItem || !overItem) return;
119
+
120
+ // ── Session reorder ───────────────────────────────────────────────────
121
+ if (activeItem.type === 'session' && overItem.type === 'session') {
122
+ const fromIdx = sortedSessions.findIndex(
123
+ (s) => s.id === activeItem.data.id
124
+ );
125
+ const toIdx = sortedSessions.findIndex(
126
+ (s) => s.id === overItem.data.id
127
+ );
128
+ if (fromIdx === -1 || toIdx === -1) return;
129
+
130
+ // Snapshot before store mutation (optimistic)
131
+ const previousSessions = [...sessions];
132
+
133
+ // Compute new order locally to pass as API payload
134
+ const reorderedIds = arrayMove(sortedSessions, fromIdx, toIdx).map(
135
+ (s) => String(s.id)
136
+ );
137
+
138
+ // Optimistic store update
139
+ reorderSessions(fromIdx, toIdx);
140
+
141
+ // Persist in background; onError rolls back
142
+ reorderSessionsMutation.mutate({
143
+ orderedIds: reorderedIds,
144
+ previousSessions,
145
+ });
146
+ toast.success(
147
+ `Sessão "${activeItem.data.title}" movida para posição ${toIdx + 1}`
148
+ );
149
+ return;
150
+ }
151
+
152
+ // ── Lesson reorder / move ────────────────────────────────────────────
153
+ if (activeItem.type === 'lesson') {
154
+ const lessonData = activeItem.data;
155
+
156
+ // Determine target session and index
157
+ let toSessionId: string;
158
+ let toIndex: number;
159
+
160
+ if (overItem.type === 'lesson') {
161
+ toSessionId = overItem.data.sessionId;
162
+ // Position relative to lessons in the target session (visible order)
163
+ const destLessons = items.filter(
164
+ (i) =>
165
+ i.type === 'lesson' &&
166
+ i.data.sessionId === toSessionId &&
167
+ i.id !== active.id
168
+ );
169
+ toIndex = destLessons.findIndex((i) => i.id === over.id);
170
+ if (toIndex === -1) toIndex = destLessons.length;
171
+ } else if (overItem.type === 'session') {
172
+ // Dropped directly onto a session header → append to that session
173
+ toSessionId = overItem.data.id as string;
174
+ const destLessons = lessons.filter(
175
+ (l) => l.sessionId === toSessionId && l.id !== active.id
176
+ );
177
+ toIndex = destLessons.length;
178
+ } else {
179
+ return;
180
+ }
181
+
182
+ const didChangeSession = lessonData.sessionId !== toSessionId;
183
+
184
+ // Snapshot before store mutation (optimistic)
185
+ const previousLessons = [...lessons];
186
+
187
+ // Optimistic store update
188
+ moveLesson(String(activeItem.data.id), toSessionId, toIndex);
189
+
190
+ if (didChangeSession) {
191
+ // Cross-session move
192
+ const targetSession = sessions.find((s) => s.id === toSessionId);
193
+ moveLessonMutation.mutate({
194
+ lessonId: String(activeItem.data.id),
195
+ fromSessionId: lessonData.sessionId,
196
+ toSessionId,
197
+ toIndex,
198
+ previousLessons,
199
+ });
200
+ toast.success(
201
+ `Aula "${lessonData.title}" movida para "${targetSession?.title ?? toSessionId}"`
202
+ );
203
+ } else {
204
+ // Reorder within same session — compute new ordered IDs
205
+ const sessionLessons = lessons
206
+ .filter((l) => l.sessionId === toSessionId)
207
+ .sort((a, b) => a.order - b.order);
208
+ const fromLessonIdx = sessionLessons.findIndex(
209
+ (l) => l.id === String(activeItem.data.id)
210
+ );
211
+ const orderedIds = arrayMove(
212
+ sessionLessons,
213
+ fromLessonIdx,
214
+ toIndex
215
+ ).map((l) => l.id);
216
+
217
+ reorderLessonsMutation.mutate({
218
+ sessionId: toSessionId,
219
+ orderedIds,
220
+ previousLessons,
221
+ });
222
+ toast(`Aula "${lessonData.title}" reordenada`);
223
+ }
224
+ }
225
+ },
226
+ [
227
+ items,
228
+ sortedSessions,
229
+ lessons,
230
+ sessions,
231
+ reorderSessions,
232
+ moveLesson,
233
+ reorderSessionsMutation,
234
+ reorderLessonsMutation,
235
+ moveLessonMutation,
236
+ ]
237
+ );
238
+
239
+ const isEmpty = items.length === 0;
240
+
241
+ const isSavingOrder =
242
+ reorderSessionsMutation.isPending ||
243
+ reorderLessonsMutation.isPending ||
244
+ moveLessonMutation.isPending;
245
+
246
+ return (
247
+ <DndContext
248
+ sensors={sensors}
249
+ collisionDetection={closestCenter}
250
+ onDragStart={handleDragStart}
251
+ onDragEnd={handleDragEnd}
252
+ >
253
+ <SortableContext
254
+ items={items.map((i) => `${i.type}:${i.id}`)}
255
+ strategy={verticalListSortingStrategy}
256
+ >
257
+ <div className="flex flex-col h-full min-h-0">
258
+ {/* Saving-order progress indicator */}
259
+ {isSavingOrder && (
260
+ <div className="flex items-center gap-1.5 px-3 py-1 shrink-0 border-b bg-primary/5">
261
+ <Loader2 className="size-3 animate-spin text-primary/60 shrink-0" />
262
+ <span className="text-[0.65rem] text-primary/70">
263
+ Salvando ordem…
264
+ </span>
265
+ </div>
266
+ )}
267
+
268
+ {/* Search result count */}
269
+ {isSearchActive && !isEmpty && (
270
+ <div className="px-3 py-1 shrink-0 border-b">
271
+ <span className="text-[0.65rem] text-muted-foreground">
272
+ {resultCount === 1
273
+ ? '1 resultado'
274
+ : `${resultCount} resultados`}
275
+ </span>
276
+ </div>
277
+ )}
278
+
279
+ {/* Search disabled-drag notice */}
280
+ {isSearchActive && (
281
+ <div className="px-3 py-1 shrink-0 border-b bg-amber-50 dark:bg-amber-950/30">
282
+ <span className="text-[0.65rem] text-amber-700 dark:text-amber-400">
283
+ Limpe a busca para reordenar itens
284
+ </span>
285
+ </div>
286
+ )}
287
+
288
+ {/* Empty state */}
289
+ {isEmpty && !isSearchActive && (
290
+ <div className="flex flex-col items-center justify-center gap-3 py-12 px-4 text-center">
291
+ <div className="flex size-12 items-center justify-center rounded-xl bg-muted/50">
292
+ <Layers className="size-5 text-muted-foreground/40" />
293
+ </div>
294
+ <div className="space-y-1">
295
+ <p className="text-sm font-medium text-foreground/70">
296
+ Nenhuma sessão
297
+ </p>
298
+ <p className="text-xs text-muted-foreground leading-relaxed max-w-[180px]">
299
+ Adicione a primeira sessão para começar a organizar o conteúdo
300
+ do curso.
301
+ </p>
302
+ </div>
303
+ <Button
304
+ size="sm"
305
+ variant="outline"
306
+ className="h-7 text-xs gap-1.5 mt-1"
307
+ disabled={createSession.isPending}
308
+ onClick={() => createSession.mutate()}
309
+ >
310
+ {createSession.isPending ? (
311
+ <Loader2 className="size-3 animate-spin" />
312
+ ) : (
313
+ <Plus className="size-3" />
314
+ )}
315
+ Nova sessão
316
+ </Button>
317
+ </div>
318
+ )}
319
+
320
+ {isEmpty && isSearchActive && (
321
+ <div className="flex flex-col items-center justify-center gap-2 py-12 px-4 text-center">
322
+ <SearchX className="size-8 text-muted-foreground/40" />
323
+ <p className="text-sm text-muted-foreground">
324
+ Nenhum resultado para &ldquo;{filterQuery.trim()}&rdquo;
325
+ </p>
326
+ </div>
327
+ )}
328
+
329
+ {/* Scrollable flat list */}
330
+ {!isEmpty && (
331
+ <div className="overflow-y-auto flex-1 min-h-0 px-1 py-1 space-y-px">
332
+ {items.map((item) => (
333
+ <div
334
+ key={`${item.type}:${item.id}`}
335
+ style={{ height: `${ROW_HEIGHT[item.type]}px` }}
336
+ >
337
+ <SortableTreeRow
338
+ item={item}
339
+ isActive={
340
+ activeItemId === item.id && activeItemType === item.type
341
+ }
342
+ isSelected={selectedIds.has(`${item.type}:${item.id}`)}
343
+ query={filterQuery}
344
+ isMatched={matchedIds.has(item.id)}
345
+ isEffectivelyExpanded={
346
+ expandedIds.has(item.id) || expandedBySearch.has(item.id)
347
+ }
348
+ lessonCountMap={lessonCountMap}
349
+ visibleItems={items}
350
+ dragDisabled={dragDisabled}
351
+ />
352
+ </div>
353
+ ))}
354
+ </div>
355
+ )}
356
+ </div>
357
+ </SortableContext>
358
+
359
+ <TreeDragOverlay activeItem={draggingItem} />
360
+ </DndContext>
361
+ );
362
+ }
@@ -0,0 +1,111 @@
1
+ 'use client';
2
+
3
+ import { Button } from '@/components/ui/button';
4
+ import { cn } from '@/lib/utils';
5
+ import { ChevronsDownUp, ChevronsUpDown, Loader2, Plus } from 'lucide-react';
6
+ import { forwardRef, useMemo } from 'react';
7
+ import { useCreateSessionMutation } from '../_data/use-course-structure-mutations';
8
+ import { CourseTreeDnd } from './course-tree-dnd';
9
+ import { MultiSelectBar } from './multi-select-bar';
10
+ import { SearchFilter, type SearchFilterHandle } from './search-filter';
11
+ import { useStructureStore } from './store';
12
+ import { buildVisibleItems } from './tree-helpers';
13
+
14
+ export const CourseTreePanel = forwardRef<SearchFilterHandle>((_, ref) => {
15
+ const course = useStructureStore((s) => s.course);
16
+ const sessions = useStructureStore((s) => s.sessions);
17
+ const lessons = useStructureStore((s) => s.lessons);
18
+ const expandedIds = useStructureStore((s) => s.expandedIds);
19
+ const filterQuery = useStructureStore((s) => s.filterQuery);
20
+ const expandAll = useStructureStore((s) => s.expandAll);
21
+ const collapseAll = useStructureStore((s) => s.collapseAll);
22
+
23
+ const createSession = useCreateSessionMutation();
24
+
25
+ const allExpanded =
26
+ sessions.length > 0 && sessions.every((s) => expandedIds.has(s.id));
27
+
28
+ const isFiltering = filterQuery.trim().length > 0;
29
+
30
+ // Compute result count only when filter is active (avoids re-work when idle)
31
+ const resultCount = useMemo(() => {
32
+ if (!isFiltering) return undefined;
33
+ const { resultCount: rc } = buildVisibleItems(
34
+ course,
35
+ sessions,
36
+ lessons,
37
+ expandedIds,
38
+ filterQuery
39
+ );
40
+ return rc;
41
+ }, [isFiltering, course, sessions, lessons, expandedIds, filterQuery]);
42
+
43
+ return (
44
+ <div className="flex flex-col h-full min-h-0">
45
+ {/* Toolbar */}
46
+ <div className="flex items-center gap-1 px-2 pt-2 pb-1.5 border-b shrink-0">
47
+ <div className="flex-1 min-w-0">
48
+ <SearchFilter ref={ref} resultCount={resultCount} />
49
+ </div>
50
+
51
+ <Button
52
+ variant="ghost"
53
+ size="icon"
54
+ className={cn(
55
+ 'size-8 shrink-0 transition-colors',
56
+ isFiltering && 'opacity-40 cursor-not-allowed pointer-events-none'
57
+ )}
58
+ title={
59
+ allExpanded
60
+ ? 'Recolher tudo (Ctrl+Shift+E)'
61
+ : 'Expandir tudo (Ctrl+Shift+E)'
62
+ }
63
+ aria-label={allExpanded ? 'Recolher tudo' : 'Expandir tudo'}
64
+ onClick={allExpanded ? collapseAll : expandAll}
65
+ disabled={isFiltering}
66
+ >
67
+ {allExpanded ? (
68
+ <ChevronsDownUp className="size-3.5" />
69
+ ) : (
70
+ <ChevronsUpDown className="size-3.5" />
71
+ )}
72
+ </Button>
73
+
74
+ <Button
75
+ variant="ghost"
76
+ size="icon"
77
+ className="size-8 shrink-0"
78
+ title="Nova sessão"
79
+ aria-label="Nova sessão"
80
+ disabled={createSession.isPending}
81
+ onClick={() => createSession.mutate()}
82
+ >
83
+ {createSession.isPending ? (
84
+ <Loader2 className="size-3.5 animate-spin" />
85
+ ) : (
86
+ <Plus className="size-3.5" />
87
+ )}
88
+ </Button>
89
+ </div>
90
+
91
+ {/* Search results hint */}
92
+ {isFiltering && resultCount !== undefined && (
93
+ <div className="px-3 py-1 text-[0.65rem] text-muted-foreground bg-muted/30 border-b shrink-0">
94
+ {resultCount === 0
95
+ ? 'Nenhum resultado encontrado'
96
+ : `${resultCount} resultado${resultCount === 1 ? '' : 's'} encontrado${resultCount === 1 ? '' : 's'}`}
97
+ </div>
98
+ )}
99
+
100
+ {/* Multi-select action bar (visible when 2+ items selected) */}
101
+ <MultiSelectBar />
102
+
103
+ {/* Virtualised / DnD tree */}
104
+ <div className="flex-1 min-h-0 overflow-hidden">
105
+ <CourseTreeDnd />
106
+ </div>
107
+ </div>
108
+ );
109
+ });
110
+
111
+ CourseTreePanel.displayName = 'CourseTreePanel';
@@ -0,0 +1,64 @@
1
+ import { Skeleton } from '@/components/ui/skeleton';
2
+
3
+ /**
4
+ * CourseTreeSkeleton
5
+ *
6
+ * Displayed in the tree panel while the course structure is loading from
7
+ * the API. Mimics the visual shape of session headers and lesson rows.
8
+ */
9
+ export function CourseTreeSkeleton() {
10
+ return (
11
+ <div
12
+ className="flex flex-col gap-1 p-3"
13
+ aria-busy="true"
14
+ aria-label="Carregando estrutura do curso"
15
+ >
16
+ {/* Course header row */}
17
+ <div className="flex items-center gap-2 px-2 py-1.5">
18
+ <Skeleton className="size-4 rounded" />
19
+ <Skeleton className="h-4 w-40" />
20
+ </div>
21
+
22
+ {/* Session 1 */}
23
+ <div className="mt-2 flex flex-col gap-1">
24
+ <div className="flex items-center gap-2 px-2 py-1.5">
25
+ <Skeleton className="size-3 rounded" />
26
+ <Skeleton className="h-3.5 w-32" />
27
+ <Skeleton className="ml-auto h-3 w-10" />
28
+ </div>
29
+ {[1, 2, 3].map((i) => (
30
+ <div key={i} className="flex items-center gap-2 pl-7 pr-2 py-1">
31
+ <Skeleton className="size-3 rounded" />
32
+ <Skeleton className="h-3 w-28" />
33
+ <Skeleton className="ml-auto h-3 w-8" />
34
+ </div>
35
+ ))}
36
+ </div>
37
+
38
+ {/* Session 2 */}
39
+ <div className="mt-2 flex flex-col gap-1">
40
+ <div className="flex items-center gap-2 px-2 py-1.5">
41
+ <Skeleton className="size-3 rounded" />
42
+ <Skeleton className="h-3.5 w-24" />
43
+ <Skeleton className="ml-auto h-3 w-10" />
44
+ </div>
45
+ {[1, 2].map((i) => (
46
+ <div key={i} className="flex items-center gap-2 pl-7 pr-2 py-1">
47
+ <Skeleton className="size-3 rounded" />
48
+ <Skeleton className="h-3 w-36" />
49
+ <Skeleton className="ml-auto h-3 w-8" />
50
+ </div>
51
+ ))}
52
+ </div>
53
+
54
+ {/* Session 3 — collapsed (no lessons) */}
55
+ <div className="mt-2 flex flex-col gap-1">
56
+ <div className="flex items-center gap-2 px-2 py-1.5">
57
+ <Skeleton className="size-3 rounded" />
58
+ <Skeleton className="h-3.5 w-28" />
59
+ <Skeleton className="ml-auto h-3 w-10" />
60
+ </div>
61
+ </div>
62
+ </div>
63
+ );
64
+ }