@adlas/create-app 1.0.0

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 (79) hide show
  1. package/README.md +476 -0
  2. package/dist/cli.d.ts +3 -0
  3. package/dist/cli.d.ts.map +1 -0
  4. package/dist/cli.js +39 -0
  5. package/dist/cli.js.map +1 -0
  6. package/dist/commands/figma.d.ts +16 -0
  7. package/dist/commands/figma.d.ts.map +1 -0
  8. package/dist/commands/figma.js +172 -0
  9. package/dist/commands/figma.js.map +1 -0
  10. package/dist/commands/index.d.ts +5 -0
  11. package/dist/commands/index.d.ts.map +1 -0
  12. package/dist/commands/index.js +5 -0
  13. package/dist/commands/index.js.map +1 -0
  14. package/dist/commands/init.d.ts +8 -0
  15. package/dist/commands/init.d.ts.map +1 -0
  16. package/dist/commands/init.js +1471 -0
  17. package/dist/commands/init.js.map +1 -0
  18. package/dist/commands/swagger.d.ts +16 -0
  19. package/dist/commands/swagger.d.ts.map +1 -0
  20. package/dist/commands/swagger.js +404 -0
  21. package/dist/commands/swagger.js.map +1 -0
  22. package/dist/commands/update.d.ts +15 -0
  23. package/dist/commands/update.d.ts.map +1 -0
  24. package/dist/commands/update.js +93 -0
  25. package/dist/commands/update.js.map +1 -0
  26. package/package.json +63 -0
  27. package/templates/.vscode/extensions.json +9 -0
  28. package/templates/.vscode/launch.json +26 -0
  29. package/templates/.vscode/settings.json +67 -0
  30. package/templates/.vscode/tasks.json +21 -0
  31. package/templates/boilerplate/config/fonts.ts +10 -0
  32. package/templates/boilerplate/config/navigationUrls.ts +47 -0
  33. package/templates/boilerplate/config/site.ts +96 -0
  34. package/templates/boilerplate/libs/I18n.ts +15 -0
  35. package/templates/boilerplate/libs/I18nNavigation.ts +5 -0
  36. package/templates/boilerplate/libs/I18nRouting.ts +9 -0
  37. package/templates/boilerplate/libs/env.ts +21 -0
  38. package/templates/boilerplate/libs/react-query/ReactQueryProvider.tsx +21 -0
  39. package/templates/boilerplate/libs/react-query/index.ts +2 -0
  40. package/templates/boilerplate/libs/react-query/queryClient.ts +62 -0
  41. package/templates/boilerplate/libs/react-query/queryKeys.ts +5 -0
  42. package/templates/boilerplate/public/images/index.ts +1 -0
  43. package/templates/boilerplate/reset.d.ts +2 -0
  44. package/templates/boilerplate/styles/globals.css +308 -0
  45. package/templates/boilerplate/types/i18n.ts +10 -0
  46. package/templates/boilerplate/types/locale.ts +8 -0
  47. package/templates/boilerplate/utils/file/fileConfig.ts +123 -0
  48. package/templates/boilerplate/utils/file/fileValidation.ts +78 -0
  49. package/templates/boilerplate/utils/file/imageCompression.ts +182 -0
  50. package/templates/boilerplate/utils/file/index.ts +3 -0
  51. package/templates/boilerplate/utils/helpers.ts +55 -0
  52. package/templates/boilerplate/validations/auth.validation.ts +92 -0
  53. package/templates/boilerplate/validations/commonValidations.ts +258 -0
  54. package/templates/boilerplate/validations/zodErrorMap.ts +101 -0
  55. package/templates/configs/.env.example +8 -0
  56. package/templates/configs/.prettierignore +23 -0
  57. package/templates/configs/.prettierrc.cjs +26 -0
  58. package/templates/configs/.prettierrc.icons.cjs +11 -0
  59. package/templates/configs/Dockerfile +6 -0
  60. package/templates/configs/commitlint.config.ts +8 -0
  61. package/templates/configs/eslint.config.mjs +119 -0
  62. package/templates/configs/knip.config.ts +32 -0
  63. package/templates/configs/lefthook.yml +42 -0
  64. package/templates/configs/lint-staged.config.js +8 -0
  65. package/templates/configs/next.config.template.ts +77 -0
  66. package/templates/configs/next.config.ts +43 -0
  67. package/templates/configs/package.json +75 -0
  68. package/templates/configs/postcss.config.mjs +15 -0
  69. package/templates/configs/svgr.config.mjs +129 -0
  70. package/templates/configs/tsconfig.json +75 -0
  71. package/templates/docs/AI_QUICK_REFERENCE.md +379 -0
  72. package/templates/docs/ARCHITECTURE_PATTERNS.md +927 -0
  73. package/templates/docs/DOCUMENTATION_INDEX.md +411 -0
  74. package/templates/docs/FIGMA_TO_CODE_GUIDE.md +768 -0
  75. package/templates/docs/IMPLEMENTATION_GUIDE.md +892 -0
  76. package/templates/docs/PROJECT_OVERVIEW.md +302 -0
  77. package/templates/docs/REFACTOR_PROGRESS.md +1113 -0
  78. package/templates/docs/SHADCN_TO_HEROUI_MIGRATION.md +1375 -0
  79. package/templates/docs/UI_COMPONENTS_GUIDE.md +893 -0
@@ -0,0 +1,308 @@
1
+ @import 'tailwindcss';
2
+ @plugin './hero.ts';
3
+ @source '../../node_modules/@heroui/theme/dist';
4
+
5
+ @custom-variant dark (&:is(.dark *));
6
+ @custom-variant max-2xl (@media (max-width: 1440px));
7
+ @custom-variant max-xl (@media (max-width: 1280px));
8
+ @custom-variant max-lg (@media (max-width: 1024px));
9
+ @custom-variant max-md (@media (max-width: 768px));
10
+ @custom-variant max-sm (@media (max-width: 640px));
11
+ @custom-variant max-xs (@media (max-width: 480px));
12
+ @custom-variant max-2xs (@media (max-width: 390px));
13
+
14
+ @theme {
15
+ /* Fonts */
16
+ --font-raleway: Raleway, sans-serif;
17
+ --font-inter: Inter, sans-serif;
18
+ --font-waterbrush: Waterbrush, sans-serif;
19
+ --default-font-family: var(--font-raleway);
20
+
21
+ /* Custom font sizes */
22
+ --text-2xs: 0.625rem;
23
+
24
+ /* Line heights */
25
+ --full: 100%;
26
+
27
+ /* Stroke widths */
28
+ --stroke-width-1.5: 1.5px;
29
+
30
+ /* Custom box shadows */
31
+ --shadow-card: 0 4px 16px rgba(0, 0, 0, 0.08);
32
+
33
+ --shadow-carousel: 0 4px 16px rgba(0, 0, 0, 0.1);
34
+
35
+ /* Custom breakpoints for mobile-first utilities (min-width) */
36
+ --breakpoint-2xs: 390px;
37
+ --breakpoint-xs: 480px;
38
+ /* Standard Tailwind breakpoints stay at defaults for min-width utilities */
39
+ /* Use max-* variants for desktop-first approach (max-width) */
40
+
41
+ --color-default-25: #fcfcfc;
42
+ --color-danger-25: #f8e9e7;
43
+ --color-success-25: #e9f9ed;
44
+ --color-warning-25: #fefce8;
45
+ --color-info-25: #e8f4f9;
46
+ --color-info-50: #d6ebf5;
47
+ --color-info-100: #add7eb;
48
+ --color-info-200: #85c3e1;
49
+ --color-info-300: #5cafd7;
50
+ --color-info-400: #339bcd;
51
+ --color-info-500: #2a7ca4;
52
+ --color-info-600: #215d7b;
53
+ --color-info-700: #183e52;
54
+ --color-info-800: #0f1f29;
55
+ --color-info-900: #060f14;
56
+ --color-info-DEFAULT: #2a7ca4;
57
+
58
+ /* Tertiary colors - Light theme */
59
+ --color-tertiary-50: #faf8f9;
60
+ --color-tertiary-100: #f5f0f3;
61
+ --color-tertiary-200: #ebe1e6;
62
+ --color-tertiary-300: #e0d2da;
63
+ --color-tertiary-400: #d2bbc5;
64
+ --color-tertiary-500: #c4a4b0;
65
+ --color-tertiary-600: #a8849a;
66
+ --color-tertiary-700: #8a6a7e;
67
+ --color-tertiary-800: #6d5364;
68
+ --color-tertiary-900: #523e4b;
69
+ --color-tertiary: #d2bbc5;
70
+
71
+ /* Custom animations */
72
+ --animate-flash: flash 1s ease-in-out 3;
73
+ --animate-scale-in: scale-in 0.5s cubic-bezier(0.34, 1.56, 0.64, 1);
74
+
75
+ @keyframes flash {
76
+ 0%,
77
+ 100% {
78
+ background-color: transparent;
79
+ }
80
+ 50% {
81
+ background-color: rgb(244 244 245);
82
+ }
83
+ }
84
+
85
+ @keyframes scale-in {
86
+ 0% {
87
+ transform: scale(0);
88
+ opacity: 0;
89
+ }
90
+ 100% {
91
+ transform: scale(1);
92
+ opacity: 1;
93
+ }
94
+ }
95
+ }
96
+
97
+ @theme dark {
98
+ /* Tertiary colors - Dark theme */
99
+ --color-tertiary-50: #523e4b;
100
+ --color-tertiary-100: #6d5364;
101
+ --color-tertiary-200: #8a6a7e;
102
+ --color-tertiary-300: #a8849a;
103
+ --color-tertiary-400: #c4a4b0;
104
+ --color-tertiary-500: #d2bbc5;
105
+ --color-tertiary-600: #e0d2da;
106
+ --color-tertiary-700: #ebe1e6;
107
+ --color-tertiary-800: #f5f0f3;
108
+ --color-tertiary-900: #faf8f9;
109
+ --color-tertiary: #d2bbc5;
110
+ }
111
+
112
+ #__next {
113
+ @apply flex h-full flex-col overflow-x-hidden;
114
+ }
115
+
116
+ @layer base {
117
+ html {
118
+ @apply h-full w-full;
119
+ padding-top: env(safe-area-inset-top);
120
+ padding-bottom: env(safe-area-inset-bottom);
121
+ padding-left: env(safe-area-inset-left);
122
+ padding-right: env(safe-area-inset-right);
123
+ }
124
+
125
+ body {
126
+ @apply bg-background text-primary h-dvh w-full antialiased;
127
+ }
128
+
129
+ * {
130
+ -webkit-tap-highlight-color: transparent;
131
+ -webkit-font-smoothing: antialiased;
132
+ -moz-osx-font-smoothing: grayscale;
133
+ -webkit-appearance: none !important;
134
+ appearance: none !important;
135
+ }
136
+
137
+ /* Remove outline from all HeroUI components */
138
+ [data-slot] {
139
+ outline: none !important;
140
+ }
141
+
142
+ button:focus,
143
+ input:focus,
144
+ textarea:focus,
145
+ select:focus,
146
+ [role='button']:focus,
147
+ [role='checkbox']:focus,
148
+ [role='radio']:focus,
149
+ [role='switch']:focus,
150
+ [role='slider']:focus,
151
+ [role='tab']:focus {
152
+ outline: none !important;
153
+ }
154
+
155
+ /* Remove Tailwind ring utilities */
156
+ .ring,
157
+ .ring-0,
158
+ .ring-1,
159
+ .ring-2,
160
+ .ring-3,
161
+ .ring-4,
162
+ .ring-8,
163
+ [class*='ring-'],
164
+ [class*='focus:ring'],
165
+ [class*='focus-visible:ring'] {
166
+ --tw-ring-shadow: 0 0 #0000 !important;
167
+ --tw-ring-offset-shadow: 0 0 #0000 !important;
168
+ box-shadow: none !important;
169
+ }
170
+
171
+ *::-webkit-scrollbar {
172
+ @apply h-1 w-1;
173
+ }
174
+
175
+ *::-webkit-scrollbar-track {
176
+ @apply bg-default-50 w-1;
177
+ }
178
+
179
+ *::-webkit-scrollbar-thumb {
180
+ @apply bg-default-300 rounded-md border-0;
181
+ }
182
+ }
183
+
184
+ @layer utilities {
185
+ .auth-container {
186
+ @apply mx-auto flex min-h-full max-w-444 flex-1 flex-row;
187
+ }
188
+
189
+ .dashboard-container {
190
+ @apply mx-auto my-18 box-border flex w-full max-w-310 flex-col gap-16;
191
+ @apply max-lg:max-w-full max-lg:px-4 max-md:my-10 max-md:gap-10;
192
+ @apply max-2xs:gap-6 max-xs:my-10 max-xs:gap-10 max-xs:px-4 max-2xs:my-6;
193
+ }
194
+ .navbar-container {
195
+ @apply mx-auto my-0 box-border flex max-w-7xl flex-row items-center justify-between;
196
+ }
197
+
198
+ .footer-container {
199
+ @apply bg-primary mx-auto flex w-full max-w-281 flex-col items-center py-20;
200
+ }
201
+
202
+ .web-container {
203
+ @apply max-xs:px-4 mx-auto my-0 box-border flex w-full max-w-324 flex-col px-20 max-lg:px-12 max-md:px-8 max-sm:px-6;
204
+ }
205
+
206
+ .header-title {
207
+ @apply mt-15 mb-12 text-4xl font-medium max-lg:my-8 max-lg:text-3xl;
208
+ }
209
+
210
+ /* Toast Styles - Sonner */
211
+ [data-sonner-toast] {
212
+ @apply shadow-card! font-raleway! rounded-none! border-1!;
213
+ }
214
+
215
+ [data-sonner-toast][data-type='success'] {
216
+ @apply bg-success-25! border-success! text-success-900!;
217
+ }
218
+
219
+ [data-sonner-toast][data-type='success'] [data-icon] {
220
+ @apply text-success!;
221
+ }
222
+
223
+ [data-sonner-toast][data-type='error'] {
224
+ @apply bg-danger-25! border-danger! text-danger-900!;
225
+ }
226
+
227
+ [data-sonner-toast][data-type='error'] [data-icon] {
228
+ @apply text-danger!;
229
+ }
230
+
231
+ [data-sonner-toast][data-type='warning'] {
232
+ @apply bg-warning-25! border-warning! text-warning-900!;
233
+ }
234
+
235
+ [data-sonner-toast][data-type='warning'] [data-icon] {
236
+ @apply text-warning-700!;
237
+ }
238
+
239
+ [data-sonner-toast][data-type='info'] {
240
+ @apply bg-info-25! border-info-DEFAULT! text-info-900!;
241
+ }
242
+
243
+ [data-sonner-toast][data-type='info'] [data-icon] {
244
+ @apply text-info-DEFAULT!;
245
+ }
246
+
247
+ /* Toast description styling */
248
+ [data-sonner-toast] [data-description] {
249
+ @apply text-current! opacity-80!;
250
+ }
251
+
252
+ /* Toast button styling */
253
+ [data-sonner-toast] [data-button] {
254
+ @apply text-background! bg-current!;
255
+ }
256
+
257
+ /* Toast close button */
258
+ [data-sonner-toast] [data-close-button] {
259
+ @apply border-current! bg-transparent! text-current! hover:bg-current/10!;
260
+ }
261
+
262
+ .leaflet-container {
263
+ @apply h-full w-full flex-1;
264
+ }
265
+
266
+ /* Grayscale filter for the entire map */
267
+ .leaflet-tile-pane {
268
+ filter: grayscale(100%) brightness(1.1) contrast(0.9);
269
+ }
270
+
271
+ /* Remove default marker styling */
272
+ .store-marker,
273
+ .store-marker-selected {
274
+ @apply border-none! bg-transparent!;
275
+ }
276
+
277
+ /* Zoom control styling */
278
+ .leaflet-control-zoom {
279
+ @apply border-primary! border-1!;
280
+ }
281
+
282
+ .leaflet-control-zoom a {
283
+ @apply text-primary! border-none! bg-white!;
284
+ }
285
+
286
+ .leaflet-control-zoom a:hover {
287
+ @apply bg-secondary!;
288
+ }
289
+
290
+ /* Attribution styling */
291
+ .leaflet-control-attribution {
292
+ @apply hidden!;
293
+ }
294
+
295
+ .tickCircleFillIcon {
296
+ @apply [&_path]:stroke-background [&_rect]:fill-success;
297
+ }
298
+
299
+ /* Editor.js content link styling */
300
+ .editor-js-content a {
301
+ @apply text-primary decoration-primary hover:text-primary/80 underline underline-offset-4 transition-all duration-200;
302
+ }
303
+
304
+ /* Animation utilities */
305
+ .animate-flash {
306
+ animation: var(--animate-flash);
307
+ }
308
+ }
@@ -0,0 +1,10 @@
1
+ import type { routing } from '@/libs/I18nRouting';
2
+ import type messages from '@/locales/en.json';
3
+
4
+ declare module 'next-intl' {
5
+ // eslint-disable-next-line ts/consistent-type-definitions
6
+ interface AppConfig {
7
+ Locale: (typeof routing.locales)[number];
8
+ Messages: typeof messages;
9
+ }
10
+ }
@@ -0,0 +1,8 @@
1
+ export const Locales = {
2
+ EN: 'en',
3
+ DE: 'de',
4
+ } as const;
5
+
6
+ export type Locale = (typeof Locales)[keyof typeof Locales];
7
+
8
+ export const LOCALES = [Locales.EN, Locales.DE] as const;
@@ -0,0 +1,123 @@
1
+ import { siteConfig } from '@/config/site';
2
+
3
+ export const FILE_CONFIG = {
4
+ MAX_FILE_SIZE: 10 * 1024 * 1024, // 10MB in bytes
5
+ ALLOWED_EXTENSIONS: {
6
+ IMAGES: ['JPG', 'JPEG', 'PNG', 'WEBP', 'HEIC', 'JPEG', 'HEIF', 'AVIF'],
7
+ DOCUMENTS: ['PDF', 'DOC', 'DOCX'],
8
+ VIDEOS: ['MP4', 'MOV', 'AVI', 'MKV', 'WMV', 'FLV', 'WEBM'],
9
+ AUDIO: ['MP3', 'WAV', 'OGG', 'AAC'],
10
+ },
11
+ MIME_TYPE_EXTENSIONS: {
12
+ 'image/*': ['JPG', 'PNG', 'WEBP', 'HEIC', 'JPEG', 'HEIF', 'AVIF'],
13
+ 'application/pdf': ['PDF'],
14
+ 'video/*': ['MP4', 'MOV', 'AVI', 'MKV', 'WEBM'],
15
+ 'audio/*': ['MP3', 'WAV', 'OGG'],
16
+ },
17
+ } as const;
18
+
19
+ // File size units for different locales
20
+ const FILE_SIZE_UNITS = {
21
+ en: ['Bytes', 'KB', 'MB', 'GB'],
22
+ de: ['Bytes', 'KB', 'MB', 'GB'],
23
+ } as const;
24
+
25
+ // Helper function to format file size
26
+ export const formatFileSize = (bytes: number, locale: 'en' | 'de' = 'en'): string => {
27
+ if (bytes === 0) {
28
+ return `0 ${FILE_SIZE_UNITS[locale][0]}`;
29
+ }
30
+
31
+ const k = 1024;
32
+ const sizes = FILE_SIZE_UNITS[locale];
33
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
34
+ const size = Number.parseFloat((bytes / k ** i).toFixed(1));
35
+
36
+ return `${size} ${sizes[i]}`;
37
+ };
38
+
39
+ // Helper function to get allowed extensions from accept string
40
+ export const getAllowedExtensions = (accept: string): string[] => {
41
+ const acceptTypes = accept.split(',').map(type => type.trim());
42
+ const extensions = new Set<string>();
43
+
44
+ acceptTypes.forEach(type => {
45
+ if (type.includes('.')) {
46
+ // Direct extension like .pdf, .jpg
47
+ extensions.add(type.replace('.', '').toUpperCase());
48
+ } else if (
49
+ FILE_CONFIG.MIME_TYPE_EXTENSIONS[type as keyof typeof FILE_CONFIG.MIME_TYPE_EXTENSIONS]
50
+ ) {
51
+ // MIME type like image/*, video/*
52
+ const typeExtensions =
53
+ FILE_CONFIG.MIME_TYPE_EXTENSIONS[type as keyof typeof FILE_CONFIG.MIME_TYPE_EXTENSIONS];
54
+
55
+ typeExtensions.forEach(ext => extensions.add(ext));
56
+ }
57
+ });
58
+
59
+ return Array.from(extensions).sort();
60
+ };
61
+
62
+ // Helper function to generate file info text
63
+ export const getFileInfoText = (
64
+ accept: string,
65
+ maxSize?: number,
66
+ locale: 'en' | 'de' = 'en',
67
+ formatLabel?: string,
68
+ maxLabel?: string,
69
+ ): string => {
70
+ const allowedExtensions = getAllowedExtensions(accept);
71
+ const maxFileSize = maxSize || FILE_CONFIG.MAX_FILE_SIZE;
72
+ const formattedSize = formatFileSize(maxFileSize, locale);
73
+
74
+ const format = formatLabel || (locale === siteConfig.locales[1] ? 'Format' : 'Format');
75
+ const max = maxLabel || (locale === siteConfig.locales[1] ? 'max' : 'max');
76
+
77
+ return `${format}: ${allowedExtensions.join(', ')} (${max} ${formattedSize})`;
78
+ };
79
+
80
+ const FILE_TYPES = {
81
+ IMAGES: {
82
+ ALL: 'image/*',
83
+ JPEG: 'image/jpeg',
84
+ JPG: 'image/jpg', // Non-standard but used by some browsers
85
+ PNG: 'image/png',
86
+ WEBP: 'image/webp',
87
+ AVIF: 'image/avif',
88
+ HEIC: 'image/heic',
89
+ HEIF: 'image/heif',
90
+ GIF: 'image/gif',
91
+ SVG: 'image/svg+xml',
92
+ BMP: 'image/bmp',
93
+ },
94
+ DOCUMENTS: {
95
+ PDF: 'application/pdf',
96
+ DOC: 'application/msword',
97
+ DOCX: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
98
+ },
99
+ VIDEOS: {
100
+ ALL: 'video/*',
101
+ MP4: 'video/mp4',
102
+ MOV: 'video/quicktime',
103
+ AVI: 'video/x-msvideo',
104
+ MKV: 'video/x-matroska',
105
+ WMV: 'video/x-ms-wmv',
106
+ FLV: 'video/x-flv',
107
+ WEBM: 'video/webm',
108
+ },
109
+ AUDIO: {
110
+ ALL: 'audio/*',
111
+ MP3: 'audio/mpeg',
112
+ WAV: 'audio/wav',
113
+ OGG: 'audio/ogg',
114
+ },
115
+ } as const;
116
+
117
+ export const ACCEPT_TYPES = {
118
+ IMAGES_ONLY: FILE_TYPES.IMAGES.ALL,
119
+ IMAGES_AND_PDF: `${FILE_TYPES.IMAGES.ALL},${FILE_TYPES.DOCUMENTS.PDF}`,
120
+ VIDEOS_ONLY: FILE_TYPES.VIDEOS.ALL,
121
+ DOCUMENTS_ONLY: FILE_TYPES.DOCUMENTS.PDF,
122
+ ALL_MEDIA: `${FILE_TYPES.IMAGES.ALL},${FILE_TYPES.VIDEOS.ALL},${FILE_TYPES.DOCUMENTS.PDF}`,
123
+ } as const;
@@ -0,0 +1,78 @@
1
+ import { FILE_CONFIG } from './fileConfig';
2
+
3
+ /**
4
+ * Result of image validation
5
+ */
6
+ type ImageValidationResult = {
7
+ valid: boolean;
8
+ error?: string;
9
+ };
10
+
11
+ /**
12
+ * Type for i18n translation function (next-intl compatible)
13
+ */
14
+ type TranslationFunction = (key: string, params?: Record<string, string | number>) => string;
15
+
16
+ /**
17
+ * Sanitize filename to prevent path traversal and other security issues
18
+ * @param filename - The original filename
19
+ * @returns Sanitized filename
20
+ */
21
+ export function sanitizeFilename(filename: string): string {
22
+ // Remove any path components
23
+ const name = filename.split('/').pop()?.split('\\').pop() || 'upload';
24
+
25
+ // Remove special characters except dots and hyphens
26
+ const sanitized = name.replace(/[^a-z0-9.-]/gi, '_');
27
+
28
+ // If no extension, add .jpg as default
29
+ const hasExtension = sanitized.includes('.') && sanitized.split('.').length > 1;
30
+
31
+ if (!hasExtension) {
32
+ return `${sanitized}.jpg`;
33
+ }
34
+
35
+ return sanitized;
36
+ }
37
+
38
+ /**
39
+ * Validate image file for Saleor upload
40
+ *
41
+ * Validates against Saleor's supported image formats:
42
+ * - JPEG/JPG
43
+ * - PNG
44
+ * - WebP
45
+ *
46
+ * Also checks file size (max 10MB from FILE_CONFIG)
47
+ *
48
+ * @param file - The file to validate
49
+ * @param t - Translation function for error messages (optional)
50
+ * @returns Validation result with error message if invalid
51
+ */
52
+ export function validateImageFile(file: File, t?: TranslationFunction): ImageValidationResult {
53
+ // Check if file is an image (Saleor accepts any image/* MIME type)
54
+ if (!file.type.startsWith('image/')) {
55
+ return {
56
+ valid: false,
57
+ error: t
58
+ ? t('imageFileTypeError')
59
+ : 'File must be an image. Please upload a valid image file.',
60
+ };
61
+ }
62
+
63
+ // Check file size
64
+ if (file.size > FILE_CONFIG.MAX_FILE_SIZE) {
65
+ const maxSizeMB = FILE_CONFIG.MAX_FILE_SIZE / (1024 * 1024);
66
+
67
+ return {
68
+ valid: false,
69
+ error: t
70
+ ? t('imageSizeError', { maxSize: maxSizeMB.toString() })
71
+ : `Image file size exceeds ${maxSizeMB}MB limit.`,
72
+ };
73
+ }
74
+
75
+ // No extension check needed - we trust the MIME type
76
+ // This allows files without extensions (like macOS screenshots)
77
+ return { valid: true };
78
+ }
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Image compression utility for reducing file size while maintaining quality
3
+ */
4
+
5
+ type CompressionOptions = {
6
+ maxWidth?: number;
7
+ maxHeight?: number;
8
+ quality?: number;
9
+ maxSizeKB?: number;
10
+ outputFormat?: 'jpeg' | 'webp' | 'png';
11
+ };
12
+
13
+ const DEFAULT_OPTIONS: Required<CompressionOptions> = {
14
+ maxWidth: 1920,
15
+ maxHeight: 1080,
16
+ quality: 0.8,
17
+ maxSizeKB: 2 * 1024, // 2MB
18
+ outputFormat: 'jpeg',
19
+ };
20
+
21
+ /**
22
+ * Compresses an image file by resizing and adjusting quality
23
+ */
24
+ export async function compressImage(file: File, options: CompressionOptions = {}): Promise<File> {
25
+ const opts = { ...DEFAULT_OPTIONS, ...options };
26
+
27
+ // Skip compression for non-image files
28
+ if (!file.type.startsWith('image/')) {
29
+ return file;
30
+ }
31
+
32
+ // Skip compression if file is already small enough
33
+ if (file.size <= opts.maxSizeKB * 1024) {
34
+ return file;
35
+ }
36
+
37
+ return new Promise((resolve, reject) => {
38
+ const canvas = document.createElement('canvas');
39
+ const ctx = canvas.getContext('2d');
40
+ const img = new Image();
41
+
42
+ if (!ctx) {
43
+ reject(new Error('Canvas context not available'));
44
+
45
+ return;
46
+ }
47
+
48
+ img.onload = () => {
49
+ try {
50
+ // Calculate new dimensions while maintaining aspect ratio
51
+ const { width: newWidth, height: newHeight } = calculateDimensions(
52
+ img.width,
53
+ img.height,
54
+ opts.maxWidth,
55
+ opts.maxHeight,
56
+ );
57
+
58
+ // Set canvas dimensions
59
+ canvas.width = newWidth;
60
+ canvas.height = newHeight;
61
+
62
+ // Draw and compress the image
63
+ ctx.drawImage(img, 0, 0, newWidth, newHeight);
64
+
65
+ // Convert to blob with compression
66
+ canvas.toBlob(
67
+ blob => {
68
+ if (!blob) {
69
+ reject(new Error('Failed to compress image'));
70
+
71
+ return;
72
+ }
73
+
74
+ // Create new file with compressed data
75
+ const compressedFile = new File(
76
+ [blob],
77
+ getCompressedFileName(file.name, opts.outputFormat),
78
+ {
79
+ type: `image/${opts.outputFormat}`,
80
+ lastModified: Date.now(),
81
+ },
82
+ );
83
+
84
+ // If still too large, try with lower quality
85
+ if (compressedFile.size > opts.maxSizeKB * 1024 && opts.quality > 0.3) {
86
+ const lowerQualityOptions = { ...opts, quality: opts.quality * 0.7 };
87
+
88
+ compressImage(file, lowerQualityOptions).then(resolve).catch(reject);
89
+ } else {
90
+ resolve(compressedFile);
91
+ }
92
+ },
93
+ `image/${opts.outputFormat}`,
94
+ opts.quality,
95
+ );
96
+ } catch (error) {
97
+ reject(error);
98
+ }
99
+ };
100
+
101
+ img.onerror = () => {
102
+ reject(new Error('Failed to load image'));
103
+ };
104
+
105
+ // Load the image
106
+ img.src = URL.createObjectURL(file);
107
+ });
108
+ }
109
+
110
+ /**
111
+ * Calculate new dimensions while maintaining aspect ratio
112
+ */
113
+ function calculateDimensions(
114
+ originalWidth: number,
115
+ originalHeight: number,
116
+ maxWidth: number,
117
+ maxHeight: number,
118
+ ): { width: number; height: number } {
119
+ const { width, height } = { width: originalWidth, height: originalHeight };
120
+
121
+ // If image is smaller than max dimensions, don't upscale
122
+ if (width <= maxWidth && height <= maxHeight) {
123
+ return { width, height };
124
+ }
125
+
126
+ // Calculate scaling factor
127
+ const widthRatio = maxWidth / width;
128
+ const heightRatio = maxHeight / height;
129
+ const scalingFactor = Math.min(widthRatio, heightRatio);
130
+
131
+ return {
132
+ width: Math.round(width * scalingFactor),
133
+ height: Math.round(height * scalingFactor),
134
+ };
135
+ }
136
+
137
+ /**
138
+ * Generate compressed file name
139
+ */
140
+ function getCompressedFileName(originalName: string, outputFormat: string): string {
141
+ const nameWithoutExtension = originalName.replace(/\.[^/.]+$/, '');
142
+
143
+ return `${nameWithoutExtension}_compressed.${outputFormat}`;
144
+ }
145
+
146
+ /**
147
+ * Get optimal compression options based on file size and type
148
+ */
149
+ export function getOptimalCompressionOptions(file: File): CompressionOptions {
150
+ const fileSizeKB = file.size / 1024;
151
+
152
+ // For very large files, use more aggressive compression
153
+ if (fileSizeKB > 2000) {
154
+ return {
155
+ maxWidth: 1280,
156
+ maxHeight: 720,
157
+ quality: 0.7,
158
+ maxSizeKB: 400,
159
+ outputFormat: 'jpeg',
160
+ };
161
+ }
162
+
163
+ // For medium files, moderate compression
164
+ if (fileSizeKB > 1000) {
165
+ return {
166
+ maxWidth: 1600,
167
+ maxHeight: 900,
168
+ quality: 0.8,
169
+ maxSizeKB: 500,
170
+ outputFormat: 'jpeg',
171
+ };
172
+ }
173
+
174
+ // For smaller files, light compression
175
+ return {
176
+ maxWidth: 1920,
177
+ maxHeight: 1080,
178
+ quality: 0.85,
179
+ maxSizeKB: 600,
180
+ outputFormat: 'jpeg',
181
+ };
182
+ }