@greatapps/common 1.1.447 → 1.1.449
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/components/modals/cards/AddCardModal.mjs +5 -2
- package/dist/components/modals/cards/AddCardModal.mjs.map +1 -1
- package/dist/components/widgets/ImageUpload/ImageCropModal.mjs +76 -0
- package/dist/components/widgets/ImageUpload/ImageCropModal.mjs.map +1 -0
- package/dist/components/widgets/ImageUpload/ImageUpload.mjs +121 -0
- package/dist/components/widgets/ImageUpload/ImageUpload.mjs.map +1 -0
- package/dist/components/widgets/ImageUpload/index.mjs +7 -0
- package/dist/components/widgets/ImageUpload/index.mjs.map +1 -0
- package/dist/index.mjs +29 -1
- package/dist/index.mjs.map +1 -1
- package/dist/infra/utils/date.mjs +11 -0
- package/dist/infra/utils/date.mjs.map +1 -1
- package/dist/modules/images/actions/process-image.action.mjs +31 -0
- package/dist/modules/images/actions/process-image.action.mjs.map +1 -0
- package/dist/modules/images/constants/image.constants.mjs +14 -0
- package/dist/modules/images/constants/image.constants.mjs.map +1 -0
- package/dist/modules/images/hooks/use-image-upload.hook.mjs +96 -0
- package/dist/modules/images/hooks/use-image-upload.hook.mjs.map +1 -0
- package/dist/modules/images/services/image-processing.service.mjs +70 -0
- package/dist/modules/images/services/image-processing.service.mjs.map +1 -0
- package/dist/modules/images/types/image.type.mjs +1 -0
- package/dist/modules/images/types/image.type.mjs.map +1 -0
- package/dist/modules/images/utils/compress-image.mjs +18 -0
- package/dist/modules/images/utils/compress-image.mjs.map +1 -0
- package/dist/modules/images/utils/crop-image.mjs +29 -0
- package/dist/modules/images/utils/crop-image.mjs.map +1 -0
- package/dist/modules/images/utils/validate-image.mjs +50 -0
- package/dist/modules/images/utils/validate-image.mjs.map +1 -0
- package/dist/modules/plans/services/plans.service.mjs +15 -11
- package/dist/modules/plans/services/plans.service.mjs.map +1 -1
- package/dist/server.mjs +4 -0
- package/dist/server.mjs.map +1 -1
- package/package.json +4 -1
- package/src/components/modals/cards/AddCardModal.tsx +2 -1
- package/src/components/widgets/ImageUpload/ImageCropModal.tsx +97 -0
- package/src/components/widgets/ImageUpload/ImageUpload.tsx +125 -0
- package/src/components/widgets/ImageUpload/index.ts +2 -0
- package/src/index.ts +24 -1
- package/src/infra/utils/date.ts +11 -0
- package/src/modules/images/actions/process-image.action.ts +38 -0
- package/src/modules/images/constants/image.constants.ts +9 -0
- package/src/modules/images/hooks/use-image-upload.hook.ts +111 -0
- package/src/modules/images/services/image-processing.service.ts +76 -0
- package/src/modules/images/types/image.type.ts +54 -0
- package/src/modules/images/utils/compress-image.ts +21 -0
- package/src/modules/images/utils/crop-image.ts +41 -0
- package/src/modules/images/utils/validate-image.ts +59 -0
- package/src/modules/plans/services/plans.service.ts +48 -38
- package/src/server.ts +10 -0
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useRef } from 'react'
|
|
4
|
+
import { useImageUpload } from '../../../modules/images/hooks/use-image-upload.hook'
|
|
5
|
+
import { ImageCropModal } from './ImageCropModal'
|
|
6
|
+
import { Button } from '../../ui/buttons/Button'
|
|
7
|
+
import type { ImageConfig } from '../../../modules/images/types/image.type'
|
|
8
|
+
|
|
9
|
+
interface ImageUploadProps {
|
|
10
|
+
config: ImageConfig
|
|
11
|
+
value?: string
|
|
12
|
+
onChange?: (value: string) => void
|
|
13
|
+
onError?: (error: string) => void
|
|
14
|
+
className?: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function ImageUpload({
|
|
18
|
+
config,
|
|
19
|
+
value,
|
|
20
|
+
onChange,
|
|
21
|
+
onError,
|
|
22
|
+
className,
|
|
23
|
+
}: ImageUploadProps) {
|
|
24
|
+
const inputRef = useRef<HTMLInputElement>(null)
|
|
25
|
+
|
|
26
|
+
const {
|
|
27
|
+
originalImage,
|
|
28
|
+
croppedPreview,
|
|
29
|
+
showCropModal,
|
|
30
|
+
isProcessing,
|
|
31
|
+
handleFileSelect,
|
|
32
|
+
handleCropConfirm,
|
|
33
|
+
setShowCropModal,
|
|
34
|
+
clear,
|
|
35
|
+
} = useImageUpload({
|
|
36
|
+
config,
|
|
37
|
+
onSuccess: (base64) => onChange?.(base64),
|
|
38
|
+
onError,
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
const displayImage = croppedPreview || value
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<div className={className}>
|
|
45
|
+
<input
|
|
46
|
+
ref={inputRef}
|
|
47
|
+
type="file"
|
|
48
|
+
accept="image/png,image/jpeg,image/webp,image/gif"
|
|
49
|
+
className="hidden"
|
|
50
|
+
onChange={(e) => {
|
|
51
|
+
const file = e.target.files?.[0]
|
|
52
|
+
if (file) handleFileSelect(file)
|
|
53
|
+
e.target.value = ''
|
|
54
|
+
}}
|
|
55
|
+
/>
|
|
56
|
+
|
|
57
|
+
{displayImage ? (
|
|
58
|
+
<div className="relative">
|
|
59
|
+
<div
|
|
60
|
+
className="overflow-hidden rounded-lg border border-gray-200"
|
|
61
|
+
style={{ aspectRatio: config.aspectRatio }}
|
|
62
|
+
>
|
|
63
|
+
<img
|
|
64
|
+
src={displayImage}
|
|
65
|
+
alt="Preview"
|
|
66
|
+
className="w-full h-full object-cover"
|
|
67
|
+
/>
|
|
68
|
+
</div>
|
|
69
|
+
|
|
70
|
+
<div className="flex gap-2 mt-2">
|
|
71
|
+
<Button
|
|
72
|
+
variant="secondary"
|
|
73
|
+
size="sm"
|
|
74
|
+
onClick={() => inputRef.current?.click()}
|
|
75
|
+
disabled={isProcessing}
|
|
76
|
+
>
|
|
77
|
+
Trocar
|
|
78
|
+
</Button>
|
|
79
|
+
<Button
|
|
80
|
+
variant="secondary"
|
|
81
|
+
size="sm"
|
|
82
|
+
onClick={() => {
|
|
83
|
+
clear()
|
|
84
|
+
onChange?.('')
|
|
85
|
+
}}
|
|
86
|
+
disabled={isProcessing}
|
|
87
|
+
>
|
|
88
|
+
Remover
|
|
89
|
+
</Button>
|
|
90
|
+
</div>
|
|
91
|
+
|
|
92
|
+
{isProcessing && (
|
|
93
|
+
<p className="paragraph-small-regular text-gray-500 mt-2">
|
|
94
|
+
Processando...
|
|
95
|
+
</p>
|
|
96
|
+
)}
|
|
97
|
+
</div>
|
|
98
|
+
) : (
|
|
99
|
+
<button
|
|
100
|
+
type="button"
|
|
101
|
+
onClick={() => inputRef.current?.click()}
|
|
102
|
+
className="w-full border-2 border-dashed border-gray-300 rounded-lg p-8 text-center hover:border-gray-400 transition-colors"
|
|
103
|
+
style={{ aspectRatio: config.aspectRatio }}
|
|
104
|
+
>
|
|
105
|
+
<p className="paragraph-medium-regular text-gray-600">
|
|
106
|
+
Clique para enviar imagem
|
|
107
|
+
</p>
|
|
108
|
+
<p className="paragraph-small-regular text-gray-400 mt-1">
|
|
109
|
+
Tamanho recomendado: {config.width} x {config.height} pixels
|
|
110
|
+
</p>
|
|
111
|
+
</button>
|
|
112
|
+
)}
|
|
113
|
+
|
|
114
|
+
{originalImage && (
|
|
115
|
+
<ImageCropModal
|
|
116
|
+
open={showCropModal}
|
|
117
|
+
onOpenChange={setShowCropModal}
|
|
118
|
+
imageSrc={originalImage}
|
|
119
|
+
aspectRatio={config.aspectRatio}
|
|
120
|
+
onConfirm={handleCropConfirm}
|
|
121
|
+
/>
|
|
122
|
+
)}
|
|
123
|
+
</div>
|
|
124
|
+
)
|
|
125
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -107,7 +107,7 @@ export type {
|
|
|
107
107
|
|
|
108
108
|
// Utils
|
|
109
109
|
export { cn } from "./infra/utils/clsx";
|
|
110
|
-
export { formatShortDate } from "./infra/utils/date";
|
|
110
|
+
export { formatShortDate, formatDateTime } from "./infra/utils/date";
|
|
111
111
|
export { buildQueryParams } from "./infra/utils/params";
|
|
112
112
|
export { parseSchema, parseResult } from "./infra/utils/parser";
|
|
113
113
|
export { withAction } from "./utils/withAction";
|
|
@@ -396,3 +396,26 @@ export {
|
|
|
396
396
|
NOTIFICATION_TYPES,
|
|
397
397
|
LANGUAGE_OPTIONS,
|
|
398
398
|
} from "./components/account/constants";
|
|
399
|
+
|
|
400
|
+
// Image Upload
|
|
401
|
+
export { ImageUpload, ImageCropModal } from "./components/widgets/ImageUpload";
|
|
402
|
+
export { useImageUpload } from "./modules/images/hooks/use-image-upload.hook";
|
|
403
|
+
export {
|
|
404
|
+
ACCEPTED_IMAGE_FORMATS,
|
|
405
|
+
MAX_FILE_SIZE_MB,
|
|
406
|
+
TARGET_FILE_SIZE_MB,
|
|
407
|
+
} from "./modules/images/constants/image.constants";
|
|
408
|
+
export type {
|
|
409
|
+
ImageConfig,
|
|
410
|
+
CropArea,
|
|
411
|
+
ProcessedImage,
|
|
412
|
+
CompressImageOptions,
|
|
413
|
+
} from "./modules/images/types/image.type";
|
|
414
|
+
export { compressImage } from "./modules/images/utils/compress-image";
|
|
415
|
+
export { cropImageToCanvas } from "./modules/images/utils/crop-image";
|
|
416
|
+
export {
|
|
417
|
+
validateImage,
|
|
418
|
+
validateImageFormat,
|
|
419
|
+
validateImageSize,
|
|
420
|
+
getImageDimensions,
|
|
421
|
+
} from "./modules/images/utils/validate-image";
|
package/src/infra/utils/date.ts
CHANGED
|
@@ -5,3 +5,14 @@ export function formatShortDate(date: Date | string | null | undefined): string
|
|
|
5
5
|
const month = String(d.getMonth() + 1).padStart(2, '0');
|
|
6
6
|
return `${day}/${month}`;
|
|
7
7
|
}
|
|
8
|
+
|
|
9
|
+
export function formatDateTime(date: Date | string | null | undefined): string | null {
|
|
10
|
+
if (!date) return null;
|
|
11
|
+
return new Date(date).toLocaleString('pt-BR', {
|
|
12
|
+
day: '2-digit',
|
|
13
|
+
month: '2-digit',
|
|
14
|
+
year: 'numeric',
|
|
15
|
+
hour: '2-digit',
|
|
16
|
+
minute: '2-digit',
|
|
17
|
+
});
|
|
18
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
'use server'
|
|
2
|
+
|
|
3
|
+
import { processImage } from '../services/image-processing.service'
|
|
4
|
+
import type {
|
|
5
|
+
ProcessImageActionInput,
|
|
6
|
+
ProcessImageActionOutput,
|
|
7
|
+
} from '../types/image.type'
|
|
8
|
+
|
|
9
|
+
export async function processImageAction(
|
|
10
|
+
input: ProcessImageActionInput
|
|
11
|
+
): Promise<ProcessImageActionOutput> {
|
|
12
|
+
try {
|
|
13
|
+
const base64Data = input.base64Image.replace(/^data:image\/\w+;base64,/, '')
|
|
14
|
+
const binaryString = atob(base64Data)
|
|
15
|
+
const bytes = new Uint8Array(binaryString.length)
|
|
16
|
+
for (let i = 0; i < binaryString.length; i++) {
|
|
17
|
+
bytes[i] = binaryString.charCodeAt(i)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const result = processImage({
|
|
21
|
+
imageBytes: bytes,
|
|
22
|
+
targetWidth: input.targetWidth,
|
|
23
|
+
targetHeight: input.targetHeight,
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
const base64Output = btoa(String.fromCharCode(...result.bytes))
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
success: true,
|
|
30
|
+
base64Image: `data:${result.format};base64,${base64Output}`,
|
|
31
|
+
}
|
|
32
|
+
} catch (error) {
|
|
33
|
+
return {
|
|
34
|
+
success: false,
|
|
35
|
+
error: error instanceof Error ? error.message : 'Erro ao processar imagem',
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useState, useCallback } from 'react'
|
|
4
|
+
import { useMutation } from '@tanstack/react-query'
|
|
5
|
+
import type { Crop } from 'react-image-crop'
|
|
6
|
+
import { compressImage } from '../utils/compress-image'
|
|
7
|
+
import { cropImageToCanvas } from '../utils/crop-image'
|
|
8
|
+
import { validateImage } from '../utils/validate-image'
|
|
9
|
+
import { processImageAction } from '../actions/process-image.action'
|
|
10
|
+
import type { ImageConfig } from '../types/image.type'
|
|
11
|
+
|
|
12
|
+
export interface UseImageUploadOptions {
|
|
13
|
+
config: ImageConfig
|
|
14
|
+
onSuccess?: (base64Image: string) => void
|
|
15
|
+
onError?: (error: string) => void
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function useImageUpload(options: UseImageUploadOptions) {
|
|
19
|
+
const { config, onSuccess, onError } = options
|
|
20
|
+
|
|
21
|
+
const [originalImage, setOriginalImage] = useState<string | null>(null)
|
|
22
|
+
const [croppedPreview, setCroppedPreview] = useState<string | null>(null)
|
|
23
|
+
const [showCropModal, setShowCropModal] = useState(false)
|
|
24
|
+
|
|
25
|
+
const processMutation = useMutation({
|
|
26
|
+
mutationFn: processImageAction,
|
|
27
|
+
onSuccess: (result) => {
|
|
28
|
+
if (result.success && result.base64Image) {
|
|
29
|
+
setCroppedPreview(result.base64Image)
|
|
30
|
+
onSuccess?.(result.base64Image)
|
|
31
|
+
} else {
|
|
32
|
+
onError?.(result.error || 'Erro desconhecido')
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
onError: (error) => {
|
|
36
|
+
onError?.(error instanceof Error ? error.message : 'Erro ao processar')
|
|
37
|
+
},
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
const handleFileSelect = useCallback(
|
|
41
|
+
async (file: File) => {
|
|
42
|
+
const validation = validateImage(file)
|
|
43
|
+
if (!validation.valid) {
|
|
44
|
+
onError?.(validation.error || 'Arquivo inválido')
|
|
45
|
+
return
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
const compressed = await compressImage(file)
|
|
50
|
+
const reader = new FileReader()
|
|
51
|
+
reader.onload = (e) => {
|
|
52
|
+
setOriginalImage(e.target?.result as string)
|
|
53
|
+
setShowCropModal(true)
|
|
54
|
+
}
|
|
55
|
+
reader.readAsDataURL(compressed)
|
|
56
|
+
} catch {
|
|
57
|
+
onError?.('Erro ao processar imagem')
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
[onError]
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
const handleCropConfirm = useCallback(
|
|
64
|
+
async (imageElement: HTMLImageElement, cropArea: Crop) => {
|
|
65
|
+
try {
|
|
66
|
+
const croppedBase64 = await cropImageToCanvas(
|
|
67
|
+
imageElement,
|
|
68
|
+
cropArea,
|
|
69
|
+
config.width,
|
|
70
|
+
config.height
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
setCroppedPreview(croppedBase64)
|
|
74
|
+
setShowCropModal(false)
|
|
75
|
+
|
|
76
|
+
processMutation.mutate({
|
|
77
|
+
base64Image: croppedBase64,
|
|
78
|
+
targetWidth: config.width,
|
|
79
|
+
targetHeight: config.height,
|
|
80
|
+
cropArea: {
|
|
81
|
+
x: cropArea.x,
|
|
82
|
+
y: cropArea.y,
|
|
83
|
+
width: cropArea.width,
|
|
84
|
+
height: cropArea.height,
|
|
85
|
+
unit: cropArea.unit,
|
|
86
|
+
},
|
|
87
|
+
})
|
|
88
|
+
} catch {
|
|
89
|
+
onError?.('Erro ao aplicar crop')
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
[config, processMutation, onError]
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
const clear = useCallback(() => {
|
|
96
|
+
setOriginalImage(null)
|
|
97
|
+
setCroppedPreview(null)
|
|
98
|
+
}, [])
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
originalImage,
|
|
102
|
+
croppedPreview,
|
|
103
|
+
showCropModal,
|
|
104
|
+
config,
|
|
105
|
+
isProcessing: processMutation.isPending,
|
|
106
|
+
handleFileSelect,
|
|
107
|
+
handleCropConfirm,
|
|
108
|
+
setShowCropModal,
|
|
109
|
+
clear,
|
|
110
|
+
}
|
|
111
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import {
|
|
2
|
+
PhotonImage,
|
|
3
|
+
resize,
|
|
4
|
+
crop,
|
|
5
|
+
SamplingFilter,
|
|
6
|
+
} from '@cf-wasm/photon/edge-light'
|
|
7
|
+
import type { ProcessImageInput, ProcessImageOutput } from '../types/image.type'
|
|
8
|
+
|
|
9
|
+
export function processImage(input: ProcessImageInput): ProcessImageOutput {
|
|
10
|
+
const {
|
|
11
|
+
imageBytes,
|
|
12
|
+
targetWidth,
|
|
13
|
+
targetHeight,
|
|
14
|
+
outputFormat = 'jpeg',
|
|
15
|
+
quality = 85,
|
|
16
|
+
} = input
|
|
17
|
+
|
|
18
|
+
const image = PhotonImage.new_from_byteslice(imageBytes)
|
|
19
|
+
|
|
20
|
+
try {
|
|
21
|
+
const sourceWidth = image.get_width()
|
|
22
|
+
const sourceHeight = image.get_height()
|
|
23
|
+
const targetAspect = targetWidth / targetHeight
|
|
24
|
+
const sourceAspect = sourceWidth / sourceHeight
|
|
25
|
+
|
|
26
|
+
let cropX1 = 0
|
|
27
|
+
let cropY1 = 0
|
|
28
|
+
let cropX2 = sourceWidth
|
|
29
|
+
let cropY2 = sourceHeight
|
|
30
|
+
|
|
31
|
+
if (sourceAspect > targetAspect) {
|
|
32
|
+
const cropWidth = Math.round(sourceHeight * targetAspect)
|
|
33
|
+
cropX1 = Math.round((sourceWidth - cropWidth) / 2)
|
|
34
|
+
cropX2 = cropX1 + cropWidth
|
|
35
|
+
} else if (sourceAspect < targetAspect) {
|
|
36
|
+
const cropHeight = Math.round(sourceWidth / targetAspect)
|
|
37
|
+
cropY1 = Math.round((sourceHeight - cropHeight) / 2)
|
|
38
|
+
cropY2 = cropY1 + cropHeight
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const cropped = crop(image, cropX1, cropY1, cropX2, cropY2)
|
|
42
|
+
const resized = resize(
|
|
43
|
+
cropped,
|
|
44
|
+
targetWidth,
|
|
45
|
+
targetHeight,
|
|
46
|
+
SamplingFilter.Triangle
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
let outputBytes: Uint8Array
|
|
50
|
+
switch (outputFormat) {
|
|
51
|
+
case 'jpeg':
|
|
52
|
+
outputBytes = resized.get_bytes_jpeg(quality)
|
|
53
|
+
break
|
|
54
|
+
case 'png':
|
|
55
|
+
outputBytes = resized.get_bytes()
|
|
56
|
+
break
|
|
57
|
+
case 'webp':
|
|
58
|
+
outputBytes = resized.get_bytes_webp()
|
|
59
|
+
break
|
|
60
|
+
default:
|
|
61
|
+
outputBytes = resized.get_bytes_jpeg(quality)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
cropped.free()
|
|
65
|
+
resized.free()
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
bytes: outputBytes,
|
|
69
|
+
width: targetWidth,
|
|
70
|
+
height: targetHeight,
|
|
71
|
+
format: `image/${outputFormat}`,
|
|
72
|
+
}
|
|
73
|
+
} finally {
|
|
74
|
+
image.free()
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
export interface ImageConfig {
|
|
2
|
+
width: number
|
|
3
|
+
height: number
|
|
4
|
+
aspectRatio: number
|
|
5
|
+
label?: string
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface CropArea {
|
|
9
|
+
x: number
|
|
10
|
+
y: number
|
|
11
|
+
width: number
|
|
12
|
+
height: number
|
|
13
|
+
unit?: '%' | 'px'
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ProcessedImage {
|
|
17
|
+
base64: string
|
|
18
|
+
width: number
|
|
19
|
+
height: number
|
|
20
|
+
format: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface CompressImageOptions {
|
|
24
|
+
maxSizeMB?: number
|
|
25
|
+
maxWidthOrHeight?: number
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface ProcessImageInput {
|
|
29
|
+
imageBytes: Uint8Array
|
|
30
|
+
targetWidth: number
|
|
31
|
+
targetHeight: number
|
|
32
|
+
outputFormat?: 'jpeg' | 'png' | 'webp'
|
|
33
|
+
quality?: number
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface ProcessImageOutput {
|
|
37
|
+
bytes: Uint8Array
|
|
38
|
+
width: number
|
|
39
|
+
height: number
|
|
40
|
+
format: string
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface ProcessImageActionInput {
|
|
44
|
+
base64Image: string
|
|
45
|
+
targetWidth: number
|
|
46
|
+
targetHeight: number
|
|
47
|
+
cropArea?: CropArea
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface ProcessImageActionOutput {
|
|
51
|
+
success: boolean
|
|
52
|
+
base64Image?: string
|
|
53
|
+
error?: string
|
|
54
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import imageCompression from 'browser-image-compression'
|
|
2
|
+
import type { CompressImageOptions } from '../types/image.type'
|
|
3
|
+
import { TARGET_FILE_SIZE_MB } from '../constants/image.constants'
|
|
4
|
+
|
|
5
|
+
export async function compressImage(
|
|
6
|
+
file: File,
|
|
7
|
+
options: CompressImageOptions = {}
|
|
8
|
+
): Promise<File> {
|
|
9
|
+
const { maxSizeMB = TARGET_FILE_SIZE_MB, maxWidthOrHeight = 1920 } = options
|
|
10
|
+
|
|
11
|
+
if (file.size <= maxSizeMB * 1024 * 1024) {
|
|
12
|
+
return file
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
return imageCompression(file, {
|
|
16
|
+
maxSizeMB,
|
|
17
|
+
maxWidthOrHeight,
|
|
18
|
+
useWebWorker: true,
|
|
19
|
+
fileType: file.type as 'image/jpeg' | 'image/png' | 'image/webp',
|
|
20
|
+
})
|
|
21
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { Crop } from 'react-image-crop'
|
|
2
|
+
|
|
3
|
+
export async function cropImageToCanvas(
|
|
4
|
+
image: HTMLImageElement,
|
|
5
|
+
crop: Crop,
|
|
6
|
+
targetWidth: number,
|
|
7
|
+
targetHeight: number
|
|
8
|
+
): Promise<string> {
|
|
9
|
+
const canvas = document.createElement('canvas')
|
|
10
|
+
const ctx = canvas.getContext('2d')
|
|
11
|
+
|
|
12
|
+
if (!ctx) throw new Error('Canvas não suportado')
|
|
13
|
+
|
|
14
|
+
const pixelCrop = {
|
|
15
|
+
x: crop.unit === '%' ? (crop.x / 100) * image.naturalWidth : crop.x,
|
|
16
|
+
y: crop.unit === '%' ? (crop.y / 100) * image.naturalHeight : crop.y,
|
|
17
|
+
width:
|
|
18
|
+
crop.unit === '%' ? (crop.width / 100) * image.naturalWidth : crop.width,
|
|
19
|
+
height:
|
|
20
|
+
crop.unit === '%'
|
|
21
|
+
? (crop.height / 100) * image.naturalHeight
|
|
22
|
+
: crop.height,
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
canvas.width = targetWidth
|
|
26
|
+
canvas.height = targetHeight
|
|
27
|
+
|
|
28
|
+
ctx.drawImage(
|
|
29
|
+
image,
|
|
30
|
+
pixelCrop.x,
|
|
31
|
+
pixelCrop.y,
|
|
32
|
+
pixelCrop.width,
|
|
33
|
+
pixelCrop.height,
|
|
34
|
+
0,
|
|
35
|
+
0,
|
|
36
|
+
targetWidth,
|
|
37
|
+
targetHeight
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
return canvas.toDataURL('image/jpeg', 0.9)
|
|
41
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ACCEPTED_IMAGE_FORMATS,
|
|
3
|
+
MAX_FILE_SIZE_MB,
|
|
4
|
+
} from '../constants/image.constants'
|
|
5
|
+
|
|
6
|
+
export interface ImageValidationResult {
|
|
7
|
+
valid: boolean
|
|
8
|
+
error?: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function validateImageFormat(file: File): ImageValidationResult {
|
|
12
|
+
if (!ACCEPTED_IMAGE_FORMATS.includes(file.type as (typeof ACCEPTED_IMAGE_FORMATS)[number])) {
|
|
13
|
+
return {
|
|
14
|
+
valid: false,
|
|
15
|
+
error: 'Formato não suportado. Use PNG, JPEG ou WebP.',
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return { valid: true }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function validateImageSize(
|
|
22
|
+
file: File,
|
|
23
|
+
maxSizeMB: number = MAX_FILE_SIZE_MB
|
|
24
|
+
): ImageValidationResult {
|
|
25
|
+
if (file.size > maxSizeMB * 1024 * 1024) {
|
|
26
|
+
return {
|
|
27
|
+
valid: false,
|
|
28
|
+
error: `Arquivo muito grande. Máximo ${maxSizeMB}MB.`,
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return { valid: true }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function validateImage(file: File): ImageValidationResult {
|
|
35
|
+
const formatValidation = validateImageFormat(file)
|
|
36
|
+
if (!formatValidation.valid) return formatValidation
|
|
37
|
+
|
|
38
|
+
const sizeValidation = validateImageSize(file)
|
|
39
|
+
if (!sizeValidation.valid) return sizeValidation
|
|
40
|
+
|
|
41
|
+
return { valid: true }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function getImageDimensions(
|
|
45
|
+
file: File
|
|
46
|
+
): Promise<{ width: number; height: number }> {
|
|
47
|
+
return new Promise((resolve, reject) => {
|
|
48
|
+
const img = new Image()
|
|
49
|
+
img.onload = () => {
|
|
50
|
+
resolve({ width: img.naturalWidth, height: img.naturalHeight })
|
|
51
|
+
URL.revokeObjectURL(img.src)
|
|
52
|
+
}
|
|
53
|
+
img.onerror = () => {
|
|
54
|
+
reject(new Error('Falha ao carregar imagem'))
|
|
55
|
+
URL.revokeObjectURL(img.src)
|
|
56
|
+
}
|
|
57
|
+
img.src = URL.createObjectURL(file)
|
|
58
|
+
})
|
|
59
|
+
}
|