@hed-hog/lms 0.0.314 → 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.
- package/dist/class-group/class-group.controller.d.ts +2 -2
- package/dist/class-group/class-group.service.d.ts +2 -2
- 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 +9 -1
- package/hedhog/data/route.yaml +54 -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/table/enterprise_user.yaml +1 -1
- package/package.json +8 -8
- 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,111 +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';
|
|
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';
|
|
@@ -1,134 +1,134 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
|
|
3
|
-
import { useVirtualizer } from '@tanstack/react-virtual';
|
|
4
|
-
import { SearchX } from 'lucide-react';
|
|
5
|
-
import { useCallback, useMemo, useRef } from 'react';
|
|
6
|
-
|
|
7
|
-
import { useStructureStore } from './store';
|
|
8
|
-
import { buildLessonCountMap, buildVisibleItems } from './tree-helpers';
|
|
9
|
-
import { TreeRow } from './tree-row';
|
|
10
|
-
import type { FlatItem } from './types';
|
|
11
|
-
|
|
12
|
-
// Row height estimates per node type (px)
|
|
13
|
-
const ROW_HEIGHT: Record<FlatItem['type'], number> = {
|
|
14
|
-
course: 34,
|
|
15
|
-
session: 32,
|
|
16
|
-
lesson: 28,
|
|
17
|
-
};
|
|
18
|
-
|
|
19
|
-
export function CourseTree() {
|
|
20
|
-
const course = useStructureStore((s) => s.course);
|
|
21
|
-
const sessions = useStructureStore((s) => s.sessions);
|
|
22
|
-
const lessons = useStructureStore((s) => s.lessons);
|
|
23
|
-
const expandedIds = useStructureStore((s) => s.expandedIds);
|
|
24
|
-
const filterQuery = useStructureStore((s) => s.filterQuery);
|
|
25
|
-
const activeItemId = useStructureStore((s) => s.activeItemId);
|
|
26
|
-
const activeItemType = useStructureStore((s) => s.activeItemType);
|
|
27
|
-
const selectedIds = useStructureStore((s) => s.selectedIds);
|
|
28
|
-
|
|
29
|
-
const scrollRef = useRef<HTMLDivElement>(null);
|
|
30
|
-
|
|
31
|
-
// ── Derived state ───────────────────────────────────────────────────────────
|
|
32
|
-
const { items, matchedIds, expandedBySearch, resultCount } = useMemo(
|
|
33
|
-
() =>
|
|
34
|
-
buildVisibleItems(course, sessions, lessons, expandedIds, filterQuery),
|
|
35
|
-
[course, sessions, lessons, expandedIds, filterQuery]
|
|
36
|
-
);
|
|
37
|
-
|
|
38
|
-
const lessonCountMap = useMemo(() => buildLessonCountMap(lessons), [lessons]);
|
|
39
|
-
|
|
40
|
-
const estimateSize = useCallback(
|
|
41
|
-
(index: number) => ROW_HEIGHT[items[index]?.type ?? 'lesson'] ?? 32,
|
|
42
|
-
[items]
|
|
43
|
-
);
|
|
44
|
-
|
|
45
|
-
// ── Virtualizer ─────────────────────────────────────────────────────────────
|
|
46
|
-
// eslint-disable-next-line react-hooks/incompatible-library -- TanStack Virtual returns non-memoizable functions; this is expected and safe here
|
|
47
|
-
const virtualizer = useVirtualizer({
|
|
48
|
-
count: items.length,
|
|
49
|
-
getScrollElement: () => scrollRef.current,
|
|
50
|
-
estimateSize,
|
|
51
|
-
overscan: 8,
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
const isSearchActive = filterQuery.trim().length > 0;
|
|
55
|
-
const isEmpty = items.length === 0;
|
|
56
|
-
|
|
57
|
-
return (
|
|
58
|
-
<div className="flex flex-col h-full min-h-0">
|
|
59
|
-
{/* Result count bar */}
|
|
60
|
-
{isSearchActive && !isEmpty && (
|
|
61
|
-
<div className="px-3 py-1 shrink-0 border-b">
|
|
62
|
-
<span className="text-[0.65rem] text-muted-foreground">
|
|
63
|
-
{resultCount === 1 ? '1 resultado' : `${resultCount} resultados`}
|
|
64
|
-
</span>
|
|
65
|
-
</div>
|
|
66
|
-
)}
|
|
67
|
-
|
|
68
|
-
{/* Empty state */}
|
|
69
|
-
{isEmpty && (
|
|
70
|
-
<div className="flex flex-col items-center justify-center gap-2 py-12 px-4 text-center">
|
|
71
|
-
<SearchX className="size-8 text-muted-foreground/40" />
|
|
72
|
-
<p className="text-sm text-muted-foreground">
|
|
73
|
-
{isSearchActive
|
|
74
|
-
? `Nenhum resultado para "${filterQuery.trim()}"`
|
|
75
|
-
: 'Nenhum item no curso.'}
|
|
76
|
-
</p>
|
|
77
|
-
</div>
|
|
78
|
-
)}
|
|
79
|
-
|
|
80
|
-
{/* Virtualised tree */}
|
|
81
|
-
{!isEmpty && (
|
|
82
|
-
<div
|
|
83
|
-
ref={scrollRef}
|
|
84
|
-
className="overflow-y-auto flex-1 min-h-0"
|
|
85
|
-
style={{ contain: 'strict' }}
|
|
86
|
-
>
|
|
87
|
-
<div
|
|
88
|
-
style={{
|
|
89
|
-
height: virtualizer.getTotalSize(),
|
|
90
|
-
width: '100%',
|
|
91
|
-
position: 'relative',
|
|
92
|
-
}}
|
|
93
|
-
>
|
|
94
|
-
{virtualizer.getVirtualItems().map((virtualRow) => {
|
|
95
|
-
const item = items[virtualRow.index];
|
|
96
|
-
if (!item) return null;
|
|
97
|
-
|
|
98
|
-
return (
|
|
99
|
-
<div
|
|
100
|
-
key={virtualRow.key}
|
|
101
|
-
data-index={virtualRow.index}
|
|
102
|
-
style={{
|
|
103
|
-
position: 'absolute',
|
|
104
|
-
top: 0,
|
|
105
|
-
left: 0,
|
|
106
|
-
width: '100%',
|
|
107
|
-
height: `${virtualRow.size}px`,
|
|
108
|
-
transform: `translateY(${virtualRow.start}px)`,
|
|
109
|
-
padding: '1px 4px',
|
|
110
|
-
}}
|
|
111
|
-
>
|
|
112
|
-
<TreeRow
|
|
113
|
-
item={item}
|
|
114
|
-
isActive={
|
|
115
|
-
activeItemId === item.id && activeItemType === item.type
|
|
116
|
-
}
|
|
117
|
-
isSelected={selectedIds.has(`${item.type}:${item.id}`)}
|
|
118
|
-
query={filterQuery}
|
|
119
|
-
isMatched={matchedIds.has(item.id)}
|
|
120
|
-
isEffectivelyExpanded={
|
|
121
|
-
expandedIds.has(item.id) || expandedBySearch.has(item.id)
|
|
122
|
-
}
|
|
123
|
-
lessonCountMap={lessonCountMap}
|
|
124
|
-
visibleItems={items}
|
|
125
|
-
/>
|
|
126
|
-
</div>
|
|
127
|
-
);
|
|
128
|
-
})}
|
|
129
|
-
</div>
|
|
130
|
-
</div>
|
|
131
|
-
)}
|
|
132
|
-
</div>
|
|
133
|
-
);
|
|
134
|
-
}
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useVirtualizer } from '@tanstack/react-virtual';
|
|
4
|
+
import { SearchX } from 'lucide-react';
|
|
5
|
+
import { useCallback, useMemo, useRef } from 'react';
|
|
6
|
+
|
|
7
|
+
import { useStructureStore } from './store';
|
|
8
|
+
import { buildLessonCountMap, buildVisibleItems } from './tree-helpers';
|
|
9
|
+
import { TreeRow } from './tree-row';
|
|
10
|
+
import type { FlatItem } from './types';
|
|
11
|
+
|
|
12
|
+
// Row height estimates per node type (px)
|
|
13
|
+
const ROW_HEIGHT: Record<FlatItem['type'], number> = {
|
|
14
|
+
course: 34,
|
|
15
|
+
session: 32,
|
|
16
|
+
lesson: 28,
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export function CourseTree() {
|
|
20
|
+
const course = useStructureStore((s) => s.course);
|
|
21
|
+
const sessions = useStructureStore((s) => s.sessions);
|
|
22
|
+
const lessons = useStructureStore((s) => s.lessons);
|
|
23
|
+
const expandedIds = useStructureStore((s) => s.expandedIds);
|
|
24
|
+
const filterQuery = useStructureStore((s) => s.filterQuery);
|
|
25
|
+
const activeItemId = useStructureStore((s) => s.activeItemId);
|
|
26
|
+
const activeItemType = useStructureStore((s) => s.activeItemType);
|
|
27
|
+
const selectedIds = useStructureStore((s) => s.selectedIds);
|
|
28
|
+
|
|
29
|
+
const scrollRef = useRef<HTMLDivElement>(null);
|
|
30
|
+
|
|
31
|
+
// ── Derived state ───────────────────────────────────────────────────────────
|
|
32
|
+
const { items, matchedIds, expandedBySearch, resultCount } = useMemo(
|
|
33
|
+
() =>
|
|
34
|
+
buildVisibleItems(course, sessions, lessons, expandedIds, filterQuery),
|
|
35
|
+
[course, sessions, lessons, expandedIds, filterQuery]
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
const lessonCountMap = useMemo(() => buildLessonCountMap(lessons), [lessons]);
|
|
39
|
+
|
|
40
|
+
const estimateSize = useCallback(
|
|
41
|
+
(index: number) => ROW_HEIGHT[items[index]?.type ?? 'lesson'] ?? 32,
|
|
42
|
+
[items]
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
// ── Virtualizer ─────────────────────────────────────────────────────────────
|
|
46
|
+
// eslint-disable-next-line react-hooks/incompatible-library -- TanStack Virtual returns non-memoizable functions; this is expected and safe here
|
|
47
|
+
const virtualizer = useVirtualizer({
|
|
48
|
+
count: items.length,
|
|
49
|
+
getScrollElement: () => scrollRef.current,
|
|
50
|
+
estimateSize,
|
|
51
|
+
overscan: 8,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const isSearchActive = filterQuery.trim().length > 0;
|
|
55
|
+
const isEmpty = items.length === 0;
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<div className="flex flex-col h-full min-h-0">
|
|
59
|
+
{/* Result count bar */}
|
|
60
|
+
{isSearchActive && !isEmpty && (
|
|
61
|
+
<div className="px-3 py-1 shrink-0 border-b">
|
|
62
|
+
<span className="text-[0.65rem] text-muted-foreground">
|
|
63
|
+
{resultCount === 1 ? '1 resultado' : `${resultCount} resultados`}
|
|
64
|
+
</span>
|
|
65
|
+
</div>
|
|
66
|
+
)}
|
|
67
|
+
|
|
68
|
+
{/* Empty state */}
|
|
69
|
+
{isEmpty && (
|
|
70
|
+
<div className="flex flex-col items-center justify-center gap-2 py-12 px-4 text-center">
|
|
71
|
+
<SearchX className="size-8 text-muted-foreground/40" />
|
|
72
|
+
<p className="text-sm text-muted-foreground">
|
|
73
|
+
{isSearchActive
|
|
74
|
+
? `Nenhum resultado para "${filterQuery.trim()}"`
|
|
75
|
+
: 'Nenhum item no curso.'}
|
|
76
|
+
</p>
|
|
77
|
+
</div>
|
|
78
|
+
)}
|
|
79
|
+
|
|
80
|
+
{/* Virtualised tree */}
|
|
81
|
+
{!isEmpty && (
|
|
82
|
+
<div
|
|
83
|
+
ref={scrollRef}
|
|
84
|
+
className="overflow-y-auto flex-1 min-h-0"
|
|
85
|
+
style={{ contain: 'strict' }}
|
|
86
|
+
>
|
|
87
|
+
<div
|
|
88
|
+
style={{
|
|
89
|
+
height: virtualizer.getTotalSize(),
|
|
90
|
+
width: '100%',
|
|
91
|
+
position: 'relative',
|
|
92
|
+
}}
|
|
93
|
+
>
|
|
94
|
+
{virtualizer.getVirtualItems().map((virtualRow) => {
|
|
95
|
+
const item = items[virtualRow.index];
|
|
96
|
+
if (!item) return null;
|
|
97
|
+
|
|
98
|
+
return (
|
|
99
|
+
<div
|
|
100
|
+
key={virtualRow.key}
|
|
101
|
+
data-index={virtualRow.index}
|
|
102
|
+
style={{
|
|
103
|
+
position: 'absolute',
|
|
104
|
+
top: 0,
|
|
105
|
+
left: 0,
|
|
106
|
+
width: '100%',
|
|
107
|
+
height: `${virtualRow.size}px`,
|
|
108
|
+
transform: `translateY(${virtualRow.start}px)`,
|
|
109
|
+
padding: '1px 4px',
|
|
110
|
+
}}
|
|
111
|
+
>
|
|
112
|
+
<TreeRow
|
|
113
|
+
item={item}
|
|
114
|
+
isActive={
|
|
115
|
+
activeItemId === item.id && activeItemType === item.type
|
|
116
|
+
}
|
|
117
|
+
isSelected={selectedIds.has(`${item.type}:${item.id}`)}
|
|
118
|
+
query={filterQuery}
|
|
119
|
+
isMatched={matchedIds.has(item.id)}
|
|
120
|
+
isEffectivelyExpanded={
|
|
121
|
+
expandedIds.has(item.id) || expandedBySearch.has(item.id)
|
|
122
|
+
}
|
|
123
|
+
lessonCountMap={lessonCountMap}
|
|
124
|
+
visibleItems={items}
|
|
125
|
+
/>
|
|
126
|
+
</div>
|
|
127
|
+
);
|
|
128
|
+
})}
|
|
129
|
+
</div>
|
|
130
|
+
</div>
|
|
131
|
+
)}
|
|
132
|
+
</div>
|
|
133
|
+
);
|
|
134
|
+
}
|