@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,362 +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
- }
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
+ }