@hed-hog/lms 0.0.312 → 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.
Files changed (68) hide show
  1. package/dist/class-group/class-group.controller.d.ts +2 -2
  2. package/dist/class-group/class-group.service.d.ts +2 -2
  3. package/dist/enterprise/dto/enterprise-profile.dto.d.ts +13 -0
  4. package/dist/enterprise/dto/enterprise-profile.dto.d.ts.map +1 -0
  5. package/dist/enterprise/dto/enterprise-profile.dto.js +3 -0
  6. package/dist/enterprise/dto/enterprise-profile.dto.js.map +1 -0
  7. package/dist/enterprise/enterprise.controller.d.ts +3 -0
  8. package/dist/enterprise/enterprise.controller.d.ts.map +1 -1
  9. package/dist/enterprise/enterprise.controller.js +14 -0
  10. package/dist/enterprise/enterprise.controller.js.map +1 -1
  11. package/dist/enterprise/enterprise.service.d.ts +3 -0
  12. package/dist/enterprise/enterprise.service.d.ts.map +1 -1
  13. package/dist/enterprise/enterprise.service.js +128 -1
  14. package/dist/enterprise/enterprise.service.js.map +1 -1
  15. package/dist/instructor/instructor.controller.d.ts +23 -0
  16. package/dist/instructor/instructor.controller.d.ts.map +1 -1
  17. package/dist/instructor/instructor.controller.js +41 -0
  18. package/dist/instructor/instructor.controller.js.map +1 -1
  19. package/dist/instructor/instructor.service.d.ts +25 -0
  20. package/dist/instructor/instructor.service.d.ts.map +1 -1
  21. package/dist/instructor/instructor.service.js +126 -8
  22. package/dist/instructor/instructor.service.js.map +1 -1
  23. package/hedhog/data/menu.yaml +23 -7
  24. package/hedhog/data/role.yaml +9 -1
  25. package/hedhog/data/route.yaml +54 -0
  26. package/hedhog/frontend/app/_components/class-form-sheet.tsx.ejs +2 -1
  27. package/hedhog/frontend/app/courses/[id]/structure/_components/confirm-dialog.tsx.ejs +44 -44
  28. package/hedhog/frontend/app/courses/[id]/structure/_components/course-tree-dnd.tsx.ejs +362 -362
  29. package/hedhog/frontend/app/courses/[id]/structure/_components/course-tree-panel.tsx.ejs +111 -111
  30. package/hedhog/frontend/app/courses/[id]/structure/_components/course-tree.tsx.ejs +134 -134
  31. package/hedhog/frontend/app/courses/[id]/structure/_components/detail-course.tsx.ejs +113 -113
  32. package/hedhog/frontend/app/courses/[id]/structure/_components/detail-lesson.tsx.ejs +314 -314
  33. package/hedhog/frontend/app/courses/[id]/structure/_components/detail-panel.tsx.ejs +62 -62
  34. package/hedhog/frontend/app/courses/[id]/structure/_components/detail-session.tsx.ejs +173 -173
  35. package/hedhog/frontend/app/courses/[id]/structure/_components/drag-handle.tsx.ejs +58 -58
  36. package/hedhog/frontend/app/courses/[id]/structure/_components/drag-overlay.tsx.ejs +51 -51
  37. package/hedhog/frontend/app/courses/[id]/structure/_components/editor-bulk.tsx.ejs +276 -276
  38. package/hedhog/frontend/app/courses/[id]/structure/_components/editor-course.tsx.ejs +1216 -1216
  39. package/hedhog/frontend/app/courses/[id]/structure/_components/editor-lesson.tsx.ejs +1824 -1824
  40. package/hedhog/frontend/app/courses/[id]/structure/_components/editor-session.tsx.ejs +443 -443
  41. package/hedhog/frontend/app/courses/[id]/structure/_components/highlighted-text.tsx.ejs +40 -40
  42. package/hedhog/frontend/app/courses/[id]/structure/_components/mock-data.ts.ejs +185 -185
  43. package/hedhog/frontend/app/courses/[id]/structure/_components/multi-select-bar.tsx.ejs +264 -264
  44. package/hedhog/frontend/app/courses/[id]/structure/_components/search-filter.tsx.ejs +95 -95
  45. package/hedhog/frontend/app/courses/[id]/structure/_components/session-picker-dialog.tsx.ejs +73 -73
  46. package/hedhog/frontend/app/courses/[id]/structure/_components/shortcuts-help.tsx.ejs +136 -136
  47. package/hedhog/frontend/app/courses/[id]/structure/_components/sortable-tree-row.tsx.ejs +80 -80
  48. package/hedhog/frontend/app/courses/[id]/structure/_components/store.ts.ejs +949 -949
  49. package/hedhog/frontend/app/courses/[id]/structure/_components/tree-context-menu.tsx.ejs +525 -525
  50. package/hedhog/frontend/app/courses/[id]/structure/_components/tree-helpers.ts.ejs +181 -181
  51. package/hedhog/frontend/app/courses/[id]/structure/_components/tree-row-course.tsx.ejs +51 -51
  52. package/hedhog/frontend/app/courses/[id]/structure/_components/tree-row-lesson.tsx.ejs +271 -271
  53. package/hedhog/frontend/app/courses/[id]/structure/_components/tree-row-session.tsx.ejs +167 -167
  54. package/hedhog/frontend/app/courses/[id]/structure/_components/tree-row.tsx.ejs +108 -108
  55. package/hedhog/frontend/app/courses/[id]/structure/_components/use-course-structure-shortcuts.ts.ejs +318 -318
  56. package/hedhog/frontend/app/courses/[id]/structure/page.tsx.ejs +10 -10
  57. package/hedhog/frontend/app/enterprise/_components/enterprise-course-create-sheet.tsx.ejs +2 -1
  58. package/hedhog/frontend/app/instructors/_components/instructor-form-sheet.tsx.ejs +438 -0
  59. package/hedhog/frontend/app/instructors/_components/instructor-types.ts.ejs +40 -0
  60. package/hedhog/frontend/app/instructors/page.tsx.ejs +696 -0
  61. package/hedhog/frontend/app/training/page.tsx.ejs +2339 -0
  62. package/hedhog/table/enterprise_user.yaml +1 -1
  63. package/package.json +8 -8
  64. package/src/enterprise/dto/enterprise-profile.dto.ts +17 -0
  65. package/src/enterprise/enterprise.controller.ts +9 -1
  66. package/src/enterprise/enterprise.service.ts +147 -4
  67. package/src/instructor/instructor.controller.ts +36 -9
  68. 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
+ }));