@hed-hog/lms 0.0.357 → 0.0.361

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 (75) hide show
  1. package/dist/course/course-operations-integration.service.d.ts +31 -0
  2. package/dist/course/course-operations-integration.service.d.ts.map +1 -1
  3. package/dist/course/course-operations-integration.service.js +286 -22
  4. package/dist/course/course-operations-integration.service.js.map +1 -1
  5. package/dist/course/course-operations.controller.d.ts +10 -0
  6. package/dist/course/course-operations.controller.d.ts.map +1 -0
  7. package/dist/course/course-operations.controller.js +67 -0
  8. package/dist/course/course-operations.controller.js.map +1 -0
  9. package/dist/course/course-structure.controller.d.ts +3 -1
  10. package/dist/course/course-structure.controller.d.ts.map +1 -1
  11. package/dist/course/course-structure.service.d.ts +3 -1
  12. package/dist/course/course-structure.service.d.ts.map +1 -1
  13. package/dist/course/course-structure.service.js +13 -6
  14. package/dist/course/course-structure.service.js.map +1 -1
  15. package/dist/course/course.module.d.ts.map +1 -1
  16. package/dist/course/course.module.js +15 -2
  17. package/dist/course/course.module.js.map +1 -1
  18. package/dist/course/dto/update-course-operations-config.dto.d.ts +6 -0
  19. package/dist/course/dto/update-course-operations-config.dto.d.ts.map +1 -0
  20. package/dist/course/dto/update-course-operations-config.dto.js +33 -0
  21. package/dist/course/dto/update-course-operations-config.dto.js.map +1 -0
  22. package/dist/course/lms-bulk-upload.controller.d.ts +37 -0
  23. package/dist/course/lms-bulk-upload.controller.d.ts.map +1 -0
  24. package/dist/course/lms-bulk-upload.controller.js +60 -0
  25. package/dist/course/lms-bulk-upload.controller.js.map +1 -0
  26. package/dist/course/lms-bulk-upload.service.d.ts +42 -0
  27. package/dist/course/lms-bulk-upload.service.d.ts.map +1 -0
  28. package/dist/course/lms-bulk-upload.service.js +169 -0
  29. package/dist/course/lms-bulk-upload.service.js.map +1 -0
  30. package/dist/course/lms-operations-task.subscriber.d.ts +13 -0
  31. package/dist/course/lms-operations-task.subscriber.d.ts.map +1 -0
  32. package/dist/course/lms-operations-task.subscriber.js +57 -0
  33. package/dist/course/lms-operations-task.subscriber.js.map +1 -0
  34. package/dist/course/lms-setting.controller.d.ts +3 -0
  35. package/dist/course/lms-setting.controller.d.ts.map +1 -1
  36. package/dist/course/lms-setting.controller.js +9 -1
  37. package/dist/course/lms-setting.controller.js.map +1 -1
  38. package/dist/enterprise/enterprise.service.js +1 -1
  39. package/dist/enterprise/enterprise.service.js.map +1 -1
  40. package/dist/instructor/instructor.service.d.ts.map +1 -1
  41. package/dist/instructor/instructor.service.js +12 -3
  42. package/dist/instructor/instructor.service.js.map +1 -1
  43. package/dist/platforma/platforma.controller.d.ts +9 -9
  44. package/hedhog/data/role.yaml +8 -0
  45. package/hedhog/data/route.yaml +62 -0
  46. package/hedhog/data/setting_group.yaml +33 -0
  47. package/hedhog/frontend/app/_lib/editor/templateSerializer.ts.ejs +26 -4
  48. package/hedhog/frontend/app/_lib/editor/types.ts.ejs +6 -0
  49. package/hedhog/frontend/app/certificates/models/CanvasStage.tsx.ejs +447 -31
  50. package/hedhog/frontend/app/certificates/models/LeftPanel.tsx.ejs +59 -47
  51. package/hedhog/frontend/app/certificates/models/RightPanel.tsx.ejs +201 -35
  52. package/hedhog/frontend/app/certificates/models/TemplateEditorPage.tsx.ejs +36 -5
  53. package/hedhog/frontend/app/courses/[id]/structure/_components/course-operations-tab.tsx.ejs +382 -0
  54. package/hedhog/frontend/app/courses/[id]/structure/_components/editor-course.tsx.ejs +31 -1
  55. package/hedhog/frontend/app/courses/[id]/structure/_components/editor-lesson.tsx.ejs +91 -20
  56. package/hedhog/frontend/app/courses/[id]/structure/_components/types.ts.ejs +1 -0
  57. package/hedhog/frontend/app/courses/[id]/structure/_data/adapters/course-structure.adapter.ts.ejs +11 -3
  58. package/hedhog/frontend/app/courses/[id]/structure/_data/types/api-course.types.ts.ejs +4 -2
  59. package/hedhog/frontend/app/courses/[id]/structure/_data/use-course-structure-query.ts.ejs +2 -2
  60. package/hedhog/frontend/app/courses/page.tsx.ejs +21 -88
  61. package/hedhog/frontend/app/enterprise/page.tsx.ejs +18 -6
  62. package/hedhog/frontend/app/exams/[id]/page.tsx.ejs +5 -4
  63. package/hedhog/frontend/app/instructors/_components/instructor-form-sheet.tsx.ejs +16 -10
  64. package/package.json +7 -7
  65. package/src/course/course-operations-integration.service.ts +460 -22
  66. package/src/course/course-operations.controller.ts +45 -0
  67. package/src/course/course-structure.service.ts +5 -1
  68. package/src/course/course.module.ts +15 -2
  69. package/src/course/dto/update-course-operations-config.dto.ts +16 -0
  70. package/src/course/lms-bulk-upload.controller.ts +27 -0
  71. package/src/course/lms-bulk-upload.service.ts +204 -0
  72. package/src/course/lms-operations-task.subscriber.ts +44 -0
  73. package/src/course/lms-setting.controller.ts +12 -1
  74. package/src/enterprise/enterprise.service.ts +1 -1
  75. package/src/instructor/instructor.service.ts +12 -3
@@ -8,6 +8,7 @@ import {
8
8
  GripVertical,
9
9
  ImageIcon,
10
10
  Minus,
11
+ QrCode,
11
12
  RectangleHorizontal,
12
13
  Type,
13
14
  UploadCloud,
@@ -60,8 +61,8 @@ export default function LeftPanel() {
60
61
  );
61
62
 
62
63
  return (
63
- <aside className="flex w-68 shrink-0 flex-col border-r border-border bg-background">
64
- <Tabs defaultValue="elements" className="flex flex-1 flex-col">
64
+ <aside className="flex h-full min-h-0 w-full flex-col border-r border-border bg-background">
65
+ <Tabs defaultValue="elements" className="flex min-h-0 flex-1 flex-col">
65
66
  <TabsList className="mx-3 mt-3 w-auto">
66
67
  <TabsTrigger value="elements" className="flex-1">
67
68
  {t('leftPanel.tabs.elements')}
@@ -72,18 +73,21 @@ export default function LeftPanel() {
72
73
  </TabsList>
73
74
 
74
75
  {/* ── ELEMENTS ── */}
75
- <TabsContent value="elements" className="flex-1 overflow-hidden">
76
- <ScrollArea className="h-full">
77
- <div className="flex flex-col gap-2 p-3">
76
+ <TabsContent
77
+ value="elements"
78
+ className="min-h-0 flex-1 overflow-hidden"
79
+ >
80
+ <ScrollArea className="h-full min-h-0">
81
+ <div className="flex flex-col gap-1.5 p-2.5">
78
82
  {/* fields */}
79
83
  <p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
80
84
  {t('leftPanel.sections.fields')}
81
85
  </p>
82
- <div className="grid grid-cols-2 gap-2">
83
- {FIELD_KEYS.map((k) => (
86
+ <div className="grid grid-cols-1 gap-1.5">
87
+ {FIELD_KEYS.filter((k) => k !== 'qrCode').map((k) => (
84
88
  <ElementCard
85
89
  key={k}
86
- icon={<FileText className="size-4" />}
90
+ icon={<FileText className="size-3.5" />}
87
91
  label={FIELD_LABELS[k as FieldKey]}
88
92
  onClick={() => add('field', { key: k })}
89
93
  dragPayload={{ type: 'field', key: k }}
@@ -92,29 +96,29 @@ export default function LeftPanel() {
92
96
  </div>
93
97
 
94
98
  {/* static text */}
95
- <p className="mt-3 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
99
+ <p className="mt-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
96
100
  {t('leftPanel.sections.text')}
97
101
  </p>
98
102
  <ElementCard
99
- icon={<Type className="size-4" />}
103
+ icon={<Type className="size-3.5" />}
100
104
  label={t('leftPanel.elements.staticText')}
101
105
  onClick={() => add('staticText')}
102
106
  dragPayload={{ type: 'staticText' }}
103
107
  />
104
108
 
105
109
  {/* shapes */}
106
- <p className="mt-3 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
110
+ <p className="mt-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
107
111
  {t('leftPanel.sections.shapes')}
108
112
  </p>
109
- <div className="grid grid-cols-2 gap-2">
113
+ <div className="grid grid-cols-2 gap-1.5">
110
114
  <ElementCard
111
- icon={<RectangleHorizontal className="size-4" />}
115
+ icon={<RectangleHorizontal className="size-3.5" />}
112
116
  label={t('leftPanel.elements.rectangle')}
113
117
  onClick={() => add('shape', { shape: 'rect' })}
114
118
  dragPayload={{ type: 'shape', shape: 'rect' }}
115
119
  />
116
120
  <ElementCard
117
- icon={<Minus className="size-4" />}
121
+ icon={<Minus className="size-3.5" />}
118
122
  label={t('leftPanel.elements.line')}
119
123
  onClick={() => add('shape', { shape: 'line' })}
120
124
  dragPayload={{ type: 'shape', shape: 'line' }}
@@ -122,25 +126,31 @@ export default function LeftPanel() {
122
126
  </div>
123
127
 
124
128
  {/* image */}
125
- <p className="mt-3 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
129
+ <p className="mt-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
126
130
  {t('leftPanel.sections.image')}
127
131
  </p>
128
- <div className="grid grid-cols-1 gap-2">
132
+ <div className="grid grid-cols-1 gap-1.5">
129
133
  <ElementCard
130
- icon={<ImageIcon className="size-4" />}
134
+ icon={<ImageIcon className="size-3.5" />}
131
135
  label="Logo do Curso"
132
136
  onClick={() => add('image', { key: 'courseLogo' })}
133
137
  dragPayload={{ type: 'image', key: 'courseLogo' }}
134
138
  />
135
139
  <ElementCard
136
- icon={<ImageIcon className="size-4" />}
140
+ icon={<ImageIcon className="size-3.5" />}
137
141
  label="Banner do Curso"
138
142
  onClick={() => add('image', { key: 'courseBanner' })}
139
143
  dragPayload={{ type: 'image', key: 'courseBanner' }}
140
144
  />
145
+ <ElementCard
146
+ icon={<QrCode className="size-3.5" />}
147
+ label={FIELD_LABELS.qrCode}
148
+ onClick={() => add('field', { key: 'qrCode' })}
149
+ dragPayload={{ type: 'field', key: 'qrCode' }}
150
+ />
141
151
  </div>
142
152
  <ElementCard
143
- icon={<ImageIcon className="size-4" />}
153
+ icon={<ImageIcon className="size-3.5" />}
144
154
  label={t('leftPanel.elements.image')}
145
155
  onClick={() => add('image')}
146
156
  dragPayload={{ type: 'image' }}
@@ -150,32 +160,34 @@ export default function LeftPanel() {
150
160
  </TabsContent>
151
161
 
152
162
  {/* ── ASSETS ── */}
153
- <TabsContent value="assets" className="flex-1 overflow-hidden">
154
- <div className="flex flex-col gap-4 p-3">
155
- <p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
156
- {t('leftPanel.background.title')}
157
- </p>
158
- <Button
159
- variant="outline"
160
- className="h-24 w-full flex-col gap-2"
161
- onClick={() => fileInputRef.current?.click()}
162
- >
163
- <UploadCloud className="size-6 text-muted-foreground" />
164
- <span className="text-xs text-muted-foreground">
165
- {t('leftPanel.background.upload')}
166
- </span>
167
- </Button>
168
- <input
169
- ref={fileInputRef}
170
- type="file"
171
- accept="image/*"
172
- className="hidden"
173
- onChange={handleBgUpload}
174
- />
175
- <p className="text-[11px] leading-relaxed text-muted-foreground">
176
- {t('leftPanel.background.description')}
177
- </p>
178
- </div>
163
+ <TabsContent value="assets" className="min-h-0 flex-1 overflow-hidden">
164
+ <ScrollArea className="h-full min-h-0">
165
+ <div className="flex flex-col gap-4 p-3">
166
+ <p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
167
+ {t('leftPanel.background.title')}
168
+ </p>
169
+ <Button
170
+ variant="outline"
171
+ className="h-24 w-full flex-col gap-2"
172
+ onClick={() => fileInputRef.current?.click()}
173
+ >
174
+ <UploadCloud className="size-6 text-muted-foreground" />
175
+ <span className="text-xs text-muted-foreground">
176
+ {t('leftPanel.background.upload')}
177
+ </span>
178
+ </Button>
179
+ <input
180
+ ref={fileInputRef}
181
+ type="file"
182
+ accept="image/*"
183
+ className="hidden"
184
+ onChange={handleBgUpload}
185
+ />
186
+ <p className="text-[11px] leading-relaxed text-muted-foreground">
187
+ {t('leftPanel.background.description')}
188
+ </p>
189
+ </div>
190
+ </ScrollArea>
179
191
  </TabsContent>
180
192
  </Tabs>
181
193
  </aside>
@@ -205,9 +217,9 @@ function ElementCard({
205
217
  draggable
206
218
  onDragStart={handleDragStart}
207
219
  onClick={onClick}
208
- className="group flex items-center gap-2 rounded-md border border-border bg-background p-2.5 text-left text-xs font-medium text-foreground transition-colors hover:bg-accent hover:text-accent-foreground active:cursor-grabbing"
220
+ className="group flex h-8 items-center gap-1.5 rounded-md border border-border bg-background px-2 py-1 text-left text-[11px] font-medium text-foreground transition-colors hover:bg-accent hover:text-accent-foreground active:cursor-grabbing"
209
221
  >
210
- <GripVertical className="size-3 shrink-0 text-muted-foreground/50 opacity-0 transition-opacity group-hover:opacity-100" />
222
+ <GripVertical className="size-2.5 shrink-0 text-muted-foreground/50 opacity-0 transition-opacity group-hover:opacity-100" />
211
223
  {icon}
212
224
  <span className="truncate">{label}</span>
213
225
  </button>
@@ -13,7 +13,6 @@ import {
13
13
  import { Slider } from '@/components/ui/slider';
14
14
  import { Switch } from '@/components/ui/switch';
15
15
  import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
16
- import { Textarea } from '@/components/ui/textarea';
17
16
  import { useApp } from '@hed-hog/next-app-provider';
18
17
  import {
19
18
  ChevronDown,
@@ -28,7 +27,7 @@ import {
28
27
  Unlock,
29
28
  } from 'lucide-react';
30
29
  import { useTranslations } from 'next-intl';
31
- import { useCallback, useRef, useState } from 'react';
30
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
32
31
  import { toast } from 'sonner';
33
32
  import { getCanvasAPI } from '../../_lib/editor/canvasInstance';
34
33
  import { fromPctH, fromPctW } from '../../_lib/editor/pctHelpers';
@@ -39,10 +38,10 @@ import { useTemplateStore } from '../../_lib/store/useTemplateStore';
39
38
  export default function RightPanel() {
40
39
  const t = useTranslations('lms.CertificateTemplateEditor');
41
40
  return (
42
- <aside className="flex w-75 shrink-0 flex-col border-l border-border bg-background">
41
+ <aside className="flex h-full min-h-0 min-w-0 w-full flex-col overflow-hidden border-l border-border bg-background">
43
42
  <Tabs
44
43
  defaultValue="props"
45
- className="flex flex-1 flex-col overflow-hidden"
44
+ className="flex min-h-0 flex-1 flex-col overflow-hidden"
46
45
  >
47
46
  <TabsList className="mx-3 mt-3 w-auto">
48
47
  <TabsTrigger value="props" className="flex-1">
@@ -56,20 +55,20 @@ export default function RightPanel() {
56
55
  </TabsTrigger>
57
56
  </TabsList>
58
57
 
59
- <TabsContent value="props" className="flex-1 overflow-hidden">
60
- <ScrollArea className="h-full">
58
+ <TabsContent value="props" className="min-w-0 flex-1 overflow-hidden">
59
+ <ScrollArea className="h-full min-w-0">
61
60
  <PropertiesInspector />
62
61
  </ScrollArea>
63
62
  </TabsContent>
64
63
 
65
- <TabsContent value="layers" className="flex-1 overflow-hidden">
66
- <ScrollArea className="h-full">
64
+ <TabsContent value="layers" className="min-w-0 flex-1 overflow-hidden">
65
+ <ScrollArea className="h-full min-w-0">
67
66
  <LayersList />
68
67
  </ScrollArea>
69
68
  </TabsContent>
70
69
 
71
- <TabsContent value="data" className="flex-1 overflow-hidden">
72
- <ScrollArea className="h-full">
70
+ <TabsContent value="data" className="min-w-0 flex-1 overflow-hidden">
71
+ <ScrollArea className="h-full min-w-0">
73
72
  <DataView />
74
73
  </ScrollArea>
75
74
  </TabsContent>
@@ -105,7 +104,9 @@ function PropertiesInspector() {
105
104
  );
106
105
  }
107
106
 
108
- const isText = obj.type === 'field' || obj.type === 'staticText';
107
+ const isQrPlaceholder = obj.type === 'field' && obj.key === 'qrCode';
108
+ const isText =
109
+ (obj.type === 'field' || obj.type === 'staticText') && !isQrPlaceholder;
109
110
  const isShape = obj.type === 'shape';
110
111
  const isImage = obj.type === 'image';
111
112
 
@@ -498,6 +499,8 @@ function ImageProperties({
498
499
  const isCoursePlaceholder =
499
500
  obj.key === 'courseLogo' || obj.key === 'courseBanner';
500
501
  const [isUploading, setIsUploading] = useState(false);
502
+ const [previewUrl, setPreviewUrl] = useState<string | null>(null);
503
+ const fileInputRef = useRef<HTMLInputElement | null>(null);
501
504
 
502
505
  const currentFileId =
503
506
  typeof obj.image?.file_id === 'number' && Number.isFinite(obj.image.file_id)
@@ -509,7 +512,50 @@ function ImageProperties({
509
512
  obj.image?.resizeMode === 'center'
510
513
  ? obj.image.resizeMode
511
514
  : 'contain';
512
- const previewUrl = currentFileId ? `/file/open/${currentFileId}` : null;
515
+
516
+ useEffect(() => {
517
+ if (!currentFileId) {
518
+ setPreviewUrl(null);
519
+ return;
520
+ }
521
+
522
+ let canceled = false;
523
+ (async () => {
524
+ try {
525
+ const openResponse = await request<{ url?: string }>({
526
+ url: `/file/open/${currentFileId}`,
527
+ method: 'PUT',
528
+ });
529
+ if (canceled) return;
530
+ setPreviewUrl(openResponse?.data?.url || `/file/open/${currentFileId}`);
531
+ } catch {
532
+ if (canceled) return;
533
+ setPreviewUrl(`/file/open/${currentFileId}`);
534
+ }
535
+ })();
536
+
537
+ return () => {
538
+ canceled = true;
539
+ };
540
+ }, [currentFileId, request]);
541
+
542
+ useEffect(() => {
543
+ if (isCoursePlaceholder) return;
544
+
545
+ const handler = (event: Event) => {
546
+ const customEvent = event as CustomEvent<{ objectId?: string }>;
547
+ if (customEvent.detail?.objectId !== obj.id) return;
548
+ fileInputRef.current?.click();
549
+ };
550
+
551
+ window.addEventListener('lms-certificate:auto-open-image-upload', handler);
552
+ return () => {
553
+ window.removeEventListener(
554
+ 'lms-certificate:auto-open-image-upload',
555
+ handler
556
+ );
557
+ };
558
+ }, [isCoursePlaceholder, obj.id]);
513
559
 
514
560
  async function handleImageUpload(e: React.ChangeEvent<HTMLInputElement>) {
515
561
  const file = e.target.files?.[0];
@@ -530,9 +576,10 @@ function ImageProperties({
530
576
  id: number;
531
577
  url: string;
532
578
  }>({
533
- url: '/lms/certificates/templates/background-image',
579
+ url: '/file',
534
580
  method: 'POST',
535
581
  data: formData,
582
+ headers: { 'Content-Type': 'multipart/form-data' },
536
583
  });
537
584
 
538
585
  const newFileId = response?.data?.id;
@@ -540,10 +587,44 @@ function ImageProperties({
540
587
  throw new Error('Invalid uploaded file id');
541
588
  }
542
589
 
590
+ const openResponse = await request<{ url?: string }>({
591
+ url: `/file/open/${newFileId}`,
592
+ method: 'PUT',
593
+ });
594
+
595
+ const openedUrl = openResponse?.data?.url || `/file/open/${newFileId}`;
596
+
597
+ const imageSize = await new Promise<{ width: number; height: number }>(
598
+ (resolve, reject) => {
599
+ const localUrl = URL.createObjectURL(file);
600
+ const image = new Image();
601
+ image.onload = () => {
602
+ const width = image.naturalWidth || image.width || 1;
603
+ const height = image.naturalHeight || image.height || 1;
604
+ URL.revokeObjectURL(localUrl);
605
+ resolve({ width, height });
606
+ };
607
+ image.onerror = () => {
608
+ URL.revokeObjectURL(localUrl);
609
+ reject(new Error('invalid image dimensions'));
610
+ };
611
+ image.src = localUrl;
612
+ }
613
+ );
614
+
615
+ const currentWidth = Math.max(fromPctW(obj.wPct), 80);
616
+ const proportionalHeight = Math.max(
617
+ Math.round((currentWidth * imageSize.height) / imageSize.width),
618
+ 40
619
+ );
620
+
543
621
  setProp({
544
622
  _tplImageFileId: newFileId,
545
- _tplImageSrc: response.data.url ?? `/file/open/${newFileId}`,
623
+ _tplImageSrc: openedUrl,
624
+ width: currentWidth,
625
+ height: proportionalHeight,
546
626
  });
627
+ setPreviewUrl(openedUrl);
547
628
 
548
629
  if (
549
630
  typeof currentFileId === 'number' &&
@@ -586,6 +667,76 @@ function ImageProperties({
586
667
  }
587
668
  }
588
669
 
670
+ async function getNaturalImageSize(src: string) {
671
+ return await new Promise<{ width: number; height: number }>(
672
+ (resolve, reject) => {
673
+ const image = new Image();
674
+ image.onload = () => {
675
+ resolve({
676
+ width: image.naturalWidth || image.width || 1,
677
+ height: image.naturalHeight || image.height || 1,
678
+ });
679
+ };
680
+ image.onerror = () => reject(new Error('invalid image dimensions'));
681
+ image.src = src;
682
+ }
683
+ );
684
+ }
685
+
686
+ async function handleResizeModeChange(value: string) {
687
+ if (
688
+ value !== 'contain' &&
689
+ value !== 'cover' &&
690
+ value !== 'stretch' &&
691
+ value !== 'center'
692
+ ) {
693
+ return;
694
+ }
695
+
696
+ const currentWidth = Math.max(fromPctW(obj.wPct), 1);
697
+ const currentHeight = Math.max(fromPctH(obj.hPct), 1);
698
+
699
+ if (value !== 'center') {
700
+ setProp({
701
+ _tplImageResizeMode: value,
702
+ width: currentWidth,
703
+ height: currentHeight,
704
+ scaleX: 1,
705
+ scaleY: 1,
706
+ });
707
+ return;
708
+ }
709
+
710
+ if (!currentFileId) {
711
+ setProp({ _tplImageResizeMode: 'center' });
712
+ return;
713
+ }
714
+
715
+ try {
716
+ let sourceUrl = previewUrl;
717
+ if (!sourceUrl) {
718
+ const openResponse = await request<{ url?: string }>({
719
+ url: `/file/open/${currentFileId}`,
720
+ method: 'PUT',
721
+ });
722
+ sourceUrl = openResponse?.data?.url || `/file/open/${currentFileId}`;
723
+ setPreviewUrl(sourceUrl);
724
+ }
725
+
726
+ const natural = await getNaturalImageSize(sourceUrl);
727
+ setProp({
728
+ _tplImageResizeMode: 'center',
729
+ width: natural.width,
730
+ height: natural.height,
731
+ scaleX: 1,
732
+ scaleY: 1,
733
+ });
734
+ } catch {
735
+ setProp({ _tplImageResizeMode: 'center' });
736
+ toast.error('Nao foi possivel obter o tamanho original da imagem.');
737
+ }
738
+ }
739
+
589
740
  return (
590
741
  <div className="flex flex-col gap-3">
591
742
  <div className="flex flex-col gap-1.5">
@@ -605,6 +756,7 @@ function ImageProperties({
605
756
  <div className="flex flex-col gap-1.5">
606
757
  <Label className="text-xs">Imagem personalizada</Label>
607
758
  <input
759
+ ref={fileInputRef}
608
760
  type="file"
609
761
  accept="image/*"
610
762
  onChange={handleImageUpload}
@@ -635,10 +787,7 @@ function ImageProperties({
635
787
 
636
788
  <div className="flex flex-col gap-1.5">
637
789
  <Label className="text-xs">Modo de redimensionamento</Label>
638
- <Select
639
- value={resizeMode}
640
- onValueChange={(value) => setProp({ _tplImageResizeMode: value })}
641
- >
790
+ <Select value={resizeMode} onValueChange={handleResizeModeChange}>
642
791
  <SelectTrigger className="w-full">
643
792
  <SelectValue />
644
793
  </SelectTrigger>
@@ -664,15 +813,7 @@ function ImageProperties({
664
813
  <img
665
814
  src={previewUrl}
666
815
  alt="Preview da imagem do certificado"
667
- className={`h-32 w-full ${
668
- resizeMode === 'cover'
669
- ? 'object-cover'
670
- : resizeMode === 'stretch'
671
- ? 'object-fill'
672
- : resizeMode === 'center'
673
- ? 'object-none object-center'
674
- : 'object-contain'
675
- }`}
816
+ className="h-32 w-full object-contain"
676
817
  loading="lazy"
677
818
  />
678
819
  </div>
@@ -956,7 +1097,33 @@ function LayerItem({
956
1097
 
957
1098
  function DataView() {
958
1099
  const template = useTemplateStore((s) => s.template);
959
- const json = JSON.stringify(template, null, 2);
1100
+ const json = useMemo(() => JSON.stringify(template, null, 2), [template]);
1101
+
1102
+ const highlightedJson = useMemo(() => {
1103
+ const escaped = json
1104
+ .replace(/&/g, '&amp;')
1105
+ .replace(/</g, '&lt;')
1106
+ .replace(/>/g, '&gt;');
1107
+
1108
+ return escaped.replace(
1109
+ /("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d+)?(?:[eE][+\-]?\d+)?)/g,
1110
+ (match) => {
1111
+ let className = 'text-violet-600 dark:text-violet-300';
1112
+
1113
+ if (match.startsWith('"')) {
1114
+ className = match.endsWith(':')
1115
+ ? 'text-sky-600 dark:text-sky-300'
1116
+ : 'text-emerald-600 dark:text-emerald-300';
1117
+ } else if (match === 'true' || match === 'false') {
1118
+ className = 'text-amber-600 dark:text-amber-300';
1119
+ } else if (match === 'null') {
1120
+ className = 'text-rose-600 dark:text-rose-300';
1121
+ }
1122
+
1123
+ return `<span class="${className}">${match}</span>`;
1124
+ }
1125
+ );
1126
+ }, [json]);
960
1127
 
961
1128
  const handleCopy = useCallback(() => {
962
1129
  navigator.clipboard.writeText(json).then(() => {
@@ -965,7 +1132,7 @@ function DataView() {
965
1132
  }, [json]);
966
1133
 
967
1134
  return (
968
- <div className="flex flex-col gap-3 p-3">
1135
+ <div className="flex min-w-0 flex-col gap-3 p-3">
969
1136
  <div className="flex items-center justify-between">
970
1137
  <p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
971
1138
  Template JSON
@@ -980,12 +1147,11 @@ function DataView() {
980
1147
  Copiar
981
1148
  </Button>
982
1149
  </div>
983
- <Textarea
984
- readOnly
985
- value={json}
986
- rows={24}
987
- className="font-mono text-[11px] leading-relaxed"
988
- />
1150
+ <div className="min-w-0 overflow-hidden rounded-md border border-input bg-transparent">
1151
+ <pre className="max-h-[65vh] min-w-0 overflow-auto p-3 font-mono text-[11px] leading-relaxed whitespace-pre-wrap break-all">
1152
+ <code dangerouslySetInnerHTML={{ __html: highlightedJson }} />
1153
+ </pre>
1154
+ </div>
989
1155
  </div>
990
1156
  );
991
1157
  }
@@ -1,5 +1,10 @@
1
1
  'use client';
2
2
 
3
+ import {
4
+ ResizableHandle,
5
+ ResizablePanel,
6
+ ResizablePanelGroup,
7
+ } from '@/components/ui/resizable';
3
8
  import { useApp } from '@hed-hog/next-app-provider';
4
9
  import { useTranslations } from 'next-intl';
5
10
  import dynamic from 'next/dynamic';
@@ -96,11 +101,37 @@ export default function TemplateEditorPage() {
96
101
  : null
97
102
  }
98
103
  />
99
- <div className="flex flex-1 overflow-hidden">
100
- <LeftPanel />
101
- <CanvasStage isLoading={isLoadingTemplate} />
102
- <RightPanel />
103
- </div>
104
+ <ResizablePanelGroup
105
+ direction="horizontal"
106
+ autoSaveId="lms-certificate-template-editor-layout"
107
+ className="min-h-0 flex-1"
108
+ >
109
+ <ResizablePanel
110
+ defaultSize={20}
111
+ minSize={14}
112
+ maxSize={32}
113
+ className="min-w-0"
114
+ >
115
+ <LeftPanel />
116
+ </ResizablePanel>
117
+
118
+ <ResizableHandle withHandle />
119
+
120
+ <ResizablePanel defaultSize={60} minSize={36} className="min-w-0">
121
+ <CanvasStage isLoading={isLoadingTemplate} />
122
+ </ResizablePanel>
123
+
124
+ <ResizableHandle withHandle />
125
+
126
+ <ResizablePanel
127
+ defaultSize={20}
128
+ minSize={14}
129
+ maxSize={36}
130
+ className="min-w-0"
131
+ >
132
+ <RightPanel />
133
+ </ResizablePanel>
134
+ </ResizablePanelGroup>
104
135
  </div>
105
136
  );
106
137
  }