@adlas/create-app 1.0.7 → 1.0.8
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/commands/init.js +9 -0
- package/dist/commands/init.js.map +1 -1
- package/package.json +1 -1
- package/templates/boilerplate/components/ui/Autocomplete.tsx +53 -0
- package/templates/boilerplate/components/ui/Breadcrumbs.tsx +49 -0
- package/templates/boilerplate/components/ui/Button.tsx +37 -0
- package/templates/boilerplate/components/ui/Checkbox.tsx +21 -0
- package/templates/boilerplate/components/ui/Chip.tsx +21 -0
- package/templates/boilerplate/components/ui/DatePicker.tsx +82 -0
- package/templates/boilerplate/components/ui/DocumentUpload.tsx +415 -0
- package/templates/boilerplate/components/ui/Icon.tsx +43 -0
- package/templates/boilerplate/components/ui/Input.tsx +27 -0
- package/templates/boilerplate/components/ui/Modal.tsx +51 -0
- package/templates/boilerplate/components/ui/NumberInput.tsx +59 -0
- package/templates/boilerplate/components/ui/PasswordInput.tsx +51 -0
- package/templates/boilerplate/components/ui/RadioGroup.tsx +50 -0
- package/templates/boilerplate/components/ui/Select.tsx +27 -0
- package/templates/boilerplate/components/ui/Tabs.tsx +21 -0
- package/templates/boilerplate/components/ui/Textarea.tsx +41 -0
- package/templates/boilerplate/components/ui/form/Form.tsx +277 -0
- package/templates/boilerplate/components/ui/form/FormContext.tsx +10 -0
- package/templates/boilerplate/components/ui/form/FormGenerator.tsx +304 -0
- package/templates/boilerplate/components/ui/index.ts +19 -0
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type { ChangeEvent, DragEvent, KeyboardEvent } from 'react';
|
|
4
|
+
|
|
5
|
+
import { memo, useCallback, useEffect, useRef, useState } from 'react';
|
|
6
|
+
|
|
7
|
+
import { Spinner } from '@heroui/react';
|
|
8
|
+
import clsx from 'clsx';
|
|
9
|
+
import { useLocale, useTranslations } from 'next-intl';
|
|
10
|
+
import Image from 'next/image';
|
|
11
|
+
|
|
12
|
+
import { uploadImageToSaleor } from '@/services/user';
|
|
13
|
+
import {
|
|
14
|
+
compressImage,
|
|
15
|
+
FILE_CONFIG,
|
|
16
|
+
formatFileSize,
|
|
17
|
+
getAllowedExtensions,
|
|
18
|
+
getFileInfoText,
|
|
19
|
+
} from '@/utils/file';
|
|
20
|
+
import { getOptimalCompressionOptions } from '@/utils/file/imageCompression';
|
|
21
|
+
|
|
22
|
+
import { Button } from './Button';
|
|
23
|
+
import { Icon } from './Icon';
|
|
24
|
+
|
|
25
|
+
// ============================================================================
|
|
26
|
+
// Types
|
|
27
|
+
// ============================================================================
|
|
28
|
+
|
|
29
|
+
export type FileValidationError = {
|
|
30
|
+
isValidationError: true;
|
|
31
|
+
errorMessage: string;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
type DocumentUploadProps = {
|
|
35
|
+
label: string;
|
|
36
|
+
description?: string;
|
|
37
|
+
onChange: (file: File | FileValidationError | null) => void;
|
|
38
|
+
onImageUploaded?: (imageId: string, imageUrl: string) => void;
|
|
39
|
+
onError?: (error: string) => void;
|
|
40
|
+
className?: string;
|
|
41
|
+
value?: string | null;
|
|
42
|
+
hasError?: boolean;
|
|
43
|
+
errorMessage?: string;
|
|
44
|
+
accept?: string;
|
|
45
|
+
required?: boolean;
|
|
46
|
+
maxSize?: number;
|
|
47
|
+
/** If true, file will not be uploaded immediately - parent handles upload */
|
|
48
|
+
deferUpload?: boolean;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
// ============================================================================
|
|
52
|
+
// Constants
|
|
53
|
+
// ============================================================================
|
|
54
|
+
|
|
55
|
+
const CONTAINER_HEIGHT = 'h-40';
|
|
56
|
+
const ICON_SIZE = 'size-5';
|
|
57
|
+
|
|
58
|
+
// ============================================================================
|
|
59
|
+
// Helper Functions
|
|
60
|
+
// ============================================================================
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Validates a file against size and type constraints
|
|
64
|
+
*/
|
|
65
|
+
function validateFile(
|
|
66
|
+
file: File,
|
|
67
|
+
accept: string,
|
|
68
|
+
maxFileSize: number,
|
|
69
|
+
t: ReturnType<typeof useTranslations>,
|
|
70
|
+
locale: 'en' | 'de',
|
|
71
|
+
): string | null {
|
|
72
|
+
// Check file size
|
|
73
|
+
if (file.size > maxFileSize) {
|
|
74
|
+
return t('fileSizeError', { maxSize: formatFileSize(maxFileSize, locale) });
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Check file type
|
|
78
|
+
const allowedExtensions = getAllowedExtensions(accept);
|
|
79
|
+
const fileExtension = file.name.split('.').pop()?.toUpperCase();
|
|
80
|
+
|
|
81
|
+
if (fileExtension && !allowedExtensions.includes(fileExtension)) {
|
|
82
|
+
return t('fileTypeError');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Cleans up blob URL if it exists
|
|
90
|
+
*/
|
|
91
|
+
function revokeObjectURL(url: string | null) {
|
|
92
|
+
if (url && url.startsWith('blob:')) {
|
|
93
|
+
URL.revokeObjectURL(url);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ============================================================================
|
|
98
|
+
// Component
|
|
99
|
+
// ============================================================================
|
|
100
|
+
|
|
101
|
+
function DocumentUploadComponent({
|
|
102
|
+
label,
|
|
103
|
+
onChange,
|
|
104
|
+
onImageUploaded,
|
|
105
|
+
onError,
|
|
106
|
+
className,
|
|
107
|
+
errorMessage,
|
|
108
|
+
value,
|
|
109
|
+
hasError,
|
|
110
|
+
accept = 'image/*,.pdf',
|
|
111
|
+
maxSize,
|
|
112
|
+
deferUpload = false,
|
|
113
|
+
}: DocumentUploadProps) {
|
|
114
|
+
const t = useTranslations('FileUpload');
|
|
115
|
+
const locale = useLocale() as 'en' | 'de';
|
|
116
|
+
const [previewUrl, setPreviewUrl] = useState<string | null>(value || null);
|
|
117
|
+
const [isDragOver, setIsDragOver] = useState(false);
|
|
118
|
+
const [validationError, setValidationError] = useState<string | null>(null);
|
|
119
|
+
const [isCompressing, setIsCompressing] = useState(false);
|
|
120
|
+
const [isUploading, setIsUploading] = useState(false);
|
|
121
|
+
const inputRef = useRef<HTMLInputElement | null>(null);
|
|
122
|
+
|
|
123
|
+
const maxFileSize = maxSize || FILE_CONFIG.MAX_FILE_SIZE;
|
|
124
|
+
|
|
125
|
+
// ============================================================================
|
|
126
|
+
// Effects
|
|
127
|
+
// ============================================================================
|
|
128
|
+
|
|
129
|
+
// Sync previewUrl with value prop from form
|
|
130
|
+
useEffect(() => {
|
|
131
|
+
if (value && typeof value === 'string') {
|
|
132
|
+
setPreviewUrl(value);
|
|
133
|
+
}
|
|
134
|
+
}, [value]);
|
|
135
|
+
|
|
136
|
+
// Cleanup object URLs on unmount or when preview changes
|
|
137
|
+
useEffect(() => {
|
|
138
|
+
return () => {
|
|
139
|
+
revokeObjectURL(previewUrl);
|
|
140
|
+
};
|
|
141
|
+
}, [previewUrl]);
|
|
142
|
+
|
|
143
|
+
// ============================================================================
|
|
144
|
+
// Handlers
|
|
145
|
+
// ============================================================================
|
|
146
|
+
|
|
147
|
+
const handleFileChange = useCallback(
|
|
148
|
+
async (file: File | null) => {
|
|
149
|
+
setValidationError(null);
|
|
150
|
+
|
|
151
|
+
if (file) {
|
|
152
|
+
try {
|
|
153
|
+
let processedFile = file;
|
|
154
|
+
|
|
155
|
+
// Compress image files before validation
|
|
156
|
+
if (file.type.startsWith('image/')) {
|
|
157
|
+
setIsCompressing(true);
|
|
158
|
+
const compressionOptions = getOptimalCompressionOptions(file);
|
|
159
|
+
|
|
160
|
+
processedFile = await compressImage(file, compressionOptions);
|
|
161
|
+
setIsCompressing(false);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const error = validateFile(processedFile, accept, maxFileSize, t, locale);
|
|
165
|
+
|
|
166
|
+
if (error) {
|
|
167
|
+
setValidationError(error);
|
|
168
|
+
setPreviewUrl(null);
|
|
169
|
+
onChange({
|
|
170
|
+
isValidationError: true,
|
|
171
|
+
errorMessage: error,
|
|
172
|
+
});
|
|
173
|
+
if (inputRef.current) {
|
|
174
|
+
inputRef.current.value = '';
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Set preview immediately for better UX
|
|
181
|
+
const objectUrl = URL.createObjectURL(processedFile);
|
|
182
|
+
setPreviewUrl(objectUrl);
|
|
183
|
+
onChange(processedFile);
|
|
184
|
+
|
|
185
|
+
// Upload to Saleor if it's an image and deferUpload is false
|
|
186
|
+
if (!deferUpload && processedFile.type.startsWith('image/') && onImageUploaded) {
|
|
187
|
+
setIsUploading(true);
|
|
188
|
+
|
|
189
|
+
try {
|
|
190
|
+
const result = await uploadImageToSaleor(processedFile);
|
|
191
|
+
|
|
192
|
+
if (result.success && result.imageId && result.imageUrl) {
|
|
193
|
+
// Notify parent component with the uploaded image URL
|
|
194
|
+
onImageUploaded(result.imageId, result.imageUrl);
|
|
195
|
+
// Revoke the blob URL before setting the new URL
|
|
196
|
+
revokeObjectURL(previewUrl);
|
|
197
|
+
setPreviewUrl(result.imageUrl);
|
|
198
|
+
} else {
|
|
199
|
+
console.error('❌ Image upload failed:', result.error);
|
|
200
|
+
onError?.(result.error || t('uploadFailed'));
|
|
201
|
+
setValidationError(result.error || t('uploadFailed'));
|
|
202
|
+
}
|
|
203
|
+
} catch (uploadError) {
|
|
204
|
+
console.error('❌ Image upload error:', uploadError);
|
|
205
|
+
const errorMsg =
|
|
206
|
+
uploadError instanceof Error ? uploadError.message : t('uploadFailed');
|
|
207
|
+
|
|
208
|
+
onError?.(errorMsg);
|
|
209
|
+
setValidationError(errorMsg);
|
|
210
|
+
} finally {
|
|
211
|
+
setIsUploading(false);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
} catch (compressionError) {
|
|
215
|
+
console.error('Image compression failed:', compressionError);
|
|
216
|
+
const errorMsg = t('compressionError');
|
|
217
|
+
|
|
218
|
+
setValidationError(errorMsg);
|
|
219
|
+
setPreviewUrl(null);
|
|
220
|
+
onChange({
|
|
221
|
+
isValidationError: true,
|
|
222
|
+
errorMessage: errorMsg,
|
|
223
|
+
});
|
|
224
|
+
if (inputRef.current) {
|
|
225
|
+
inputRef.current.value = '';
|
|
226
|
+
}
|
|
227
|
+
setIsCompressing(false);
|
|
228
|
+
}
|
|
229
|
+
} else {
|
|
230
|
+
onChange(file);
|
|
231
|
+
setPreviewUrl(file);
|
|
232
|
+
}
|
|
233
|
+
},
|
|
234
|
+
[accept, maxFileSize, onChange, onError, onImageUploaded, previewUrl, t, locale, deferUpload],
|
|
235
|
+
);
|
|
236
|
+
|
|
237
|
+
const handleChange = useCallback(
|
|
238
|
+
(e: ChangeEvent<HTMLInputElement>) => {
|
|
239
|
+
const file = e.target.files?.[0] || null;
|
|
240
|
+
handleFileChange(file);
|
|
241
|
+
},
|
|
242
|
+
[handleFileChange],
|
|
243
|
+
);
|
|
244
|
+
|
|
245
|
+
const handleDragOver = useCallback(
|
|
246
|
+
(e: DragEvent<HTMLDivElement>) => {
|
|
247
|
+
e.preventDefault();
|
|
248
|
+
if (!isCompressing && !isUploading) {
|
|
249
|
+
setIsDragOver(true);
|
|
250
|
+
}
|
|
251
|
+
},
|
|
252
|
+
[isCompressing, isUploading],
|
|
253
|
+
);
|
|
254
|
+
|
|
255
|
+
const handleDragLeave = useCallback((e: DragEvent<HTMLDivElement>) => {
|
|
256
|
+
e.preventDefault();
|
|
257
|
+
setIsDragOver(false);
|
|
258
|
+
}, []);
|
|
259
|
+
|
|
260
|
+
const handleDrop = useCallback(
|
|
261
|
+
(e: DragEvent<HTMLDivElement>) => {
|
|
262
|
+
e.preventDefault();
|
|
263
|
+
setIsDragOver(false);
|
|
264
|
+
|
|
265
|
+
if (isCompressing || isUploading) {
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const files = e.dataTransfer.files;
|
|
270
|
+
|
|
271
|
+
if (files.length > 0) {
|
|
272
|
+
handleFileChange(files[0] || null);
|
|
273
|
+
}
|
|
274
|
+
},
|
|
275
|
+
[handleFileChange, isCompressing, isUploading],
|
|
276
|
+
);
|
|
277
|
+
|
|
278
|
+
const handleTriggerInput = useCallback(() => {
|
|
279
|
+
if (!isCompressing && !isUploading) {
|
|
280
|
+
inputRef.current?.click();
|
|
281
|
+
}
|
|
282
|
+
}, [isCompressing, isUploading]);
|
|
283
|
+
|
|
284
|
+
const handleRemoveFile = useCallback(() => {
|
|
285
|
+
setValidationError(null);
|
|
286
|
+
if (inputRef.current) {
|
|
287
|
+
inputRef.current.value = '';
|
|
288
|
+
}
|
|
289
|
+
handleTriggerInput();
|
|
290
|
+
}, [handleTriggerInput]);
|
|
291
|
+
|
|
292
|
+
const handleKeyDown = useCallback(
|
|
293
|
+
(e: KeyboardEvent<HTMLDivElement>) => {
|
|
294
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
295
|
+
e.preventDefault();
|
|
296
|
+
handleTriggerInput();
|
|
297
|
+
}
|
|
298
|
+
},
|
|
299
|
+
[handleTriggerInput],
|
|
300
|
+
);
|
|
301
|
+
|
|
302
|
+
// ============================================================================
|
|
303
|
+
// Render
|
|
304
|
+
// ============================================================================
|
|
305
|
+
|
|
306
|
+
return (
|
|
307
|
+
<div
|
|
308
|
+
className={clsx(
|
|
309
|
+
'relative flex w-full cursor-pointer flex-col items-center justify-center overflow-hidden border border-dashed border-default-200 p-4 transition-all duration-200 hover:border-default-500',
|
|
310
|
+
CONTAINER_HEIGHT,
|
|
311
|
+
{
|
|
312
|
+
'border-danger!': hasError || validationError,
|
|
313
|
+
'border-default-500! bg-default-50!': isDragOver,
|
|
314
|
+
'border-transparent!': previewUrl,
|
|
315
|
+
'hover:border-default-300':
|
|
316
|
+
!hasError && !isDragOver && !validationError && !isCompressing && !isUploading,
|
|
317
|
+
'cursor-not-allowed! opacity-60': isCompressing || isUploading,
|
|
318
|
+
},
|
|
319
|
+
className,
|
|
320
|
+
)}
|
|
321
|
+
role="button"
|
|
322
|
+
tabIndex={0}
|
|
323
|
+
onClick={handleTriggerInput}
|
|
324
|
+
onDragLeave={handleDragLeave}
|
|
325
|
+
onDragOver={handleDragOver}
|
|
326
|
+
onDrop={handleDrop}
|
|
327
|
+
onKeyDown={handleKeyDown}
|
|
328
|
+
>
|
|
329
|
+
{/* Background Image */}
|
|
330
|
+
{previewUrl && (
|
|
331
|
+
<>
|
|
332
|
+
<Image fill alt="Preview" className="object-cover" src={previewUrl} />
|
|
333
|
+
{/* Dark Overlay */}
|
|
334
|
+
<div className="absolute inset-0 bg-black/30" />
|
|
335
|
+
</>
|
|
336
|
+
)}
|
|
337
|
+
|
|
338
|
+
{/* Content */}
|
|
339
|
+
<div className="relative z-10 flex flex-col items-center justify-center">
|
|
340
|
+
<div className="flex items-center justify-center">
|
|
341
|
+
{isCompressing || isUploading ? (
|
|
342
|
+
<Spinner color="white" />
|
|
343
|
+
) : (
|
|
344
|
+
!previewUrl && (
|
|
345
|
+
<Icon
|
|
346
|
+
className={clsx(ICON_SIZE, 'stroke-default-700 stroke-1.5', {
|
|
347
|
+
'stroke-danger!': hasError || validationError,
|
|
348
|
+
})}
|
|
349
|
+
name="UploadIcon"
|
|
350
|
+
/>
|
|
351
|
+
)
|
|
352
|
+
)}
|
|
353
|
+
</div>
|
|
354
|
+
<input
|
|
355
|
+
ref={inputRef}
|
|
356
|
+
accept={accept}
|
|
357
|
+
className="hidden"
|
|
358
|
+
type="file"
|
|
359
|
+
onChange={handleChange}
|
|
360
|
+
/>
|
|
361
|
+
<div className="mt-2 flex flex-col items-center justify-center text-center">
|
|
362
|
+
{isCompressing || isUploading ? null : (
|
|
363
|
+
<span
|
|
364
|
+
className={clsx('text-base leading-6 font-semibold', {
|
|
365
|
+
'text-white': previewUrl,
|
|
366
|
+
'text-default-800': !previewUrl,
|
|
367
|
+
})}
|
|
368
|
+
>
|
|
369
|
+
{label}
|
|
370
|
+
</span>
|
|
371
|
+
)}
|
|
372
|
+
{(hasError || validationError) && (
|
|
373
|
+
<span className="mt-0.5 text-sm leading-5 font-medium text-danger">
|
|
374
|
+
{validationError || errorMessage}
|
|
375
|
+
</span>
|
|
376
|
+
)}
|
|
377
|
+
{!hasError && !validationError && (
|
|
378
|
+
<p
|
|
379
|
+
className={clsx('mt-0.5 text-sm leading-5 font-medium', {
|
|
380
|
+
'text-white': previewUrl,
|
|
381
|
+
'text-default-600': !previewUrl,
|
|
382
|
+
})}
|
|
383
|
+
>
|
|
384
|
+
{isCompressing
|
|
385
|
+
? t('compressing')
|
|
386
|
+
: isUploading
|
|
387
|
+
? t('uploading')
|
|
388
|
+
: previewUrl
|
|
389
|
+
? null
|
|
390
|
+
: t('clickOrDrag')}
|
|
391
|
+
</p>
|
|
392
|
+
)}
|
|
393
|
+
{!previewUrl && (
|
|
394
|
+
<p className="mt-3 text-xs leading-4 font-medium text-default-500">
|
|
395
|
+
{getFileInfoText(accept, maxFileSize, locale, t('format'), t('max'))}
|
|
396
|
+
</p>
|
|
397
|
+
)}
|
|
398
|
+
{previewUrl && !isCompressing && !isUploading && (
|
|
399
|
+
<Button
|
|
400
|
+
className="mt-2 bg-white text-primary"
|
|
401
|
+
size="sm"
|
|
402
|
+
variant="solid"
|
|
403
|
+
onClick={e => e.stopPropagation()}
|
|
404
|
+
onPress={handleRemoveFile}
|
|
405
|
+
>
|
|
406
|
+
{t('changeImage')}
|
|
407
|
+
</Button>
|
|
408
|
+
)}
|
|
409
|
+
</div>
|
|
410
|
+
</div>
|
|
411
|
+
</div>
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
export const DocumentUpload = memo(DocumentUploadComponent);
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type { SVGProps } from 'react';
|
|
4
|
+
|
|
5
|
+
import { memo } from 'react';
|
|
6
|
+
|
|
7
|
+
import clsx from 'clsx';
|
|
8
|
+
|
|
9
|
+
import * as Icons from '@/components/icons';
|
|
10
|
+
|
|
11
|
+
export type IconName = keyof typeof Icons;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Icon component for displaying SVG icons
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* // Single color icon (default: size-5, stroke-1.5)
|
|
18
|
+
* <Icon name="HeartIcon" className="stroke-blue-500" />
|
|
19
|
+
*
|
|
20
|
+
* @example
|
|
21
|
+
* // Two-tone icon with nth-child selectors
|
|
22
|
+
* <Icon
|
|
23
|
+
* name="CheckBookmarkIcon"
|
|
24
|
+
* className="[&_path:nth-child(1)]:stroke-primary [&_path:nth-child(2)]:stroke-default-300"
|
|
25
|
+
* />
|
|
26
|
+
*/
|
|
27
|
+
export type IconProps = {
|
|
28
|
+
name: IconName;
|
|
29
|
+
} & SVGProps<SVGSVGElement>;
|
|
30
|
+
|
|
31
|
+
const IconComponent = ({ name, className = '', ...props }: IconProps) => {
|
|
32
|
+
const Component = Icons[name];
|
|
33
|
+
|
|
34
|
+
if (!Component) {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return <Component className={clsx('size-4 stroke-1.5 outline-none', className)} {...props} />;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
IconComponent.displayName = 'Icon';
|
|
42
|
+
|
|
43
|
+
export const Icon = memo(IconComponent);
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type { ComponentProps } from 'react';
|
|
4
|
+
|
|
5
|
+
import { extendVariants, Input as HerouiInput } from '@heroui/react';
|
|
6
|
+
|
|
7
|
+
const BaseInput = extendVariants(HerouiInput, {
|
|
8
|
+
variants: {
|
|
9
|
+
color: {},
|
|
10
|
+
isDisabled: { true: {} },
|
|
11
|
+
size: {
|
|
12
|
+
sm: {},
|
|
13
|
+
md: {},
|
|
14
|
+
lg: {},
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
defaultVariants: {
|
|
18
|
+
size: 'md',
|
|
19
|
+
},
|
|
20
|
+
compoundVariants: [],
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const Input = (props: ComponentProps<typeof BaseInput>) => {
|
|
24
|
+
return <BaseInput {...props} />;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export { Input };
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type { ComponentProps } from 'react';
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
extendVariants,
|
|
7
|
+
Modal as HeroUIModal,
|
|
8
|
+
ModalBody,
|
|
9
|
+
ModalContent,
|
|
10
|
+
ModalFooter,
|
|
11
|
+
ModalHeader,
|
|
12
|
+
} from '@heroui/react';
|
|
13
|
+
|
|
14
|
+
import { Button } from './Button';
|
|
15
|
+
import { Icon } from './Icon';
|
|
16
|
+
|
|
17
|
+
const Modal = extendVariants(HeroUIModal, {
|
|
18
|
+
slots: {
|
|
19
|
+
backdrop: '',
|
|
20
|
+
base: '',
|
|
21
|
+
wrapper: '',
|
|
22
|
+
body: '',
|
|
23
|
+
closeButton: '',
|
|
24
|
+
},
|
|
25
|
+
variants: {
|
|
26
|
+
radius: {},
|
|
27
|
+
},
|
|
28
|
+
defaultVariants: {
|
|
29
|
+
radius: 'none',
|
|
30
|
+
shadow: 'none',
|
|
31
|
+
placement: 'center',
|
|
32
|
+
backdrop: 'blur',
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const CustomModal = (props: ComponentProps<typeof Modal>) => {
|
|
37
|
+
const { ...restProps } = props;
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<Modal
|
|
41
|
+
closeButton={
|
|
42
|
+
<Button isIconOnly size="sm" variant="flat">
|
|
43
|
+
<Icon name="CloseIcon" className="size-5! stroke-default-700" />
|
|
44
|
+
</Button>
|
|
45
|
+
}
|
|
46
|
+
{...restProps}
|
|
47
|
+
/>
|
|
48
|
+
);
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export { CustomModal as Modal, ModalBody, ModalContent, ModalFooter, ModalHeader };
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type { ChangeEvent, ComponentProps } from 'react';
|
|
4
|
+
|
|
5
|
+
import { Input } from './Input';
|
|
6
|
+
|
|
7
|
+
type NumberInputProps = {
|
|
8
|
+
formatOptions?: Intl.NumberFormatOptions;
|
|
9
|
+
onValueChange?: (value: string) => void;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const NumberInput = (props: ComponentProps<typeof Input> & NumberInputProps) => {
|
|
13
|
+
const { value, onChange, onValueChange, formatOptions, ...restProps } = props;
|
|
14
|
+
|
|
15
|
+
const handleInput = (e: ChangeEvent<HTMLInputElement>) => {
|
|
16
|
+
let rawInput = e.target.value;
|
|
17
|
+
|
|
18
|
+
if (rawInput.startsWith(' ')) {
|
|
19
|
+
rawInput = rawInput.trimStart();
|
|
20
|
+
e.target.value = rawInput;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const processedInput = rawInput.replace(/\D/g, '');
|
|
24
|
+
|
|
25
|
+
if (processedInput !== rawInput) {
|
|
26
|
+
e.target.value = processedInput;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (onChange && typeof onChange === 'function') {
|
|
30
|
+
const newEvent = {
|
|
31
|
+
...e,
|
|
32
|
+
target: {
|
|
33
|
+
...e.target,
|
|
34
|
+
value: processedInput,
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
onChange(newEvent);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (onValueChange) {
|
|
42
|
+
if (processedInput === '') {
|
|
43
|
+
onValueChange('');
|
|
44
|
+
} else {
|
|
45
|
+
onValueChange(processedInput);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
// Apply number formatting if formatOptions are provided
|
|
51
|
+
const formattedValue =
|
|
52
|
+
formatOptions && value
|
|
53
|
+
? new Intl.NumberFormat(undefined, formatOptions).format(Number(value))
|
|
54
|
+
: value;
|
|
55
|
+
|
|
56
|
+
return <Input type="text" value={formattedValue} onInput={handleInput} {...restProps} />;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export { NumberInput };
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import type { ComponentProps, FormEvent } from 'react';
|
|
3
|
+
|
|
4
|
+
import { useState } from 'react';
|
|
5
|
+
|
|
6
|
+
import { Button } from './Button';
|
|
7
|
+
import { Icon } from './Icon';
|
|
8
|
+
import { Input } from './Input';
|
|
9
|
+
|
|
10
|
+
const PasswordInput = (props: ComponentProps<typeof Input>) => {
|
|
11
|
+
const [isVisible, setIsVisible] = useState(false);
|
|
12
|
+
const { onInput, ...restProps } = props;
|
|
13
|
+
|
|
14
|
+
const toggleVisibility = () => setIsVisible(!isVisible);
|
|
15
|
+
|
|
16
|
+
const handleInput = (e: FormEvent<HTMLInputElement>) => {
|
|
17
|
+
const target = e.target as HTMLInputElement;
|
|
18
|
+
|
|
19
|
+
if (target.value.startsWith(' ')) {
|
|
20
|
+
target.value = target.value.trimStart();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (onInput) {
|
|
24
|
+
onInput(e);
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<Input
|
|
30
|
+
endContent={
|
|
31
|
+
<Button
|
|
32
|
+
size="sm"
|
|
33
|
+
variant="light"
|
|
34
|
+
aria-label="toggle password visibility"
|
|
35
|
+
className="min-w-0 self-center focus:outline-none"
|
|
36
|
+
onPress={toggleVisibility}
|
|
37
|
+
>
|
|
38
|
+
<Icon
|
|
39
|
+
name={isVisible ? 'EyeSlashIcon' : 'EyeIcon'}
|
|
40
|
+
className="pointer-events-none size-4 stroke-default-400 stroke-1.5"
|
|
41
|
+
/>
|
|
42
|
+
</Button>
|
|
43
|
+
}
|
|
44
|
+
type={isVisible ? 'text' : 'password'}
|
|
45
|
+
onInput={handleInput}
|
|
46
|
+
{...restProps}
|
|
47
|
+
/>
|
|
48
|
+
);
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export { PasswordInput };
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type { ComponentProps } from 'react';
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
extendVariants,
|
|
7
|
+
Radio as HerouiRadio,
|
|
8
|
+
RadioGroup as HerouiRadioGroup,
|
|
9
|
+
} from '@heroui/react';
|
|
10
|
+
|
|
11
|
+
const RadioGroup = extendVariants(HerouiRadioGroup, {
|
|
12
|
+
variants: {
|
|
13
|
+
orientation: {
|
|
14
|
+
vertical: {
|
|
15
|
+
wrapper: '',
|
|
16
|
+
},
|
|
17
|
+
horizontal: {
|
|
18
|
+
wrapper: '',
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
defaultVariants: {
|
|
23
|
+
orientation: 'horizontal',
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const BaseRadio = extendVariants(HerouiRadio, {
|
|
28
|
+
variants: {
|
|
29
|
+
color: {},
|
|
30
|
+
isDisabled: { true: {} },
|
|
31
|
+
size: {
|
|
32
|
+
sm: {},
|
|
33
|
+
md: {},
|
|
34
|
+
lg: {},
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
defaultVariants: {
|
|
38
|
+
size: 'md',
|
|
39
|
+
},
|
|
40
|
+
compoundVariants: [],
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
type RadioProps = ComponentProps<typeof BaseRadio>;
|
|
44
|
+
const Radio = (props: RadioProps) => {
|
|
45
|
+
const { ...restProps } = props;
|
|
46
|
+
|
|
47
|
+
return <BaseRadio {...restProps}>{restProps.children}</BaseRadio>;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export { Radio, RadioGroup };
|