@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.
- package/dist/course/course-structure.controller.d.ts +60 -0
- package/dist/course/course-structure.controller.d.ts.map +1 -1
- package/dist/course/course-structure.controller.js +79 -0
- package/dist/course/course-structure.controller.js.map +1 -1
- package/dist/course/course-structure.service.d.ts +61 -1
- package/dist/course/course-structure.service.d.ts.map +1 -1
- package/dist/course/course-structure.service.js +326 -1
- package/dist/course/course-structure.service.js.map +1 -1
- package/dist/course/course.controller.d.ts +52 -4
- package/dist/course/course.controller.d.ts.map +1 -1
- package/dist/course/course.service.d.ts +52 -5
- package/dist/course/course.service.d.ts.map +1 -1
- package/dist/course/course.service.js +78 -57
- package/dist/course/course.service.js.map +1 -1
- package/dist/course/dto/create-course-structure-lesson.dto.d.ts.map +1 -1
- package/dist/course/dto/create-course-structure-lesson.dto.js +5 -1
- package/dist/course/dto/create-course-structure-lesson.dto.js.map +1 -1
- package/dist/course/dto/create-course.dto.d.ts +1 -1
- package/dist/course/dto/create-course.dto.d.ts.map +1 -1
- package/dist/course/dto/create-course.dto.js +4 -1
- package/dist/course/dto/create-course.dto.js.map +1 -1
- package/dist/course/dto/move-lesson.dto.d.ts +10 -0
- package/dist/course/dto/move-lesson.dto.d.ts.map +1 -0
- package/dist/course/dto/move-lesson.dto.js +28 -0
- package/dist/course/dto/move-lesson.dto.js.map +1 -0
- package/dist/course/dto/paste-lessons.dto.d.ts +4 -0
- package/dist/course/dto/paste-lessons.dto.d.ts.map +1 -0
- package/dist/course/dto/paste-lessons.dto.js +24 -0
- package/dist/course/dto/paste-lessons.dto.js.map +1 -0
- package/dist/course/dto/reorder-lessons.dto.d.ts +5 -0
- package/dist/course/dto/reorder-lessons.dto.d.ts.map +1 -0
- package/dist/course/dto/reorder-lessons.dto.js +24 -0
- package/dist/course/dto/reorder-lessons.dto.js.map +1 -0
- package/dist/course/dto/reorder-sessions.dto.d.ts +5 -0
- package/dist/course/dto/reorder-sessions.dto.d.ts.map +1 -0
- package/dist/course/dto/reorder-sessions.dto.js +24 -0
- package/dist/course/dto/reorder-sessions.dto.js.map +1 -0
- package/dist/training/training.controller.js +1 -1
- package/dist/training/training.controller.js.map +1 -1
- package/hedhog/data/image_type.yaml +20 -0
- package/hedhog/data/menu.yaml +2 -2
- package/hedhog/data/route.yaml +60 -6
- package/hedhog/frontend/app/_components/class-form-sheet.tsx.ejs +146 -165
- package/hedhog/frontend/app/_components/course-avatar.tsx.ejs +70 -0
- package/hedhog/frontend/app/_components/course-form-sheet.tsx.ejs +372 -22
- package/hedhog/frontend/app/classes/[id]/page.tsx.ejs +437 -77
- package/hedhog/frontend/app/classes/page.tsx.ejs +311 -289
- package/hedhog/frontend/app/courses/[id]/_components/CourseCertificateCard.tsx.ejs +10 -7
- package/hedhog/frontend/app/courses/[id]/_components/CourseClassificationCard.tsx.ejs +23 -32
- package/hedhog/frontend/app/courses/[id]/_components/CourseContentCard.tsx.ejs +3 -9
- package/hedhog/frontend/app/courses/[id]/_components/CourseDangerZoneCard.tsx.ejs +26 -16
- package/hedhog/frontend/app/courses/[id]/_components/CourseFlagsCard.tsx.ejs +19 -5
- package/hedhog/frontend/app/courses/[id]/_components/CourseMainInfoCard.tsx.ejs +10 -14
- package/hedhog/frontend/app/courses/[id]/_components/CourseMediaCard.tsx.ejs +131 -107
- package/hedhog/frontend/app/courses/[id]/_components/CourseRelationsCard.tsx.ejs +10 -7
- package/hedhog/frontend/app/courses/[id]/_components/CourseSectionCard.tsx.ejs +38 -19
- package/hedhog/frontend/app/courses/[id]/_components/CourseSummaryCard.tsx.ejs +1 -1
- package/hedhog/frontend/app/courses/[id]/_components/course-edit-types.ts.ejs +1 -1
- package/hedhog/frontend/app/courses/[id]/page.tsx.ejs +336 -1057
- package/hedhog/frontend/app/courses/[id]/structure/_components/confirm-dialog.tsx.ejs +45 -0
- package/hedhog/frontend/app/courses/[id]/structure/_components/course-tree-dnd.tsx.ejs +362 -0
- package/hedhog/frontend/app/courses/[id]/structure/_components/course-tree-panel.tsx.ejs +111 -0
- package/hedhog/frontend/app/courses/[id]/structure/_components/course-tree-skeleton.tsx.ejs +64 -0
- package/hedhog/frontend/app/courses/[id]/structure/_components/course-tree.tsx.ejs +134 -0
- package/hedhog/frontend/app/courses/[id]/structure/_components/detail-course.tsx.ejs +113 -0
- package/hedhog/frontend/app/courses/[id]/structure/_components/detail-lesson.tsx.ejs +314 -0
- package/hedhog/frontend/app/courses/[id]/structure/_components/detail-panel.tsx.ejs +62 -0
- package/hedhog/frontend/app/courses/[id]/structure/_components/detail-session.tsx.ejs +174 -0
- package/hedhog/frontend/app/courses/[id]/structure/_components/drag-handle.tsx.ejs +58 -0
- package/hedhog/frontend/app/courses/[id]/structure/_components/drag-overlay.tsx.ejs +52 -0
- package/hedhog/frontend/app/courses/[id]/structure/_components/editor-bulk.tsx.ejs +276 -0
- package/hedhog/frontend/app/courses/[id]/structure/_components/editor-course.tsx.ejs +1216 -0
- package/hedhog/frontend/app/courses/[id]/structure/_components/editor-lesson.tsx.ejs +1827 -0
- package/hedhog/frontend/app/courses/[id]/structure/_components/editor-session.tsx.ejs +443 -0
- package/hedhog/frontend/app/courses/[id]/structure/_components/highlighted-text.tsx.ejs +41 -0
- package/hedhog/frontend/app/courses/[id]/structure/_components/mock-data.ts.ejs +184 -0
- package/hedhog/frontend/app/courses/[id]/structure/_components/multi-select-bar.tsx.ejs +264 -0
- package/hedhog/frontend/app/courses/[id]/structure/_components/search-filter.tsx.ejs +96 -0
- package/hedhog/frontend/app/courses/[id]/structure/_components/session-picker-dialog.tsx.ejs +74 -0
- package/hedhog/frontend/app/courses/[id]/structure/_components/shortcuts-help.tsx.ejs +136 -0
- package/hedhog/frontend/app/courses/[id]/structure/_components/sortable-tree-row.tsx.ejs +80 -0
- package/hedhog/frontend/app/courses/[id]/structure/_components/store.ts.ejs +948 -0
- package/hedhog/frontend/app/courses/[id]/structure/_components/tree-context-menu.tsx.ejs +525 -0
- package/hedhog/frontend/app/courses/[id]/structure/_components/tree-display-settings-popover.tsx.ejs +150 -0
- package/hedhog/frontend/app/courses/[id]/structure/_components/tree-helpers.ts.ejs +182 -0
- package/hedhog/frontend/app/courses/[id]/structure/_components/tree-row-course.tsx.ejs +52 -0
- package/hedhog/frontend/app/courses/[id]/structure/_components/tree-row-lesson.tsx.ejs +271 -0
- package/hedhog/frontend/app/courses/[id]/structure/_components/tree-row-session.tsx.ejs +167 -0
- package/hedhog/frontend/app/courses/[id]/structure/_components/tree-row.tsx.ejs +108 -0
- package/hedhog/frontend/app/courses/[id]/structure/_components/types.ts.ejs +122 -0
- package/hedhog/frontend/app/courses/[id]/structure/_components/use-course-structure-shortcuts.ts.ejs +318 -0
- package/hedhog/frontend/app/courses/[id]/structure/_components/use-tree-display-settings.ts.ejs +97 -0
- package/hedhog/frontend/app/courses/[id]/structure/_data/adapters/course-structure.adapter.ts.ejs +347 -0
- package/hedhog/frontend/app/courses/[id]/structure/_data/course-structure-contract.ts.ejs +195 -0
- package/hedhog/frontend/app/courses/[id]/structure/_data/services/course-structure.service.ts.ejs +420 -0
- package/hedhog/frontend/app/courses/[id]/structure/_data/types/api-course.types.ts.ejs +254 -0
- package/hedhog/frontend/app/courses/[id]/structure/_data/use-course-structure-mutations.ts.ejs +987 -0
- package/hedhog/frontend/app/courses/[id]/structure/_data/use-course-structure-query.ts.ejs +86 -0
- package/hedhog/frontend/app/courses/[id]/structure/_data/use-course-structure.ts.ejs +160 -0
- package/hedhog/frontend/app/courses/[id]/structure/page.tsx.ejs +10 -3212
- package/hedhog/frontend/app/courses/page.tsx.ejs +45 -26
- package/hedhog/frontend/app/{training → paths}/page.tsx.ejs +29 -7
- package/hedhog/frontend/messages/en.json +88 -10
- package/hedhog/frontend/messages/pt.json +88 -10
- package/hedhog/table/course.yaml +1 -1
- package/hedhog/table/image_type.yaml +14 -0
- package/package.json +7 -7
- package/src/course/course-structure.controller.ts +63 -0
- package/src/course/course-structure.service.ts +390 -3
- package/src/course/course.service.ts +59 -27
- package/src/course/dto/create-course-structure-lesson.dto.ts +3 -2
- package/src/course/dto/create-course.dto.ts +4 -1
- package/src/course/dto/move-lesson.dto.ts +17 -0
- package/src/course/dto/paste-lessons.dto.ts +9 -0
- package/src/course/dto/reorder-lessons.dto.ts +10 -0
- package/src/course/dto/reorder-sessions.dto.ts +10 -0
- package/src/training/training.controller.ts +1 -1
|
@@ -37,9 +37,17 @@ import {
|
|
|
37
37
|
SheetTitle,
|
|
38
38
|
} from '@/components/ui/sheet';
|
|
39
39
|
import { Textarea } from '@/components/ui/textarea';
|
|
40
|
-
import {
|
|
41
|
-
import {
|
|
42
|
-
|
|
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">
|