@hed-hog/lms 0.0.314 → 0.0.316
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.
- package/dist/enterprise/dto/enterprise-profile.dto.d.ts +13 -0
- package/dist/enterprise/dto/enterprise-profile.dto.d.ts.map +1 -0
- package/dist/enterprise/dto/enterprise-profile.dto.js +3 -0
- package/dist/enterprise/dto/enterprise-profile.dto.js.map +1 -0
- package/dist/enterprise/enterprise.controller.d.ts +3 -0
- package/dist/enterprise/enterprise.controller.d.ts.map +1 -1
- package/dist/enterprise/enterprise.controller.js +14 -0
- package/dist/enterprise/enterprise.controller.js.map +1 -1
- package/dist/enterprise/enterprise.service.d.ts +3 -0
- package/dist/enterprise/enterprise.service.d.ts.map +1 -1
- package/dist/enterprise/enterprise.service.js +128 -1
- package/dist/enterprise/enterprise.service.js.map +1 -1
- package/dist/instructor/instructor.controller.d.ts +23 -0
- package/dist/instructor/instructor.controller.d.ts.map +1 -1
- package/dist/instructor/instructor.controller.js +41 -0
- package/dist/instructor/instructor.controller.js.map +1 -1
- package/dist/instructor/instructor.service.d.ts +25 -0
- package/dist/instructor/instructor.service.d.ts.map +1 -1
- package/dist/instructor/instructor.service.js +126 -8
- package/dist/instructor/instructor.service.js.map +1 -1
- package/hedhog/data/menu.yaml +23 -7
- package/hedhog/data/role.yaml +17 -1
- package/hedhog/data/route.yaml +48 -0
- package/hedhog/frontend/app/_components/class-form-sheet.tsx.ejs +2 -1
- package/hedhog/frontend/app/courses/[id]/structure/_components/confirm-dialog.tsx.ejs +44 -44
- package/hedhog/frontend/app/courses/[id]/structure/_components/course-tree-dnd.tsx.ejs +362 -362
- package/hedhog/frontend/app/courses/[id]/structure/_components/course-tree-panel.tsx.ejs +111 -111
- package/hedhog/frontend/app/courses/[id]/structure/_components/course-tree.tsx.ejs +134 -134
- package/hedhog/frontend/app/courses/[id]/structure/_components/detail-course.tsx.ejs +113 -113
- package/hedhog/frontend/app/courses/[id]/structure/_components/detail-lesson.tsx.ejs +314 -314
- package/hedhog/frontend/app/courses/[id]/structure/_components/detail-panel.tsx.ejs +62 -62
- package/hedhog/frontend/app/courses/[id]/structure/_components/detail-session.tsx.ejs +173 -173
- package/hedhog/frontend/app/courses/[id]/structure/_components/drag-handle.tsx.ejs +58 -58
- package/hedhog/frontend/app/courses/[id]/structure/_components/drag-overlay.tsx.ejs +51 -51
- package/hedhog/frontend/app/courses/[id]/structure/_components/editor-bulk.tsx.ejs +276 -276
- package/hedhog/frontend/app/courses/[id]/structure/_components/editor-course.tsx.ejs +1216 -1216
- package/hedhog/frontend/app/courses/[id]/structure/_components/editor-lesson.tsx.ejs +1824 -1824
- package/hedhog/frontend/app/courses/[id]/structure/_components/editor-session.tsx.ejs +443 -443
- package/hedhog/frontend/app/courses/[id]/structure/_components/highlighted-text.tsx.ejs +40 -40
- package/hedhog/frontend/app/courses/[id]/structure/_components/mock-data.ts.ejs +185 -185
- package/hedhog/frontend/app/courses/[id]/structure/_components/multi-select-bar.tsx.ejs +264 -264
- package/hedhog/frontend/app/courses/[id]/structure/_components/search-filter.tsx.ejs +95 -95
- package/hedhog/frontend/app/courses/[id]/structure/_components/session-picker-dialog.tsx.ejs +73 -73
- package/hedhog/frontend/app/courses/[id]/structure/_components/shortcuts-help.tsx.ejs +136 -136
- package/hedhog/frontend/app/courses/[id]/structure/_components/sortable-tree-row.tsx.ejs +80 -80
- package/hedhog/frontend/app/courses/[id]/structure/_components/store.ts.ejs +949 -949
- package/hedhog/frontend/app/courses/[id]/structure/_components/tree-context-menu.tsx.ejs +525 -525
- package/hedhog/frontend/app/courses/[id]/structure/_components/tree-helpers.ts.ejs +181 -181
- package/hedhog/frontend/app/courses/[id]/structure/_components/tree-row-course.tsx.ejs +51 -51
- package/hedhog/frontend/app/courses/[id]/structure/_components/tree-row-lesson.tsx.ejs +271 -271
- package/hedhog/frontend/app/courses/[id]/structure/_components/tree-row-session.tsx.ejs +167 -167
- package/hedhog/frontend/app/courses/[id]/structure/_components/tree-row.tsx.ejs +108 -108
- package/hedhog/frontend/app/courses/[id]/structure/_components/use-course-structure-shortcuts.ts.ejs +318 -318
- package/hedhog/frontend/app/courses/[id]/structure/page.tsx.ejs +10 -10
- package/hedhog/frontend/app/enterprise/_components/enterprise-course-create-sheet.tsx.ejs +2 -1
- package/hedhog/frontend/app/instructors/_components/instructor-form-sheet.tsx.ejs +438 -0
- package/hedhog/frontend/app/instructors/_components/instructor-types.ts.ejs +40 -0
- package/hedhog/frontend/app/instructors/page.tsx.ejs +696 -0
- package/hedhog/frontend/app/training/page.tsx.ejs +2339 -0
- package/hedhog/query/add_route_role.sql +15 -0
- package/hedhog/table/enterprise_user.yaml +1 -1
- package/package.json +6 -6
- package/src/enterprise/dto/enterprise-profile.dto.ts +17 -0
- package/src/enterprise/enterprise.controller.ts +9 -1
- package/src/enterprise/enterprise.service.ts +147 -4
- package/src/instructor/instructor.controller.ts +36 -9
- 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 “{filterQuery.trim()}”
|
|
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 “{filterQuery.trim()}”
|
|
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
|
+
}
|