@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.
- package/dist/course/course-structure.controller.d.ts +60 -0
- package/dist/course/course-structure.controller.d.ts.map +1 -1
- package/dist/course/course-structure.controller.js +79 -0
- package/dist/course/course-structure.controller.js.map +1 -1
- package/dist/course/course-structure.service.d.ts +61 -1
- package/dist/course/course-structure.service.d.ts.map +1 -1
- package/dist/course/course-structure.service.js +326 -1
- package/dist/course/course-structure.service.js.map +1 -1
- package/dist/course/course.controller.d.ts +52 -4
- package/dist/course/course.controller.d.ts.map +1 -1
- package/dist/course/course.service.d.ts +52 -5
- package/dist/course/course.service.d.ts.map +1 -1
- package/dist/course/course.service.js +78 -57
- package/dist/course/course.service.js.map +1 -1
- package/dist/course/dto/create-course-structure-lesson.dto.d.ts.map +1 -1
- package/dist/course/dto/create-course-structure-lesson.dto.js +5 -1
- package/dist/course/dto/create-course-structure-lesson.dto.js.map +1 -1
- package/dist/course/dto/create-course.dto.d.ts +1 -1
- package/dist/course/dto/create-course.dto.d.ts.map +1 -1
- package/dist/course/dto/create-course.dto.js +4 -1
- package/dist/course/dto/create-course.dto.js.map +1 -1
- package/dist/course/dto/move-lesson.dto.d.ts +10 -0
- package/dist/course/dto/move-lesson.dto.d.ts.map +1 -0
- package/dist/course/dto/move-lesson.dto.js +28 -0
- package/dist/course/dto/move-lesson.dto.js.map +1 -0
- package/dist/course/dto/paste-lessons.dto.d.ts +4 -0
- package/dist/course/dto/paste-lessons.dto.d.ts.map +1 -0
- package/dist/course/dto/paste-lessons.dto.js +24 -0
- package/dist/course/dto/paste-lessons.dto.js.map +1 -0
- package/dist/course/dto/reorder-lessons.dto.d.ts +5 -0
- package/dist/course/dto/reorder-lessons.dto.d.ts.map +1 -0
- package/dist/course/dto/reorder-lessons.dto.js +24 -0
- package/dist/course/dto/reorder-lessons.dto.js.map +1 -0
- package/dist/course/dto/reorder-sessions.dto.d.ts +5 -0
- package/dist/course/dto/reorder-sessions.dto.d.ts.map +1 -0
- package/dist/course/dto/reorder-sessions.dto.js +24 -0
- package/dist/course/dto/reorder-sessions.dto.js.map +1 -0
- package/dist/training/training.controller.js +1 -1
- package/dist/training/training.controller.js.map +1 -1
- package/hedhog/data/image_type.yaml +20 -0
- package/hedhog/data/menu.yaml +2 -2
- package/hedhog/data/route.yaml +60 -6
- package/hedhog/frontend/app/_components/class-form-sheet.tsx.ejs +146 -165
- package/hedhog/frontend/app/_components/course-avatar.tsx.ejs +70 -0
- package/hedhog/frontend/app/_components/course-form-sheet.tsx.ejs +372 -22
- package/hedhog/frontend/app/classes/[id]/page.tsx.ejs +437 -77
- package/hedhog/frontend/app/classes/page.tsx.ejs +311 -289
- package/hedhog/frontend/app/courses/[id]/_components/CourseCertificateCard.tsx.ejs +10 -7
- package/hedhog/frontend/app/courses/[id]/_components/CourseClassificationCard.tsx.ejs +23 -32
- package/hedhog/frontend/app/courses/[id]/_components/CourseContentCard.tsx.ejs +3 -9
- package/hedhog/frontend/app/courses/[id]/_components/CourseDangerZoneCard.tsx.ejs +26 -16
- package/hedhog/frontend/app/courses/[id]/_components/CourseFlagsCard.tsx.ejs +19 -5
- package/hedhog/frontend/app/courses/[id]/_components/CourseMainInfoCard.tsx.ejs +10 -14
- package/hedhog/frontend/app/courses/[id]/_components/CourseMediaCard.tsx.ejs +131 -107
- package/hedhog/frontend/app/courses/[id]/_components/CourseRelationsCard.tsx.ejs +10 -7
- package/hedhog/frontend/app/courses/[id]/_components/CourseSectionCard.tsx.ejs +38 -19
- package/hedhog/frontend/app/courses/[id]/_components/CourseSummaryCard.tsx.ejs +1 -1
- package/hedhog/frontend/app/courses/[id]/_components/course-edit-types.ts.ejs +1 -1
- package/hedhog/frontend/app/courses/[id]/page.tsx.ejs +336 -1057
- package/hedhog/frontend/app/courses/[id]/structure/_components/confirm-dialog.tsx.ejs +45 -0
- package/hedhog/frontend/app/courses/[id]/structure/_components/course-tree-dnd.tsx.ejs +362 -0
- package/hedhog/frontend/app/courses/[id]/structure/_components/course-tree-panel.tsx.ejs +111 -0
- package/hedhog/frontend/app/courses/[id]/structure/_components/course-tree-skeleton.tsx.ejs +64 -0
- package/hedhog/frontend/app/courses/[id]/structure/_components/course-tree.tsx.ejs +134 -0
- package/hedhog/frontend/app/courses/[id]/structure/_components/detail-course.tsx.ejs +113 -0
- package/hedhog/frontend/app/courses/[id]/structure/_components/detail-lesson.tsx.ejs +314 -0
- package/hedhog/frontend/app/courses/[id]/structure/_components/detail-panel.tsx.ejs +62 -0
- package/hedhog/frontend/app/courses/[id]/structure/_components/detail-session.tsx.ejs +174 -0
- package/hedhog/frontend/app/courses/[id]/structure/_components/drag-handle.tsx.ejs +58 -0
- package/hedhog/frontend/app/courses/[id]/structure/_components/drag-overlay.tsx.ejs +52 -0
- package/hedhog/frontend/app/courses/[id]/structure/_components/editor-bulk.tsx.ejs +276 -0
- package/hedhog/frontend/app/courses/[id]/structure/_components/editor-course.tsx.ejs +1216 -0
- package/hedhog/frontend/app/courses/[id]/structure/_components/editor-lesson.tsx.ejs +1827 -0
- package/hedhog/frontend/app/courses/[id]/structure/_components/editor-session.tsx.ejs +443 -0
- package/hedhog/frontend/app/courses/[id]/structure/_components/highlighted-text.tsx.ejs +41 -0
- package/hedhog/frontend/app/courses/[id]/structure/_components/mock-data.ts.ejs +184 -0
- package/hedhog/frontend/app/courses/[id]/structure/_components/multi-select-bar.tsx.ejs +264 -0
- package/hedhog/frontend/app/courses/[id]/structure/_components/search-filter.tsx.ejs +96 -0
- package/hedhog/frontend/app/courses/[id]/structure/_components/session-picker-dialog.tsx.ejs +74 -0
- package/hedhog/frontend/app/courses/[id]/structure/_components/shortcuts-help.tsx.ejs +136 -0
- package/hedhog/frontend/app/courses/[id]/structure/_components/sortable-tree-row.tsx.ejs +80 -0
- package/hedhog/frontend/app/courses/[id]/structure/_components/store.ts.ejs +948 -0
- package/hedhog/frontend/app/courses/[id]/structure/_components/tree-context-menu.tsx.ejs +525 -0
- package/hedhog/frontend/app/courses/[id]/structure/_components/tree-display-settings-popover.tsx.ejs +150 -0
- package/hedhog/frontend/app/courses/[id]/structure/_components/tree-helpers.ts.ejs +182 -0
- package/hedhog/frontend/app/courses/[id]/structure/_components/tree-row-course.tsx.ejs +52 -0
- package/hedhog/frontend/app/courses/[id]/structure/_components/tree-row-lesson.tsx.ejs +271 -0
- package/hedhog/frontend/app/courses/[id]/structure/_components/tree-row-session.tsx.ejs +167 -0
- package/hedhog/frontend/app/courses/[id]/structure/_components/tree-row.tsx.ejs +108 -0
- package/hedhog/frontend/app/courses/[id]/structure/_components/types.ts.ejs +122 -0
- package/hedhog/frontend/app/courses/[id]/structure/_components/use-course-structure-shortcuts.ts.ejs +318 -0
- package/hedhog/frontend/app/courses/[id]/structure/_components/use-tree-display-settings.ts.ejs +97 -0
- package/hedhog/frontend/app/courses/[id]/structure/_data/adapters/course-structure.adapter.ts.ejs +347 -0
- package/hedhog/frontend/app/courses/[id]/structure/_data/course-structure-contract.ts.ejs +195 -0
- package/hedhog/frontend/app/courses/[id]/structure/_data/services/course-structure.service.ts.ejs +420 -0
- package/hedhog/frontend/app/courses/[id]/structure/_data/types/api-course.types.ts.ejs +254 -0
- package/hedhog/frontend/app/courses/[id]/structure/_data/use-course-structure-mutations.ts.ejs +987 -0
- package/hedhog/frontend/app/courses/[id]/structure/_data/use-course-structure-query.ts.ejs +86 -0
- package/hedhog/frontend/app/courses/[id]/structure/_data/use-course-structure.ts.ejs +160 -0
- package/hedhog/frontend/app/courses/[id]/structure/page.tsx.ejs +10 -3212
- package/hedhog/frontend/app/courses/page.tsx.ejs +45 -26
- package/hedhog/frontend/app/{training → paths}/page.tsx.ejs +29 -7
- package/hedhog/frontend/messages/en.json +88 -10
- package/hedhog/frontend/messages/pt.json +88 -10
- package/hedhog/table/course.yaml +1 -1
- package/hedhog/table/image_type.yaml +14 -0
- package/package.json +7 -7
- package/src/course/course-structure.controller.ts +63 -0
- package/src/course/course-structure.service.ts +390 -3
- package/src/course/course.service.ts +59 -27
- package/src/course/dto/create-course-structure-lesson.dto.ts +3 -2
- package/src/course/dto/create-course.dto.ts +4 -1
- package/src/course/dto/move-lesson.dto.ts +17 -0
- package/src/course/dto/paste-lessons.dto.ts +9 -0
- package/src/course/dto/reorder-lessons.dto.ts +10 -0
- package/src/course/dto/reorder-sessions.dto.ts +10 -0
- 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 “{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
|
+
}
|
|
@@ -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
|
+
}
|