@hed-hog/lms 0.0.306 → 0.0.309

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 (117) 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/classes/[id]/page.tsx.ejs +437 -77
  47. package/hedhog/frontend/app/classes/page.tsx.ejs +311 -289
  48. package/hedhog/frontend/app/courses/[id]/_components/CourseCertificateCard.tsx.ejs +10 -7
  49. package/hedhog/frontend/app/courses/[id]/_components/CourseClassificationCard.tsx.ejs +23 -32
  50. package/hedhog/frontend/app/courses/[id]/_components/CourseContentCard.tsx.ejs +3 -9
  51. package/hedhog/frontend/app/courses/[id]/_components/CourseDangerZoneCard.tsx.ejs +26 -16
  52. package/hedhog/frontend/app/courses/[id]/_components/CourseFlagsCard.tsx.ejs +19 -5
  53. package/hedhog/frontend/app/courses/[id]/_components/CourseMainInfoCard.tsx.ejs +10 -14
  54. package/hedhog/frontend/app/courses/[id]/_components/CourseMediaCard.tsx.ejs +131 -107
  55. package/hedhog/frontend/app/courses/[id]/_components/CourseRelationsCard.tsx.ejs +10 -7
  56. package/hedhog/frontend/app/courses/[id]/_components/CourseSectionCard.tsx.ejs +38 -19
  57. package/hedhog/frontend/app/courses/[id]/_components/CourseSummaryCard.tsx.ejs +1 -1
  58. package/hedhog/frontend/app/courses/[id]/_components/course-edit-types.ts.ejs +1 -1
  59. package/hedhog/frontend/app/courses/[id]/page.tsx.ejs +336 -1057
  60. package/hedhog/frontend/app/courses/[id]/structure/_components/confirm-dialog.tsx.ejs +45 -0
  61. package/hedhog/frontend/app/courses/[id]/structure/_components/course-tree-dnd.tsx.ejs +362 -0
  62. package/hedhog/frontend/app/courses/[id]/structure/_components/course-tree-panel.tsx.ejs +111 -0
  63. package/hedhog/frontend/app/courses/[id]/structure/_components/course-tree-skeleton.tsx.ejs +64 -0
  64. package/hedhog/frontend/app/courses/[id]/structure/_components/course-tree.tsx.ejs +134 -0
  65. package/hedhog/frontend/app/courses/[id]/structure/_components/detail-course.tsx.ejs +113 -0
  66. package/hedhog/frontend/app/courses/[id]/structure/_components/detail-lesson.tsx.ejs +314 -0
  67. package/hedhog/frontend/app/courses/[id]/structure/_components/detail-panel.tsx.ejs +62 -0
  68. package/hedhog/frontend/app/courses/[id]/structure/_components/detail-session.tsx.ejs +174 -0
  69. package/hedhog/frontend/app/courses/[id]/structure/_components/drag-handle.tsx.ejs +58 -0
  70. package/hedhog/frontend/app/courses/[id]/structure/_components/drag-overlay.tsx.ejs +52 -0
  71. package/hedhog/frontend/app/courses/[id]/structure/_components/editor-bulk.tsx.ejs +276 -0
  72. package/hedhog/frontend/app/courses/[id]/structure/_components/editor-course.tsx.ejs +1216 -0
  73. package/hedhog/frontend/app/courses/[id]/structure/_components/editor-lesson.tsx.ejs +1827 -0
  74. package/hedhog/frontend/app/courses/[id]/structure/_components/editor-session.tsx.ejs +443 -0
  75. package/hedhog/frontend/app/courses/[id]/structure/_components/highlighted-text.tsx.ejs +41 -0
  76. package/hedhog/frontend/app/courses/[id]/structure/_components/mock-data.ts.ejs +184 -0
  77. package/hedhog/frontend/app/courses/[id]/structure/_components/multi-select-bar.tsx.ejs +264 -0
  78. package/hedhog/frontend/app/courses/[id]/structure/_components/search-filter.tsx.ejs +96 -0
  79. package/hedhog/frontend/app/courses/[id]/structure/_components/session-picker-dialog.tsx.ejs +74 -0
  80. package/hedhog/frontend/app/courses/[id]/structure/_components/shortcuts-help.tsx.ejs +136 -0
  81. package/hedhog/frontend/app/courses/[id]/structure/_components/sortable-tree-row.tsx.ejs +80 -0
  82. package/hedhog/frontend/app/courses/[id]/structure/_components/store.ts.ejs +948 -0
  83. package/hedhog/frontend/app/courses/[id]/structure/_components/tree-context-menu.tsx.ejs +525 -0
  84. package/hedhog/frontend/app/courses/[id]/structure/_components/tree-display-settings-popover.tsx.ejs +150 -0
  85. package/hedhog/frontend/app/courses/[id]/structure/_components/tree-helpers.ts.ejs +182 -0
  86. package/hedhog/frontend/app/courses/[id]/structure/_components/tree-row-course.tsx.ejs +52 -0
  87. package/hedhog/frontend/app/courses/[id]/structure/_components/tree-row-lesson.tsx.ejs +271 -0
  88. package/hedhog/frontend/app/courses/[id]/structure/_components/tree-row-session.tsx.ejs +167 -0
  89. package/hedhog/frontend/app/courses/[id]/structure/_components/tree-row.tsx.ejs +108 -0
  90. package/hedhog/frontend/app/courses/[id]/structure/_components/types.ts.ejs +122 -0
  91. package/hedhog/frontend/app/courses/[id]/structure/_components/use-course-structure-shortcuts.ts.ejs +318 -0
  92. package/hedhog/frontend/app/courses/[id]/structure/_components/use-tree-display-settings.ts.ejs +97 -0
  93. package/hedhog/frontend/app/courses/[id]/structure/_data/adapters/course-structure.adapter.ts.ejs +347 -0
  94. package/hedhog/frontend/app/courses/[id]/structure/_data/course-structure-contract.ts.ejs +195 -0
  95. package/hedhog/frontend/app/courses/[id]/structure/_data/services/course-structure.service.ts.ejs +420 -0
  96. package/hedhog/frontend/app/courses/[id]/structure/_data/types/api-course.types.ts.ejs +254 -0
  97. package/hedhog/frontend/app/courses/[id]/structure/_data/use-course-structure-mutations.ts.ejs +987 -0
  98. package/hedhog/frontend/app/courses/[id]/structure/_data/use-course-structure-query.ts.ejs +86 -0
  99. package/hedhog/frontend/app/courses/[id]/structure/_data/use-course-structure.ts.ejs +160 -0
  100. package/hedhog/frontend/app/courses/[id]/structure/page.tsx.ejs +10 -3212
  101. package/hedhog/frontend/app/courses/page.tsx.ejs +45 -26
  102. package/hedhog/frontend/app/{training → paths}/page.tsx.ejs +29 -7
  103. package/hedhog/frontend/messages/en.json +88 -10
  104. package/hedhog/frontend/messages/pt.json +88 -10
  105. package/hedhog/table/course.yaml +1 -1
  106. package/hedhog/table/image_type.yaml +14 -0
  107. package/package.json +7 -7
  108. package/src/course/course-structure.controller.ts +63 -0
  109. package/src/course/course-structure.service.ts +390 -3
  110. package/src/course/course.service.ts +59 -27
  111. package/src/course/dto/create-course-structure-lesson.dto.ts +3 -2
  112. package/src/course/dto/create-course.dto.ts +4 -1
  113. package/src/course/dto/move-lesson.dto.ts +17 -0
  114. package/src/course/dto/paste-lessons.dto.ts +9 -0
  115. package/src/course/dto/reorder-lessons.dto.ts +10 -0
  116. package/src/course/dto/reorder-sessions.dto.ts +10 -0
  117. 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">