@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.
Files changed (49) hide show
  1. package/dist/components/modals/cards/AddCardModal.mjs +5 -2
  2. package/dist/components/modals/cards/AddCardModal.mjs.map +1 -1
  3. package/dist/components/widgets/ImageUpload/ImageCropModal.mjs +76 -0
  4. package/dist/components/widgets/ImageUpload/ImageCropModal.mjs.map +1 -0
  5. package/dist/components/widgets/ImageUpload/ImageUpload.mjs +121 -0
  6. package/dist/components/widgets/ImageUpload/ImageUpload.mjs.map +1 -0
  7. package/dist/components/widgets/ImageUpload/index.mjs +7 -0
  8. package/dist/components/widgets/ImageUpload/index.mjs.map +1 -0
  9. package/dist/index.mjs +29 -1
  10. package/dist/index.mjs.map +1 -1
  11. package/dist/infra/utils/date.mjs +11 -0
  12. package/dist/infra/utils/date.mjs.map +1 -1
  13. package/dist/modules/images/actions/process-image.action.mjs +31 -0
  14. package/dist/modules/images/actions/process-image.action.mjs.map +1 -0
  15. package/dist/modules/images/constants/image.constants.mjs +14 -0
  16. package/dist/modules/images/constants/image.constants.mjs.map +1 -0
  17. package/dist/modules/images/hooks/use-image-upload.hook.mjs +96 -0
  18. package/dist/modules/images/hooks/use-image-upload.hook.mjs.map +1 -0
  19. package/dist/modules/images/services/image-processing.service.mjs +70 -0
  20. package/dist/modules/images/services/image-processing.service.mjs.map +1 -0
  21. package/dist/modules/images/types/image.type.mjs +1 -0
  22. package/dist/modules/images/types/image.type.mjs.map +1 -0
  23. package/dist/modules/images/utils/compress-image.mjs +18 -0
  24. package/dist/modules/images/utils/compress-image.mjs.map +1 -0
  25. package/dist/modules/images/utils/crop-image.mjs +29 -0
  26. package/dist/modules/images/utils/crop-image.mjs.map +1 -0
  27. package/dist/modules/images/utils/validate-image.mjs +50 -0
  28. package/dist/modules/images/utils/validate-image.mjs.map +1 -0
  29. package/dist/modules/plans/services/plans.service.mjs +15 -11
  30. package/dist/modules/plans/services/plans.service.mjs.map +1 -1
  31. package/dist/server.mjs +4 -0
  32. package/dist/server.mjs.map +1 -1
  33. package/package.json +4 -1
  34. package/src/components/modals/cards/AddCardModal.tsx +2 -1
  35. package/src/components/widgets/ImageUpload/ImageCropModal.tsx +97 -0
  36. package/src/components/widgets/ImageUpload/ImageUpload.tsx +125 -0
  37. package/src/components/widgets/ImageUpload/index.ts +2 -0
  38. package/src/index.ts +24 -1
  39. package/src/infra/utils/date.ts +11 -0
  40. package/src/modules/images/actions/process-image.action.ts +38 -0
  41. package/src/modules/images/constants/image.constants.ts +9 -0
  42. package/src/modules/images/hooks/use-image-upload.hook.ts +111 -0
  43. package/src/modules/images/services/image-processing.service.ts +76 -0
  44. package/src/modules/images/types/image.type.ts +54 -0
  45. package/src/modules/images/utils/compress-image.ts +21 -0
  46. package/src/modules/images/utils/crop-image.ts +41 -0
  47. package/src/modules/images/utils/validate-image.ts +59 -0
  48. package/src/modules/plans/services/plans.service.ts +48 -38
  49. 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
+ }
@@ -0,0 +1,2 @@
1
+ export { ImageUpload } from './ImageUpload'
2
+ export { ImageCropModal } from './ImageCropModal'
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";
@@ -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,9 @@
1
+ export const ACCEPTED_IMAGE_FORMATS = [
2
+ 'image/png',
3
+ 'image/jpeg',
4
+ 'image/webp',
5
+ 'image/gif',
6
+ ] as const
7
+
8
+ export const MAX_FILE_SIZE_MB = 10
9
+ export const TARGET_FILE_SIZE_MB = 1
@@ -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
+ }