@hed-hog/lms 0.0.306 → 0.0.310

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/dist/course/course-structure.controller.d.ts +60 -0
  2. package/dist/course/course-structure.controller.d.ts.map +1 -1
  3. package/dist/course/course-structure.controller.js +79 -0
  4. package/dist/course/course-structure.controller.js.map +1 -1
  5. package/dist/course/course-structure.service.d.ts +61 -1
  6. package/dist/course/course-structure.service.d.ts.map +1 -1
  7. package/dist/course/course-structure.service.js +326 -1
  8. package/dist/course/course-structure.service.js.map +1 -1
  9. package/dist/course/course.controller.d.ts +52 -4
  10. package/dist/course/course.controller.d.ts.map +1 -1
  11. package/dist/course/course.service.d.ts +52 -5
  12. package/dist/course/course.service.d.ts.map +1 -1
  13. package/dist/course/course.service.js +78 -57
  14. package/dist/course/course.service.js.map +1 -1
  15. package/dist/course/dto/create-course-structure-lesson.dto.d.ts.map +1 -1
  16. package/dist/course/dto/create-course-structure-lesson.dto.js +5 -1
  17. package/dist/course/dto/create-course-structure-lesson.dto.js.map +1 -1
  18. package/dist/course/dto/create-course.dto.d.ts +1 -1
  19. package/dist/course/dto/create-course.dto.d.ts.map +1 -1
  20. package/dist/course/dto/create-course.dto.js +4 -1
  21. package/dist/course/dto/create-course.dto.js.map +1 -1
  22. package/dist/course/dto/move-lesson.dto.d.ts +10 -0
  23. package/dist/course/dto/move-lesson.dto.d.ts.map +1 -0
  24. package/dist/course/dto/move-lesson.dto.js +28 -0
  25. package/dist/course/dto/move-lesson.dto.js.map +1 -0
  26. package/dist/course/dto/paste-lessons.dto.d.ts +4 -0
  27. package/dist/course/dto/paste-lessons.dto.d.ts.map +1 -0
  28. package/dist/course/dto/paste-lessons.dto.js +24 -0
  29. package/dist/course/dto/paste-lessons.dto.js.map +1 -0
  30. package/dist/course/dto/reorder-lessons.dto.d.ts +5 -0
  31. package/dist/course/dto/reorder-lessons.dto.d.ts.map +1 -0
  32. package/dist/course/dto/reorder-lessons.dto.js +24 -0
  33. package/dist/course/dto/reorder-lessons.dto.js.map +1 -0
  34. package/dist/course/dto/reorder-sessions.dto.d.ts +5 -0
  35. package/dist/course/dto/reorder-sessions.dto.d.ts.map +1 -0
  36. package/dist/course/dto/reorder-sessions.dto.js +24 -0
  37. package/dist/course/dto/reorder-sessions.dto.js.map +1 -0
  38. package/dist/training/training.controller.js +1 -1
  39. package/dist/training/training.controller.js.map +1 -1
  40. package/hedhog/data/image_type.yaml +20 -0
  41. package/hedhog/data/menu.yaml +2 -2
  42. package/hedhog/data/route.yaml +60 -6
  43. package/hedhog/frontend/app/_components/class-form-sheet.tsx.ejs +146 -165
  44. package/hedhog/frontend/app/_components/course-avatar.tsx.ejs +70 -0
  45. package/hedhog/frontend/app/_components/course-form-sheet.tsx.ejs +372 -22
  46. package/hedhog/frontend/app/certificates/models/CanvasStage.tsx.ejs +10 -1
  47. package/hedhog/frontend/app/certificates/models/TopBar.tsx.ejs +44 -3
  48. package/hedhog/frontend/app/certificates/models/page.tsx.ejs +32 -0
  49. package/hedhog/frontend/app/classes/[id]/page.tsx.ejs +437 -77
  50. package/hedhog/frontend/app/classes/page.tsx.ejs +311 -289
  51. package/hedhog/frontend/app/courses/[id]/_components/CourseCertificateCard.tsx.ejs +10 -7
  52. package/hedhog/frontend/app/courses/[id]/_components/CourseClassificationCard.tsx.ejs +23 -32
  53. package/hedhog/frontend/app/courses/[id]/_components/CourseContentCard.tsx.ejs +3 -9
  54. package/hedhog/frontend/app/courses/[id]/_components/CourseDangerZoneCard.tsx.ejs +26 -16
  55. package/hedhog/frontend/app/courses/[id]/_components/CourseFlagsCard.tsx.ejs +19 -5
  56. package/hedhog/frontend/app/courses/[id]/_components/CourseMainInfoCard.tsx.ejs +10 -14
  57. package/hedhog/frontend/app/courses/[id]/_components/CourseMediaCard.tsx.ejs +131 -107
  58. package/hedhog/frontend/app/courses/[id]/_components/CourseRelationsCard.tsx.ejs +10 -7
  59. package/hedhog/frontend/app/courses/[id]/_components/CourseSectionCard.tsx.ejs +38 -19
  60. package/hedhog/frontend/app/courses/[id]/_components/CourseSummaryCard.tsx.ejs +1 -1
  61. package/hedhog/frontend/app/courses/[id]/_components/course-edit-types.ts.ejs +1 -1
  62. package/hedhog/frontend/app/courses/[id]/page.tsx.ejs +336 -1057
  63. package/hedhog/frontend/app/courses/[id]/structure/_components/confirm-dialog.tsx.ejs +45 -0
  64. package/hedhog/frontend/app/courses/[id]/structure/_components/course-tree-dnd.tsx.ejs +362 -0
  65. package/hedhog/frontend/app/courses/[id]/structure/_components/course-tree-panel.tsx.ejs +111 -0
  66. package/hedhog/frontend/app/courses/[id]/structure/_components/course-tree-skeleton.tsx.ejs +64 -0
  67. package/hedhog/frontend/app/courses/[id]/structure/_components/course-tree.tsx.ejs +134 -0
  68. package/hedhog/frontend/app/courses/[id]/structure/_components/detail-course.tsx.ejs +113 -0
  69. package/hedhog/frontend/app/courses/[id]/structure/_components/detail-lesson.tsx.ejs +314 -0
  70. package/hedhog/frontend/app/courses/[id]/structure/_components/detail-panel.tsx.ejs +62 -0
  71. package/hedhog/frontend/app/courses/[id]/structure/_components/detail-session.tsx.ejs +174 -0
  72. package/hedhog/frontend/app/courses/[id]/structure/_components/drag-handle.tsx.ejs +58 -0
  73. package/hedhog/frontend/app/courses/[id]/structure/_components/drag-overlay.tsx.ejs +52 -0
  74. package/hedhog/frontend/app/courses/[id]/structure/_components/editor-bulk.tsx.ejs +276 -0
  75. package/hedhog/frontend/app/courses/[id]/structure/_components/editor-course.tsx.ejs +1216 -0
  76. package/hedhog/frontend/app/courses/[id]/structure/_components/editor-lesson.tsx.ejs +1827 -0
  77. package/hedhog/frontend/app/courses/[id]/structure/_components/editor-session.tsx.ejs +443 -0
  78. package/hedhog/frontend/app/courses/[id]/structure/_components/highlighted-text.tsx.ejs +41 -0
  79. package/hedhog/frontend/app/courses/[id]/structure/_components/mock-data.ts.ejs +184 -0
  80. package/hedhog/frontend/app/courses/[id]/structure/_components/multi-select-bar.tsx.ejs +264 -0
  81. package/hedhog/frontend/app/courses/[id]/structure/_components/search-filter.tsx.ejs +96 -0
  82. package/hedhog/frontend/app/courses/[id]/structure/_components/session-picker-dialog.tsx.ejs +74 -0
  83. package/hedhog/frontend/app/courses/[id]/structure/_components/shortcuts-help.tsx.ejs +136 -0
  84. package/hedhog/frontend/app/courses/[id]/structure/_components/sortable-tree-row.tsx.ejs +80 -0
  85. package/hedhog/frontend/app/courses/[id]/structure/_components/store.ts.ejs +948 -0
  86. package/hedhog/frontend/app/courses/[id]/structure/_components/tree-context-menu.tsx.ejs +525 -0
  87. package/hedhog/frontend/app/courses/[id]/structure/_components/tree-display-settings-popover.tsx.ejs +150 -0
  88. package/hedhog/frontend/app/courses/[id]/structure/_components/tree-helpers.ts.ejs +182 -0
  89. package/hedhog/frontend/app/courses/[id]/structure/_components/tree-row-course.tsx.ejs +52 -0
  90. package/hedhog/frontend/app/courses/[id]/structure/_components/tree-row-lesson.tsx.ejs +271 -0
  91. package/hedhog/frontend/app/courses/[id]/structure/_components/tree-row-session.tsx.ejs +167 -0
  92. package/hedhog/frontend/app/courses/[id]/structure/_components/tree-row.tsx.ejs +108 -0
  93. package/hedhog/frontend/app/courses/[id]/structure/_components/types.ts.ejs +122 -0
  94. package/hedhog/frontend/app/courses/[id]/structure/_components/use-course-structure-shortcuts.ts.ejs +318 -0
  95. package/hedhog/frontend/app/courses/[id]/structure/_components/use-tree-display-settings.ts.ejs +97 -0
  96. package/hedhog/frontend/app/courses/[id]/structure/_data/adapters/course-structure.adapter.ts.ejs +347 -0
  97. package/hedhog/frontend/app/courses/[id]/structure/_data/course-structure-contract.ts.ejs +195 -0
  98. package/hedhog/frontend/app/courses/[id]/structure/_data/services/course-structure.service.ts.ejs +420 -0
  99. package/hedhog/frontend/app/courses/[id]/structure/_data/types/api-course.types.ts.ejs +254 -0
  100. package/hedhog/frontend/app/courses/[id]/structure/_data/use-course-structure-mutations.ts.ejs +987 -0
  101. package/hedhog/frontend/app/courses/[id]/structure/_data/use-course-structure-query.ts.ejs +86 -0
  102. package/hedhog/frontend/app/courses/[id]/structure/_data/use-course-structure.ts.ejs +160 -0
  103. package/hedhog/frontend/app/courses/[id]/structure/page.tsx.ejs +10 -3212
  104. package/hedhog/frontend/app/courses/page.tsx.ejs +45 -26
  105. package/hedhog/frontend/app/{training → paths}/page.tsx.ejs +29 -7
  106. package/hedhog/frontend/messages/en.json +91 -11
  107. package/hedhog/frontend/messages/pt.json +91 -11
  108. package/hedhog/table/course.yaml +1 -1
  109. package/hedhog/table/image_type.yaml +14 -0
  110. package/package.json +7 -7
  111. package/src/course/course-structure.controller.ts +63 -0
  112. package/src/course/course-structure.service.ts +390 -3
  113. package/src/course/course.service.ts +59 -27
  114. package/src/course/dto/create-course-structure-lesson.dto.ts +3 -2
  115. package/src/course/dto/create-course.dto.ts +4 -1
  116. package/src/course/dto/move-lesson.dto.ts +17 -0
  117. package/src/course/dto/paste-lessons.dto.ts +9 -0
  118. package/src/course/dto/reorder-lessons.dto.ts +10 -0
  119. package/src/course/dto/reorder-sessions.dto.ts +10 -0
  120. package/src/training/training.controller.ts +1 -1
@@ -37,9 +37,17 @@ import {
37
37
  SheetTitle,
38
38
  } from '@/components/ui/sheet';
39
39
  import { Textarea } from '@/components/ui/textarea';
40
- import { ChevronsUpDown, Loader2, Plus } from 'lucide-react';
41
- import { useState } from 'react';
42
- import { Controller, UseFormReturn } from 'react-hook-form';
40
+ import { useApp } from '@hed-hog/next-app-provider';
41
+ import {
42
+ ChevronsUpDown,
43
+ ImageIcon,
44
+ Loader2,
45
+ Plus,
46
+ Upload,
47
+ X,
48
+ } from 'lucide-react';
49
+ import { useEffect, useRef, useState } from 'react';
50
+ import { Controller, UseFormReturn, useWatch } from 'react-hook-form';
43
51
  import { z } from 'zod';
44
52
 
45
53
  export type CourseCategoryOption = {
@@ -47,9 +55,168 @@ export type CourseCategoryOption = {
47
55
  label: string;
48
56
  };
49
57
 
58
+ // ---------------------------------------------------------------------------
59
+ // Color extraction utilities
60
+ // ---------------------------------------------------------------------------
61
+
62
+ export function rgbToHex(r: number, g: number, b: number): string {
63
+ return (
64
+ '#' +
65
+ [r, g, b]
66
+ .map((v) => Math.max(0, Math.min(255, v)).toString(16).padStart(2, '0'))
67
+ .join('')
68
+ );
69
+ }
70
+
71
+ export function getColorDistance(
72
+ a: [number, number, number],
73
+ b: [number, number, number]
74
+ ): number {
75
+ return Math.sqrt(
76
+ (a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2 + (a[2] - b[2]) ** 2
77
+ );
78
+ }
79
+
80
+ export function quantizeColor(
81
+ r: number,
82
+ g: number,
83
+ b: number,
84
+ factor = 32
85
+ ): [number, number, number] {
86
+ return [
87
+ Math.round(r / factor) * factor,
88
+ Math.round(g / factor) * factor,
89
+ Math.round(b / factor) * factor,
90
+ ];
91
+ }
92
+
93
+ export function isIgnoredColor(
94
+ r: number,
95
+ g: number,
96
+ b: number,
97
+ a: number
98
+ ): boolean {
99
+ if (a < 128) return true;
100
+ if (r > 240 && g > 240 && b > 240) return true; // near-white
101
+ if (r < 20 && g < 20 && b < 20) return true; // near-black
102
+ const max = Math.max(r, g, b);
103
+ const min = Math.min(r, g, b);
104
+ if (max - min < 30) return true; // near-gray
105
+ return false;
106
+ }
107
+
108
+ export function getSaturation(r: number, g: number, b: number): number {
109
+ const max = Math.max(r, g, b) / 255;
110
+ const min = Math.min(r, g, b) / 255;
111
+ if (max === 0) return 0;
112
+ return (max - min) / max;
113
+ }
114
+
115
+ export type ExtractedColors = {
116
+ primary: string | null;
117
+ secondary: string | null;
118
+ };
119
+
120
+ export function extractLogoColors(
121
+ file: File,
122
+ signal?: { cancelled: boolean }
123
+ ): Promise<ExtractedColors> {
124
+ return new Promise((resolve, reject) => {
125
+ const objectUrl = URL.createObjectURL(file);
126
+ const img = new Image();
127
+
128
+ img.onload = () => {
129
+ URL.revokeObjectURL(objectUrl);
130
+
131
+ if (signal?.cancelled) {
132
+ resolve({ primary: null, secondary: null });
133
+ return;
134
+ }
135
+
136
+ const MAX_SIDE = 150;
137
+ const scale = Math.min(1, MAX_SIDE / Math.max(img.width, img.height, 1));
138
+ const w = Math.max(1, Math.round(img.width * scale));
139
+ const h = Math.max(1, Math.round(img.height * scale));
140
+
141
+ const canvas = document.createElement('canvas');
142
+ canvas.width = w;
143
+ canvas.height = h;
144
+ const ctx = canvas.getContext('2d');
145
+ if (!ctx) {
146
+ resolve({ primary: null, secondary: null });
147
+ return;
148
+ }
149
+
150
+ ctx.drawImage(img, 0, 0, w, h);
151
+ const { data } = ctx.getImageData(0, 0, w, h);
152
+
153
+ const counts = new Map<
154
+ string,
155
+ { color: [number, number, number]; count: number }
156
+ >();
157
+
158
+ // Sample every 4th pixel (stride = 4 pixels = 16 bytes)
159
+ for (let i = 0; i < data.length; i += 16) {
160
+ const r = data[i] ?? 0;
161
+ const g = data[i + 1] ?? 0;
162
+ const b = data[i + 2] ?? 0;
163
+ const a = data[i + 3] ?? 0;
164
+
165
+ if (isIgnoredColor(r, g, b, a)) continue;
166
+
167
+ const [qr, qg, qb] = quantizeColor(r, g, b);
168
+ const key = `${qr},${qg},${qb}`;
169
+ const existing = counts.get(key);
170
+ if (existing) {
171
+ existing.count++;
172
+ } else {
173
+ counts.set(key, { color: [qr, qg, qb], count: 1 });
174
+ }
175
+ }
176
+
177
+ if (counts.size === 0) {
178
+ resolve({ primary: null, secondary: null });
179
+ return;
180
+ }
181
+
182
+ const sorted = Array.from(counts.values()).sort((a, b) => {
183
+ const scoreA = a.count * (1 + getSaturation(...a.color));
184
+ const scoreB = b.count * (1 + getSaturation(...b.color));
185
+ return scoreB - scoreA;
186
+ });
187
+
188
+ const primary = sorted[0]!.color;
189
+ let secondary: [number, number, number] | null = null;
190
+
191
+ for (let i = 1; i < sorted.length; i++) {
192
+ const candidate = sorted[i]!.color;
193
+ if (getColorDistance(primary, candidate) >= 100) {
194
+ secondary = candidate;
195
+ break;
196
+ }
197
+ }
198
+
199
+ resolve({
200
+ primary: rgbToHex(...primary),
201
+ secondary: secondary ? rgbToHex(...secondary) : null,
202
+ });
203
+ };
204
+
205
+ img.onerror = () => {
206
+ URL.revokeObjectURL(objectUrl);
207
+ reject(new Error('Failed to load image for color extraction'));
208
+ };
209
+
210
+ img.src = objectUrl;
211
+ });
212
+ }
213
+
50
214
  export function getCourseSheetSchema(t: (key: string) => string) {
51
215
  return z.object({
52
216
  nomeInterno: z.string().min(3, t('form.validation.internalNameMinLength')),
217
+ slug: z
218
+ .string()
219
+ .regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, t('form.validation.slugPattern')),
53
220
  tituloComercial: z.string().optional(),
54
221
  descricao: z.string().optional(),
55
222
  nivel: z.enum(['iniciante', 'intermediario', 'avancado']).optional(),
@@ -57,11 +224,13 @@ export function getCourseSheetSchema(t: (key: string) => string) {
57
224
  categorias: z.array(z.string()).optional(),
58
225
  primaryColor: z.string().optional(),
59
226
  secondaryColor: z.string().optional(),
227
+ logoFileId: z.number().nullable().optional(),
60
228
  });
61
229
  }
62
230
 
63
231
  export type CourseSheetFormValues = {
64
232
  nomeInterno: string;
233
+ slug: string;
65
234
  tituloComercial: string;
66
235
  descricao: string;
67
236
  nivel: 'iniciante' | 'intermediario' | 'avancado';
@@ -69,10 +238,12 @@ export type CourseSheetFormValues = {
69
238
  categorias: string[];
70
239
  primaryColor: string;
71
240
  secondaryColor: string;
241
+ logoFileId?: number | null;
72
242
  };
73
243
 
74
244
  export const DEFAULT_COURSE_FORM_VALUES: CourseSheetFormValues = {
75
245
  nomeInterno: '',
246
+ slug: '',
76
247
  tituloComercial: '',
77
248
  descricao: '',
78
249
  nivel: 'iniciante',
@@ -80,6 +251,7 @@ export const DEFAULT_COURSE_FORM_VALUES: CourseSheetFormValues = {
80
251
  categorias: [],
81
252
  primaryColor: '#1D4ED8',
82
253
  secondaryColor: '#111827',
254
+ logoFileId: null,
83
255
  };
84
256
 
85
257
  type CourseFormSheetProps = {
@@ -90,9 +262,9 @@ type CourseFormSheetProps = {
90
262
  form: UseFormReturn<CourseSheetFormValues>;
91
263
  onSubmit: (data: CourseSheetFormValues) => Promise<void>;
92
264
  t: (key: string) => string;
93
- courseCode?: string | null;
94
265
  categories?: readonly CourseCategoryOption[];
95
266
  onCreateCategory?: () => void;
267
+ initialLogoFileId?: number | null;
96
268
  };
97
269
 
98
270
  export function CourseFormSheet({
@@ -103,12 +275,124 @@ export function CourseFormSheet({
103
275
  form,
104
276
  onSubmit,
105
277
  t,
106
- courseCode,
107
278
  categories = [],
108
279
  onCreateCategory,
280
+ initialLogoFileId,
109
281
  }: CourseFormSheetProps) {
282
+ const { request } = useApp();
110
283
  const [categoryOpen, setCategoryOpen] = useState(false);
111
284
  const [categorySearch, setCategorySearch] = useState('');
285
+ const slugTouchedRef = useRef(false);
286
+ const watchedNomeInterno = useWatch({
287
+ control: form.control,
288
+ name: 'nomeInterno',
289
+ });
290
+
291
+ // Reset slug-touched flag when sheet opens for create
292
+ useEffect(() => {
293
+ if (open && !editing) {
294
+ slugTouchedRef.current = false;
295
+ }
296
+ }, [open, editing]);
297
+
298
+ // Auto-generate slug from nomeInterno when creating
299
+ useEffect(() => {
300
+ if (editing || slugTouchedRef.current) return;
301
+ const generated = (watchedNomeInterno ?? '')
302
+ .trim()
303
+ .toLowerCase()
304
+ .normalize('NFD')
305
+ .replace(/[\u0300-\u036f]/g, '')
306
+ .replace(/[^a-z0-9]+/g, '-')
307
+ .replace(/^-+|-+$/g, '');
308
+ form.setValue('slug', generated, { shouldValidate: false });
309
+ }, [watchedNomeInterno, editing]);
310
+ const [logoPreviewUrl, setLogoPreviewUrl] = useState<string | null>(null);
311
+ const [logoUploading, setLogoUploading] = useState(false);
312
+ const [colorExtracting, setColorExtracting] = useState(false);
313
+ const logoInputRef = useRef<HTMLInputElement>(null);
314
+ const colorExtractionRef = useRef<{ cancelled: boolean } | null>(null);
315
+
316
+ useEffect(() => {
317
+ if (!initialLogoFileId) {
318
+ setLogoPreviewUrl(null);
319
+ return;
320
+ }
321
+
322
+ let cancelled = false;
323
+
324
+ request<{ url?: string }>({
325
+ url: `/file/open/${initialLogoFileId}`,
326
+ method: 'PUT',
327
+ })
328
+ .then((res) => {
329
+ if (!cancelled && res?.data?.url) setLogoPreviewUrl(res.data.url);
330
+ })
331
+ .catch(() => {
332
+ if (!cancelled) setLogoPreviewUrl(null);
333
+ });
334
+
335
+ return () => {
336
+ cancelled = true;
337
+ };
338
+ }, [initialLogoFileId, request]);
339
+
340
+ async function handleLogoUpload(e: React.ChangeEvent<HTMLInputElement>) {
341
+ const file = e.target.files?.[0];
342
+ if (!file) return;
343
+
344
+ // Cancel any in-flight color extraction
345
+ if (colorExtractionRef.current) colorExtractionRef.current.cancelled = true;
346
+ const signal = { cancelled: false };
347
+ colorExtractionRef.current = signal;
348
+ setColorExtracting(true);
349
+
350
+ extractLogoColors(file, signal)
351
+ .then((colors) => {
352
+ if (signal.cancelled) return;
353
+ if (colors.primary)
354
+ form.setValue('primaryColor', colors.primary, { shouldDirty: true });
355
+ if (colors.secondary)
356
+ form.setValue('secondaryColor', colors.secondary, {
357
+ shouldDirty: true,
358
+ });
359
+ })
360
+ .catch((err) =>
361
+ console.warn('[CourseFormSheet] Color extraction failed', err)
362
+ )
363
+ .finally(() => {
364
+ if (!signal.cancelled) setColorExtracting(false);
365
+ });
366
+
367
+ setLogoUploading(true);
368
+ try {
369
+ const formData = new FormData();
370
+ formData.append('file', file);
371
+ const uploadRes = await request<{ id?: number }>({
372
+ url: '/file',
373
+ method: 'POST',
374
+ data: formData,
375
+ headers: { 'Content-Type': 'multipart/form-data' },
376
+ });
377
+ const fileId = uploadRes?.data?.id;
378
+ if (!fileId) return;
379
+ const openRes = await request<{ url?: string }>({
380
+ url: `/file/open/${fileId}`,
381
+ method: 'PUT',
382
+ });
383
+ if (openRes?.data?.url) setLogoPreviewUrl(openRes.data.url);
384
+ form.setValue('logoFileId', fileId, { shouldDirty: true });
385
+ } finally {
386
+ setLogoUploading(false);
387
+ if (logoInputRef.current) logoInputRef.current.value = '';
388
+ }
389
+ }
390
+
391
+ function handleRemoveLogo() {
392
+ setLogoPreviewUrl(null);
393
+ form.setValue('logoFileId', null, { shouldDirty: true });
394
+ if (logoInputRef.current) logoInputRef.current.value = '';
395
+ }
112
396
 
113
397
  return (
114
398
  <Sheet open={open} onOpenChange={onOpenChange}>
@@ -131,23 +415,6 @@ export function CourseFormSheet({
131
415
  onSubmit={form.handleSubmit(onSubmit)}
132
416
  className="flex flex-1 flex-col gap-5 py-6 px-4"
133
417
  >
134
- {editing && courseCode && (
135
- <Field>
136
- <FieldLabel htmlFor="codigo">
137
- {t('form.fields.code.label')}
138
- </FieldLabel>
139
- <Input
140
- id="codigo"
141
- value={courseCode}
142
- readOnly
143
- className="uppercase"
144
- />
145
- <FieldDescription>
146
- Codigo gerado automaticamente pelo sistema.
147
- </FieldDescription>
148
- </Field>
149
- )}
150
-
151
418
  <Field>
152
419
  <FieldLabel htmlFor="nomeInterno">
153
420
  {t('form.fields.internalName.label')}{' '}
@@ -166,6 +433,82 @@ export function CourseFormSheet({
166
433
  </FieldError>
167
434
  </Field>
168
435
 
436
+ <Field>
437
+ <FieldLabel htmlFor="slug">
438
+ {t('form.fields.slug.label')}{' '}
439
+ <span className="text-destructive">*</span>
440
+ </FieldLabel>
441
+ <Input
442
+ id="slug"
443
+ placeholder={t('form.fields.slug.placeholder')}
444
+ {...form.register('slug', {
445
+ onChange: () => {
446
+ slugTouchedRef.current = true;
447
+ },
448
+ })}
449
+ />
450
+ <FieldDescription>
451
+ {t('form.fields.slug.description')}
452
+ </FieldDescription>
453
+ <FieldError>{form.formState.errors.slug?.message}</FieldError>
454
+ </Field>
455
+
456
+ <Field>
457
+ <FieldLabel>Logo do Curso</FieldLabel>
458
+ <div className="flex items-start gap-4">
459
+ {logoPreviewUrl ? (
460
+ <img
461
+ src={logoPreviewUrl}
462
+ alt="Logo"
463
+ className="size-16 shrink-0 rounded-lg border object-cover"
464
+ />
465
+ ) : (
466
+ <div className="flex size-16 shrink-0 items-center justify-center rounded-lg border border-dashed bg-muted/40">
467
+ <ImageIcon className="size-6 text-muted-foreground/50" />
468
+ </div>
469
+ )}
470
+ <div className="flex flex-col gap-2">
471
+ <input
472
+ ref={logoInputRef}
473
+ type="file"
474
+ accept="image/*"
475
+ className="hidden"
476
+ onChange={handleLogoUpload}
477
+ />
478
+ <Button
479
+ type="button"
480
+ variant="outline"
481
+ size="sm"
482
+ disabled={logoUploading}
483
+ onClick={() => logoInputRef.current?.click()}
484
+ className="gap-2"
485
+ >
486
+ {logoUploading ? (
487
+ <Loader2 className="size-3.5 animate-spin" />
488
+ ) : (
489
+ <Upload className="size-3.5" />
490
+ )}
491
+ {logoPreviewUrl ? 'Substituir logo' : 'Carregar logo'}
492
+ </Button>
493
+ {logoPreviewUrl && (
494
+ <Button
495
+ type="button"
496
+ variant="ghost"
497
+ size="sm"
498
+ onClick={handleRemoveLogo}
499
+ className="gap-2 text-destructive hover:text-destructive"
500
+ >
501
+ <X className="size-3.5" />
502
+ Remover
503
+ </Button>
504
+ )}
505
+ </div>
506
+ </div>
507
+ <FieldDescription>
508
+ Imagem opcional para identificar o curso.
509
+ </FieldDescription>
510
+ </Field>
511
+
169
512
  <Field>
170
513
  <FieldLabel htmlFor="tituloComercial">
171
514
  {t('form.fields.commercialTitle.label')}
@@ -407,6 +750,13 @@ export function CourseFormSheet({
407
750
  <FieldError>{form.formState.errors.categorias?.message}</FieldError>
408
751
  </Field>
409
752
 
753
+ {colorExtracting && (
754
+ <div className="flex items-center gap-1.5 text-xs text-muted-foreground">
755
+ <Loader2 className="size-3 animate-spin" />
756
+ Detectando cores...
757
+ </div>
758
+ )}
759
+
410
760
  <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
411
761
  <Field>
412
762
  <FieldLabel htmlFor="primaryColor">
@@ -658,7 +658,16 @@ async function applyBg(canvas: any, fabricMod: any, src: string) {
658
658
  });
659
659
  const scaleX = CANVAS_W / (img.width || 1);
660
660
  const scaleY = CANVAS_H / (img.height || 1);
661
- img.set({ scaleX, scaleY, selectable: false, evented: false });
661
+ img.set({
662
+ left: 0,
663
+ top: 0,
664
+ originX: 'left',
665
+ originY: 'top',
666
+ scaleX,
667
+ scaleY,
668
+ selectable: false,
669
+ evented: false,
670
+ });
662
671
  canvas.backgroundImage = img;
663
672
  canvas.requestRenderAll();
664
673
  } catch {
@@ -21,7 +21,7 @@ import {
21
21
  ZoomIn,
22
22
  ZoomOut,
23
23
  } from 'lucide-react';
24
- import { useCallback, useRef } from 'react';
24
+ import { useCallback, useEffect, useRef } from 'react';
25
25
  import { toast } from 'sonner';
26
26
  import { getCanvasAPI } from '../../_lib/editor/canvasInstance';
27
27
  import {
@@ -63,6 +63,49 @@ export default function Topbar({ templateContext }: TopbarProps) {
63
63
 
64
64
  const importRef = useRef<HTMLInputElement>(null);
65
65
 
66
+ const isFirstRender = useRef(true);
67
+ const autoSaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
68
+ const templateContextRef = useRef(templateContext);
69
+ templateContextRef.current = templateContext;
70
+ const requestRef = useRef(request);
71
+ requestRef.current = request;
72
+
73
+ useEffect(() => {
74
+ if (isFirstRender.current) {
75
+ isFirstRender.current = false;
76
+ return;
77
+ }
78
+
79
+ localStorage.setItem(LS_KEY, JSON.stringify(templateState));
80
+
81
+ const ctx = templateContextRef.current;
82
+ if (!ctx) return;
83
+
84
+ if (autoSaveTimerRef.current) clearTimeout(autoSaveTimerRef.current);
85
+
86
+ autoSaveTimerRef.current = setTimeout(() => {
87
+ requestRef
88
+ .current({
89
+ url: `/lms/certificates/templates/${ctx.id}`,
90
+ method: 'PATCH',
91
+ data: {
92
+ name: templateState.name,
93
+ slug: ctx.slug,
94
+ status: ctx.status,
95
+ templateContent: JSON.stringify(templateState),
96
+ },
97
+ })
98
+ .catch(() => {
99
+ toast.error('Falha ao salvar automaticamente');
100
+ });
101
+ }, 1500);
102
+
103
+ return () => {
104
+ if (autoSaveTimerRef.current) clearTimeout(autoSaveTimerRef.current);
105
+ };
106
+ // eslint-disable-next-line react-hooks/exhaustive-deps
107
+ }, [templateState]);
108
+
66
109
  const handleNew = useCallback(() => {
67
110
  resetTemplate();
68
111
  getCanvasAPI()?.loadTemplate(useTemplateStore.getState().template);
@@ -72,7 +115,6 @@ export default function Topbar({ templateContext }: TopbarProps) {
72
115
  const handleSave = useCallback(() => {
73
116
  const run = async () => {
74
117
  if (!templateContext) {
75
- localStorage.setItem(LS_KEY, JSON.stringify(templateState));
76
118
  toast.success('Template salvo no localStorage');
77
119
  return;
78
120
  }
@@ -88,7 +130,6 @@ export default function Topbar({ templateContext }: TopbarProps) {
88
130
  },
89
131
  });
90
132
 
91
- localStorage.setItem(LS_KEY, JSON.stringify(templateState));
92
133
  toast.success('Template salvo');
93
134
  };
94
135
 
@@ -99,6 +99,7 @@ type CreateCertificateTemplatePayload = {
99
99
  type UpdateCertificateTemplatePayload = {
100
100
  name: string;
101
101
  description?: string;
102
+ status: TemplateStatus;
102
103
  };
103
104
 
104
105
  const PAGE_SIZES = [6, 12, 24];
@@ -114,6 +115,7 @@ const updateTemplateSchema = (t: (key: string) => string) =>
114
115
  z.object({
115
116
  name: z.string().trim().min(1, t('editSheet.validation.required')),
116
117
  description: z.string().trim().optional(),
118
+ status: z.enum(['draft', 'active', 'inactive']),
117
119
  });
118
120
 
119
121
  type CreateTemplateFormValues = z.infer<
@@ -173,6 +175,7 @@ export default function ModelsPage() {
173
175
  defaultValues: {
174
176
  name: '',
175
177
  description: '',
178
+ status: 'draft',
176
179
  },
177
180
  });
178
181
 
@@ -304,6 +307,7 @@ export default function ModelsPage() {
304
307
  editForm.reset({
305
308
  name: '',
306
309
  description: '',
310
+ status: 'draft',
307
311
  });
308
312
  setEditingTemplateId(null);
309
313
  }
@@ -325,6 +329,7 @@ export default function ModelsPage() {
325
329
  editForm.reset({
326
330
  name: template.name,
327
331
  description: template.description ?? '',
332
+ status: template.status,
328
333
  });
329
334
  setIsEditSheetOpen(true);
330
335
  }
@@ -410,6 +415,7 @@ export default function ModelsPage() {
410
415
  const payload: UpdateCertificateTemplatePayload = {
411
416
  name,
412
417
  description: values.description?.trim() || undefined,
418
+ status: values.status,
413
419
  };
414
420
 
415
421
  try {
@@ -825,6 +831,32 @@ export default function ModelsPage() {
825
831
  />
826
832
  </div>
827
833
 
834
+ <div className="space-y-2">
835
+ <p className="text-sm font-medium">
836
+ {t('editSheet.fields.status')}
837
+ </p>
838
+ <Controller
839
+ name="status"
840
+ control={editForm.control}
841
+ render={({ field }) => (
842
+ <Select value={field.value} onValueChange={field.onChange}>
843
+ <SelectTrigger className="w-full">
844
+ <SelectValue placeholder={t('editSheet.fields.status')} />
845
+ </SelectTrigger>
846
+ <SelectContent>
847
+ <SelectItem value="draft">{t('status.draft')}</SelectItem>
848
+ <SelectItem value="active">
849
+ {t('status.active')}
850
+ </SelectItem>
851
+ <SelectItem value="inactive">
852
+ {t('status.inactive')}
853
+ </SelectItem>
854
+ </SelectContent>
855
+ </Select>
856
+ )}
857
+ />
858
+ </div>
859
+
828
860
  {editForm.formState.errors.name?.message ? (
829
861
  <p className="text-sm text-destructive">
830
862
  {editForm.formState.errors.name.message}