@greatapps/common 1.1.454 → 1.1.456
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/widgets/ImageUpload/ImageCropModal.mjs +102 -28
- package/dist/components/widgets/ImageUpload/ImageCropModal.mjs.map +1 -1
- package/dist/modules/images/actions/process-image.action.mjs +1 -1
- package/dist/modules/images/actions/process-image.action.mjs.map +1 -1
- package/dist/modules/images/hooks/use-image-upload.hook.mjs +7 -30
- package/dist/modules/images/hooks/use-image-upload.hook.mjs.map +1 -1
- package/package.json +1 -1
- package/src/components/widgets/ImageUpload/ImageCropModal.tsx +86 -3
- package/src/modules/images/actions/process-image.action.ts +3 -1
- package/src/modules/images/hooks/use-image-upload.hook.ts +9 -32
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
"use client";
|
|
2
|
-
import { jsx, jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
3
3
|
import { useRef, useState } from "react";
|
|
4
4
|
import ReactCrop, {
|
|
5
5
|
centerCrop,
|
|
@@ -14,14 +14,73 @@ import {
|
|
|
14
14
|
DialogTitle
|
|
15
15
|
} from "../../ui/overlay/Dialog";
|
|
16
16
|
import { Button } from "../../ui/buttons/Button";
|
|
17
|
+
const baseCropStyles = `
|
|
18
|
+
.custom-crop-area .ReactCrop__crop-selection {
|
|
19
|
+
border: 2px solid white !important;
|
|
20
|
+
box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.5) !important;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/* Grid de 9 quadrados - linhas pontilhadas finas */
|
|
24
|
+
.custom-crop-area .ReactCrop__crop-selection .crop-grid {
|
|
25
|
+
position: absolute;
|
|
26
|
+
inset: 0;
|
|
27
|
+
pointer-events: none;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
.custom-crop-area .ReactCrop__crop-selection .crop-grid-line {
|
|
31
|
+
position: absolute;
|
|
32
|
+
background: repeating-linear-gradient(
|
|
33
|
+
to right,
|
|
34
|
+
rgba(255, 255, 255, 0.5) 0,
|
|
35
|
+
rgba(255, 255, 255, 0.5) 4px,
|
|
36
|
+
transparent 4px,
|
|
37
|
+
transparent 8px
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
.custom-crop-area .ReactCrop__crop-selection .crop-grid-line.horizontal {
|
|
42
|
+
left: 0;
|
|
43
|
+
right: 0;
|
|
44
|
+
height: 1px;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
.custom-crop-area .ReactCrop__crop-selection .crop-grid-line.vertical {
|
|
48
|
+
top: 0;
|
|
49
|
+
bottom: 0;
|
|
50
|
+
width: 1px;
|
|
51
|
+
background: repeating-linear-gradient(
|
|
52
|
+
to bottom,
|
|
53
|
+
rgba(255, 255, 255, 0.5) 0,
|
|
54
|
+
rgba(255, 255, 255, 0.5) 4px,
|
|
55
|
+
transparent 4px,
|
|
56
|
+
transparent 8px
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
.custom-crop-area .ReactCrop__crop-selection .crop-grid-line.h1 { top: 33.33%; }
|
|
61
|
+
.custom-crop-area .ReactCrop__crop-selection .crop-grid-line.h2 { top: 66.66%; }
|
|
62
|
+
.custom-crop-area .ReactCrop__crop-selection .crop-grid-line.v1 { left: 33.33%; }
|
|
63
|
+
.custom-crop-area .ReactCrop__crop-selection .crop-grid-line.v2 { left: 66.66%; }
|
|
64
|
+
|
|
65
|
+
/* C\xEDrculo inscrito */
|
|
66
|
+
.custom-crop-area.with-circle .ReactCrop__crop-selection .crop-circle {
|
|
67
|
+
position: absolute;
|
|
68
|
+
inset: 0;
|
|
69
|
+
border: 1px dashed rgba(255, 255, 255, 0.6);
|
|
70
|
+
border-radius: 50%;
|
|
71
|
+
pointer-events: none;
|
|
72
|
+
}
|
|
73
|
+
`;
|
|
17
74
|
function ImageCropModal({
|
|
18
75
|
open,
|
|
19
76
|
onOpenChange,
|
|
20
77
|
imageSrc,
|
|
21
78
|
aspectRatio,
|
|
22
|
-
onConfirm
|
|
79
|
+
onConfirm,
|
|
80
|
+
showCircleGuide
|
|
23
81
|
}) {
|
|
24
82
|
const imgRef = useRef(null);
|
|
83
|
+
const containerRef = useRef(null);
|
|
25
84
|
const [crop, setCrop] = useState();
|
|
26
85
|
const onImageLoad = (e) => {
|
|
27
86
|
const { naturalWidth, naturalHeight } = e.currentTarget;
|
|
@@ -42,33 +101,48 @@ function ImageCropModal({
|
|
|
42
101
|
onConfirm(imgRef.current, crop);
|
|
43
102
|
}
|
|
44
103
|
};
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
/* @__PURE__ */
|
|
48
|
-
|
|
49
|
-
{
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
104
|
+
const shouldShowCircle = showCircleGuide ?? aspectRatio === 1;
|
|
105
|
+
const renderSelectionAddon = () => /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
106
|
+
/* @__PURE__ */ jsxs("div", { className: "crop-grid", children: [
|
|
107
|
+
/* @__PURE__ */ jsx("div", { className: "crop-grid-line horizontal h1" }),
|
|
108
|
+
/* @__PURE__ */ jsx("div", { className: "crop-grid-line horizontal h2" }),
|
|
109
|
+
/* @__PURE__ */ jsx("div", { className: "crop-grid-line vertical v1" }),
|
|
110
|
+
/* @__PURE__ */ jsx("div", { className: "crop-grid-line vertical v2" })
|
|
111
|
+
] }),
|
|
112
|
+
shouldShowCircle && /* @__PURE__ */ jsx("div", { className: "crop-circle" })
|
|
113
|
+
] });
|
|
114
|
+
return /* @__PURE__ */ jsxs(Dialog, { open, onOpenChange, children: [
|
|
115
|
+
/* @__PURE__ */ jsx("style", { children: baseCropStyles }),
|
|
116
|
+
/* @__PURE__ */ jsxs(DialogContent, { className: "max-w-2xl", children: [
|
|
117
|
+
/* @__PURE__ */ jsx(DialogHeader, { children: /* @__PURE__ */ jsx(DialogTitle, { children: "Ajustar imagem" }) }),
|
|
118
|
+
/* @__PURE__ */ jsx("div", { ref: containerRef, className: "flex justify-center py-4 relative", children: /* @__PURE__ */ jsx(
|
|
119
|
+
ReactCrop,
|
|
120
|
+
{
|
|
121
|
+
crop,
|
|
122
|
+
onChange: (c) => setCrop(c),
|
|
123
|
+
aspect: aspectRatio,
|
|
124
|
+
className: `max-h-[60vh] custom-crop-area ${shouldShowCircle ? "with-circle" : ""}`,
|
|
125
|
+
keepSelection: true,
|
|
126
|
+
renderSelectionAddon,
|
|
127
|
+
children: /* @__PURE__ */ jsx(
|
|
128
|
+
"img",
|
|
129
|
+
{
|
|
130
|
+
ref: imgRef,
|
|
131
|
+
src: imageSrc,
|
|
132
|
+
alt: "Crop",
|
|
133
|
+
onLoad: onImageLoad,
|
|
134
|
+
className: "max-h-[60vh] object-contain"
|
|
135
|
+
}
|
|
136
|
+
)
|
|
137
|
+
}
|
|
138
|
+
) }),
|
|
139
|
+
/* @__PURE__ */ jsx("p", { className: "paragraph-small-regular text-zinc-500 text-center", children: "Arraste para ajustar a \xE1rea de corte" }),
|
|
140
|
+
/* @__PURE__ */ jsxs(DialogFooter, { children: [
|
|
141
|
+
/* @__PURE__ */ jsx(Button, { variant: "secondary", onClick: () => onOpenChange(false), children: "Cancelar" }),
|
|
142
|
+
/* @__PURE__ */ jsx(Button, { onClick: handleConfirm, children: "Confirmar" })
|
|
143
|
+
] })
|
|
70
144
|
] })
|
|
71
|
-
] })
|
|
145
|
+
] });
|
|
72
146
|
}
|
|
73
147
|
export {
|
|
74
148
|
ImageCropModal
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../src/components/widgets/ImageUpload/ImageCropModal.tsx"],"sourcesContent":["'use client'\n\nimport { useRef, useState } from 'react'\nimport ReactCrop, {\n type Crop,\n centerCrop,\n makeAspectCrop,\n} from 'react-image-crop'\nimport 'react-image-crop/dist/ReactCrop.css'\nimport {\n Dialog,\n DialogContent,\n DialogFooter,\n DialogHeader,\n DialogTitle,\n} from '../../ui/overlay/Dialog'\nimport { Button } from '../../ui/buttons/Button'\n\ninterface ImageCropModalProps {\n open: boolean\n onOpenChange: (open: boolean) => void\n imageSrc: string\n aspectRatio: number\n onConfirm: (image: HTMLImageElement, crop: Crop) => void\n}\n\nexport function ImageCropModal({\n open,\n onOpenChange,\n imageSrc,\n aspectRatio,\n onConfirm,\n}: ImageCropModalProps) {\n const imgRef = useRef<HTMLImageElement>(null)\n const [crop, setCrop] = useState<Crop>()\n\n const onImageLoad = (e: React.SyntheticEvent<HTMLImageElement>) => {\n const { naturalWidth, naturalHeight } = e.currentTarget\n\n const initialCrop = centerCrop(\n makeAspectCrop(\n { unit: '%', width: 90 },\n aspectRatio,\n naturalWidth,\n naturalHeight\n ),\n naturalWidth,\n naturalHeight\n )\n\n setCrop(initialCrop)\n }\n\n const handleConfirm = () => {\n if (imgRef.current && crop) {\n onConfirm(imgRef.current, crop)\n }\n }\n\n return (\n <Dialog open={open} onOpenChange={onOpenChange}>\n <DialogContent className=\"max-w-2xl\">\n <DialogHeader>\n <DialogTitle>Ajustar imagem</DialogTitle>\n </DialogHeader>\n\n <div className=\"flex justify-center py-4\">\n <ReactCrop\n crop={crop}\n onChange={(c) => setCrop(c)}\n aspect={aspectRatio}\n className
|
|
1
|
+
{"version":3,"sources":["../../../../src/components/widgets/ImageUpload/ImageCropModal.tsx"],"sourcesContent":["'use client'\n\nimport { useRef, useState } from 'react'\nimport ReactCrop, {\n type Crop,\n centerCrop,\n makeAspectCrop,\n} from 'react-image-crop'\nimport 'react-image-crop/dist/ReactCrop.css'\nimport {\n Dialog,\n DialogContent,\n DialogFooter,\n DialogHeader,\n DialogTitle,\n} from '../../ui/overlay/Dialog'\nimport { Button } from '../../ui/buttons/Button'\n\n// CSS customizado para grid pontilhado fino e círculo\nconst baseCropStyles = `\n .custom-crop-area .ReactCrop__crop-selection {\n border: 2px solid white !important;\n box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.5) !important;\n }\n\n /* Grid de 9 quadrados - linhas pontilhadas finas */\n .custom-crop-area .ReactCrop__crop-selection .crop-grid {\n position: absolute;\n inset: 0;\n pointer-events: none;\n }\n\n .custom-crop-area .ReactCrop__crop-selection .crop-grid-line {\n position: absolute;\n background: repeating-linear-gradient(\n to right,\n rgba(255, 255, 255, 0.5) 0,\n rgba(255, 255, 255, 0.5) 4px,\n transparent 4px,\n transparent 8px\n );\n }\n\n .custom-crop-area .ReactCrop__crop-selection .crop-grid-line.horizontal {\n left: 0;\n right: 0;\n height: 1px;\n }\n\n .custom-crop-area .ReactCrop__crop-selection .crop-grid-line.vertical {\n top: 0;\n bottom: 0;\n width: 1px;\n background: repeating-linear-gradient(\n to bottom,\n rgba(255, 255, 255, 0.5) 0,\n rgba(255, 255, 255, 0.5) 4px,\n transparent 4px,\n transparent 8px\n );\n }\n\n .custom-crop-area .ReactCrop__crop-selection .crop-grid-line.h1 { top: 33.33%; }\n .custom-crop-area .ReactCrop__crop-selection .crop-grid-line.h2 { top: 66.66%; }\n .custom-crop-area .ReactCrop__crop-selection .crop-grid-line.v1 { left: 33.33%; }\n .custom-crop-area .ReactCrop__crop-selection .crop-grid-line.v2 { left: 66.66%; }\n\n /* Círculo inscrito */\n .custom-crop-area.with-circle .ReactCrop__crop-selection .crop-circle {\n position: absolute;\n inset: 0;\n border: 1px dashed rgba(255, 255, 255, 0.6);\n border-radius: 50%;\n pointer-events: none;\n }\n`\n\ninterface ImageCropModalProps {\n open: boolean\n onOpenChange: (open: boolean) => void\n imageSrc: string\n aspectRatio: number\n onConfirm: (image: HTMLImageElement, crop: Crop) => void\n showCircleGuide?: boolean\n}\n\nexport function ImageCropModal({\n open,\n onOpenChange,\n imageSrc,\n aspectRatio,\n onConfirm,\n showCircleGuide,\n}: ImageCropModalProps) {\n const imgRef = useRef<HTMLImageElement>(null)\n const containerRef = useRef<HTMLDivElement>(null)\n const [crop, setCrop] = useState<Crop>()\n\n const onImageLoad = (e: React.SyntheticEvent<HTMLImageElement>) => {\n const { naturalWidth, naturalHeight } = e.currentTarget\n\n const initialCrop = centerCrop(\n makeAspectCrop(\n { unit: '%', width: 90 },\n aspectRatio,\n naturalWidth,\n naturalHeight\n ),\n naturalWidth,\n naturalHeight\n )\n\n setCrop(initialCrop)\n }\n\n const handleConfirm = () => {\n if (imgRef.current && crop) {\n onConfirm(imgRef.current, crop)\n }\n }\n\n // Determina se deve mostrar círculo (aspect ratio 1:1 por padrão)\n const shouldShowCircle = showCircleGuide ?? aspectRatio === 1\n\n // Renderiza grid e círculo dentro da área de crop\n const renderSelectionAddon = () => (\n <>\n {/* Grid de 9 quadrados com linhas pontilhadas */}\n <div className=\"crop-grid\">\n <div className=\"crop-grid-line horizontal h1\" />\n <div className=\"crop-grid-line horizontal h2\" />\n <div className=\"crop-grid-line vertical v1\" />\n <div className=\"crop-grid-line vertical v2\" />\n </div>\n {/* Círculo inscrito */}\n {shouldShowCircle && <div className=\"crop-circle\" />}\n </>\n )\n\n return (\n <Dialog open={open} onOpenChange={onOpenChange}>\n <style>{baseCropStyles}</style>\n <DialogContent className=\"max-w-2xl\">\n <DialogHeader>\n <DialogTitle>Ajustar imagem</DialogTitle>\n </DialogHeader>\n\n <div ref={containerRef} className=\"flex justify-center py-4 relative\">\n <ReactCrop\n crop={crop}\n onChange={(c) => setCrop(c)}\n aspect={aspectRatio}\n className={`max-h-[60vh] custom-crop-area ${shouldShowCircle ? 'with-circle' : ''}`}\n keepSelection\n renderSelectionAddon={renderSelectionAddon}\n >\n <img\n ref={imgRef}\n src={imageSrc}\n alt=\"Crop\"\n onLoad={onImageLoad}\n className=\"max-h-[60vh] object-contain\"\n />\n </ReactCrop>\n </div>\n\n <p className=\"paragraph-small-regular text-zinc-500 text-center\">\n Arraste para ajustar a área de corte\n </p>\n\n <DialogFooter>\n <Button variant=\"secondary\" onClick={() => onOpenChange(false)}>\n Cancelar\n </Button>\n <Button onClick={handleConfirm}>Confirmar</Button>\n </DialogFooter>\n </DialogContent>\n </Dialog>\n )\n}\n"],"mappings":";AA8HI,mBAGI,KADF,YAFF;AA5HJ,SAAS,QAAQ,gBAAgB;AACjC,OAAO;AAAA,EAEL;AAAA,EACA;AAAA,OACK;AACP,OAAO;AACP;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AACP,SAAS,cAAc;AAGvB,MAAM,iBAAiB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAmEhB,SAAS,eAAe;AAAA,EAC7B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,GAAwB;AACtB,QAAM,SAAS,OAAyB,IAAI;AAC5C,QAAM,eAAe,OAAuB,IAAI;AAChD,QAAM,CAAC,MAAM,OAAO,IAAI,SAAe;AAEvC,QAAM,cAAc,CAAC,MAA8C;AACjE,UAAM,EAAE,cAAc,cAAc,IAAI,EAAE;AAE1C,UAAM,cAAc;AAAA,MAClB;AAAA,QACE,EAAE,MAAM,KAAK,OAAO,GAAG;AAAA,QACvB;AAAA,QACA;AAAA,QACA;AAAA,MACF;AAAA,MACA;AAAA,MACA;AAAA,IACF;AAEA,YAAQ,WAAW;AAAA,EACrB;AAEA,QAAM,gBAAgB,MAAM;AAC1B,QAAI,OAAO,WAAW,MAAM;AAC1B,gBAAU,OAAO,SAAS,IAAI;AAAA,IAChC;AAAA,EACF;AAGA,QAAM,mBAAmB,mBAAmB,gBAAgB;AAG5D,QAAM,uBAAuB,MAC3B,iCAEE;AAAA,yBAAC,SAAI,WAAU,aACb;AAAA,0BAAC,SAAI,WAAU,gCAA+B;AAAA,MAC9C,oBAAC,SAAI,WAAU,gCAA+B;AAAA,MAC9C,oBAAC,SAAI,WAAU,8BAA6B;AAAA,MAC5C,oBAAC,SAAI,WAAU,8BAA6B;AAAA,OAC9C;AAAA,IAEC,oBAAoB,oBAAC,SAAI,WAAU,eAAc;AAAA,KACpD;AAGF,SACE,qBAAC,UAAO,MAAY,cAClB;AAAA,wBAAC,WAAO,0BAAe;AAAA,IACvB,qBAAC,iBAAc,WAAU,aACvB;AAAA,0BAAC,gBACC,8BAAC,eAAY,4BAAc,GAC7B;AAAA,MAEA,oBAAC,SAAI,KAAK,cAAc,WAAU,qCAChC;AAAA,QAAC;AAAA;AAAA,UACC;AAAA,UACA,UAAU,CAAC,MAAM,QAAQ,CAAC;AAAA,UAC1B,QAAQ;AAAA,UACR,WAAW,iCAAiC,mBAAmB,gBAAgB,EAAE;AAAA,UACjF,eAAa;AAAA,UACb;AAAA,UAEA;AAAA,YAAC;AAAA;AAAA,cACC,KAAK;AAAA,cACL,KAAK;AAAA,cACL,KAAI;AAAA,cACJ,QAAQ;AAAA,cACR,WAAU;AAAA;AAAA,UACZ;AAAA;AAAA,MACF,GACF;AAAA,MAEA,oBAAC,OAAE,WAAU,qDAAoD,qDAEjE;AAAA,MAEA,qBAAC,gBACC;AAAA,4BAAC,UAAO,SAAQ,aAAY,SAAS,MAAM,aAAa,KAAK,GAAG,sBAEhE;AAAA,QACA,oBAAC,UAAO,SAAS,eAAe,uBAAS;AAAA,SAC3C;AAAA,OACF;AAAA,KACF;AAEJ;","names":[]}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use server";
|
|
2
|
-
import { processImage } from "../services/image-processing.service";
|
|
3
2
|
async function processImageAction(input) {
|
|
4
3
|
try {
|
|
4
|
+
const { processImage } = await import("../services/image-processing.service");
|
|
5
5
|
const base64Data = input.base64Image.replace(/^data:image\/\w+;base64,/, "");
|
|
6
6
|
const binaryString = atob(base64Data);
|
|
7
7
|
const bytes = new Uint8Array(binaryString.length);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../src/modules/images/actions/process-image.action.ts"],"sourcesContent":["'use server'\n\nimport
|
|
1
|
+
{"version":3,"sources":["../../../../src/modules/images/actions/process-image.action.ts"],"sourcesContent":["'use server'\n\nimport type {\n ProcessImageActionInput,\n ProcessImageActionOutput,\n} from '../types/image.type'\n\nexport async function processImageAction(\n input: ProcessImageActionInput\n): Promise<ProcessImageActionOutput> {\n try {\n // Import dinâmico para evitar erro de fs.createReadStream no edge runtime\n const { processImage } = await import('../services/image-processing.service')\n\n const base64Data = input.base64Image.replace(/^data:image\\/\\w+;base64,/, '')\n const binaryString = atob(base64Data)\n const bytes = new Uint8Array(binaryString.length)\n for (let i = 0; i < binaryString.length; i++) {\n bytes[i] = binaryString.charCodeAt(i)\n }\n\n const result = await processImage({\n imageBytes: bytes,\n targetWidth: input.targetWidth,\n targetHeight: input.targetHeight,\n })\n\n const base64Output = btoa(String.fromCharCode(...result.bytes))\n\n return {\n success: true,\n base64Image: `data:${result.format};base64,${base64Output}`,\n }\n } catch (error) {\n return {\n success: false,\n error: error instanceof Error ? error.message : 'Erro ao processar imagem',\n }\n }\n}\n"],"mappings":";AAOA,eAAsB,mBACpB,OACmC;AACnC,MAAI;AAEF,UAAM,EAAE,aAAa,IAAI,MAAM,OAAO,sCAAsC;AAE5E,UAAM,aAAa,MAAM,YAAY,QAAQ,4BAA4B,EAAE;AAC3E,UAAM,eAAe,KAAK,UAAU;AACpC,UAAM,QAAQ,IAAI,WAAW,aAAa,MAAM;AAChD,aAAS,IAAI,GAAG,IAAI,aAAa,QAAQ,KAAK;AAC5C,YAAM,CAAC,IAAI,aAAa,WAAW,CAAC;AAAA,IACtC;AAEA,UAAM,SAAS,MAAM,aAAa;AAAA,MAChC,YAAY;AAAA,MACZ,aAAa,MAAM;AAAA,MACnB,cAAc,MAAM;AAAA,IACtB,CAAC;AAED,UAAM,eAAe,KAAK,OAAO,aAAa,GAAG,OAAO,KAAK,CAAC;AAE9D,WAAO;AAAA,MACL,SAAS;AAAA,MACT,aAAa,QAAQ,OAAO,MAAM,WAAW,YAAY;AAAA,IAC3D;AAAA,EACF,SAAS,OAAO;AACd,WAAO;AAAA,MACL,SAAS;AAAA,MACT,OAAO,iBAAiB,QAAQ,MAAM,UAAU;AAAA,IAClD;AAAA,EACF;AACF;","names":[]}
|
|
@@ -1,29 +1,14 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { useState, useCallback } from "react";
|
|
3
|
-
import { useMutation } from "@tanstack/react-query";
|
|
4
3
|
import { compressImage } from "../utils/compress-image";
|
|
5
4
|
import { cropImageToCanvas } from "../utils/crop-image";
|
|
6
5
|
import { validateImage } from "../utils/validate-image";
|
|
7
|
-
import { processImageAction } from "../actions/process-image.action";
|
|
8
6
|
function useImageUpload(options) {
|
|
9
7
|
const { config, onSuccess, onError } = options;
|
|
10
8
|
const [originalImage, setOriginalImage] = useState(null);
|
|
11
9
|
const [croppedPreview, setCroppedPreview] = useState(null);
|
|
12
10
|
const [showCropModal, setShowCropModal] = useState(false);
|
|
13
|
-
const
|
|
14
|
-
mutationFn: processImageAction,
|
|
15
|
-
onSuccess: (result) => {
|
|
16
|
-
if (result.success && result.base64Image) {
|
|
17
|
-
setCroppedPreview(result.base64Image);
|
|
18
|
-
onSuccess?.(result.base64Image);
|
|
19
|
-
} else {
|
|
20
|
-
onError?.(result.error || "Erro desconhecido");
|
|
21
|
-
}
|
|
22
|
-
},
|
|
23
|
-
onError: (error) => {
|
|
24
|
-
onError?.(error instanceof Error ? error.message : "Erro ao processar");
|
|
25
|
-
}
|
|
26
|
-
});
|
|
11
|
+
const [isProcessing, setIsProcessing] = useState(false);
|
|
27
12
|
const handleFileSelect = useCallback(
|
|
28
13
|
async (file) => {
|
|
29
14
|
const validation = validateImage(file);
|
|
@@ -47,6 +32,7 @@ function useImageUpload(options) {
|
|
|
47
32
|
);
|
|
48
33
|
const handleCropConfirm = useCallback(
|
|
49
34
|
async (imageElement, cropArea) => {
|
|
35
|
+
setIsProcessing(true);
|
|
50
36
|
try {
|
|
51
37
|
const croppedBase64 = await cropImageToCanvas(
|
|
52
38
|
imageElement,
|
|
@@ -56,23 +42,14 @@ function useImageUpload(options) {
|
|
|
56
42
|
);
|
|
57
43
|
setCroppedPreview(croppedBase64);
|
|
58
44
|
setShowCropModal(false);
|
|
59
|
-
|
|
60
|
-
base64Image: croppedBase64,
|
|
61
|
-
targetWidth: config.width,
|
|
62
|
-
targetHeight: config.height,
|
|
63
|
-
cropArea: {
|
|
64
|
-
x: cropArea.x,
|
|
65
|
-
y: cropArea.y,
|
|
66
|
-
width: cropArea.width,
|
|
67
|
-
height: cropArea.height,
|
|
68
|
-
unit: cropArea.unit
|
|
69
|
-
}
|
|
70
|
-
});
|
|
45
|
+
onSuccess?.(croppedBase64);
|
|
71
46
|
} catch {
|
|
72
47
|
onError?.("Erro ao aplicar crop");
|
|
48
|
+
} finally {
|
|
49
|
+
setIsProcessing(false);
|
|
73
50
|
}
|
|
74
51
|
},
|
|
75
|
-
[config,
|
|
52
|
+
[config, onSuccess, onError]
|
|
76
53
|
);
|
|
77
54
|
const clear = useCallback(() => {
|
|
78
55
|
setOriginalImage(null);
|
|
@@ -83,7 +60,7 @@ function useImageUpload(options) {
|
|
|
83
60
|
croppedPreview,
|
|
84
61
|
showCropModal,
|
|
85
62
|
config,
|
|
86
|
-
isProcessing
|
|
63
|
+
isProcessing,
|
|
87
64
|
handleFileSelect,
|
|
88
65
|
handleCropConfirm,
|
|
89
66
|
setShowCropModal,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../src/modules/images/hooks/use-image-upload.hook.ts"],"sourcesContent":["'use client'\n\nimport { useState, useCallback } from 'react'\nimport
|
|
1
|
+
{"version":3,"sources":["../../../../src/modules/images/hooks/use-image-upload.hook.ts"],"sourcesContent":["'use client'\n\nimport { useState, useCallback } from 'react'\nimport type { Crop } from 'react-image-crop'\nimport { compressImage } from '../utils/compress-image'\nimport { cropImageToCanvas } from '../utils/crop-image'\nimport { validateImage } from '../utils/validate-image'\nimport type { ImageConfig } from '../types/image.type'\n\nexport interface UseImageUploadOptions {\n config: ImageConfig\n onSuccess?: (base64Image: string) => void\n onError?: (error: string) => void\n}\n\nexport function useImageUpload(options: UseImageUploadOptions) {\n const { config, onSuccess, onError } = options\n\n const [originalImage, setOriginalImage] = useState<string | null>(null)\n const [croppedPreview, setCroppedPreview] = useState<string | null>(null)\n const [showCropModal, setShowCropModal] = useState(false)\n const [isProcessing, setIsProcessing] = useState(false)\n\n const handleFileSelect = useCallback(\n async (file: File) => {\n const validation = validateImage(file)\n if (!validation.valid) {\n onError?.(validation.error || 'Arquivo inválido')\n return\n }\n\n try {\n const compressed = await compressImage(file)\n const reader = new FileReader()\n reader.onload = (e) => {\n setOriginalImage(e.target?.result as string)\n setShowCropModal(true)\n }\n reader.readAsDataURL(compressed)\n } catch {\n onError?.('Erro ao processar imagem')\n }\n },\n [onError]\n )\n\n const handleCropConfirm = useCallback(\n async (imageElement: HTMLImageElement, cropArea: Crop) => {\n setIsProcessing(true)\n try {\n // Crop é feito inteiramente no cliente via Canvas\n // Já produz imagem no tamanho final (config.width x config.height)\n const croppedBase64 = await cropImageToCanvas(\n imageElement,\n cropArea,\n config.width,\n config.height\n )\n\n setCroppedPreview(croppedBase64)\n setShowCropModal(false)\n onSuccess?.(croppedBase64)\n } catch {\n onError?.('Erro ao aplicar crop')\n } finally {\n setIsProcessing(false)\n }\n },\n [config, onSuccess, onError]\n )\n\n const clear = useCallback(() => {\n setOriginalImage(null)\n setCroppedPreview(null)\n }, [])\n\n return {\n originalImage,\n croppedPreview,\n showCropModal,\n config,\n isProcessing,\n handleFileSelect,\n handleCropConfirm,\n setShowCropModal,\n clear,\n }\n}\n"],"mappings":";AAEA,SAAS,UAAU,mBAAmB;AAEtC,SAAS,qBAAqB;AAC9B,SAAS,yBAAyB;AAClC,SAAS,qBAAqB;AASvB,SAAS,eAAe,SAAgC;AAC7D,QAAM,EAAE,QAAQ,WAAW,QAAQ,IAAI;AAEvC,QAAM,CAAC,eAAe,gBAAgB,IAAI,SAAwB,IAAI;AACtE,QAAM,CAAC,gBAAgB,iBAAiB,IAAI,SAAwB,IAAI;AACxE,QAAM,CAAC,eAAe,gBAAgB,IAAI,SAAS,KAAK;AACxD,QAAM,CAAC,cAAc,eAAe,IAAI,SAAS,KAAK;AAEtD,QAAM,mBAAmB;AAAA,IACvB,OAAO,SAAe;AACpB,YAAM,aAAa,cAAc,IAAI;AACrC,UAAI,CAAC,WAAW,OAAO;AACrB,kBAAU,WAAW,SAAS,qBAAkB;AAChD;AAAA,MACF;AAEA,UAAI;AACF,cAAM,aAAa,MAAM,cAAc,IAAI;AAC3C,cAAM,SAAS,IAAI,WAAW;AAC9B,eAAO,SAAS,CAAC,MAAM;AACrB,2BAAiB,EAAE,QAAQ,MAAgB;AAC3C,2BAAiB,IAAI;AAAA,QACvB;AACA,eAAO,cAAc,UAAU;AAAA,MACjC,QAAQ;AACN,kBAAU,0BAA0B;AAAA,MACtC;AAAA,IACF;AAAA,IACA,CAAC,OAAO;AAAA,EACV;AAEA,QAAM,oBAAoB;AAAA,IACxB,OAAO,cAAgC,aAAmB;AACxD,sBAAgB,IAAI;AACpB,UAAI;AAGF,cAAM,gBAAgB,MAAM;AAAA,UAC1B;AAAA,UACA;AAAA,UACA,OAAO;AAAA,UACP,OAAO;AAAA,QACT;AAEA,0BAAkB,aAAa;AAC/B,yBAAiB,KAAK;AACtB,oBAAY,aAAa;AAAA,MAC3B,QAAQ;AACN,kBAAU,sBAAsB;AAAA,MAClC,UAAE;AACA,wBAAgB,KAAK;AAAA,MACvB;AAAA,IACF;AAAA,IACA,CAAC,QAAQ,WAAW,OAAO;AAAA,EAC7B;AAEA,QAAM,QAAQ,YAAY,MAAM;AAC9B,qBAAiB,IAAI;AACrB,sBAAkB,IAAI;AAAA,EACxB,GAAG,CAAC,CAAC;AAEL,SAAO;AAAA,IACL;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,EACF;AACF;","names":[]}
|
package/package.json
CHANGED
|
@@ -16,12 +16,72 @@ import {
|
|
|
16
16
|
} from '../../ui/overlay/Dialog'
|
|
17
17
|
import { Button } from '../../ui/buttons/Button'
|
|
18
18
|
|
|
19
|
+
// CSS customizado para grid pontilhado fino e círculo
|
|
20
|
+
const baseCropStyles = `
|
|
21
|
+
.custom-crop-area .ReactCrop__crop-selection {
|
|
22
|
+
border: 2px solid white !important;
|
|
23
|
+
box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.5) !important;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/* Grid de 9 quadrados - linhas pontilhadas finas */
|
|
27
|
+
.custom-crop-area .ReactCrop__crop-selection .crop-grid {
|
|
28
|
+
position: absolute;
|
|
29
|
+
inset: 0;
|
|
30
|
+
pointer-events: none;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
.custom-crop-area .ReactCrop__crop-selection .crop-grid-line {
|
|
34
|
+
position: absolute;
|
|
35
|
+
background: repeating-linear-gradient(
|
|
36
|
+
to right,
|
|
37
|
+
rgba(255, 255, 255, 0.5) 0,
|
|
38
|
+
rgba(255, 255, 255, 0.5) 4px,
|
|
39
|
+
transparent 4px,
|
|
40
|
+
transparent 8px
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
.custom-crop-area .ReactCrop__crop-selection .crop-grid-line.horizontal {
|
|
45
|
+
left: 0;
|
|
46
|
+
right: 0;
|
|
47
|
+
height: 1px;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
.custom-crop-area .ReactCrop__crop-selection .crop-grid-line.vertical {
|
|
51
|
+
top: 0;
|
|
52
|
+
bottom: 0;
|
|
53
|
+
width: 1px;
|
|
54
|
+
background: repeating-linear-gradient(
|
|
55
|
+
to bottom,
|
|
56
|
+
rgba(255, 255, 255, 0.5) 0,
|
|
57
|
+
rgba(255, 255, 255, 0.5) 4px,
|
|
58
|
+
transparent 4px,
|
|
59
|
+
transparent 8px
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
.custom-crop-area .ReactCrop__crop-selection .crop-grid-line.h1 { top: 33.33%; }
|
|
64
|
+
.custom-crop-area .ReactCrop__crop-selection .crop-grid-line.h2 { top: 66.66%; }
|
|
65
|
+
.custom-crop-area .ReactCrop__crop-selection .crop-grid-line.v1 { left: 33.33%; }
|
|
66
|
+
.custom-crop-area .ReactCrop__crop-selection .crop-grid-line.v2 { left: 66.66%; }
|
|
67
|
+
|
|
68
|
+
/* Círculo inscrito */
|
|
69
|
+
.custom-crop-area.with-circle .ReactCrop__crop-selection .crop-circle {
|
|
70
|
+
position: absolute;
|
|
71
|
+
inset: 0;
|
|
72
|
+
border: 1px dashed rgba(255, 255, 255, 0.6);
|
|
73
|
+
border-radius: 50%;
|
|
74
|
+
pointer-events: none;
|
|
75
|
+
}
|
|
76
|
+
`
|
|
77
|
+
|
|
19
78
|
interface ImageCropModalProps {
|
|
20
79
|
open: boolean
|
|
21
80
|
onOpenChange: (open: boolean) => void
|
|
22
81
|
imageSrc: string
|
|
23
82
|
aspectRatio: number
|
|
24
83
|
onConfirm: (image: HTMLImageElement, crop: Crop) => void
|
|
84
|
+
showCircleGuide?: boolean
|
|
25
85
|
}
|
|
26
86
|
|
|
27
87
|
export function ImageCropModal({
|
|
@@ -30,8 +90,10 @@ export function ImageCropModal({
|
|
|
30
90
|
imageSrc,
|
|
31
91
|
aspectRatio,
|
|
32
92
|
onConfirm,
|
|
93
|
+
showCircleGuide,
|
|
33
94
|
}: ImageCropModalProps) {
|
|
34
95
|
const imgRef = useRef<HTMLImageElement>(null)
|
|
96
|
+
const containerRef = useRef<HTMLDivElement>(null)
|
|
35
97
|
const [crop, setCrop] = useState<Crop>()
|
|
36
98
|
|
|
37
99
|
const onImageLoad = (e: React.SyntheticEvent<HTMLImageElement>) => {
|
|
@@ -57,19 +119,40 @@ export function ImageCropModal({
|
|
|
57
119
|
}
|
|
58
120
|
}
|
|
59
121
|
|
|
122
|
+
// Determina se deve mostrar círculo (aspect ratio 1:1 por padrão)
|
|
123
|
+
const shouldShowCircle = showCircleGuide ?? aspectRatio === 1
|
|
124
|
+
|
|
125
|
+
// Renderiza grid e círculo dentro da área de crop
|
|
126
|
+
const renderSelectionAddon = () => (
|
|
127
|
+
<>
|
|
128
|
+
{/* Grid de 9 quadrados com linhas pontilhadas */}
|
|
129
|
+
<div className="crop-grid">
|
|
130
|
+
<div className="crop-grid-line horizontal h1" />
|
|
131
|
+
<div className="crop-grid-line horizontal h2" />
|
|
132
|
+
<div className="crop-grid-line vertical v1" />
|
|
133
|
+
<div className="crop-grid-line vertical v2" />
|
|
134
|
+
</div>
|
|
135
|
+
{/* Círculo inscrito */}
|
|
136
|
+
{shouldShowCircle && <div className="crop-circle" />}
|
|
137
|
+
</>
|
|
138
|
+
)
|
|
139
|
+
|
|
60
140
|
return (
|
|
61
141
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
142
|
+
<style>{baseCropStyles}</style>
|
|
62
143
|
<DialogContent className="max-w-2xl">
|
|
63
144
|
<DialogHeader>
|
|
64
145
|
<DialogTitle>Ajustar imagem</DialogTitle>
|
|
65
146
|
</DialogHeader>
|
|
66
147
|
|
|
67
|
-
<div className="flex justify-center py-4">
|
|
148
|
+
<div ref={containerRef} className="flex justify-center py-4 relative">
|
|
68
149
|
<ReactCrop
|
|
69
150
|
crop={crop}
|
|
70
151
|
onChange={(c) => setCrop(c)}
|
|
71
152
|
aspect={aspectRatio}
|
|
72
|
-
className=
|
|
153
|
+
className={`max-h-[60vh] custom-crop-area ${shouldShowCircle ? 'with-circle' : ''}`}
|
|
154
|
+
keepSelection
|
|
155
|
+
renderSelectionAddon={renderSelectionAddon}
|
|
73
156
|
>
|
|
74
157
|
<img
|
|
75
158
|
ref={imgRef}
|
|
@@ -81,7 +164,7 @@ export function ImageCropModal({
|
|
|
81
164
|
</ReactCrop>
|
|
82
165
|
</div>
|
|
83
166
|
|
|
84
|
-
<p className="paragraph-small-regular text-
|
|
167
|
+
<p className="paragraph-small-regular text-zinc-500 text-center">
|
|
85
168
|
Arraste para ajustar a área de corte
|
|
86
169
|
</p>
|
|
87
170
|
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
'use server'
|
|
2
2
|
|
|
3
|
-
import { processImage } from '../services/image-processing.service'
|
|
4
3
|
import type {
|
|
5
4
|
ProcessImageActionInput,
|
|
6
5
|
ProcessImageActionOutput,
|
|
@@ -10,6 +9,9 @@ export async function processImageAction(
|
|
|
10
9
|
input: ProcessImageActionInput
|
|
11
10
|
): Promise<ProcessImageActionOutput> {
|
|
12
11
|
try {
|
|
12
|
+
// Import dinâmico para evitar erro de fs.createReadStream no edge runtime
|
|
13
|
+
const { processImage } = await import('../services/image-processing.service')
|
|
14
|
+
|
|
13
15
|
const base64Data = input.base64Image.replace(/^data:image\/\w+;base64,/, '')
|
|
14
16
|
const binaryString = atob(base64Data)
|
|
15
17
|
const bytes = new Uint8Array(binaryString.length)
|
|
@@ -1,12 +1,10 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
3
|
import { useState, useCallback } from 'react'
|
|
4
|
-
import { useMutation } from '@tanstack/react-query'
|
|
5
4
|
import type { Crop } from 'react-image-crop'
|
|
6
5
|
import { compressImage } from '../utils/compress-image'
|
|
7
6
|
import { cropImageToCanvas } from '../utils/crop-image'
|
|
8
7
|
import { validateImage } from '../utils/validate-image'
|
|
9
|
-
import { processImageAction } from '../actions/process-image.action'
|
|
10
8
|
import type { ImageConfig } from '../types/image.type'
|
|
11
9
|
|
|
12
10
|
export interface UseImageUploadOptions {
|
|
@@ -21,21 +19,7 @@ export function useImageUpload(options: UseImageUploadOptions) {
|
|
|
21
19
|
const [originalImage, setOriginalImage] = useState<string | null>(null)
|
|
22
20
|
const [croppedPreview, setCroppedPreview] = useState<string | null>(null)
|
|
23
21
|
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
|
-
})
|
|
22
|
+
const [isProcessing, setIsProcessing] = useState(false)
|
|
39
23
|
|
|
40
24
|
const handleFileSelect = useCallback(
|
|
41
25
|
async (file: File) => {
|
|
@@ -62,7 +46,10 @@ export function useImageUpload(options: UseImageUploadOptions) {
|
|
|
62
46
|
|
|
63
47
|
const handleCropConfirm = useCallback(
|
|
64
48
|
async (imageElement: HTMLImageElement, cropArea: Crop) => {
|
|
49
|
+
setIsProcessing(true)
|
|
65
50
|
try {
|
|
51
|
+
// Crop é feito inteiramente no cliente via Canvas
|
|
52
|
+
// Já produz imagem no tamanho final (config.width x config.height)
|
|
66
53
|
const croppedBase64 = await cropImageToCanvas(
|
|
67
54
|
imageElement,
|
|
68
55
|
cropArea,
|
|
@@ -72,24 +59,14 @@ export function useImageUpload(options: UseImageUploadOptions) {
|
|
|
72
59
|
|
|
73
60
|
setCroppedPreview(croppedBase64)
|
|
74
61
|
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
|
-
})
|
|
62
|
+
onSuccess?.(croppedBase64)
|
|
88
63
|
} catch {
|
|
89
64
|
onError?.('Erro ao aplicar crop')
|
|
65
|
+
} finally {
|
|
66
|
+
setIsProcessing(false)
|
|
90
67
|
}
|
|
91
68
|
},
|
|
92
|
-
[config,
|
|
69
|
+
[config, onSuccess, onError]
|
|
93
70
|
)
|
|
94
71
|
|
|
95
72
|
const clear = useCallback(() => {
|
|
@@ -102,7 +79,7 @@ export function useImageUpload(options: UseImageUploadOptions) {
|
|
|
102
79
|
croppedPreview,
|
|
103
80
|
showCropModal,
|
|
104
81
|
config,
|
|
105
|
-
isProcessing
|
|
82
|
+
isProcessing,
|
|
106
83
|
handleFileSelect,
|
|
107
84
|
handleCropConfirm,
|
|
108
85
|
setShowCropModal,
|