@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.
- 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/certificates/models/CanvasStage.tsx.ejs +10 -1
- package/hedhog/frontend/app/certificates/models/TopBar.tsx.ejs +44 -3
- package/hedhog/frontend/app/certificates/models/page.tsx.ejs +32 -0
- 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 +91 -11
- package/hedhog/frontend/messages/pt.json +91 -11
- 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">
|
|
@@ -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({
|
|
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}
|