@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,949 +1,949 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Course Structure — Zustand Store
|
|
3
|
-
*
|
|
4
|
-
* Responsibility split:
|
|
5
|
-
*
|
|
6
|
-
* ┌───────────────────────────────────────────────────────────────────┐
|
|
7
|
-
* │ DATA FIELDS (course / sessions / lessons) │
|
|
8
|
-
* │ Currently seeded from mock-data.ts. │
|
|
9
|
-
* │ │
|
|
10
|
-
* │ TODO[API]: Once `useCourseStructure` switches to React Query, │
|
|
11
|
-
* │ remove these three fields from the store and derive them from │
|
|
12
|
-
* │ the query cache instead. The store then becomes UI-only. │
|
|
13
|
-
* ├───────────────────────────────────────────────────────────────────┤
|
|
14
|
-
* │ UI-ONLY FIELDS (keep forever) │
|
|
15
|
-
* │ • activeItemId / activeItemType — detail panel selection │
|
|
16
|
-
* │ • expandedIds — tree accordion state │
|
|
17
|
-
* │ • filterQuery — search bar value │
|
|
18
|
-
* │ • selectedIds / lastClickedId — multi-select │
|
|
19
|
-
* │ • copiedIds / copiedType — clipboard │
|
|
20
|
-
* │ • inlineRenamingId — inline edit │
|
|
21
|
-
* │ • mobileSheetOpen — mobile sheet │
|
|
22
|
-
* │ • confirmDialog / sessionPickerDialog — dialog state │
|
|
23
|
-
* └───────────────────────────────────────────────────────────────────┘
|
|
24
|
-
*/
|
|
25
|
-
import { create } from 'zustand';
|
|
26
|
-
|
|
27
|
-
import type {
|
|
28
|
-
Course,
|
|
29
|
-
CourseFormValues,
|
|
30
|
-
FlatItem,
|
|
31
|
-
Lesson,
|
|
32
|
-
LessonFormValues,
|
|
33
|
-
Session,
|
|
34
|
-
SessionFormValues,
|
|
35
|
-
} from './types';
|
|
36
|
-
|
|
37
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
38
|
-
// State interface
|
|
39
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
40
|
-
|
|
41
|
-
interface StructureState {
|
|
42
|
-
// Data
|
|
43
|
-
courseId: string;
|
|
44
|
-
course: Course;
|
|
45
|
-
sessions: Session[];
|
|
46
|
-
lessons: Lesson[];
|
|
47
|
-
|
|
48
|
-
// Active detail
|
|
49
|
-
activeItemId: string | null;
|
|
50
|
-
activeItemType: 'course' | 'session' | 'lesson' | null;
|
|
51
|
-
|
|
52
|
-
// Tree UI
|
|
53
|
-
expandedIds: Set<string>;
|
|
54
|
-
filterQuery: string;
|
|
55
|
-
|
|
56
|
-
// Multi-select
|
|
57
|
-
selectedIds: Set<string>;
|
|
58
|
-
lastClickedId: string | null;
|
|
59
|
-
|
|
60
|
-
// Clipboard (mock)
|
|
61
|
-
copiedIds: string[];
|
|
62
|
-
copiedType: 'session' | 'lesson' | null;
|
|
63
|
-
|
|
64
|
-
// Inline rename
|
|
65
|
-
inlineRenamingId: string | null;
|
|
66
|
-
|
|
67
|
-
// Mobile
|
|
68
|
-
mobileSheetOpen: boolean;
|
|
69
|
-
|
|
70
|
-
// Confirm dialog state
|
|
71
|
-
confirmDialog: {
|
|
72
|
-
open: boolean;
|
|
73
|
-
title: string;
|
|
74
|
-
description: string;
|
|
75
|
-
onConfirm: (() => void) | null;
|
|
76
|
-
};
|
|
77
|
-
|
|
78
|
-
// Session picker dialog state
|
|
79
|
-
sessionPickerDialog: {
|
|
80
|
-
open: boolean;
|
|
81
|
-
title: string;
|
|
82
|
-
excludeSessionId: string | null;
|
|
83
|
-
onPick: ((sessionId: string) => void) | null;
|
|
84
|
-
};
|
|
85
|
-
|
|
86
|
-
// ── Selection ──────────────────────────────────────────────────────────────
|
|
87
|
-
selectItem: (
|
|
88
|
-
id: string,
|
|
89
|
-
type: 'course' | 'session' | 'lesson',
|
|
90
|
-
modifiers?: { ctrl?: boolean; shift?: boolean },
|
|
91
|
-
visibleItems?: FlatItem[]
|
|
92
|
-
) => void;
|
|
93
|
-
clearSelection: () => void;
|
|
94
|
-
|
|
95
|
-
// ── Clipboard ──────────────────────────────────────────────────────────────
|
|
96
|
-
copyItems: (ids: string[], type: 'session' | 'lesson') => void;
|
|
97
|
-
clearClipboard: () => void;
|
|
98
|
-
|
|
99
|
-
// ── Expand / collapse ──────────────────────────────────────────────────────
|
|
100
|
-
toggleExpand: (sessionId: string) => void;
|
|
101
|
-
expandAll: () => void;
|
|
102
|
-
collapseAll: () => void;
|
|
103
|
-
|
|
104
|
-
// ── Filter ─────────────────────────────────────────────────────────────────
|
|
105
|
-
setFilter: (query: string) => void;
|
|
106
|
-
|
|
107
|
-
// ── Mobile ─────────────────────────────────────────────────────────────────
|
|
108
|
-
setMobileSheetOpen: (open: boolean) => void;
|
|
109
|
-
|
|
110
|
-
// ── CRUD: Course ───────────────────────────────────────────────────────────
|
|
111
|
-
updateCourse: (data: CourseFormValues) => void;
|
|
112
|
-
|
|
113
|
-
// ── CRUD: Session ──────────────────────────────────────────────────────────
|
|
114
|
-
addSession: () => Session;
|
|
115
|
-
updateSession: (id: string, data: SessionFormValues) => void;
|
|
116
|
-
deleteSession: (id: string) => void;
|
|
117
|
-
|
|
118
|
-
// ── CRUD: Lesson ───────────────────────────────────────────────────────────
|
|
119
|
-
addLesson: (sessionId: string) => Lesson;
|
|
120
|
-
updateLesson: (
|
|
121
|
-
id: string,
|
|
122
|
-
data: Partial<
|
|
123
|
-
LessonFormValues & {
|
|
124
|
-
transcription?: string;
|
|
125
|
-
resources?: Lesson['resources'];
|
|
126
|
-
}
|
|
127
|
-
>
|
|
128
|
-
) => void;
|
|
129
|
-
deleteLesson: (id: string) => void;
|
|
130
|
-
|
|
131
|
-
// ── Bulk delete ────────────────────────────────────────────────────────────
|
|
132
|
-
deleteSelected: () => void;
|
|
133
|
-
|
|
134
|
-
// ── Inline rename ──────────────────────────────────────────────────────────
|
|
135
|
-
startRename: (id: string) => void;
|
|
136
|
-
commitRename: (id: string, newTitle: string) => void;
|
|
137
|
-
cancelRename: () => void;
|
|
138
|
-
renameItem: (id: string, newTitle: string) => void;
|
|
139
|
-
|
|
140
|
-
// ── Duplicate ──────────────────────────────────────────────────────────────
|
|
141
|
-
duplicateSession: (id: string) => void;
|
|
142
|
-
duplicateLesson: (id: string) => void;
|
|
143
|
-
duplicateSelected: () => void;
|
|
144
|
-
|
|
145
|
-
// ── Paste ──────────────────────────────────────────────────────────────────
|
|
146
|
-
pasteSessions: () => void;
|
|
147
|
-
pasteLessons: (toSessionId: string) => void;
|
|
148
|
-
|
|
149
|
-
// ── Move multiple ──────────────────────────────────────────────────────────
|
|
150
|
-
moveLessons: (lessonIds: string[], toSessionId: string) => void;
|
|
151
|
-
|
|
152
|
-
// ── DnD reorder ────────────────────────────────────────────────────────────
|
|
153
|
-
reorderSessions: (fromIndex: number, toIndex: number) => void;
|
|
154
|
-
moveLesson: (lessonId: string, toSessionId: string, toIndex: number) => void;
|
|
155
|
-
|
|
156
|
-
// ── API integration ────────────────────────────────────────────────────────
|
|
157
|
-
/** Set the active course ID (called by page.tsx on mount). */
|
|
158
|
-
setCourseId: (id: string) => void;
|
|
159
|
-
/** Set the course metadata received from the API. */
|
|
160
|
-
setCourse: (course: Course) => void;
|
|
161
|
-
/** Replace sessions+lessons in the store with data received from the API. */
|
|
162
|
-
setStructureFromApi: (data: {
|
|
163
|
-
course?: Course | null;
|
|
164
|
-
sessions: Session[];
|
|
165
|
-
lessons: Lesson[];
|
|
166
|
-
}) => void;
|
|
167
|
-
/** Append a newly-created session (from API response) and select it. */
|
|
168
|
-
addSessionFromApi: (session: Session) => void;
|
|
169
|
-
/** Append a newly-created lesson (from API response) and select it. */
|
|
170
|
-
addLessonFromApi: (lesson: Lesson) => void;
|
|
171
|
-
/**
|
|
172
|
-
* Append a batch of lessons (from API duplicate/paste response).
|
|
173
|
-
* Selects the last lesson in the batch and expands its session.
|
|
174
|
-
*/
|
|
175
|
-
addLessonsFromApi: (lessons: Lesson[]) => void;
|
|
176
|
-
/**
|
|
177
|
-
* Append a session + its cloned lessons (from duplicateSession API response).
|
|
178
|
-
* Selects the new session.
|
|
179
|
-
*/
|
|
180
|
-
addSessionWithLessonsFromApi: (session: Session, lessons: Lesson[]) => void;
|
|
181
|
-
|
|
182
|
-
// ── Confirm dialog ─────────────────────────────────────────────────────────
|
|
183
|
-
showConfirm: (opts: {
|
|
184
|
-
title: string;
|
|
185
|
-
description: string;
|
|
186
|
-
onConfirm: () => void;
|
|
187
|
-
}) => void;
|
|
188
|
-
closeConfirm: () => void;
|
|
189
|
-
|
|
190
|
-
// ── Session picker dialog ──────────────────────────────────────────────────
|
|
191
|
-
showSessionPicker: (opts: {
|
|
192
|
-
title: string;
|
|
193
|
-
excludeSessionId?: string | null;
|
|
194
|
-
onPick: (sessionId: string) => void;
|
|
195
|
-
}) => void;
|
|
196
|
-
closeSessionPicker: () => void;
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
200
|
-
// Helpers
|
|
201
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
202
|
-
|
|
203
|
-
function getNextCode(items: Array<{ code: string }>, prefix: string): string {
|
|
204
|
-
const regex = new RegExp(`^${prefix}(\\d+)$`, 'i');
|
|
205
|
-
const max = items.reduce((m, item) => {
|
|
206
|
-
const match = item.code?.trim().match(regex);
|
|
207
|
-
if (!match) return m;
|
|
208
|
-
const n = Number(match[1]);
|
|
209
|
-
return Number.isFinite(n) ? Math.max(m, n) : m;
|
|
210
|
-
}, 0);
|
|
211
|
-
return `${prefix}${String(max + 1).padStart(2, '0')}`;
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
function newId(): string {
|
|
215
|
-
if (
|
|
216
|
-
typeof crypto !== 'undefined' &&
|
|
217
|
-
typeof crypto.randomUUID === 'function'
|
|
218
|
-
) {
|
|
219
|
-
return crypto.randomUUID();
|
|
220
|
-
}
|
|
221
|
-
return `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
function getExpandedIdsStorageKey(courseId: string): string {
|
|
225
|
-
return `lms:course-structure:${courseId}:expanded-ids`;
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
function loadExpandedIds(courseId: string): Set<string> {
|
|
229
|
-
if (typeof window === 'undefined' || !courseId) return new Set();
|
|
230
|
-
try {
|
|
231
|
-
const saved = JSON.parse(
|
|
232
|
-
localStorage.getItem(getExpandedIdsStorageKey(courseId)) ?? '[]'
|
|
233
|
-
);
|
|
234
|
-
if (!Array.isArray(saved)) return new Set();
|
|
235
|
-
return new Set(saved.filter((id): id is string => typeof id === 'string'));
|
|
236
|
-
} catch {
|
|
237
|
-
return new Set();
|
|
238
|
-
}
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
function saveExpandedIds(courseId: string, expandedIds: Set<string>): void {
|
|
242
|
-
if (typeof window === 'undefined' || !courseId) return;
|
|
243
|
-
try {
|
|
244
|
-
localStorage.setItem(
|
|
245
|
-
getExpandedIdsStorageKey(courseId),
|
|
246
|
-
JSON.stringify([...expandedIds])
|
|
247
|
-
);
|
|
248
|
-
} catch {
|
|
249
|
-
// Ignore storage quota/privacy errors.
|
|
250
|
-
}
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
254
|
-
// Store
|
|
255
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
256
|
-
|
|
257
|
-
export const useStructureStore = create<StructureState>((set, get) => ({
|
|
258
|
-
courseId: '',
|
|
259
|
-
course: {
|
|
260
|
-
id: '',
|
|
261
|
-
code: '',
|
|
262
|
-
name: '',
|
|
263
|
-
title: 'Carregando...',
|
|
264
|
-
description: '',
|
|
265
|
-
slug: '',
|
|
266
|
-
published: false,
|
|
267
|
-
},
|
|
268
|
-
sessions: [],
|
|
269
|
-
lessons: [],
|
|
270
|
-
|
|
271
|
-
activeItemId: null,
|
|
272
|
-
activeItemType: null,
|
|
273
|
-
expandedIds: new Set<string>(),
|
|
274
|
-
filterQuery: '',
|
|
275
|
-
selectedIds: new Set<string>(),
|
|
276
|
-
lastClickedId: null as string | null,
|
|
277
|
-
copiedIds: [] as string[],
|
|
278
|
-
copiedType: null as 'session' | 'lesson' | null,
|
|
279
|
-
inlineRenamingId: null as string | null,
|
|
280
|
-
mobileSheetOpen: false,
|
|
281
|
-
confirmDialog: {
|
|
282
|
-
open: false,
|
|
283
|
-
title: '',
|
|
284
|
-
description: '',
|
|
285
|
-
onConfirm: null as (() => void) | null,
|
|
286
|
-
},
|
|
287
|
-
sessionPickerDialog: {
|
|
288
|
-
open: false,
|
|
289
|
-
title: '',
|
|
290
|
-
excludeSessionId: null as string | null,
|
|
291
|
-
onPick: null as ((sessionId: string) => void) | null,
|
|
292
|
-
},
|
|
293
|
-
|
|
294
|
-
selectItem: (id, type, modifiers = {}, visibleItems = []) =>
|
|
295
|
-
set((s) => {
|
|
296
|
-
// SHIFT: extend range based on visible order
|
|
297
|
-
if (modifiers.shift && s.lastClickedId && visibleItems.length > 0) {
|
|
298
|
-
const lastIdx = visibleItems.findIndex((i) => i.id === s.lastClickedId);
|
|
299
|
-
const currIdx = visibleItems.findIndex((i) => i.id === id);
|
|
300
|
-
if (lastIdx !== -1 && currIdx !== -1) {
|
|
301
|
-
const [from, to] =
|
|
302
|
-
lastIdx <= currIdx ? [lastIdx, currIdx] : [currIdx, lastIdx];
|
|
303
|
-
const next = new Set(s.selectedIds);
|
|
304
|
-
for (let i = from; i <= to; i++) {
|
|
305
|
-
const node = visibleItems[i];
|
|
306
|
-
if (node) next.add(`${node.type}:${node.id}`);
|
|
307
|
-
}
|
|
308
|
-
return { selectedIds: next, activeItemId: id, activeItemType: type };
|
|
309
|
-
}
|
|
310
|
-
}
|
|
311
|
-
// CTRL/CMD: toggle individual item
|
|
312
|
-
if (modifiers.ctrl) {
|
|
313
|
-
const key = `${type}:${id}`;
|
|
314
|
-
const next = new Set(s.selectedIds);
|
|
315
|
-
if (next.has(key)) next.delete(key);
|
|
316
|
-
else next.add(key);
|
|
317
|
-
return {
|
|
318
|
-
selectedIds: next,
|
|
319
|
-
activeItemId: id,
|
|
320
|
-
activeItemType: type,
|
|
321
|
-
lastClickedId: id,
|
|
322
|
-
};
|
|
323
|
-
}
|
|
324
|
-
// Plain click: single selection
|
|
325
|
-
return {
|
|
326
|
-
selectedIds: new Set([`${type}:${id}`]),
|
|
327
|
-
activeItemId: id,
|
|
328
|
-
activeItemType: type,
|
|
329
|
-
lastClickedId: id,
|
|
330
|
-
};
|
|
331
|
-
}),
|
|
332
|
-
|
|
333
|
-
clearSelection: () =>
|
|
334
|
-
set((s) => ({
|
|
335
|
-
selectedIds: new Set(
|
|
336
|
-
s.activeItemId && s.activeItemType
|
|
337
|
-
? [`${s.activeItemType}:${s.activeItemId}`]
|
|
338
|
-
: []
|
|
339
|
-
),
|
|
340
|
-
})),
|
|
341
|
-
|
|
342
|
-
copyItems: (ids, type) => set({ copiedIds: ids, copiedType: type }),
|
|
343
|
-
clearClipboard: () => set({ copiedIds: [], copiedType: null }),
|
|
344
|
-
|
|
345
|
-
toggleExpand: (sessionId) =>
|
|
346
|
-
set((s) => {
|
|
347
|
-
const next = new Set(s.expandedIds);
|
|
348
|
-
if (next.has(sessionId)) next.delete(sessionId);
|
|
349
|
-
else next.add(sessionId);
|
|
350
|
-
saveExpandedIds(s.courseId, next);
|
|
351
|
-
return { expandedIds: next };
|
|
352
|
-
}),
|
|
353
|
-
|
|
354
|
-
expandAll: () =>
|
|
355
|
-
set((s) => {
|
|
356
|
-
const next = new Set(s.sessions.map((ss) => ss.id));
|
|
357
|
-
saveExpandedIds(s.courseId, next);
|
|
358
|
-
return { expandedIds: next };
|
|
359
|
-
}),
|
|
360
|
-
|
|
361
|
-
collapseAll: () =>
|
|
362
|
-
set((s) => {
|
|
363
|
-
const next = new Set<string>();
|
|
364
|
-
saveExpandedIds(s.courseId, next);
|
|
365
|
-
return { expandedIds: next };
|
|
366
|
-
}),
|
|
367
|
-
|
|
368
|
-
setFilter: (query) => set({ filterQuery: query }),
|
|
369
|
-
|
|
370
|
-
setMobileSheetOpen: (open) => set({ mobileSheetOpen: open }),
|
|
371
|
-
|
|
372
|
-
updateCourse: (data) => set((s) => ({ course: { ...s.course, ...data } })),
|
|
373
|
-
|
|
374
|
-
addSession: () => {
|
|
375
|
-
const { sessions } = get();
|
|
376
|
-
const code = getNextCode(sessions, 'S');
|
|
377
|
-
const newSession: Session = {
|
|
378
|
-
id: newId(),
|
|
379
|
-
code,
|
|
380
|
-
title: `Nova Sessão ${code}`,
|
|
381
|
-
duration: 30,
|
|
382
|
-
order: sessions.length,
|
|
383
|
-
};
|
|
384
|
-
set((s) => {
|
|
385
|
-
const nextExpandedIds = new Set([...s.expandedIds, newSession.id]);
|
|
386
|
-
saveExpandedIds(s.courseId, nextExpandedIds);
|
|
387
|
-
return {
|
|
388
|
-
sessions: [...s.sessions, newSession],
|
|
389
|
-
expandedIds: nextExpandedIds,
|
|
390
|
-
activeItemId: newSession.id,
|
|
391
|
-
activeItemType: 'session',
|
|
392
|
-
selectedIds: new Set([`session:${newSession.id}`]),
|
|
393
|
-
lastClickedId: newSession.id,
|
|
394
|
-
};
|
|
395
|
-
});
|
|
396
|
-
return newSession;
|
|
397
|
-
},
|
|
398
|
-
|
|
399
|
-
updateSession: (id, data) =>
|
|
400
|
-
set((s) => ({
|
|
401
|
-
sessions: s.sessions.map((ss) =>
|
|
402
|
-
ss.id === id ? { ...ss, ...data } : ss
|
|
403
|
-
),
|
|
404
|
-
})),
|
|
405
|
-
|
|
406
|
-
deleteSession: (id) =>
|
|
407
|
-
set((s) => {
|
|
408
|
-
const nextExpandedIds = new Set(s.expandedIds);
|
|
409
|
-
nextExpandedIds.delete(id);
|
|
410
|
-
const isActive = s.activeItemId === id && s.activeItemType === 'session';
|
|
411
|
-
saveExpandedIds(s.courseId, nextExpandedIds);
|
|
412
|
-
return {
|
|
413
|
-
sessions: s.sessions.filter((ss) => ss.id !== id),
|
|
414
|
-
lessons: s.lessons.filter((l) => l.sessionId !== id),
|
|
415
|
-
expandedIds: nextExpandedIds,
|
|
416
|
-
selectedIds: new Set([`course:${s.course.id}`]),
|
|
417
|
-
activeItemId: isActive ? s.course.id : s.activeItemId,
|
|
418
|
-
activeItemType: isActive ? 'course' : s.activeItemType,
|
|
419
|
-
};
|
|
420
|
-
}),
|
|
421
|
-
|
|
422
|
-
addLesson: (sessionId) => {
|
|
423
|
-
const { lessons } = get();
|
|
424
|
-
const sessionLessons = lessons.filter((l) => l.sessionId === sessionId);
|
|
425
|
-
const code = getNextCode(lessons, 'A');
|
|
426
|
-
const newLesson: Lesson = {
|
|
427
|
-
id: newId(),
|
|
428
|
-
code,
|
|
429
|
-
title: `Nova Aula ${code}`,
|
|
430
|
-
publicDescription: '',
|
|
431
|
-
privateDescription: '',
|
|
432
|
-
type: 'video',
|
|
433
|
-
duration: 10,
|
|
434
|
-
sessionId,
|
|
435
|
-
order: sessionLessons.length,
|
|
436
|
-
resources: [],
|
|
437
|
-
};
|
|
438
|
-
set((s) => {
|
|
439
|
-
const nextExpandedIds = new Set([...s.expandedIds, sessionId]);
|
|
440
|
-
saveExpandedIds(s.courseId, nextExpandedIds);
|
|
441
|
-
return {
|
|
442
|
-
lessons: [...s.lessons, newLesson],
|
|
443
|
-
expandedIds: nextExpandedIds,
|
|
444
|
-
activeItemId: newLesson.id,
|
|
445
|
-
activeItemType: 'lesson',
|
|
446
|
-
selectedIds: new Set([`lesson:${newLesson.id}`]),
|
|
447
|
-
lastClickedId: newLesson.id,
|
|
448
|
-
};
|
|
449
|
-
});
|
|
450
|
-
return newLesson;
|
|
451
|
-
},
|
|
452
|
-
|
|
453
|
-
updateLesson: (id, data) =>
|
|
454
|
-
set((s) => ({
|
|
455
|
-
lessons: s.lessons.map((l) => (l.id === id ? { ...l, ...data } : l)),
|
|
456
|
-
})),
|
|
457
|
-
|
|
458
|
-
deleteLesson: (id) =>
|
|
459
|
-
set((s) => {
|
|
460
|
-
const isActive = s.activeItemId === id && s.activeItemType === 'lesson';
|
|
461
|
-
const nextSelected = new Set(s.selectedIds);
|
|
462
|
-
nextSelected.delete(`lesson:${id}`);
|
|
463
|
-
if (nextSelected.size === 0) nextSelected.add(`course:${s.course.id}`);
|
|
464
|
-
return {
|
|
465
|
-
lessons: s.lessons.filter((l) => l.id !== id),
|
|
466
|
-
selectedIds: nextSelected,
|
|
467
|
-
activeItemId: isActive ? s.course.id : s.activeItemId,
|
|
468
|
-
activeItemType: isActive ? 'course' : s.activeItemType,
|
|
469
|
-
};
|
|
470
|
-
}),
|
|
471
|
-
|
|
472
|
-
deleteSelected: () =>
|
|
473
|
-
set((s) => {
|
|
474
|
-
const sessionIds = new Set(
|
|
475
|
-
s.sessions.filter((ss) => s.selectedIds.has(ss.id)).map((ss) => ss.id)
|
|
476
|
-
);
|
|
477
|
-
const lessonIds = new Set(
|
|
478
|
-
s.lessons
|
|
479
|
-
.filter((l) => s.selectedIds.has(l.id) || sessionIds.has(l.sessionId))
|
|
480
|
-
.map((l) => l.id)
|
|
481
|
-
);
|
|
482
|
-
const nextExpandedIds = new Set(
|
|
483
|
-
[...s.expandedIds].filter((id) => !sessionIds.has(id))
|
|
484
|
-
);
|
|
485
|
-
saveExpandedIds(s.courseId, nextExpandedIds);
|
|
486
|
-
return {
|
|
487
|
-
sessions: s.sessions.filter((ss) => !sessionIds.has(ss.id)),
|
|
488
|
-
lessons: s.lessons.filter((l) => !lessonIds.has(l.id)),
|
|
489
|
-
selectedIds: new Set([`course:${s.course.id}`]),
|
|
490
|
-
activeItemId: s.course.id,
|
|
491
|
-
activeItemType: 'course' as const,
|
|
492
|
-
expandedIds: nextExpandedIds,
|
|
493
|
-
};
|
|
494
|
-
}),
|
|
495
|
-
|
|
496
|
-
startRename: (id) => set({ inlineRenamingId: id }),
|
|
497
|
-
cancelRename: () => set({ inlineRenamingId: null }),
|
|
498
|
-
|
|
499
|
-
renameItem: (id, newTitle) => {
|
|
500
|
-
const trimmed = newTitle.trim();
|
|
501
|
-
if (!trimmed) return;
|
|
502
|
-
set((s) => {
|
|
503
|
-
const isSession = s.sessions.some((ss) => ss.id === id);
|
|
504
|
-
if (isSession) {
|
|
505
|
-
return {
|
|
506
|
-
sessions: s.sessions.map((ss) =>
|
|
507
|
-
ss.id === id ? { ...ss, title: trimmed } : ss
|
|
508
|
-
),
|
|
509
|
-
};
|
|
510
|
-
}
|
|
511
|
-
return {
|
|
512
|
-
lessons: s.lessons.map((l) =>
|
|
513
|
-
l.id === id ? { ...l, title: trimmed } : l
|
|
514
|
-
),
|
|
515
|
-
};
|
|
516
|
-
});
|
|
517
|
-
},
|
|
518
|
-
|
|
519
|
-
commitRename: (id, newTitle) => {
|
|
520
|
-
get().renameItem(id, newTitle);
|
|
521
|
-
set({ inlineRenamingId: null });
|
|
522
|
-
},
|
|
523
|
-
|
|
524
|
-
duplicateSession: (id) => {
|
|
525
|
-
const { sessions, lessons } = get();
|
|
526
|
-
const session = sessions.find((ss) => ss.id === id);
|
|
527
|
-
if (!session) return;
|
|
528
|
-
|
|
529
|
-
const newSessionId = newId();
|
|
530
|
-
const newSession: Session = {
|
|
531
|
-
...session,
|
|
532
|
-
id: newSessionId,
|
|
533
|
-
title: `Cópia de ${session.title}`,
|
|
534
|
-
order: sessions.length,
|
|
535
|
-
};
|
|
536
|
-
|
|
537
|
-
const sessionLessons = lessons
|
|
538
|
-
.filter((l) => l.sessionId === id)
|
|
539
|
-
.sort((a, b) => a.order - b.order);
|
|
540
|
-
|
|
541
|
-
const newLessons: Lesson[] = sessionLessons.map((l) => ({
|
|
542
|
-
...l,
|
|
543
|
-
id: newId(),
|
|
544
|
-
sessionId: newSessionId,
|
|
545
|
-
}));
|
|
546
|
-
|
|
547
|
-
set((s) => {
|
|
548
|
-
const nextExpandedIds = new Set([...s.expandedIds, newSessionId]);
|
|
549
|
-
saveExpandedIds(s.courseId, nextExpandedIds);
|
|
550
|
-
return {
|
|
551
|
-
sessions: [...s.sessions, newSession],
|
|
552
|
-
lessons: [...s.lessons, ...newLessons],
|
|
553
|
-
activeItemId: newSessionId,
|
|
554
|
-
activeItemType: 'session',
|
|
555
|
-
selectedIds: new Set([`session:${newSessionId}`]),
|
|
556
|
-
expandedIds: nextExpandedIds,
|
|
557
|
-
};
|
|
558
|
-
});
|
|
559
|
-
},
|
|
560
|
-
|
|
561
|
-
duplicateLesson: (id) => {
|
|
562
|
-
const { lessons } = get();
|
|
563
|
-
const lesson = lessons.find((l) => l.id === id);
|
|
564
|
-
if (!lesson) return;
|
|
565
|
-
|
|
566
|
-
const sessionLessons = lessons
|
|
567
|
-
.filter((l) => l.sessionId === lesson.sessionId)
|
|
568
|
-
.sort((a, b) => a.order - b.order);
|
|
569
|
-
|
|
570
|
-
const insertAt = lesson.order + 1;
|
|
571
|
-
const newLesson: Lesson = {
|
|
572
|
-
...lesson,
|
|
573
|
-
id: newId(),
|
|
574
|
-
title: `Cópia de ${lesson.title}`,
|
|
575
|
-
order: insertAt,
|
|
576
|
-
resources: [...lesson.resources],
|
|
577
|
-
};
|
|
578
|
-
|
|
579
|
-
// Shift all lessons at or after insertAt
|
|
580
|
-
const updatedSession = sessionLessons.map((l) =>
|
|
581
|
-
l.order >= insertAt ? { ...l, order: l.order + 1 } : l
|
|
582
|
-
);
|
|
583
|
-
|
|
584
|
-
const unchanged = lessons.filter((l) => l.sessionId !== lesson.sessionId);
|
|
585
|
-
|
|
586
|
-
set((s) => {
|
|
587
|
-
const nextExpandedIds = new Set([...s.expandedIds, lesson.sessionId]);
|
|
588
|
-
saveExpandedIds(s.courseId, nextExpandedIds);
|
|
589
|
-
return {
|
|
590
|
-
lessons: [...unchanged, ...updatedSession, newLesson],
|
|
591
|
-
activeItemId: newLesson.id,
|
|
592
|
-
activeItemType: 'lesson',
|
|
593
|
-
selectedIds: new Set([`lesson:${newLesson.id}`]),
|
|
594
|
-
expandedIds: nextExpandedIds,
|
|
595
|
-
};
|
|
596
|
-
});
|
|
597
|
-
},
|
|
598
|
-
|
|
599
|
-
duplicateSelected: () => {
|
|
600
|
-
const { selectedIds, sessions, lessons, activeItemType } = get();
|
|
601
|
-
const selectedSessionIds = sessions
|
|
602
|
-
.filter((ss) => selectedIds.has(ss.id))
|
|
603
|
-
.map((ss) => ss.id);
|
|
604
|
-
const selectedLessonIds = lessons
|
|
605
|
-
.filter((l) => selectedIds.has(l.id))
|
|
606
|
-
.map((l) => l.id);
|
|
607
|
-
|
|
608
|
-
if (selectedSessionIds.length > 0) {
|
|
609
|
-
// Duplicate each selected session
|
|
610
|
-
for (const id of selectedSessionIds) {
|
|
611
|
-
get().duplicateSession(id);
|
|
612
|
-
}
|
|
613
|
-
} else if (selectedLessonIds.length > 0) {
|
|
614
|
-
for (const id of selectedLessonIds) {
|
|
615
|
-
get().duplicateLesson(id);
|
|
616
|
-
}
|
|
617
|
-
} else if (activeItemType === 'session' && get().activeItemId) {
|
|
618
|
-
get().duplicateSession(get().activeItemId!);
|
|
619
|
-
} else if (activeItemType === 'lesson' && get().activeItemId) {
|
|
620
|
-
get().duplicateLesson(get().activeItemId!);
|
|
621
|
-
}
|
|
622
|
-
},
|
|
623
|
-
|
|
624
|
-
pasteSessions: () => {
|
|
625
|
-
const { copiedIds, sessions, lessons } = get();
|
|
626
|
-
if (!copiedIds.length) return;
|
|
627
|
-
|
|
628
|
-
const newSessions: Session[] = [];
|
|
629
|
-
const newLessons: Lesson[] = [];
|
|
630
|
-
let nextOrder = sessions.length;
|
|
631
|
-
|
|
632
|
-
for (const copiedId of copiedIds) {
|
|
633
|
-
const session = sessions.find((ss) => ss.id === copiedId);
|
|
634
|
-
if (!session) continue;
|
|
635
|
-
|
|
636
|
-
const newSessionId = newId();
|
|
637
|
-
newSessions.push({
|
|
638
|
-
...session,
|
|
639
|
-
id: newSessionId,
|
|
640
|
-
title: `Cópia de ${session.title}`,
|
|
641
|
-
order: nextOrder++,
|
|
642
|
-
});
|
|
643
|
-
|
|
644
|
-
const sessionLessons = lessons
|
|
645
|
-
.filter((l) => l.sessionId === copiedId)
|
|
646
|
-
.sort((a, b) => a.order - b.order);
|
|
647
|
-
|
|
648
|
-
for (const l of sessionLessons) {
|
|
649
|
-
newLessons.push({
|
|
650
|
-
...l,
|
|
651
|
-
id: newId(),
|
|
652
|
-
sessionId: newSessionId,
|
|
653
|
-
});
|
|
654
|
-
}
|
|
655
|
-
}
|
|
656
|
-
|
|
657
|
-
if (!newSessions.length) return;
|
|
658
|
-
const lastNew = newSessions[newSessions.length - 1];
|
|
659
|
-
|
|
660
|
-
set((s) => {
|
|
661
|
-
const nextExpandedIds = new Set([
|
|
662
|
-
...s.expandedIds,
|
|
663
|
-
...newSessions.map((ss) => ss.id),
|
|
664
|
-
]);
|
|
665
|
-
saveExpandedIds(s.courseId, nextExpandedIds);
|
|
666
|
-
return {
|
|
667
|
-
sessions: [...s.sessions, ...newSessions],
|
|
668
|
-
lessons: [...s.lessons, ...newLessons],
|
|
669
|
-
activeItemId: lastNew!.id,
|
|
670
|
-
activeItemType: 'session',
|
|
671
|
-
selectedIds: new Set(newSessions.map((ss) => `session:${ss.id}`)),
|
|
672
|
-
expandedIds: nextExpandedIds,
|
|
673
|
-
};
|
|
674
|
-
});
|
|
675
|
-
},
|
|
676
|
-
|
|
677
|
-
pasteLessons: (toSessionId) => {
|
|
678
|
-
const { copiedIds, lessons } = get();
|
|
679
|
-
if (!copiedIds.length) return;
|
|
680
|
-
|
|
681
|
-
const destLessons = lessons
|
|
682
|
-
.filter((l) => l.sessionId === toSessionId)
|
|
683
|
-
.sort((a, b) => a.order - b.order);
|
|
684
|
-
let nextOrder = destLessons.length;
|
|
685
|
-
|
|
686
|
-
const newLessons: Lesson[] = [];
|
|
687
|
-
|
|
688
|
-
for (const copiedId of copiedIds) {
|
|
689
|
-
const lesson = lessons.find((l) => l.id === copiedId);
|
|
690
|
-
if (!lesson) continue;
|
|
691
|
-
newLessons.push({
|
|
692
|
-
...lesson,
|
|
693
|
-
id: newId(),
|
|
694
|
-
sessionId: toSessionId,
|
|
695
|
-
title: `Cópia de ${lesson.title}`,
|
|
696
|
-
order: nextOrder++,
|
|
697
|
-
resources: [...lesson.resources],
|
|
698
|
-
});
|
|
699
|
-
}
|
|
700
|
-
|
|
701
|
-
if (!newLessons.length) return;
|
|
702
|
-
const lastNew = newLessons[newLessons.length - 1];
|
|
703
|
-
|
|
704
|
-
set((s) => {
|
|
705
|
-
const nextExpandedIds = new Set([...s.expandedIds, toSessionId]);
|
|
706
|
-
saveExpandedIds(s.courseId, nextExpandedIds);
|
|
707
|
-
return {
|
|
708
|
-
lessons: [...s.lessons, ...newLessons],
|
|
709
|
-
activeItemId: lastNew!.id,
|
|
710
|
-
activeItemType: 'lesson',
|
|
711
|
-
selectedIds: new Set(newLessons.map((l) => `lesson:${l.id}`)),
|
|
712
|
-
expandedIds: nextExpandedIds,
|
|
713
|
-
};
|
|
714
|
-
});
|
|
715
|
-
},
|
|
716
|
-
|
|
717
|
-
moveLessons: (lessonIds, toSessionId) =>
|
|
718
|
-
set((s) => {
|
|
719
|
-
const moving = s.lessons
|
|
720
|
-
.filter((l) => lessonIds.includes(l.id))
|
|
721
|
-
.sort((a, b) => a.order - b.order);
|
|
722
|
-
if (!moving.length) return {};
|
|
723
|
-
|
|
724
|
-
const fromSessionIds = new Set(moving.map((l) => l.sessionId));
|
|
725
|
-
|
|
726
|
-
// Lessons not being moved
|
|
727
|
-
const staying = s.lessons.filter((l) => !lessonIds.includes(l.id));
|
|
728
|
-
|
|
729
|
-
// Reindex source sessions (skip toSessionId — handled with dest)
|
|
730
|
-
const reindexedSource: Lesson[] = [];
|
|
731
|
-
for (const fromId of fromSessionIds) {
|
|
732
|
-
if (fromId === toSessionId) continue;
|
|
733
|
-
const srcLessons = staying
|
|
734
|
-
.filter((l) => l.sessionId === fromId)
|
|
735
|
-
.sort((a, b) => a.order - b.order)
|
|
736
|
-
.map((l, i) => ({ ...l, order: i }));
|
|
737
|
-
reindexedSource.push(...srcLessons);
|
|
738
|
-
}
|
|
739
|
-
|
|
740
|
-
// Current dest lessons (not being moved)
|
|
741
|
-
const destExisting = staying
|
|
742
|
-
.filter((l) => l.sessionId === toSessionId)
|
|
743
|
-
.sort((a, b) => a.order - b.order);
|
|
744
|
-
|
|
745
|
-
const movedToNewSession: Lesson[] = moving.map((l, i) => ({
|
|
746
|
-
...l,
|
|
747
|
-
sessionId: toSessionId,
|
|
748
|
-
order: destExisting.length + i,
|
|
749
|
-
}));
|
|
750
|
-
|
|
751
|
-
// Unchanged lessons (not in any affected session)
|
|
752
|
-
const unchanged = staying.filter(
|
|
753
|
-
(l) => !fromSessionIds.has(l.sessionId) && l.sessionId !== toSessionId
|
|
754
|
-
);
|
|
755
|
-
|
|
756
|
-
const nextExpandedIds = new Set([...s.expandedIds, toSessionId]);
|
|
757
|
-
saveExpandedIds(s.courseId, nextExpandedIds);
|
|
758
|
-
|
|
759
|
-
return {
|
|
760
|
-
lessons: [
|
|
761
|
-
...unchanged,
|
|
762
|
-
...reindexedSource,
|
|
763
|
-
...destExisting,
|
|
764
|
-
...movedToNewSession,
|
|
765
|
-
],
|
|
766
|
-
expandedIds: nextExpandedIds,
|
|
767
|
-
selectedIds: new Set(movedToNewSession.map((l) => `lesson:${l.id}`)),
|
|
768
|
-
activeItemId:
|
|
769
|
-
movedToNewSession[movedToNewSession.length - 1]?.id ?? s.activeItemId,
|
|
770
|
-
activeItemType: 'lesson',
|
|
771
|
-
};
|
|
772
|
-
}),
|
|
773
|
-
|
|
774
|
-
reorderSessions: (fromIndex, toIndex) =>
|
|
775
|
-
set((s) => {
|
|
776
|
-
const sorted = [...s.sessions].sort((a, b) => a.order - b.order);
|
|
777
|
-
const [moved] = sorted.splice(fromIndex, 1);
|
|
778
|
-
if (!moved) return {};
|
|
779
|
-
sorted.splice(toIndex, 0, moved);
|
|
780
|
-
const reindexed = sorted.map((ss, i) => ({ ...ss, order: i }));
|
|
781
|
-
return { sessions: reindexed };
|
|
782
|
-
}),
|
|
783
|
-
|
|
784
|
-
moveLesson: (lessonId, toSessionId, toIndex) =>
|
|
785
|
-
set((s) => {
|
|
786
|
-
const lesson = s.lessons.find((l) => l.id === lessonId);
|
|
787
|
-
if (!lesson) return {};
|
|
788
|
-
|
|
789
|
-
const fromSessionId = lesson.sessionId;
|
|
790
|
-
const sameSession = fromSessionId === toSessionId;
|
|
791
|
-
|
|
792
|
-
const destLessons = s.lessons
|
|
793
|
-
.filter((l) => l.sessionId === toSessionId && l.id !== lessonId)
|
|
794
|
-
.sort((a, b) => a.order - b.order);
|
|
795
|
-
|
|
796
|
-
destLessons.splice(toIndex, 0, { ...lesson, sessionId: toSessionId });
|
|
797
|
-
const updatedDest = destLessons.map((l, i) => ({ ...l, order: i }));
|
|
798
|
-
|
|
799
|
-
let updatedSrc: Lesson[] = [];
|
|
800
|
-
if (!sameSession) {
|
|
801
|
-
updatedSrc = s.lessons
|
|
802
|
-
.filter((l) => l.sessionId === fromSessionId && l.id !== lessonId)
|
|
803
|
-
.sort((a, b) => a.order - b.order)
|
|
804
|
-
.map((l, i) => ({ ...l, order: i }));
|
|
805
|
-
}
|
|
806
|
-
|
|
807
|
-
const unchanged = s.lessons.filter(
|
|
808
|
-
(l) =>
|
|
809
|
-
l.id !== lessonId &&
|
|
810
|
-
l.sessionId !== toSessionId &&
|
|
811
|
-
(sameSession || l.sessionId !== fromSessionId)
|
|
812
|
-
);
|
|
813
|
-
|
|
814
|
-
return {
|
|
815
|
-
lessons: [
|
|
816
|
-
...unchanged,
|
|
817
|
-
...(sameSession ? [] : updatedSrc),
|
|
818
|
-
...updatedDest,
|
|
819
|
-
],
|
|
820
|
-
};
|
|
821
|
-
}),
|
|
822
|
-
|
|
823
|
-
setCourseId: (id) => set({ courseId: id, expandedIds: loadExpandedIds(id) }),
|
|
824
|
-
|
|
825
|
-
setCourse: (course) => set({ course }),
|
|
826
|
-
|
|
827
|
-
setStructureFromApi: (data) => {
|
|
828
|
-
const courseId = get().courseId || data.course?.id || '';
|
|
829
|
-
const validSessionIds = new Set(data.sessions.map((session) => session.id));
|
|
830
|
-
const nextExpandedIds = new Set(
|
|
831
|
-
[...get().expandedIds].filter((id) => validSessionIds.has(id))
|
|
832
|
-
);
|
|
833
|
-
saveExpandedIds(courseId, nextExpandedIds);
|
|
834
|
-
|
|
835
|
-
const patch: Partial<StructureState> = {
|
|
836
|
-
sessions: data.sessions,
|
|
837
|
-
lessons: data.lessons,
|
|
838
|
-
expandedIds: nextExpandedIds,
|
|
839
|
-
};
|
|
840
|
-
if (data.course) {
|
|
841
|
-
patch.course = data.course;
|
|
842
|
-
// Set initial active item to the course node when loading for the first time
|
|
843
|
-
if (!get().activeItemId) {
|
|
844
|
-
patch.activeItemId = data.course.id;
|
|
845
|
-
patch.activeItemType = 'course';
|
|
846
|
-
patch.selectedIds = new Set([`course:${data.course.id}`]);
|
|
847
|
-
patch.lastClickedId = data.course.id;
|
|
848
|
-
}
|
|
849
|
-
}
|
|
850
|
-
set(patch);
|
|
851
|
-
},
|
|
852
|
-
|
|
853
|
-
addSessionFromApi: (session) =>
|
|
854
|
-
set((s) => {
|
|
855
|
-
const nextExpandedIds = new Set([...s.expandedIds, session.id]);
|
|
856
|
-
saveExpandedIds(s.courseId, nextExpandedIds);
|
|
857
|
-
return {
|
|
858
|
-
sessions: [...s.sessions, session],
|
|
859
|
-
expandedIds: nextExpandedIds,
|
|
860
|
-
activeItemId: session.id,
|
|
861
|
-
activeItemType: 'session',
|
|
862
|
-
selectedIds: new Set([`session:${session.id}`]),
|
|
863
|
-
lastClickedId: session.id,
|
|
864
|
-
};
|
|
865
|
-
}),
|
|
866
|
-
|
|
867
|
-
addLessonFromApi: (lesson) =>
|
|
868
|
-
set((s) => {
|
|
869
|
-
const nextExpandedIds = new Set([...s.expandedIds, lesson.sessionId]);
|
|
870
|
-
saveExpandedIds(s.courseId, nextExpandedIds);
|
|
871
|
-
return {
|
|
872
|
-
lessons: [...s.lessons, lesson],
|
|
873
|
-
expandedIds: nextExpandedIds,
|
|
874
|
-
activeItemId: lesson.id,
|
|
875
|
-
activeItemType: 'lesson',
|
|
876
|
-
selectedIds: new Set([`lesson:${lesson.id}`]),
|
|
877
|
-
lastClickedId: lesson.id,
|
|
878
|
-
};
|
|
879
|
-
}),
|
|
880
|
-
|
|
881
|
-
addLessonsFromApi: (lessons) => {
|
|
882
|
-
if (!lessons.length) return;
|
|
883
|
-
const last = lessons[lessons.length - 1]!;
|
|
884
|
-
set((s) => {
|
|
885
|
-
const nextExpandedIds = new Set([
|
|
886
|
-
...s.expandedIds,
|
|
887
|
-
...lessons.map((l) => l.sessionId),
|
|
888
|
-
]);
|
|
889
|
-
saveExpandedIds(s.courseId, nextExpandedIds);
|
|
890
|
-
return {
|
|
891
|
-
lessons: [...s.lessons, ...lessons],
|
|
892
|
-
expandedIds: nextExpandedIds,
|
|
893
|
-
activeItemId: last.id,
|
|
894
|
-
activeItemType: 'lesson',
|
|
895
|
-
selectedIds: new Set(lessons.map((l) => `lesson:${l.id}`)),
|
|
896
|
-
lastClickedId: last.id,
|
|
897
|
-
};
|
|
898
|
-
});
|
|
899
|
-
},
|
|
900
|
-
|
|
901
|
-
addSessionWithLessonsFromApi: (session, lessons) =>
|
|
902
|
-
set((s) => {
|
|
903
|
-
const nextExpandedIds = new Set([...s.expandedIds, session.id]);
|
|
904
|
-
saveExpandedIds(s.courseId, nextExpandedIds);
|
|
905
|
-
return {
|
|
906
|
-
sessions: [...s.sessions, session],
|
|
907
|
-
lessons: [...s.lessons, ...lessons],
|
|
908
|
-
expandedIds: nextExpandedIds,
|
|
909
|
-
activeItemId: session.id,
|
|
910
|
-
activeItemType: 'session',
|
|
911
|
-
selectedIds: new Set([`session:${session.id}`]),
|
|
912
|
-
lastClickedId: session.id,
|
|
913
|
-
};
|
|
914
|
-
}),
|
|
915
|
-
|
|
916
|
-
showConfirm: (opts) =>
|
|
917
|
-
set({
|
|
918
|
-
confirmDialog: {
|
|
919
|
-
open: true,
|
|
920
|
-
title: opts.title,
|
|
921
|
-
description: opts.description,
|
|
922
|
-
onConfirm: opts.onConfirm,
|
|
923
|
-
},
|
|
924
|
-
}),
|
|
925
|
-
|
|
926
|
-
closeConfirm: () =>
|
|
927
|
-
set((s) => ({
|
|
928
|
-
confirmDialog: { ...s.confirmDialog, open: false, onConfirm: null },
|
|
929
|
-
})),
|
|
930
|
-
|
|
931
|
-
showSessionPicker: (opts) =>
|
|
932
|
-
set({
|
|
933
|
-
sessionPickerDialog: {
|
|
934
|
-
open: true,
|
|
935
|
-
title: opts.title,
|
|
936
|
-
excludeSessionId: opts.excludeSessionId ?? null,
|
|
937
|
-
onPick: opts.onPick,
|
|
938
|
-
},
|
|
939
|
-
}),
|
|
940
|
-
|
|
941
|
-
closeSessionPicker: () =>
|
|
942
|
-
set((s) => ({
|
|
943
|
-
sessionPickerDialog: {
|
|
944
|
-
...s.sessionPickerDialog,
|
|
945
|
-
open: false,
|
|
946
|
-
onPick: null,
|
|
947
|
-
},
|
|
948
|
-
})),
|
|
949
|
-
}));
|
|
1
|
+
/**
|
|
2
|
+
* Course Structure — Zustand Store
|
|
3
|
+
*
|
|
4
|
+
* Responsibility split:
|
|
5
|
+
*
|
|
6
|
+
* ┌───────────────────────────────────────────────────────────────────┐
|
|
7
|
+
* │ DATA FIELDS (course / sessions / lessons) │
|
|
8
|
+
* │ Currently seeded from mock-data.ts. │
|
|
9
|
+
* │ │
|
|
10
|
+
* │ TODO[API]: Once `useCourseStructure` switches to React Query, │
|
|
11
|
+
* │ remove these three fields from the store and derive them from │
|
|
12
|
+
* │ the query cache instead. The store then becomes UI-only. │
|
|
13
|
+
* ├───────────────────────────────────────────────────────────────────┤
|
|
14
|
+
* │ UI-ONLY FIELDS (keep forever) │
|
|
15
|
+
* │ • activeItemId / activeItemType — detail panel selection │
|
|
16
|
+
* │ • expandedIds — tree accordion state │
|
|
17
|
+
* │ • filterQuery — search bar value │
|
|
18
|
+
* │ • selectedIds / lastClickedId — multi-select │
|
|
19
|
+
* │ • copiedIds / copiedType — clipboard │
|
|
20
|
+
* │ • inlineRenamingId — inline edit │
|
|
21
|
+
* │ • mobileSheetOpen — mobile sheet │
|
|
22
|
+
* │ • confirmDialog / sessionPickerDialog — dialog state │
|
|
23
|
+
* └───────────────────────────────────────────────────────────────────┘
|
|
24
|
+
*/
|
|
25
|
+
import { create } from 'zustand';
|
|
26
|
+
|
|
27
|
+
import type {
|
|
28
|
+
Course,
|
|
29
|
+
CourseFormValues,
|
|
30
|
+
FlatItem,
|
|
31
|
+
Lesson,
|
|
32
|
+
LessonFormValues,
|
|
33
|
+
Session,
|
|
34
|
+
SessionFormValues,
|
|
35
|
+
} from './types';
|
|
36
|
+
|
|
37
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
38
|
+
// State interface
|
|
39
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
interface StructureState {
|
|
42
|
+
// Data
|
|
43
|
+
courseId: string;
|
|
44
|
+
course: Course;
|
|
45
|
+
sessions: Session[];
|
|
46
|
+
lessons: Lesson[];
|
|
47
|
+
|
|
48
|
+
// Active detail
|
|
49
|
+
activeItemId: string | null;
|
|
50
|
+
activeItemType: 'course' | 'session' | 'lesson' | null;
|
|
51
|
+
|
|
52
|
+
// Tree UI
|
|
53
|
+
expandedIds: Set<string>;
|
|
54
|
+
filterQuery: string;
|
|
55
|
+
|
|
56
|
+
// Multi-select
|
|
57
|
+
selectedIds: Set<string>;
|
|
58
|
+
lastClickedId: string | null;
|
|
59
|
+
|
|
60
|
+
// Clipboard (mock)
|
|
61
|
+
copiedIds: string[];
|
|
62
|
+
copiedType: 'session' | 'lesson' | null;
|
|
63
|
+
|
|
64
|
+
// Inline rename
|
|
65
|
+
inlineRenamingId: string | null;
|
|
66
|
+
|
|
67
|
+
// Mobile
|
|
68
|
+
mobileSheetOpen: boolean;
|
|
69
|
+
|
|
70
|
+
// Confirm dialog state
|
|
71
|
+
confirmDialog: {
|
|
72
|
+
open: boolean;
|
|
73
|
+
title: string;
|
|
74
|
+
description: string;
|
|
75
|
+
onConfirm: (() => void) | null;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
// Session picker dialog state
|
|
79
|
+
sessionPickerDialog: {
|
|
80
|
+
open: boolean;
|
|
81
|
+
title: string;
|
|
82
|
+
excludeSessionId: string | null;
|
|
83
|
+
onPick: ((sessionId: string) => void) | null;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
// ── Selection ──────────────────────────────────────────────────────────────
|
|
87
|
+
selectItem: (
|
|
88
|
+
id: string,
|
|
89
|
+
type: 'course' | 'session' | 'lesson',
|
|
90
|
+
modifiers?: { ctrl?: boolean; shift?: boolean },
|
|
91
|
+
visibleItems?: FlatItem[]
|
|
92
|
+
) => void;
|
|
93
|
+
clearSelection: () => void;
|
|
94
|
+
|
|
95
|
+
// ── Clipboard ──────────────────────────────────────────────────────────────
|
|
96
|
+
copyItems: (ids: string[], type: 'session' | 'lesson') => void;
|
|
97
|
+
clearClipboard: () => void;
|
|
98
|
+
|
|
99
|
+
// ── Expand / collapse ──────────────────────────────────────────────────────
|
|
100
|
+
toggleExpand: (sessionId: string) => void;
|
|
101
|
+
expandAll: () => void;
|
|
102
|
+
collapseAll: () => void;
|
|
103
|
+
|
|
104
|
+
// ── Filter ─────────────────────────────────────────────────────────────────
|
|
105
|
+
setFilter: (query: string) => void;
|
|
106
|
+
|
|
107
|
+
// ── Mobile ─────────────────────────────────────────────────────────────────
|
|
108
|
+
setMobileSheetOpen: (open: boolean) => void;
|
|
109
|
+
|
|
110
|
+
// ── CRUD: Course ───────────────────────────────────────────────────────────
|
|
111
|
+
updateCourse: (data: CourseFormValues) => void;
|
|
112
|
+
|
|
113
|
+
// ── CRUD: Session ──────────────────────────────────────────────────────────
|
|
114
|
+
addSession: () => Session;
|
|
115
|
+
updateSession: (id: string, data: SessionFormValues) => void;
|
|
116
|
+
deleteSession: (id: string) => void;
|
|
117
|
+
|
|
118
|
+
// ── CRUD: Lesson ───────────────────────────────────────────────────────────
|
|
119
|
+
addLesson: (sessionId: string) => Lesson;
|
|
120
|
+
updateLesson: (
|
|
121
|
+
id: string,
|
|
122
|
+
data: Partial<
|
|
123
|
+
LessonFormValues & {
|
|
124
|
+
transcription?: string;
|
|
125
|
+
resources?: Lesson['resources'];
|
|
126
|
+
}
|
|
127
|
+
>
|
|
128
|
+
) => void;
|
|
129
|
+
deleteLesson: (id: string) => void;
|
|
130
|
+
|
|
131
|
+
// ── Bulk delete ────────────────────────────────────────────────────────────
|
|
132
|
+
deleteSelected: () => void;
|
|
133
|
+
|
|
134
|
+
// ── Inline rename ──────────────────────────────────────────────────────────
|
|
135
|
+
startRename: (id: string) => void;
|
|
136
|
+
commitRename: (id: string, newTitle: string) => void;
|
|
137
|
+
cancelRename: () => void;
|
|
138
|
+
renameItem: (id: string, newTitle: string) => void;
|
|
139
|
+
|
|
140
|
+
// ── Duplicate ──────────────────────────────────────────────────────────────
|
|
141
|
+
duplicateSession: (id: string) => void;
|
|
142
|
+
duplicateLesson: (id: string) => void;
|
|
143
|
+
duplicateSelected: () => void;
|
|
144
|
+
|
|
145
|
+
// ── Paste ──────────────────────────────────────────────────────────────────
|
|
146
|
+
pasteSessions: () => void;
|
|
147
|
+
pasteLessons: (toSessionId: string) => void;
|
|
148
|
+
|
|
149
|
+
// ── Move multiple ──────────────────────────────────────────────────────────
|
|
150
|
+
moveLessons: (lessonIds: string[], toSessionId: string) => void;
|
|
151
|
+
|
|
152
|
+
// ── DnD reorder ────────────────────────────────────────────────────────────
|
|
153
|
+
reorderSessions: (fromIndex: number, toIndex: number) => void;
|
|
154
|
+
moveLesson: (lessonId: string, toSessionId: string, toIndex: number) => void;
|
|
155
|
+
|
|
156
|
+
// ── API integration ────────────────────────────────────────────────────────
|
|
157
|
+
/** Set the active course ID (called by page.tsx on mount). */
|
|
158
|
+
setCourseId: (id: string) => void;
|
|
159
|
+
/** Set the course metadata received from the API. */
|
|
160
|
+
setCourse: (course: Course) => void;
|
|
161
|
+
/** Replace sessions+lessons in the store with data received from the API. */
|
|
162
|
+
setStructureFromApi: (data: {
|
|
163
|
+
course?: Course | null;
|
|
164
|
+
sessions: Session[];
|
|
165
|
+
lessons: Lesson[];
|
|
166
|
+
}) => void;
|
|
167
|
+
/** Append a newly-created session (from API response) and select it. */
|
|
168
|
+
addSessionFromApi: (session: Session) => void;
|
|
169
|
+
/** Append a newly-created lesson (from API response) and select it. */
|
|
170
|
+
addLessonFromApi: (lesson: Lesson) => void;
|
|
171
|
+
/**
|
|
172
|
+
* Append a batch of lessons (from API duplicate/paste response).
|
|
173
|
+
* Selects the last lesson in the batch and expands its session.
|
|
174
|
+
*/
|
|
175
|
+
addLessonsFromApi: (lessons: Lesson[]) => void;
|
|
176
|
+
/**
|
|
177
|
+
* Append a session + its cloned lessons (from duplicateSession API response).
|
|
178
|
+
* Selects the new session.
|
|
179
|
+
*/
|
|
180
|
+
addSessionWithLessonsFromApi: (session: Session, lessons: Lesson[]) => void;
|
|
181
|
+
|
|
182
|
+
// ── Confirm dialog ─────────────────────────────────────────────────────────
|
|
183
|
+
showConfirm: (opts: {
|
|
184
|
+
title: string;
|
|
185
|
+
description: string;
|
|
186
|
+
onConfirm: () => void;
|
|
187
|
+
}) => void;
|
|
188
|
+
closeConfirm: () => void;
|
|
189
|
+
|
|
190
|
+
// ── Session picker dialog ──────────────────────────────────────────────────
|
|
191
|
+
showSessionPicker: (opts: {
|
|
192
|
+
title: string;
|
|
193
|
+
excludeSessionId?: string | null;
|
|
194
|
+
onPick: (sessionId: string) => void;
|
|
195
|
+
}) => void;
|
|
196
|
+
closeSessionPicker: () => void;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
200
|
+
// Helpers
|
|
201
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
202
|
+
|
|
203
|
+
function getNextCode(items: Array<{ code: string }>, prefix: string): string {
|
|
204
|
+
const regex = new RegExp(`^${prefix}(\\d+)$`, 'i');
|
|
205
|
+
const max = items.reduce((m, item) => {
|
|
206
|
+
const match = item.code?.trim().match(regex);
|
|
207
|
+
if (!match) return m;
|
|
208
|
+
const n = Number(match[1]);
|
|
209
|
+
return Number.isFinite(n) ? Math.max(m, n) : m;
|
|
210
|
+
}, 0);
|
|
211
|
+
return `${prefix}${String(max + 1).padStart(2, '0')}`;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function newId(): string {
|
|
215
|
+
if (
|
|
216
|
+
typeof crypto !== 'undefined' &&
|
|
217
|
+
typeof crypto.randomUUID === 'function'
|
|
218
|
+
) {
|
|
219
|
+
return crypto.randomUUID();
|
|
220
|
+
}
|
|
221
|
+
return `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function getExpandedIdsStorageKey(courseId: string): string {
|
|
225
|
+
return `lms:course-structure:${courseId}:expanded-ids`;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function loadExpandedIds(courseId: string): Set<string> {
|
|
229
|
+
if (typeof window === 'undefined' || !courseId) return new Set();
|
|
230
|
+
try {
|
|
231
|
+
const saved = JSON.parse(
|
|
232
|
+
localStorage.getItem(getExpandedIdsStorageKey(courseId)) ?? '[]'
|
|
233
|
+
);
|
|
234
|
+
if (!Array.isArray(saved)) return new Set();
|
|
235
|
+
return new Set(saved.filter((id): id is string => typeof id === 'string'));
|
|
236
|
+
} catch {
|
|
237
|
+
return new Set();
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function saveExpandedIds(courseId: string, expandedIds: Set<string>): void {
|
|
242
|
+
if (typeof window === 'undefined' || !courseId) return;
|
|
243
|
+
try {
|
|
244
|
+
localStorage.setItem(
|
|
245
|
+
getExpandedIdsStorageKey(courseId),
|
|
246
|
+
JSON.stringify([...expandedIds])
|
|
247
|
+
);
|
|
248
|
+
} catch {
|
|
249
|
+
// Ignore storage quota/privacy errors.
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
254
|
+
// Store
|
|
255
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
256
|
+
|
|
257
|
+
export const useStructureStore = create<StructureState>((set, get) => ({
|
|
258
|
+
courseId: '',
|
|
259
|
+
course: {
|
|
260
|
+
id: '',
|
|
261
|
+
code: '',
|
|
262
|
+
name: '',
|
|
263
|
+
title: 'Carregando...',
|
|
264
|
+
description: '',
|
|
265
|
+
slug: '',
|
|
266
|
+
published: false,
|
|
267
|
+
},
|
|
268
|
+
sessions: [],
|
|
269
|
+
lessons: [],
|
|
270
|
+
|
|
271
|
+
activeItemId: null,
|
|
272
|
+
activeItemType: null,
|
|
273
|
+
expandedIds: new Set<string>(),
|
|
274
|
+
filterQuery: '',
|
|
275
|
+
selectedIds: new Set<string>(),
|
|
276
|
+
lastClickedId: null as string | null,
|
|
277
|
+
copiedIds: [] as string[],
|
|
278
|
+
copiedType: null as 'session' | 'lesson' | null,
|
|
279
|
+
inlineRenamingId: null as string | null,
|
|
280
|
+
mobileSheetOpen: false,
|
|
281
|
+
confirmDialog: {
|
|
282
|
+
open: false,
|
|
283
|
+
title: '',
|
|
284
|
+
description: '',
|
|
285
|
+
onConfirm: null as (() => void) | null,
|
|
286
|
+
},
|
|
287
|
+
sessionPickerDialog: {
|
|
288
|
+
open: false,
|
|
289
|
+
title: '',
|
|
290
|
+
excludeSessionId: null as string | null,
|
|
291
|
+
onPick: null as ((sessionId: string) => void) | null,
|
|
292
|
+
},
|
|
293
|
+
|
|
294
|
+
selectItem: (id, type, modifiers = {}, visibleItems = []) =>
|
|
295
|
+
set((s) => {
|
|
296
|
+
// SHIFT: extend range based on visible order
|
|
297
|
+
if (modifiers.shift && s.lastClickedId && visibleItems.length > 0) {
|
|
298
|
+
const lastIdx = visibleItems.findIndex((i) => i.id === s.lastClickedId);
|
|
299
|
+
const currIdx = visibleItems.findIndex((i) => i.id === id);
|
|
300
|
+
if (lastIdx !== -1 && currIdx !== -1) {
|
|
301
|
+
const [from, to] =
|
|
302
|
+
lastIdx <= currIdx ? [lastIdx, currIdx] : [currIdx, lastIdx];
|
|
303
|
+
const next = new Set(s.selectedIds);
|
|
304
|
+
for (let i = from; i <= to; i++) {
|
|
305
|
+
const node = visibleItems[i];
|
|
306
|
+
if (node) next.add(`${node.type}:${node.id}`);
|
|
307
|
+
}
|
|
308
|
+
return { selectedIds: next, activeItemId: id, activeItemType: type };
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
// CTRL/CMD: toggle individual item
|
|
312
|
+
if (modifiers.ctrl) {
|
|
313
|
+
const key = `${type}:${id}`;
|
|
314
|
+
const next = new Set(s.selectedIds);
|
|
315
|
+
if (next.has(key)) next.delete(key);
|
|
316
|
+
else next.add(key);
|
|
317
|
+
return {
|
|
318
|
+
selectedIds: next,
|
|
319
|
+
activeItemId: id,
|
|
320
|
+
activeItemType: type,
|
|
321
|
+
lastClickedId: id,
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
// Plain click: single selection
|
|
325
|
+
return {
|
|
326
|
+
selectedIds: new Set([`${type}:${id}`]),
|
|
327
|
+
activeItemId: id,
|
|
328
|
+
activeItemType: type,
|
|
329
|
+
lastClickedId: id,
|
|
330
|
+
};
|
|
331
|
+
}),
|
|
332
|
+
|
|
333
|
+
clearSelection: () =>
|
|
334
|
+
set((s) => ({
|
|
335
|
+
selectedIds: new Set(
|
|
336
|
+
s.activeItemId && s.activeItemType
|
|
337
|
+
? [`${s.activeItemType}:${s.activeItemId}`]
|
|
338
|
+
: []
|
|
339
|
+
),
|
|
340
|
+
})),
|
|
341
|
+
|
|
342
|
+
copyItems: (ids, type) => set({ copiedIds: ids, copiedType: type }),
|
|
343
|
+
clearClipboard: () => set({ copiedIds: [], copiedType: null }),
|
|
344
|
+
|
|
345
|
+
toggleExpand: (sessionId) =>
|
|
346
|
+
set((s) => {
|
|
347
|
+
const next = new Set(s.expandedIds);
|
|
348
|
+
if (next.has(sessionId)) next.delete(sessionId);
|
|
349
|
+
else next.add(sessionId);
|
|
350
|
+
saveExpandedIds(s.courseId, next);
|
|
351
|
+
return { expandedIds: next };
|
|
352
|
+
}),
|
|
353
|
+
|
|
354
|
+
expandAll: () =>
|
|
355
|
+
set((s) => {
|
|
356
|
+
const next = new Set(s.sessions.map((ss) => ss.id));
|
|
357
|
+
saveExpandedIds(s.courseId, next);
|
|
358
|
+
return { expandedIds: next };
|
|
359
|
+
}),
|
|
360
|
+
|
|
361
|
+
collapseAll: () =>
|
|
362
|
+
set((s) => {
|
|
363
|
+
const next = new Set<string>();
|
|
364
|
+
saveExpandedIds(s.courseId, next);
|
|
365
|
+
return { expandedIds: next };
|
|
366
|
+
}),
|
|
367
|
+
|
|
368
|
+
setFilter: (query) => set({ filterQuery: query }),
|
|
369
|
+
|
|
370
|
+
setMobileSheetOpen: (open) => set({ mobileSheetOpen: open }),
|
|
371
|
+
|
|
372
|
+
updateCourse: (data) => set((s) => ({ course: { ...s.course, ...data } })),
|
|
373
|
+
|
|
374
|
+
addSession: () => {
|
|
375
|
+
const { sessions } = get();
|
|
376
|
+
const code = getNextCode(sessions, 'S');
|
|
377
|
+
const newSession: Session = {
|
|
378
|
+
id: newId(),
|
|
379
|
+
code,
|
|
380
|
+
title: `Nova Sessão ${code}`,
|
|
381
|
+
duration: 30,
|
|
382
|
+
order: sessions.length,
|
|
383
|
+
};
|
|
384
|
+
set((s) => {
|
|
385
|
+
const nextExpandedIds = new Set([...s.expandedIds, newSession.id]);
|
|
386
|
+
saveExpandedIds(s.courseId, nextExpandedIds);
|
|
387
|
+
return {
|
|
388
|
+
sessions: [...s.sessions, newSession],
|
|
389
|
+
expandedIds: nextExpandedIds,
|
|
390
|
+
activeItemId: newSession.id,
|
|
391
|
+
activeItemType: 'session',
|
|
392
|
+
selectedIds: new Set([`session:${newSession.id}`]),
|
|
393
|
+
lastClickedId: newSession.id,
|
|
394
|
+
};
|
|
395
|
+
});
|
|
396
|
+
return newSession;
|
|
397
|
+
},
|
|
398
|
+
|
|
399
|
+
updateSession: (id, data) =>
|
|
400
|
+
set((s) => ({
|
|
401
|
+
sessions: s.sessions.map((ss) =>
|
|
402
|
+
ss.id === id ? { ...ss, ...data } : ss
|
|
403
|
+
),
|
|
404
|
+
})),
|
|
405
|
+
|
|
406
|
+
deleteSession: (id) =>
|
|
407
|
+
set((s) => {
|
|
408
|
+
const nextExpandedIds = new Set(s.expandedIds);
|
|
409
|
+
nextExpandedIds.delete(id);
|
|
410
|
+
const isActive = s.activeItemId === id && s.activeItemType === 'session';
|
|
411
|
+
saveExpandedIds(s.courseId, nextExpandedIds);
|
|
412
|
+
return {
|
|
413
|
+
sessions: s.sessions.filter((ss) => ss.id !== id),
|
|
414
|
+
lessons: s.lessons.filter((l) => l.sessionId !== id),
|
|
415
|
+
expandedIds: nextExpandedIds,
|
|
416
|
+
selectedIds: new Set([`course:${s.course.id}`]),
|
|
417
|
+
activeItemId: isActive ? s.course.id : s.activeItemId,
|
|
418
|
+
activeItemType: isActive ? 'course' : s.activeItemType,
|
|
419
|
+
};
|
|
420
|
+
}),
|
|
421
|
+
|
|
422
|
+
addLesson: (sessionId) => {
|
|
423
|
+
const { lessons } = get();
|
|
424
|
+
const sessionLessons = lessons.filter((l) => l.sessionId === sessionId);
|
|
425
|
+
const code = getNextCode(lessons, 'A');
|
|
426
|
+
const newLesson: Lesson = {
|
|
427
|
+
id: newId(),
|
|
428
|
+
code,
|
|
429
|
+
title: `Nova Aula ${code}`,
|
|
430
|
+
publicDescription: '',
|
|
431
|
+
privateDescription: '',
|
|
432
|
+
type: 'video',
|
|
433
|
+
duration: 10,
|
|
434
|
+
sessionId,
|
|
435
|
+
order: sessionLessons.length,
|
|
436
|
+
resources: [],
|
|
437
|
+
};
|
|
438
|
+
set((s) => {
|
|
439
|
+
const nextExpandedIds = new Set([...s.expandedIds, sessionId]);
|
|
440
|
+
saveExpandedIds(s.courseId, nextExpandedIds);
|
|
441
|
+
return {
|
|
442
|
+
lessons: [...s.lessons, newLesson],
|
|
443
|
+
expandedIds: nextExpandedIds,
|
|
444
|
+
activeItemId: newLesson.id,
|
|
445
|
+
activeItemType: 'lesson',
|
|
446
|
+
selectedIds: new Set([`lesson:${newLesson.id}`]),
|
|
447
|
+
lastClickedId: newLesson.id,
|
|
448
|
+
};
|
|
449
|
+
});
|
|
450
|
+
return newLesson;
|
|
451
|
+
},
|
|
452
|
+
|
|
453
|
+
updateLesson: (id, data) =>
|
|
454
|
+
set((s) => ({
|
|
455
|
+
lessons: s.lessons.map((l) => (l.id === id ? { ...l, ...data } : l)),
|
|
456
|
+
})),
|
|
457
|
+
|
|
458
|
+
deleteLesson: (id) =>
|
|
459
|
+
set((s) => {
|
|
460
|
+
const isActive = s.activeItemId === id && s.activeItemType === 'lesson';
|
|
461
|
+
const nextSelected = new Set(s.selectedIds);
|
|
462
|
+
nextSelected.delete(`lesson:${id}`);
|
|
463
|
+
if (nextSelected.size === 0) nextSelected.add(`course:${s.course.id}`);
|
|
464
|
+
return {
|
|
465
|
+
lessons: s.lessons.filter((l) => l.id !== id),
|
|
466
|
+
selectedIds: nextSelected,
|
|
467
|
+
activeItemId: isActive ? s.course.id : s.activeItemId,
|
|
468
|
+
activeItemType: isActive ? 'course' : s.activeItemType,
|
|
469
|
+
};
|
|
470
|
+
}),
|
|
471
|
+
|
|
472
|
+
deleteSelected: () =>
|
|
473
|
+
set((s) => {
|
|
474
|
+
const sessionIds = new Set(
|
|
475
|
+
s.sessions.filter((ss) => s.selectedIds.has(ss.id)).map((ss) => ss.id)
|
|
476
|
+
);
|
|
477
|
+
const lessonIds = new Set(
|
|
478
|
+
s.lessons
|
|
479
|
+
.filter((l) => s.selectedIds.has(l.id) || sessionIds.has(l.sessionId))
|
|
480
|
+
.map((l) => l.id)
|
|
481
|
+
);
|
|
482
|
+
const nextExpandedIds = new Set(
|
|
483
|
+
[...s.expandedIds].filter((id) => !sessionIds.has(id))
|
|
484
|
+
);
|
|
485
|
+
saveExpandedIds(s.courseId, nextExpandedIds);
|
|
486
|
+
return {
|
|
487
|
+
sessions: s.sessions.filter((ss) => !sessionIds.has(ss.id)),
|
|
488
|
+
lessons: s.lessons.filter((l) => !lessonIds.has(l.id)),
|
|
489
|
+
selectedIds: new Set([`course:${s.course.id}`]),
|
|
490
|
+
activeItemId: s.course.id,
|
|
491
|
+
activeItemType: 'course' as const,
|
|
492
|
+
expandedIds: nextExpandedIds,
|
|
493
|
+
};
|
|
494
|
+
}),
|
|
495
|
+
|
|
496
|
+
startRename: (id) => set({ inlineRenamingId: id }),
|
|
497
|
+
cancelRename: () => set({ inlineRenamingId: null }),
|
|
498
|
+
|
|
499
|
+
renameItem: (id, newTitle) => {
|
|
500
|
+
const trimmed = newTitle.trim();
|
|
501
|
+
if (!trimmed) return;
|
|
502
|
+
set((s) => {
|
|
503
|
+
const isSession = s.sessions.some((ss) => ss.id === id);
|
|
504
|
+
if (isSession) {
|
|
505
|
+
return {
|
|
506
|
+
sessions: s.sessions.map((ss) =>
|
|
507
|
+
ss.id === id ? { ...ss, title: trimmed } : ss
|
|
508
|
+
),
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
return {
|
|
512
|
+
lessons: s.lessons.map((l) =>
|
|
513
|
+
l.id === id ? { ...l, title: trimmed } : l
|
|
514
|
+
),
|
|
515
|
+
};
|
|
516
|
+
});
|
|
517
|
+
},
|
|
518
|
+
|
|
519
|
+
commitRename: (id, newTitle) => {
|
|
520
|
+
get().renameItem(id, newTitle);
|
|
521
|
+
set({ inlineRenamingId: null });
|
|
522
|
+
},
|
|
523
|
+
|
|
524
|
+
duplicateSession: (id) => {
|
|
525
|
+
const { sessions, lessons } = get();
|
|
526
|
+
const session = sessions.find((ss) => ss.id === id);
|
|
527
|
+
if (!session) return;
|
|
528
|
+
|
|
529
|
+
const newSessionId = newId();
|
|
530
|
+
const newSession: Session = {
|
|
531
|
+
...session,
|
|
532
|
+
id: newSessionId,
|
|
533
|
+
title: `Cópia de ${session.title}`,
|
|
534
|
+
order: sessions.length,
|
|
535
|
+
};
|
|
536
|
+
|
|
537
|
+
const sessionLessons = lessons
|
|
538
|
+
.filter((l) => l.sessionId === id)
|
|
539
|
+
.sort((a, b) => a.order - b.order);
|
|
540
|
+
|
|
541
|
+
const newLessons: Lesson[] = sessionLessons.map((l) => ({
|
|
542
|
+
...l,
|
|
543
|
+
id: newId(),
|
|
544
|
+
sessionId: newSessionId,
|
|
545
|
+
}));
|
|
546
|
+
|
|
547
|
+
set((s) => {
|
|
548
|
+
const nextExpandedIds = new Set([...s.expandedIds, newSessionId]);
|
|
549
|
+
saveExpandedIds(s.courseId, nextExpandedIds);
|
|
550
|
+
return {
|
|
551
|
+
sessions: [...s.sessions, newSession],
|
|
552
|
+
lessons: [...s.lessons, ...newLessons],
|
|
553
|
+
activeItemId: newSessionId,
|
|
554
|
+
activeItemType: 'session',
|
|
555
|
+
selectedIds: new Set([`session:${newSessionId}`]),
|
|
556
|
+
expandedIds: nextExpandedIds,
|
|
557
|
+
};
|
|
558
|
+
});
|
|
559
|
+
},
|
|
560
|
+
|
|
561
|
+
duplicateLesson: (id) => {
|
|
562
|
+
const { lessons } = get();
|
|
563
|
+
const lesson = lessons.find((l) => l.id === id);
|
|
564
|
+
if (!lesson) return;
|
|
565
|
+
|
|
566
|
+
const sessionLessons = lessons
|
|
567
|
+
.filter((l) => l.sessionId === lesson.sessionId)
|
|
568
|
+
.sort((a, b) => a.order - b.order);
|
|
569
|
+
|
|
570
|
+
const insertAt = lesson.order + 1;
|
|
571
|
+
const newLesson: Lesson = {
|
|
572
|
+
...lesson,
|
|
573
|
+
id: newId(),
|
|
574
|
+
title: `Cópia de ${lesson.title}`,
|
|
575
|
+
order: insertAt,
|
|
576
|
+
resources: [...lesson.resources],
|
|
577
|
+
};
|
|
578
|
+
|
|
579
|
+
// Shift all lessons at or after insertAt
|
|
580
|
+
const updatedSession = sessionLessons.map((l) =>
|
|
581
|
+
l.order >= insertAt ? { ...l, order: l.order + 1 } : l
|
|
582
|
+
);
|
|
583
|
+
|
|
584
|
+
const unchanged = lessons.filter((l) => l.sessionId !== lesson.sessionId);
|
|
585
|
+
|
|
586
|
+
set((s) => {
|
|
587
|
+
const nextExpandedIds = new Set([...s.expandedIds, lesson.sessionId]);
|
|
588
|
+
saveExpandedIds(s.courseId, nextExpandedIds);
|
|
589
|
+
return {
|
|
590
|
+
lessons: [...unchanged, ...updatedSession, newLesson],
|
|
591
|
+
activeItemId: newLesson.id,
|
|
592
|
+
activeItemType: 'lesson',
|
|
593
|
+
selectedIds: new Set([`lesson:${newLesson.id}`]),
|
|
594
|
+
expandedIds: nextExpandedIds,
|
|
595
|
+
};
|
|
596
|
+
});
|
|
597
|
+
},
|
|
598
|
+
|
|
599
|
+
duplicateSelected: () => {
|
|
600
|
+
const { selectedIds, sessions, lessons, activeItemType } = get();
|
|
601
|
+
const selectedSessionIds = sessions
|
|
602
|
+
.filter((ss) => selectedIds.has(ss.id))
|
|
603
|
+
.map((ss) => ss.id);
|
|
604
|
+
const selectedLessonIds = lessons
|
|
605
|
+
.filter((l) => selectedIds.has(l.id))
|
|
606
|
+
.map((l) => l.id);
|
|
607
|
+
|
|
608
|
+
if (selectedSessionIds.length > 0) {
|
|
609
|
+
// Duplicate each selected session
|
|
610
|
+
for (const id of selectedSessionIds) {
|
|
611
|
+
get().duplicateSession(id);
|
|
612
|
+
}
|
|
613
|
+
} else if (selectedLessonIds.length > 0) {
|
|
614
|
+
for (const id of selectedLessonIds) {
|
|
615
|
+
get().duplicateLesson(id);
|
|
616
|
+
}
|
|
617
|
+
} else if (activeItemType === 'session' && get().activeItemId) {
|
|
618
|
+
get().duplicateSession(get().activeItemId!);
|
|
619
|
+
} else if (activeItemType === 'lesson' && get().activeItemId) {
|
|
620
|
+
get().duplicateLesson(get().activeItemId!);
|
|
621
|
+
}
|
|
622
|
+
},
|
|
623
|
+
|
|
624
|
+
pasteSessions: () => {
|
|
625
|
+
const { copiedIds, sessions, lessons } = get();
|
|
626
|
+
if (!copiedIds.length) return;
|
|
627
|
+
|
|
628
|
+
const newSessions: Session[] = [];
|
|
629
|
+
const newLessons: Lesson[] = [];
|
|
630
|
+
let nextOrder = sessions.length;
|
|
631
|
+
|
|
632
|
+
for (const copiedId of copiedIds) {
|
|
633
|
+
const session = sessions.find((ss) => ss.id === copiedId);
|
|
634
|
+
if (!session) continue;
|
|
635
|
+
|
|
636
|
+
const newSessionId = newId();
|
|
637
|
+
newSessions.push({
|
|
638
|
+
...session,
|
|
639
|
+
id: newSessionId,
|
|
640
|
+
title: `Cópia de ${session.title}`,
|
|
641
|
+
order: nextOrder++,
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
const sessionLessons = lessons
|
|
645
|
+
.filter((l) => l.sessionId === copiedId)
|
|
646
|
+
.sort((a, b) => a.order - b.order);
|
|
647
|
+
|
|
648
|
+
for (const l of sessionLessons) {
|
|
649
|
+
newLessons.push({
|
|
650
|
+
...l,
|
|
651
|
+
id: newId(),
|
|
652
|
+
sessionId: newSessionId,
|
|
653
|
+
});
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
if (!newSessions.length) return;
|
|
658
|
+
const lastNew = newSessions[newSessions.length - 1];
|
|
659
|
+
|
|
660
|
+
set((s) => {
|
|
661
|
+
const nextExpandedIds = new Set([
|
|
662
|
+
...s.expandedIds,
|
|
663
|
+
...newSessions.map((ss) => ss.id),
|
|
664
|
+
]);
|
|
665
|
+
saveExpandedIds(s.courseId, nextExpandedIds);
|
|
666
|
+
return {
|
|
667
|
+
sessions: [...s.sessions, ...newSessions],
|
|
668
|
+
lessons: [...s.lessons, ...newLessons],
|
|
669
|
+
activeItemId: lastNew!.id,
|
|
670
|
+
activeItemType: 'session',
|
|
671
|
+
selectedIds: new Set(newSessions.map((ss) => `session:${ss.id}`)),
|
|
672
|
+
expandedIds: nextExpandedIds,
|
|
673
|
+
};
|
|
674
|
+
});
|
|
675
|
+
},
|
|
676
|
+
|
|
677
|
+
pasteLessons: (toSessionId) => {
|
|
678
|
+
const { copiedIds, lessons } = get();
|
|
679
|
+
if (!copiedIds.length) return;
|
|
680
|
+
|
|
681
|
+
const destLessons = lessons
|
|
682
|
+
.filter((l) => l.sessionId === toSessionId)
|
|
683
|
+
.sort((a, b) => a.order - b.order);
|
|
684
|
+
let nextOrder = destLessons.length;
|
|
685
|
+
|
|
686
|
+
const newLessons: Lesson[] = [];
|
|
687
|
+
|
|
688
|
+
for (const copiedId of copiedIds) {
|
|
689
|
+
const lesson = lessons.find((l) => l.id === copiedId);
|
|
690
|
+
if (!lesson) continue;
|
|
691
|
+
newLessons.push({
|
|
692
|
+
...lesson,
|
|
693
|
+
id: newId(),
|
|
694
|
+
sessionId: toSessionId,
|
|
695
|
+
title: `Cópia de ${lesson.title}`,
|
|
696
|
+
order: nextOrder++,
|
|
697
|
+
resources: [...lesson.resources],
|
|
698
|
+
});
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
if (!newLessons.length) return;
|
|
702
|
+
const lastNew = newLessons[newLessons.length - 1];
|
|
703
|
+
|
|
704
|
+
set((s) => {
|
|
705
|
+
const nextExpandedIds = new Set([...s.expandedIds, toSessionId]);
|
|
706
|
+
saveExpandedIds(s.courseId, nextExpandedIds);
|
|
707
|
+
return {
|
|
708
|
+
lessons: [...s.lessons, ...newLessons],
|
|
709
|
+
activeItemId: lastNew!.id,
|
|
710
|
+
activeItemType: 'lesson',
|
|
711
|
+
selectedIds: new Set(newLessons.map((l) => `lesson:${l.id}`)),
|
|
712
|
+
expandedIds: nextExpandedIds,
|
|
713
|
+
};
|
|
714
|
+
});
|
|
715
|
+
},
|
|
716
|
+
|
|
717
|
+
moveLessons: (lessonIds, toSessionId) =>
|
|
718
|
+
set((s) => {
|
|
719
|
+
const moving = s.lessons
|
|
720
|
+
.filter((l) => lessonIds.includes(l.id))
|
|
721
|
+
.sort((a, b) => a.order - b.order);
|
|
722
|
+
if (!moving.length) return {};
|
|
723
|
+
|
|
724
|
+
const fromSessionIds = new Set(moving.map((l) => l.sessionId));
|
|
725
|
+
|
|
726
|
+
// Lessons not being moved
|
|
727
|
+
const staying = s.lessons.filter((l) => !lessonIds.includes(l.id));
|
|
728
|
+
|
|
729
|
+
// Reindex source sessions (skip toSessionId — handled with dest)
|
|
730
|
+
const reindexedSource: Lesson[] = [];
|
|
731
|
+
for (const fromId of fromSessionIds) {
|
|
732
|
+
if (fromId === toSessionId) continue;
|
|
733
|
+
const srcLessons = staying
|
|
734
|
+
.filter((l) => l.sessionId === fromId)
|
|
735
|
+
.sort((a, b) => a.order - b.order)
|
|
736
|
+
.map((l, i) => ({ ...l, order: i }));
|
|
737
|
+
reindexedSource.push(...srcLessons);
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
// Current dest lessons (not being moved)
|
|
741
|
+
const destExisting = staying
|
|
742
|
+
.filter((l) => l.sessionId === toSessionId)
|
|
743
|
+
.sort((a, b) => a.order - b.order);
|
|
744
|
+
|
|
745
|
+
const movedToNewSession: Lesson[] = moving.map((l, i) => ({
|
|
746
|
+
...l,
|
|
747
|
+
sessionId: toSessionId,
|
|
748
|
+
order: destExisting.length + i,
|
|
749
|
+
}));
|
|
750
|
+
|
|
751
|
+
// Unchanged lessons (not in any affected session)
|
|
752
|
+
const unchanged = staying.filter(
|
|
753
|
+
(l) => !fromSessionIds.has(l.sessionId) && l.sessionId !== toSessionId
|
|
754
|
+
);
|
|
755
|
+
|
|
756
|
+
const nextExpandedIds = new Set([...s.expandedIds, toSessionId]);
|
|
757
|
+
saveExpandedIds(s.courseId, nextExpandedIds);
|
|
758
|
+
|
|
759
|
+
return {
|
|
760
|
+
lessons: [
|
|
761
|
+
...unchanged,
|
|
762
|
+
...reindexedSource,
|
|
763
|
+
...destExisting,
|
|
764
|
+
...movedToNewSession,
|
|
765
|
+
],
|
|
766
|
+
expandedIds: nextExpandedIds,
|
|
767
|
+
selectedIds: new Set(movedToNewSession.map((l) => `lesson:${l.id}`)),
|
|
768
|
+
activeItemId:
|
|
769
|
+
movedToNewSession[movedToNewSession.length - 1]?.id ?? s.activeItemId,
|
|
770
|
+
activeItemType: 'lesson',
|
|
771
|
+
};
|
|
772
|
+
}),
|
|
773
|
+
|
|
774
|
+
reorderSessions: (fromIndex, toIndex) =>
|
|
775
|
+
set((s) => {
|
|
776
|
+
const sorted = [...s.sessions].sort((a, b) => a.order - b.order);
|
|
777
|
+
const [moved] = sorted.splice(fromIndex, 1);
|
|
778
|
+
if (!moved) return {};
|
|
779
|
+
sorted.splice(toIndex, 0, moved);
|
|
780
|
+
const reindexed = sorted.map((ss, i) => ({ ...ss, order: i }));
|
|
781
|
+
return { sessions: reindexed };
|
|
782
|
+
}),
|
|
783
|
+
|
|
784
|
+
moveLesson: (lessonId, toSessionId, toIndex) =>
|
|
785
|
+
set((s) => {
|
|
786
|
+
const lesson = s.lessons.find((l) => l.id === lessonId);
|
|
787
|
+
if (!lesson) return {};
|
|
788
|
+
|
|
789
|
+
const fromSessionId = lesson.sessionId;
|
|
790
|
+
const sameSession = fromSessionId === toSessionId;
|
|
791
|
+
|
|
792
|
+
const destLessons = s.lessons
|
|
793
|
+
.filter((l) => l.sessionId === toSessionId && l.id !== lessonId)
|
|
794
|
+
.sort((a, b) => a.order - b.order);
|
|
795
|
+
|
|
796
|
+
destLessons.splice(toIndex, 0, { ...lesson, sessionId: toSessionId });
|
|
797
|
+
const updatedDest = destLessons.map((l, i) => ({ ...l, order: i }));
|
|
798
|
+
|
|
799
|
+
let updatedSrc: Lesson[] = [];
|
|
800
|
+
if (!sameSession) {
|
|
801
|
+
updatedSrc = s.lessons
|
|
802
|
+
.filter((l) => l.sessionId === fromSessionId && l.id !== lessonId)
|
|
803
|
+
.sort((a, b) => a.order - b.order)
|
|
804
|
+
.map((l, i) => ({ ...l, order: i }));
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
const unchanged = s.lessons.filter(
|
|
808
|
+
(l) =>
|
|
809
|
+
l.id !== lessonId &&
|
|
810
|
+
l.sessionId !== toSessionId &&
|
|
811
|
+
(sameSession || l.sessionId !== fromSessionId)
|
|
812
|
+
);
|
|
813
|
+
|
|
814
|
+
return {
|
|
815
|
+
lessons: [
|
|
816
|
+
...unchanged,
|
|
817
|
+
...(sameSession ? [] : updatedSrc),
|
|
818
|
+
...updatedDest,
|
|
819
|
+
],
|
|
820
|
+
};
|
|
821
|
+
}),
|
|
822
|
+
|
|
823
|
+
setCourseId: (id) => set({ courseId: id, expandedIds: loadExpandedIds(id) }),
|
|
824
|
+
|
|
825
|
+
setCourse: (course) => set({ course }),
|
|
826
|
+
|
|
827
|
+
setStructureFromApi: (data) => {
|
|
828
|
+
const courseId = get().courseId || data.course?.id || '';
|
|
829
|
+
const validSessionIds = new Set(data.sessions.map((session) => session.id));
|
|
830
|
+
const nextExpandedIds = new Set(
|
|
831
|
+
[...get().expandedIds].filter((id) => validSessionIds.has(id))
|
|
832
|
+
);
|
|
833
|
+
saveExpandedIds(courseId, nextExpandedIds);
|
|
834
|
+
|
|
835
|
+
const patch: Partial<StructureState> = {
|
|
836
|
+
sessions: data.sessions,
|
|
837
|
+
lessons: data.lessons,
|
|
838
|
+
expandedIds: nextExpandedIds,
|
|
839
|
+
};
|
|
840
|
+
if (data.course) {
|
|
841
|
+
patch.course = data.course;
|
|
842
|
+
// Set initial active item to the course node when loading for the first time
|
|
843
|
+
if (!get().activeItemId) {
|
|
844
|
+
patch.activeItemId = data.course.id;
|
|
845
|
+
patch.activeItemType = 'course';
|
|
846
|
+
patch.selectedIds = new Set([`course:${data.course.id}`]);
|
|
847
|
+
patch.lastClickedId = data.course.id;
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
set(patch);
|
|
851
|
+
},
|
|
852
|
+
|
|
853
|
+
addSessionFromApi: (session) =>
|
|
854
|
+
set((s) => {
|
|
855
|
+
const nextExpandedIds = new Set([...s.expandedIds, session.id]);
|
|
856
|
+
saveExpandedIds(s.courseId, nextExpandedIds);
|
|
857
|
+
return {
|
|
858
|
+
sessions: [...s.sessions, session],
|
|
859
|
+
expandedIds: nextExpandedIds,
|
|
860
|
+
activeItemId: session.id,
|
|
861
|
+
activeItemType: 'session',
|
|
862
|
+
selectedIds: new Set([`session:${session.id}`]),
|
|
863
|
+
lastClickedId: session.id,
|
|
864
|
+
};
|
|
865
|
+
}),
|
|
866
|
+
|
|
867
|
+
addLessonFromApi: (lesson) =>
|
|
868
|
+
set((s) => {
|
|
869
|
+
const nextExpandedIds = new Set([...s.expandedIds, lesson.sessionId]);
|
|
870
|
+
saveExpandedIds(s.courseId, nextExpandedIds);
|
|
871
|
+
return {
|
|
872
|
+
lessons: [...s.lessons, lesson],
|
|
873
|
+
expandedIds: nextExpandedIds,
|
|
874
|
+
activeItemId: lesson.id,
|
|
875
|
+
activeItemType: 'lesson',
|
|
876
|
+
selectedIds: new Set([`lesson:${lesson.id}`]),
|
|
877
|
+
lastClickedId: lesson.id,
|
|
878
|
+
};
|
|
879
|
+
}),
|
|
880
|
+
|
|
881
|
+
addLessonsFromApi: (lessons) => {
|
|
882
|
+
if (!lessons.length) return;
|
|
883
|
+
const last = lessons[lessons.length - 1]!;
|
|
884
|
+
set((s) => {
|
|
885
|
+
const nextExpandedIds = new Set([
|
|
886
|
+
...s.expandedIds,
|
|
887
|
+
...lessons.map((l) => l.sessionId),
|
|
888
|
+
]);
|
|
889
|
+
saveExpandedIds(s.courseId, nextExpandedIds);
|
|
890
|
+
return {
|
|
891
|
+
lessons: [...s.lessons, ...lessons],
|
|
892
|
+
expandedIds: nextExpandedIds,
|
|
893
|
+
activeItemId: last.id,
|
|
894
|
+
activeItemType: 'lesson',
|
|
895
|
+
selectedIds: new Set(lessons.map((l) => `lesson:${l.id}`)),
|
|
896
|
+
lastClickedId: last.id,
|
|
897
|
+
};
|
|
898
|
+
});
|
|
899
|
+
},
|
|
900
|
+
|
|
901
|
+
addSessionWithLessonsFromApi: (session, lessons) =>
|
|
902
|
+
set((s) => {
|
|
903
|
+
const nextExpandedIds = new Set([...s.expandedIds, session.id]);
|
|
904
|
+
saveExpandedIds(s.courseId, nextExpandedIds);
|
|
905
|
+
return {
|
|
906
|
+
sessions: [...s.sessions, session],
|
|
907
|
+
lessons: [...s.lessons, ...lessons],
|
|
908
|
+
expandedIds: nextExpandedIds,
|
|
909
|
+
activeItemId: session.id,
|
|
910
|
+
activeItemType: 'session',
|
|
911
|
+
selectedIds: new Set([`session:${session.id}`]),
|
|
912
|
+
lastClickedId: session.id,
|
|
913
|
+
};
|
|
914
|
+
}),
|
|
915
|
+
|
|
916
|
+
showConfirm: (opts) =>
|
|
917
|
+
set({
|
|
918
|
+
confirmDialog: {
|
|
919
|
+
open: true,
|
|
920
|
+
title: opts.title,
|
|
921
|
+
description: opts.description,
|
|
922
|
+
onConfirm: opts.onConfirm,
|
|
923
|
+
},
|
|
924
|
+
}),
|
|
925
|
+
|
|
926
|
+
closeConfirm: () =>
|
|
927
|
+
set((s) => ({
|
|
928
|
+
confirmDialog: { ...s.confirmDialog, open: false, onConfirm: null },
|
|
929
|
+
})),
|
|
930
|
+
|
|
931
|
+
showSessionPicker: (opts) =>
|
|
932
|
+
set({
|
|
933
|
+
sessionPickerDialog: {
|
|
934
|
+
open: true,
|
|
935
|
+
title: opts.title,
|
|
936
|
+
excludeSessionId: opts.excludeSessionId ?? null,
|
|
937
|
+
onPick: opts.onPick,
|
|
938
|
+
},
|
|
939
|
+
}),
|
|
940
|
+
|
|
941
|
+
closeSessionPicker: () =>
|
|
942
|
+
set((s) => ({
|
|
943
|
+
sessionPickerDialog: {
|
|
944
|
+
...s.sessionPickerDialog,
|
|
945
|
+
open: false,
|
|
946
|
+
onPick: null,
|
|
947
|
+
},
|
|
948
|
+
})),
|
|
949
|
+
}));
|