@hed-hog/lms 0.0.306 → 0.0.310
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/certificates/models/CanvasStage.tsx.ejs +10 -1
- package/hedhog/frontend/app/certificates/models/TopBar.tsx.ejs +44 -3
- package/hedhog/frontend/app/certificates/models/page.tsx.ejs +32 -0
- 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 +91 -11
- package/hedhog/frontend/messages/pt.json +91 -11
- 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,182 @@
|
|
|
1
|
+
import type { Course, FlatItem, Lesson, Session } from './types';
|
|
2
|
+
|
|
3
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
4
|
+
// Text Highlight Helpers
|
|
5
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
export interface TextSegment {
|
|
8
|
+
text: string;
|
|
9
|
+
highlight: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Splits `text` into segments marking the first occurrence of `query`.
|
|
14
|
+
* Returns a single non-highlighted segment when query is empty or not found.
|
|
15
|
+
*/
|
|
16
|
+
export function getTextSegments(text: string, query: string): TextSegment[] {
|
|
17
|
+
const q = query.trim();
|
|
18
|
+
if (!q) return [{ text, highlight: false }];
|
|
19
|
+
|
|
20
|
+
const idx = text.toLowerCase().indexOf(q.toLowerCase());
|
|
21
|
+
if (idx === -1) return [{ text, highlight: false }];
|
|
22
|
+
|
|
23
|
+
const segments: TextSegment[] = [];
|
|
24
|
+
if (idx > 0) segments.push({ text: text.slice(0, idx), highlight: false });
|
|
25
|
+
segments.push({ text: text.slice(idx, idx + q.length), highlight: true });
|
|
26
|
+
const tail = text.slice(idx + q.length);
|
|
27
|
+
if (tail.length > 0) segments.push({ text: tail, highlight: false });
|
|
28
|
+
return segments;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
32
|
+
// Flat Tree Builder
|
|
33
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
export interface VisibleTreeResult {
|
|
36
|
+
/** Ordered list of visible tree nodes. */
|
|
37
|
+
items: FlatItem[];
|
|
38
|
+
/** IDs of nodes that directly match the search query (for highlighting). */
|
|
39
|
+
matchedIds: Set<string>;
|
|
40
|
+
/** Session IDs that are effectively expanded (auto-expanded by search). */
|
|
41
|
+
expandedBySearch: Set<string>;
|
|
42
|
+
/** Number of directly matching items. */
|
|
43
|
+
resultCount: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function lessonMatchesQuery(l: Lesson, q: string): boolean {
|
|
47
|
+
return (
|
|
48
|
+
l.title.toLowerCase().includes(q) ||
|
|
49
|
+
l.code.toLowerCase().includes(q) ||
|
|
50
|
+
l.type.toLowerCase().includes(q)
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function sessionMatchesQuery(s: Session, q: string): boolean {
|
|
55
|
+
return (
|
|
56
|
+
s.title.toLowerCase().includes(q) ||
|
|
57
|
+
s.code.toLowerCase().includes(q)
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function courseMatchesQuery(c: Course, q: string): boolean {
|
|
62
|
+
return (
|
|
63
|
+
c.title.toLowerCase().includes(q) ||
|
|
64
|
+
c.code.toLowerCase().includes(q)
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Builds the ordered, visible flat list of tree nodes.
|
|
70
|
+
*
|
|
71
|
+
* - When `query` is empty: uses `expandedIds` for expand/collapse.
|
|
72
|
+
* - When `query` is active: filters items, auto-expands sessions with results,
|
|
73
|
+
* and returns `matchedIds` for highlight + `expandedBySearch` for chevron state.
|
|
74
|
+
*/
|
|
75
|
+
export function buildVisibleItems(
|
|
76
|
+
course: Course,
|
|
77
|
+
sessions: Session[],
|
|
78
|
+
lessons: Lesson[],
|
|
79
|
+
expandedIds: Set<string>,
|
|
80
|
+
query: string
|
|
81
|
+
): VisibleTreeResult {
|
|
82
|
+
const sortedSessions = [...sessions].sort((a, b) => a.order - b.order);
|
|
83
|
+
const q = query.trim().toLowerCase();
|
|
84
|
+
|
|
85
|
+
// ── No active query: normal expand/collapse behaviour ─────────────────────
|
|
86
|
+
if (!q) {
|
|
87
|
+
const items: FlatItem[] = [];
|
|
88
|
+
items.push({ id: course.id, type: 'course', depth: 0, data: course });
|
|
89
|
+
|
|
90
|
+
for (const session of sortedSessions) {
|
|
91
|
+
items.push({ id: session.id, type: 'session', depth: 1, data: session });
|
|
92
|
+
if (expandedIds.has(session.id)) {
|
|
93
|
+
const sessionLessons = lessons
|
|
94
|
+
.filter((l) => l.sessionId === session.id)
|
|
95
|
+
.sort((a, b) => a.order - b.order);
|
|
96
|
+
for (const lesson of sessionLessons) {
|
|
97
|
+
items.push({ id: lesson.id, type: 'lesson', depth: 2, data: lesson });
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
items,
|
|
104
|
+
matchedIds: new Set(),
|
|
105
|
+
expandedBySearch: new Set(),
|
|
106
|
+
resultCount: 0,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ── Active query: filter + highlight ──────────────────────────────────────
|
|
111
|
+
const matchedIds = new Set<string>();
|
|
112
|
+
const expandedBySearch = new Set<string>();
|
|
113
|
+
|
|
114
|
+
// Step 1 — mark all direct matches
|
|
115
|
+
if (courseMatchesQuery(course, q)) matchedIds.add(course.id);
|
|
116
|
+
|
|
117
|
+
for (const session of sortedSessions) {
|
|
118
|
+
if (sessionMatchesQuery(session, q)) matchedIds.add(session.id);
|
|
119
|
+
|
|
120
|
+
const sessionLessons = lessons.filter((l) => l.sessionId === session.id);
|
|
121
|
+
for (const lesson of sessionLessons) {
|
|
122
|
+
if (lessonMatchesQuery(lesson, q)) matchedIds.add(lesson.id);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Step 2 — compute which sessions need to be auto-expanded
|
|
127
|
+
for (const session of sortedSessions) {
|
|
128
|
+
const hasMatchingLesson = lessons.some(
|
|
129
|
+
(l) => l.sessionId === session.id && matchedIds.has(l.id)
|
|
130
|
+
);
|
|
131
|
+
if (matchedIds.has(session.id) || hasMatchingLesson) {
|
|
132
|
+
expandedBySearch.add(session.id);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Step 3 — build the visible list
|
|
137
|
+
const items: FlatItem[] = [];
|
|
138
|
+
const anyMatch = matchedIds.size > 0;
|
|
139
|
+
|
|
140
|
+
// Course is always shown if anything matched (ancestor context)
|
|
141
|
+
if (anyMatch) {
|
|
142
|
+
items.push({ id: course.id, type: 'course', depth: 0, data: course });
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
for (const session of sortedSessions) {
|
|
146
|
+
const sessionLessons = lessons
|
|
147
|
+
.filter((l) => l.sessionId === session.id)
|
|
148
|
+
.sort((a, b) => a.order - b.order);
|
|
149
|
+
|
|
150
|
+
const sessionDirectMatch = matchedIds.has(session.id);
|
|
151
|
+
const matchingLessons = sessionLessons.filter((l) => matchedIds.has(l.id));
|
|
152
|
+
|
|
153
|
+
if (!sessionDirectMatch && matchingLessons.length === 0) continue;
|
|
154
|
+
|
|
155
|
+
items.push({ id: session.id, type: 'session', depth: 1, data: session });
|
|
156
|
+
|
|
157
|
+
// Show only matching lessons (session match shows all its lessons for context)
|
|
158
|
+
const lessonsToShow = sessionDirectMatch ? sessionLessons : matchingLessons;
|
|
159
|
+
for (const lesson of lessonsToShow) {
|
|
160
|
+
items.push({ id: lesson.id, type: 'lesson', depth: 2, data: lesson });
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return {
|
|
165
|
+
items,
|
|
166
|
+
matchedIds,
|
|
167
|
+
expandedBySearch,
|
|
168
|
+
resultCount: matchedIds.size,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Returns the total lesson count for each session — keyed by session ID.
|
|
174
|
+
* Computed once for the whole tree to avoid per-row array scans.
|
|
175
|
+
*/
|
|
176
|
+
export function buildLessonCountMap(lessons: Lesson[]): Map<string, number> {
|
|
177
|
+
const map = new Map<string, number>();
|
|
178
|
+
for (const lesson of lessons) {
|
|
179
|
+
map.set(lesson.sessionId, (map.get(lesson.sessionId) ?? 0) + 1);
|
|
180
|
+
}
|
|
181
|
+
return map;
|
|
182
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { cn } from '@/lib/utils';
|
|
4
|
+
import { BookOpen } from 'lucide-react';
|
|
5
|
+
import { HighlightedText } from './highlighted-text';
|
|
6
|
+
import type { Course } from './types';
|
|
7
|
+
|
|
8
|
+
interface TreeRowCourseProps {
|
|
9
|
+
data: Course;
|
|
10
|
+
isSelected: boolean;
|
|
11
|
+
isActive: boolean;
|
|
12
|
+
query: string;
|
|
13
|
+
isMatched: boolean;
|
|
14
|
+
onClick: (e: React.MouseEvent) => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function TreeRowCourse({
|
|
18
|
+
data,
|
|
19
|
+
isSelected,
|
|
20
|
+
isActive,
|
|
21
|
+
query,
|
|
22
|
+
onClick,
|
|
23
|
+
}: TreeRowCourseProps) {
|
|
24
|
+
return (
|
|
25
|
+
<div
|
|
26
|
+
onClick={onClick}
|
|
27
|
+
role="treeitem"
|
|
28
|
+
aria-selected={isActive}
|
|
29
|
+
className={cn(
|
|
30
|
+
'flex items-center gap-2 px-3 py-1.5 rounded-md cursor-pointer select-none transition-colors text-sm font-semibold h-full',
|
|
31
|
+
isActive && 'bg-accent text-accent-foreground ring-1 ring-inset ring-primary/20',
|
|
32
|
+
isSelected && !isActive && 'bg-accent/60',
|
|
33
|
+
!isActive && !isSelected && 'hover:bg-muted/60'
|
|
34
|
+
)}
|
|
35
|
+
>
|
|
36
|
+
<BookOpen className="size-3.5 shrink-0 text-primary" aria-hidden />
|
|
37
|
+
<span className="truncate flex-1">
|
|
38
|
+
<HighlightedText text={data.title} query={query} />
|
|
39
|
+
</span>
|
|
40
|
+
<span
|
|
41
|
+
className={cn(
|
|
42
|
+
'text-[0.6rem] px-1.5 py-0.5 rounded-full shrink-0 font-medium leading-none',
|
|
43
|
+
data.published
|
|
44
|
+
? 'bg-emerald-500/15 text-emerald-700 dark:text-emerald-400'
|
|
45
|
+
: 'bg-muted text-muted-foreground'
|
|
46
|
+
)}
|
|
47
|
+
>
|
|
48
|
+
{data.published ? 'Publicado' : 'Rascunho'}
|
|
49
|
+
</span>
|
|
50
|
+
</div>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Input } from '@/components/ui/input';
|
|
4
|
+
import { cn } from '@/lib/utils';
|
|
5
|
+
import {
|
|
6
|
+
ClipboardList,
|
|
7
|
+
Eye,
|
|
8
|
+
EyeOff,
|
|
9
|
+
FileText,
|
|
10
|
+
Film,
|
|
11
|
+
HelpCircle,
|
|
12
|
+
Lock,
|
|
13
|
+
Paperclip,
|
|
14
|
+
ScrollText,
|
|
15
|
+
Video,
|
|
16
|
+
type LucideIcon,
|
|
17
|
+
} from 'lucide-react';
|
|
18
|
+
import { useEffect, useRef } from 'react';
|
|
19
|
+
import { useUpdateLessonMutation } from '../_data/use-course-structure-mutations';
|
|
20
|
+
import { HighlightedText } from './highlighted-text';
|
|
21
|
+
import { useStructureStore } from './store';
|
|
22
|
+
import type { Lesson, LessonStatus, LessonType, Visibility } from './types';
|
|
23
|
+
import { useTreeDisplaySettings } from './use-tree-display-settings';
|
|
24
|
+
|
|
25
|
+
// ── Type configs ──────────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
const LESSON_TYPE_CONFIG: Record<
|
|
28
|
+
LessonType,
|
|
29
|
+
{ icon: LucideIcon; color: string; label: string }
|
|
30
|
+
> = {
|
|
31
|
+
video: { icon: Video, color: 'text-violet-500', label: 'Vídeo' },
|
|
32
|
+
post: { icon: FileText, color: 'text-emerald-500', label: 'Post' },
|
|
33
|
+
questao: { icon: HelpCircle, color: 'text-amber-500', label: 'Quiz' },
|
|
34
|
+
exercicio: {
|
|
35
|
+
icon: ClipboardList,
|
|
36
|
+
color: 'text-rose-500',
|
|
37
|
+
label: 'Exercício',
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const STATUS_CONFIG: Record<LessonStatus, { label: string; dot: string }> = {
|
|
42
|
+
preparada: { label: 'Preparada', dot: 'bg-slate-400' },
|
|
43
|
+
gravada: { label: 'Gravada', dot: 'bg-sky-500' },
|
|
44
|
+
editada: { label: 'Editada', dot: 'bg-violet-500' },
|
|
45
|
+
finalizada: { label: 'Finalizada', dot: 'bg-amber-500' },
|
|
46
|
+
publicada: { label: 'Publicada', dot: 'bg-emerald-500' },
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const VISIBILITY_CONFIG: Record<
|
|
50
|
+
Visibility,
|
|
51
|
+
{ icon: LucideIcon; color: string; label: string }
|
|
52
|
+
> = {
|
|
53
|
+
publico: { icon: Eye, color: 'text-emerald-500', label: 'Público' },
|
|
54
|
+
privado: { icon: EyeOff, color: 'text-muted-foreground', label: 'Privado' },
|
|
55
|
+
restrito: { icon: Lock, color: 'text-amber-500', label: 'Restrito' },
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
// ── Props ─────────────────────────────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
interface TreeRowLessonProps {
|
|
61
|
+
data: Lesson;
|
|
62
|
+
isSelected: boolean;
|
|
63
|
+
isActive: boolean;
|
|
64
|
+
query: string;
|
|
65
|
+
isMatched: boolean;
|
|
66
|
+
onClick: (e: React.MouseEvent) => void;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ── Component ─────────────────────────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
export function TreeRowLesson({
|
|
72
|
+
data,
|
|
73
|
+
isSelected,
|
|
74
|
+
isActive,
|
|
75
|
+
query,
|
|
76
|
+
onClick,
|
|
77
|
+
}: TreeRowLessonProps) {
|
|
78
|
+
const cfg = LESSON_TYPE_CONFIG[data.type];
|
|
79
|
+
const Icon = cfg.icon;
|
|
80
|
+
|
|
81
|
+
const statusCfg = data.status ? STATUS_CONFIG[data.status] : null;
|
|
82
|
+
const visibilityCfg = data.visibility
|
|
83
|
+
? VISIBILITY_CONFIG[data.visibility]
|
|
84
|
+
: null;
|
|
85
|
+
|
|
86
|
+
const {
|
|
87
|
+
showStatusDot,
|
|
88
|
+
showVisibility,
|
|
89
|
+
showCode,
|
|
90
|
+
showVideoIndicator,
|
|
91
|
+
showResourcesIndicator,
|
|
92
|
+
showTranscriptionIndicator,
|
|
93
|
+
} = useTreeDisplaySettings();
|
|
94
|
+
|
|
95
|
+
const inlineRenamingId = useStructureStore((s) => s.inlineRenamingId);
|
|
96
|
+
const cancelRename = useStructureStore((s) => s.cancelRename);
|
|
97
|
+
const renameItem = useStructureStore((s) => s.renameItem);
|
|
98
|
+
const startRename = useStructureStore((s) => s.startRename);
|
|
99
|
+
|
|
100
|
+
const updateLesson = useUpdateLessonMutation();
|
|
101
|
+
|
|
102
|
+
const isRenaming = inlineRenamingId === data.id;
|
|
103
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
104
|
+
|
|
105
|
+
useEffect(() => {
|
|
106
|
+
if (isRenaming) {
|
|
107
|
+
inputRef.current?.focus();
|
|
108
|
+
inputRef.current?.select();
|
|
109
|
+
}
|
|
110
|
+
}, [isRenaming]);
|
|
111
|
+
|
|
112
|
+
function commitAndPersist(newTitle: string) {
|
|
113
|
+
const trimmed = newTitle.trim() || data.title;
|
|
114
|
+
renameItem(data.id, trimmed);
|
|
115
|
+
cancelRename();
|
|
116
|
+
updateLesson.mutate({
|
|
117
|
+
lessonId: data.id,
|
|
118
|
+
sessionId: data.sessionId,
|
|
119
|
+
formValues: {
|
|
120
|
+
title: trimmed,
|
|
121
|
+
type: data.type,
|
|
122
|
+
duration: data.duration,
|
|
123
|
+
publicDescription: data.publicDescription,
|
|
124
|
+
privateDescription: data.privateDescription,
|
|
125
|
+
status: data.status,
|
|
126
|
+
visibility: data.visibility,
|
|
127
|
+
videoProvider: data.videoProvider,
|
|
128
|
+
videoUrl: data.videoUrl,
|
|
129
|
+
autoDuration: data.autoDuration,
|
|
130
|
+
transcription: data.transcription,
|
|
131
|
+
postContent: data.postContent,
|
|
132
|
+
linkedExam: data.linkedExam,
|
|
133
|
+
},
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
|
|
138
|
+
e.stopPropagation();
|
|
139
|
+
if (e.key === 'Enter') {
|
|
140
|
+
commitAndPersist(inputRef.current?.value ?? data.title);
|
|
141
|
+
} else if (e.key === 'Escape') {
|
|
142
|
+
cancelRename();
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const durationH = Math.floor(data.duration / 60);
|
|
147
|
+
const durationM = data.duration % 60;
|
|
148
|
+
const durationLabel =
|
|
149
|
+
durationH > 0
|
|
150
|
+
? `${durationH}h${durationM > 0 ? `${durationM}m` : ''}`
|
|
151
|
+
: `${durationM}m`;
|
|
152
|
+
|
|
153
|
+
return (
|
|
154
|
+
<div
|
|
155
|
+
onClick={isRenaming ? undefined : onClick}
|
|
156
|
+
onDoubleClick={(e) => {
|
|
157
|
+
e.stopPropagation();
|
|
158
|
+
startRename(data.id);
|
|
159
|
+
}}
|
|
160
|
+
role="treeitem"
|
|
161
|
+
aria-selected={isActive}
|
|
162
|
+
title={`${cfg.label} · ${data.duration}min`}
|
|
163
|
+
className={cn(
|
|
164
|
+
'flex items-center gap-1.5 pl-7 pr-2 rounded-md cursor-pointer select-none text-sm h-full group',
|
|
165
|
+
'transition-colors duration-100',
|
|
166
|
+
isActive &&
|
|
167
|
+
'bg-accent text-accent-foreground ring-1 ring-inset ring-primary/20',
|
|
168
|
+
isSelected && !isActive && 'bg-accent/60',
|
|
169
|
+
!isActive && !isSelected && 'hover:bg-muted/60'
|
|
170
|
+
)}
|
|
171
|
+
>
|
|
172
|
+
{/* Type icon */}
|
|
173
|
+
<Icon className={cn('size-3.5 shrink-0', cfg.color)} aria-hidden />
|
|
174
|
+
|
|
175
|
+
{/* Inline rename or title */}
|
|
176
|
+
{isRenaming ? (
|
|
177
|
+
<Input
|
|
178
|
+
ref={inputRef}
|
|
179
|
+
defaultValue={data.title}
|
|
180
|
+
className="h-5 text-xs px-1 py-0 flex-1 min-w-0"
|
|
181
|
+
onClick={(e) => e.stopPropagation()}
|
|
182
|
+
onBlur={(e) => commitAndPersist(e.target.value)}
|
|
183
|
+
onKeyDown={handleKeyDown}
|
|
184
|
+
aria-label="Renomear aula"
|
|
185
|
+
/>
|
|
186
|
+
) : (
|
|
187
|
+
<span className="truncate flex-1 text-xs leading-tight">
|
|
188
|
+
<HighlightedText text={data.title} query={query} />
|
|
189
|
+
</span>
|
|
190
|
+
)}
|
|
191
|
+
|
|
192
|
+
{/* Meta (hidden during rename) */}
|
|
193
|
+
{!isRenaming && (
|
|
194
|
+
<div className="flex items-center gap-1 shrink-0">
|
|
195
|
+
{/* Code */}
|
|
196
|
+
{showCode && (
|
|
197
|
+
<span className="inline-flex items-center rounded border border-border/60 bg-muted/60 px-1 text-[0.6rem] font-mono text-muted-foreground leading-4">
|
|
198
|
+
{data.code}
|
|
199
|
+
</span>
|
|
200
|
+
)}
|
|
201
|
+
{/* Duration */}
|
|
202
|
+
<span className="text-[0.6rem] text-muted-foreground tabular-nums">
|
|
203
|
+
{durationLabel}
|
|
204
|
+
</span>
|
|
205
|
+
{/* Visibility icon */}
|
|
206
|
+
{showVisibility &&
|
|
207
|
+
visibilityCfg &&
|
|
208
|
+
(() => {
|
|
209
|
+
const VisIcon = visibilityCfg.icon;
|
|
210
|
+
return (
|
|
211
|
+
<span
|
|
212
|
+
title={`Visibilidade: ${visibilityCfg.label}`}
|
|
213
|
+
aria-label={`Visibilidade: ${visibilityCfg.label}`}
|
|
214
|
+
className="inline-flex items-center"
|
|
215
|
+
>
|
|
216
|
+
<VisIcon
|
|
217
|
+
className={cn('size-3 shrink-0', visibilityCfg.color)}
|
|
218
|
+
/>
|
|
219
|
+
</span>
|
|
220
|
+
);
|
|
221
|
+
})()}
|
|
222
|
+
{/* Status dot */}
|
|
223
|
+
{showStatusDot && statusCfg && (
|
|
224
|
+
<span
|
|
225
|
+
className={cn('size-1.5 rounded-full shrink-0', statusCfg.dot)}
|
|
226
|
+
title={`Status: ${statusCfg.label}`}
|
|
227
|
+
aria-label={`Status: ${statusCfg.label}`}
|
|
228
|
+
/>
|
|
229
|
+
)}
|
|
230
|
+
{/* Video linked indicator */}
|
|
231
|
+
{showVideoIndicator && data.type === 'video' && data.videoUrl && (
|
|
232
|
+
<span
|
|
233
|
+
title="Vídeo vinculado"
|
|
234
|
+
aria-label="Vídeo vinculado"
|
|
235
|
+
className="inline-flex items-center"
|
|
236
|
+
>
|
|
237
|
+
<Film className="size-3 shrink-0 text-violet-500" />
|
|
238
|
+
</span>
|
|
239
|
+
)}
|
|
240
|
+
{/* Resources indicator */}
|
|
241
|
+
{showResourcesIndicator && data.resources.length > 0 && (
|
|
242
|
+
<span
|
|
243
|
+
title={`${data.resources.length} recurso${data.resources.length > 1 ? 's' : ''} para download`}
|
|
244
|
+
aria-label={`${data.resources.length} recursos`}
|
|
245
|
+
className="inline-flex items-center gap-0.5"
|
|
246
|
+
>
|
|
247
|
+
<Paperclip className="size-3 shrink-0 text-sky-500" />
|
|
248
|
+
{data.resources.length > 1 && (
|
|
249
|
+
<span className="text-[0.55rem] text-sky-500 tabular-nums leading-none">
|
|
250
|
+
{data.resources.length}
|
|
251
|
+
</span>
|
|
252
|
+
)}
|
|
253
|
+
</span>
|
|
254
|
+
)}
|
|
255
|
+
{/* Transcription indicator */}
|
|
256
|
+
{showTranscriptionIndicator &&
|
|
257
|
+
data.type === 'video' &&
|
|
258
|
+
data.transcription && (
|
|
259
|
+
<span
|
|
260
|
+
title="Possui transcrição"
|
|
261
|
+
aria-label="Possui transcrição"
|
|
262
|
+
className="inline-flex items-center"
|
|
263
|
+
>
|
|
264
|
+
<ScrollText className="size-3 shrink-0 text-emerald-500" />
|
|
265
|
+
</span>
|
|
266
|
+
)}
|
|
267
|
+
</div>
|
|
268
|
+
)}
|
|
269
|
+
</div>
|
|
270
|
+
);
|
|
271
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Input } from '@/components/ui/input';
|
|
4
|
+
import { cn } from '@/lib/utils';
|
|
5
|
+
import { ChevronDown, ChevronRight, Layers } from 'lucide-react';
|
|
6
|
+
import { useEffect, useRef } from 'react';
|
|
7
|
+
import { useUpdateSessionMutation } from '../_data/use-course-structure-mutations';
|
|
8
|
+
import { HighlightedText } from './highlighted-text';
|
|
9
|
+
import { useStructureStore } from './store';
|
|
10
|
+
import type { Session } from './types';
|
|
11
|
+
|
|
12
|
+
interface TreeRowSessionProps {
|
|
13
|
+
data: Session;
|
|
14
|
+
lessonCount: number;
|
|
15
|
+
isExpanded: boolean;
|
|
16
|
+
isSelected: boolean;
|
|
17
|
+
isActive: boolean;
|
|
18
|
+
query: string;
|
|
19
|
+
isMatched: boolean;
|
|
20
|
+
onClick: (e: React.MouseEvent) => void;
|
|
21
|
+
onToggleExpand: (e: React.MouseEvent) => void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function TreeRowSession({
|
|
25
|
+
data,
|
|
26
|
+
lessonCount,
|
|
27
|
+
isExpanded,
|
|
28
|
+
isSelected,
|
|
29
|
+
isActive,
|
|
30
|
+
query,
|
|
31
|
+
onClick,
|
|
32
|
+
onToggleExpand,
|
|
33
|
+
}: TreeRowSessionProps) {
|
|
34
|
+
const inlineRenamingId = useStructureStore((s) => s.inlineRenamingId);
|
|
35
|
+
const cancelRename = useStructureStore((s) => s.cancelRename);
|
|
36
|
+
const startRename = useStructureStore((s) => s.startRename);
|
|
37
|
+
const renameItem = useStructureStore((s) => s.renameItem);
|
|
38
|
+
|
|
39
|
+
const updateSession = useUpdateSessionMutation();
|
|
40
|
+
|
|
41
|
+
const isRenaming = inlineRenamingId === data.id;
|
|
42
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
43
|
+
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
if (isRenaming) {
|
|
46
|
+
inputRef.current?.focus();
|
|
47
|
+
inputRef.current?.select();
|
|
48
|
+
}
|
|
49
|
+
}, [isRenaming]);
|
|
50
|
+
|
|
51
|
+
function commitAndPersist(newTitle: string) {
|
|
52
|
+
const trimmed = newTitle.trim() || data.title;
|
|
53
|
+
renameItem(data.id, trimmed);
|
|
54
|
+
cancelRename();
|
|
55
|
+
updateSession.mutate({
|
|
56
|
+
sessionId: data.id,
|
|
57
|
+
formValues: {
|
|
58
|
+
title: trimmed,
|
|
59
|
+
duration: data.duration,
|
|
60
|
+
code: data.code,
|
|
61
|
+
description: '',
|
|
62
|
+
visibility: data.visibility ?? 'publico',
|
|
63
|
+
published: data.published ?? false,
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
|
|
69
|
+
e.stopPropagation();
|
|
70
|
+
if (e.key === 'Enter') {
|
|
71
|
+
commitAndPersist(inputRef.current?.value ?? data.title);
|
|
72
|
+
} else if (e.key === 'Escape') {
|
|
73
|
+
cancelRename();
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const durationH = Math.floor((data.duration ?? 0) / 60);
|
|
78
|
+
const durationM = (data.duration ?? 0) % 60;
|
|
79
|
+
const durationLabel =
|
|
80
|
+
durationH > 0
|
|
81
|
+
? `${durationH}h${durationM > 0 ? ` ${durationM}m` : ''}`
|
|
82
|
+
: `${durationM}m`;
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<div
|
|
86
|
+
onClick={isRenaming ? undefined : onClick}
|
|
87
|
+
onDoubleClick={(e) => {
|
|
88
|
+
e.stopPropagation();
|
|
89
|
+
startRename(data.id);
|
|
90
|
+
}}
|
|
91
|
+
role="treeitem"
|
|
92
|
+
aria-expanded={isExpanded}
|
|
93
|
+
aria-selected={isActive}
|
|
94
|
+
className={cn(
|
|
95
|
+
'flex items-center gap-1.5 px-2 py-1.5 rounded-md cursor-pointer select-none text-sm group h-full',
|
|
96
|
+
'transition-colors duration-100',
|
|
97
|
+
isActive &&
|
|
98
|
+
'bg-accent text-accent-foreground ring-1 ring-inset ring-primary/20',
|
|
99
|
+
isSelected && !isActive && 'bg-accent/60',
|
|
100
|
+
!isActive && !isSelected && 'hover:bg-muted/60'
|
|
101
|
+
)}
|
|
102
|
+
>
|
|
103
|
+
{/* Expand/collapse chevron */}
|
|
104
|
+
<button
|
|
105
|
+
className="shrink-0 text-muted-foreground hover:text-foreground transition-colors p-0.5 rounded focus-visible:ring-1 focus-visible:ring-ring"
|
|
106
|
+
onClick={onToggleExpand}
|
|
107
|
+
aria-label={isExpanded ? 'Recolher sessão' : 'Expandir sessão'}
|
|
108
|
+
tabIndex={-1}
|
|
109
|
+
>
|
|
110
|
+
{isExpanded ? (
|
|
111
|
+
<ChevronDown className="size-3.5" />
|
|
112
|
+
) : (
|
|
113
|
+
<ChevronRight className="size-3.5" />
|
|
114
|
+
)}
|
|
115
|
+
</button>
|
|
116
|
+
|
|
117
|
+
{/* Session icon */}
|
|
118
|
+
<Layers
|
|
119
|
+
className="size-3.5 shrink-0 text-blue-500 dark:text-blue-400"
|
|
120
|
+
aria-hidden
|
|
121
|
+
/>
|
|
122
|
+
|
|
123
|
+
{/* Inline rename or title */}
|
|
124
|
+
{isRenaming ? (
|
|
125
|
+
<Input
|
|
126
|
+
ref={inputRef}
|
|
127
|
+
defaultValue={data.title}
|
|
128
|
+
className="h-5 text-xs px-1 py-0 flex-1 min-w-0"
|
|
129
|
+
onClick={(e) => e.stopPropagation()}
|
|
130
|
+
onBlur={(e) => commitAndPersist(e.target.value)}
|
|
131
|
+
onKeyDown={handleKeyDown}
|
|
132
|
+
aria-label="Renomear sessão"
|
|
133
|
+
/>
|
|
134
|
+
) : (
|
|
135
|
+
<span className="truncate flex-1 font-medium leading-tight">
|
|
136
|
+
<HighlightedText text={data.title} query={query} />
|
|
137
|
+
</span>
|
|
138
|
+
)}
|
|
139
|
+
|
|
140
|
+
{/* Meta badges (hidden during rename) */}
|
|
141
|
+
{!isRenaming && (
|
|
142
|
+
<div className="flex items-center gap-1 shrink-0">
|
|
143
|
+
{/* Duration - show on hover */}
|
|
144
|
+
{data.duration > 0 && (
|
|
145
|
+
<span className="text-[0.6rem] text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity tabular-nums">
|
|
146
|
+
{durationLabel}
|
|
147
|
+
</span>
|
|
148
|
+
)}
|
|
149
|
+
{/* Lesson count - always visible */}
|
|
150
|
+
<span className="text-[0.62rem] font-medium tabular-nums text-muted-foreground min-w-[1.5rem] text-right">
|
|
151
|
+
{lessonCount}
|
|
152
|
+
</span>
|
|
153
|
+
{/* Published status dot */}
|
|
154
|
+
<span
|
|
155
|
+
className={cn(
|
|
156
|
+
'size-1.5 rounded-full shrink-0',
|
|
157
|
+
data.published === false
|
|
158
|
+
? 'bg-muted-foreground/30'
|
|
159
|
+
: 'bg-emerald-500'
|
|
160
|
+
)}
|
|
161
|
+
title={data.published === false ? 'Rascunho' : 'Publicada'}
|
|
162
|
+
/>
|
|
163
|
+
</div>
|
|
164
|
+
)}
|
|
165
|
+
</div>
|
|
166
|
+
);
|
|
167
|
+
}
|