@greatapps/common 1.1.547 → 1.1.551

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.
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
3
- import { useRef, useState } from "react";
3
+ import { useEffect, useRef, useState } from "react";
4
4
  import ReactCrop, {
5
5
  centerCrop,
6
6
  makeAspectCrop
@@ -144,15 +144,45 @@ function ImageCropModal({
144
144
  onImageLoad: onImageLoadCallback,
145
145
  showCircleGuide,
146
146
  minWidth,
147
- minHeight
147
+ minHeight,
148
+ fileName,
149
+ onSelectAnother
148
150
  }) {
149
151
  const imgRef = useRef(null);
150
152
  const [crop, setCrop] = useState();
151
153
  const [imageSize, setImageSize] = useState({ width: 0, height: 0 });
152
- const onImageLoad = (e) => {
154
+ const [imageStatus, setImageStatus] = useState("loading");
155
+ const onImageLoadCallbackRef = useRef(onImageLoadCallback);
156
+ useEffect(() => {
157
+ onImageLoadCallbackRef.current = onImageLoadCallback;
158
+ });
159
+ useEffect(() => {
160
+ if (!open || !imageSrc) {
161
+ setImageStatus("loading");
162
+ return;
163
+ }
164
+ setImageStatus("loading");
165
+ let cancelled = false;
166
+ const img = new window.Image();
167
+ img.onload = () => {
168
+ if (cancelled) return;
169
+ const { naturalWidth, naturalHeight } = img;
170
+ setImageSize({ width: naturalWidth, height: naturalHeight });
171
+ onImageLoadCallbackRef.current?.(naturalWidth, naturalHeight);
172
+ const tooSmall = minWidth !== void 0 && naturalWidth < minWidth || minHeight !== void 0 && naturalHeight < minHeight;
173
+ setImageStatus(tooSmall ? "too-small" : "ok");
174
+ };
175
+ img.onerror = () => {
176
+ if (cancelled) return;
177
+ setImageStatus("too-small");
178
+ };
179
+ img.src = imageSrc;
180
+ return () => {
181
+ cancelled = true;
182
+ };
183
+ }, [open, imageSrc, minWidth, minHeight]);
184
+ const onCropImgLoad = (e) => {
153
185
  const { naturalWidth, naturalHeight } = e.currentTarget;
154
- setImageSize({ width: naturalWidth, height: naturalHeight });
155
- onImageLoadCallback?.(naturalWidth, naturalHeight);
156
186
  const initialCrop = centerCrop(
157
187
  makeAspectCrop(
158
188
  { unit: "%", width: 90 },
@@ -186,17 +216,35 @@ function ImageCropModal({
186
216
  ] }),
187
217
  shouldShowCircle && /* @__PURE__ */ jsx("div", { className: "crop-circle" })
188
218
  ] });
219
+ const dimensionText = minWidth !== void 0 && minHeight !== void 0 ? `${minWidth}x${minHeight}px` : null;
220
+ const isTooSmall = imageStatus === "too-small";
189
221
  return /* @__PURE__ */ jsxs(Dialog, { open, onOpenChange, children: [
190
222
  /* @__PURE__ */ jsx("style", { children: baseCropStyles }),
191
- /* @__PURE__ */ jsxs(
223
+ /* @__PURE__ */ jsx(
192
224
  DialogContent,
193
225
  {
194
- className: "w-full md:w-[650px]! lg:w-[650px]! max-w-[650px]! max-[650px]:w-[calc(100%-32px)] p-0 gap-0 z-[1003]!",
226
+ className: isTooSmall ? "w-full md:w-[480px]! lg:w-[480px]! max-w-[480px]! max-[480px]:w-[calc(100%-32px)] p-0 gap-0 z-[1003]!" : "w-full md:w-[650px]! lg:w-[650px]! max-w-[650px]! max-[650px]:w-[calc(100%-32px)] p-0 gap-0 z-[1003]!",
195
227
  overlayClassName: "z-[1002]!",
196
- children: [
228
+ children: isTooSmall ? /* @__PURE__ */ jsxs(Fragment, { children: [
229
+ /* @__PURE__ */ jsx(DialogHeader, { className: "p-5", children: /* @__PURE__ */ jsx(DialogTitle, { className: "text-center text-[16px]!", children: dimensionText ? `Foto menor que ${dimensionText}` : "Foto muito pequena" }) }),
230
+ /* @__PURE__ */ jsx("div", { className: "px-5 pb-5", children: /* @__PURE__ */ jsxs("p", { className: "paragraph-small-regular text-zinc-600 text-center", children: [
231
+ fileName ? `O arquivo "${fileName}"` : "A imagem selecionada",
232
+ " \xE9 menor",
233
+ dimensionText ? ` que ${dimensionText}` : " que o tamanho m\xEDnimo exigido",
234
+ ".",
235
+ " ",
236
+ "Fa\xE7a envio de uma imagem ",
237
+ dimensionText ? `com pelo menos ${dimensionText}` : "maior",
238
+ " para continuar."
239
+ ] }) }),
240
+ /* @__PURE__ */ jsxs(DialogFooter, { className: "flex-row! items-center justify-start! gap-3 p-5 border-t border-zinc-200", children: [
241
+ onSelectAnother && /* @__PURE__ */ jsx(Button, { onClick: onSelectAnother, children: "Enviar outra" }),
242
+ /* @__PURE__ */ jsx(Button, { variant: "secondary", onClick: handleCancel, children: "Cancelar" })
243
+ ] })
244
+ ] }) : /* @__PURE__ */ jsxs(Fragment, { children: [
197
245
  /* @__PURE__ */ jsx(DialogHeader, { className: "p-5", children: /* @__PURE__ */ jsx(DialogTitle, { className: "text-center text-[16px]!", children: "Ajustar imagem" }) }),
198
246
  /* @__PURE__ */ jsxs("div", { className: "flex flex-col gap-4 px-5 pb-5", children: [
199
- /* @__PURE__ */ jsx("div", { className: "flex justify-center overflow-hidden rounded-lg", children: /* @__PURE__ */ jsx(
247
+ /* @__PURE__ */ jsx("div", { className: "flex justify-center overflow-hidden rounded-lg min-h-[200px]", children: imageStatus === "ok" && /* @__PURE__ */ jsx(
200
248
  ReactCrop,
201
249
  {
202
250
  crop,
@@ -213,7 +261,7 @@ function ImageCropModal({
213
261
  ref: imgRef,
214
262
  src: imageSrc,
215
263
  alt: "Crop",
216
- onLoad: onImageLoad,
264
+ onLoad: onCropImgLoad,
217
265
  draggable: false,
218
266
  className: "max-h-[50vh] object-contain select-none"
219
267
  }
@@ -223,10 +271,10 @@ function ImageCropModal({
223
271
  /* @__PURE__ */ jsx("p", { className: "paragraph-small-regular text-zinc-500 text-center", children: "Arraste para ajustar a \xE1rea de corte" })
224
272
  ] }),
225
273
  /* @__PURE__ */ jsxs(DialogFooter, { className: "flex-row! items-center justify-start! gap-3 p-5 border-t border-zinc-200", children: [
226
- /* @__PURE__ */ jsx(Button, { onClick: handleConfirm, children: "Confirmar" }),
274
+ /* @__PURE__ */ jsx(Button, { onClick: handleConfirm, disabled: imageStatus !== "ok" || !crop, children: "Confirmar" }),
227
275
  /* @__PURE__ */ jsx(Button, { variant: "secondary", onClick: handleCancel, children: "Cancelar" })
228
276
  ] })
229
- ]
277
+ ] })
230
278
  }
231
279
  )
232
280
  ] });
@@ -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\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 /* Ocultar handles das laterais (manter só os 4 cantos) */\n .custom-crop-area .ReactCrop__drag-handle.ord-n,\n .custom-crop-area .ReactCrop__drag-handle.ord-s,\n .custom-crop-area .ReactCrop__drag-handle.ord-e,\n .custom-crop-area .ReactCrop__drag-handle.ord-w {\n display: none !important;\n }\n\n /* Estilizar handles dos cantos — indicador preto em L nos cantos */\n .custom-crop-area .ReactCrop__drag-handle.ord-nw,\n .custom-crop-area .ReactCrop__drag-handle.ord-ne,\n .custom-crop-area .ReactCrop__drag-handle.ord-sw,\n .custom-crop-area .ReactCrop__drag-handle.ord-se {\n width: 20px;\n height: 20px;\n background: transparent !important;\n border: none !important;\n }\n\n .custom-crop-area .ReactCrop__drag-handle.ord-nw::after,\n .custom-crop-area .ReactCrop__drag-handle.ord-ne::after,\n .custom-crop-area .ReactCrop__drag-handle.ord-sw::after,\n .custom-crop-area .ReactCrop__drag-handle.ord-se::after {\n content: '';\n position: absolute;\n width: 22px;\n height: 22px;\n border-color: white;\n border-style: solid;\n border-width: 0;\n }\n\n .custom-crop-area .ReactCrop__drag-handle.ord-nw::after {\n top: -2px; left: -2px;\n border-top-width: 4px;\n border-left-width: 4px;\n }\n\n .custom-crop-area .ReactCrop__drag-handle.ord-ne::after {\n top: -2px; right: -2px;\n border-top-width: 4px;\n border-right-width: 4px;\n }\n\n .custom-crop-area .ReactCrop__drag-handle.ord-sw::after {\n bottom: -2px; left: -2px;\n border-bottom-width: 4px;\n border-left-width: 4px;\n }\n\n .custom-crop-area .ReactCrop__drag-handle.ord-se::after {\n bottom: -2px; right: -2px;\n border-bottom-width: 4px;\n border-right-width: 4px;\n }\n\n /* Desabilitar drag da imagem de fundo */\n .custom-crop-area img {\n pointer-events: none;\n user-select: none;\n -webkit-user-drag: 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 onCancel?: () => void\n onImageLoad?: (naturalWidth: number, naturalHeight: number) => void\n showCircleGuide?: boolean\n minWidth?: number\n minHeight?: number\n}\n\nexport function ImageCropModal({\n open,\n onOpenChange,\n imageSrc,\n aspectRatio,\n onConfirm,\n onCancel,\n onImageLoad: onImageLoadCallback,\n showCircleGuide,\n minWidth,\n minHeight,\n}: ImageCropModalProps) {\n const imgRef = useRef<HTMLImageElement>(null)\n const [crop, setCrop] = useState<Crop>()\n const [imageSize, setImageSize] = useState({ width: 0, height: 0 })\n\n const onImageLoad = (e: React.SyntheticEvent<HTMLImageElement>) => {\n const { naturalWidth, naturalHeight } = e.currentTarget\n setImageSize({ width: naturalWidth, height: naturalHeight })\n\n // Notifica o hook para verificar dimensões mínimas\n onImageLoadCallback?.(naturalWidth, naturalHeight)\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 // Calcula tamanho mínimo em porcentagem baseado nas dimensões mínimas em pixels\n const minCropWidth = minWidth && imageSize.width > 0\n ? Math.min((minWidth / imageSize.width) * 100, 100)\n : undefined\n const minCropHeight = minHeight && imageSize.height > 0\n ? Math.min((minHeight / imageSize.height) * 100, 100)\n : undefined\n\n const handleConfirm = () => {\n if (imgRef.current && crop) {\n onConfirm(imgRef.current, crop)\n }\n }\n\n const handleCancel = () => {\n onCancel?.()\n onOpenChange(false)\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, círculo e bolinhas 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\n className=\"w-full md:w-[650px]! lg:w-[650px]! max-w-[650px]! max-[650px]:w-[calc(100%-32px)] p-0 gap-0 z-[1003]!\"\n overlayClassName=\"z-[1002]!\"\n >\n <DialogHeader className=\"p-5\">\n <DialogTitle className=\"text-center text-[16px]!\">\n Ajustar imagem\n </DialogTitle>\n </DialogHeader>\n\n <div className=\"flex flex-col gap-4 px-5 pb-5\">\n <div className=\"flex justify-center overflow-hidden rounded-lg\">\n <ReactCrop\n crop={crop}\n onChange={(c) => setCrop(c)}\n aspect={aspectRatio}\n className={`max-h-[50vh] custom-crop-area ${shouldShowCircle ? 'with-circle' : ''}`}\n keepSelection\n minWidth={minCropWidth}\n minHeight={minCropHeight}\n renderSelectionAddon={renderSelectionAddon}\n >\n <img\n ref={imgRef}\n src={imageSrc}\n alt=\"Crop\"\n onLoad={onImageLoad}\n draggable={false}\n className=\"max-h-[50vh] object-contain select-none\"\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 </div>\n\n <DialogFooter className=\"flex-row! items-center justify-start! gap-3 p-5 border-t border-zinc-200\">\n <Button onClick={handleConfirm}>Confirmar</Button>\n <Button variant=\"secondary\" onClick={handleCancel}>\n Cancelar\n </Button>\n </DialogFooter>\n </DialogContent>\n </Dialog>\n )\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../../../src/components/widgets/ImageUpload/ImageCropModal.tsx"],"sourcesContent":["'use client'\n\nimport { useEffect, 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 /* Ocultar handles das laterais (manter só os 4 cantos) */\n .custom-crop-area .ReactCrop__drag-handle.ord-n,\n .custom-crop-area .ReactCrop__drag-handle.ord-s,\n .custom-crop-area .ReactCrop__drag-handle.ord-e,\n .custom-crop-area .ReactCrop__drag-handle.ord-w {\n display: none !important;\n }\n\n /* Estilizar handles dos cantos — indicador preto em L nos cantos */\n .custom-crop-area .ReactCrop__drag-handle.ord-nw,\n .custom-crop-area .ReactCrop__drag-handle.ord-ne,\n .custom-crop-area .ReactCrop__drag-handle.ord-sw,\n .custom-crop-area .ReactCrop__drag-handle.ord-se {\n width: 20px;\n height: 20px;\n background: transparent !important;\n border: none !important;\n }\n\n .custom-crop-area .ReactCrop__drag-handle.ord-nw::after,\n .custom-crop-area .ReactCrop__drag-handle.ord-ne::after,\n .custom-crop-area .ReactCrop__drag-handle.ord-sw::after,\n .custom-crop-area .ReactCrop__drag-handle.ord-se::after {\n content: '';\n position: absolute;\n width: 22px;\n height: 22px;\n border-color: white;\n border-style: solid;\n border-width: 0;\n }\n\n .custom-crop-area .ReactCrop__drag-handle.ord-nw::after {\n top: -2px; left: -2px;\n border-top-width: 4px;\n border-left-width: 4px;\n }\n\n .custom-crop-area .ReactCrop__drag-handle.ord-ne::after {\n top: -2px; right: -2px;\n border-top-width: 4px;\n border-right-width: 4px;\n }\n\n .custom-crop-area .ReactCrop__drag-handle.ord-sw::after {\n bottom: -2px; left: -2px;\n border-bottom-width: 4px;\n border-left-width: 4px;\n }\n\n .custom-crop-area .ReactCrop__drag-handle.ord-se::after {\n bottom: -2px; right: -2px;\n border-bottom-width: 4px;\n border-right-width: 4px;\n }\n\n /* Desabilitar drag da imagem de fundo */\n .custom-crop-area img {\n pointer-events: none;\n user-select: none;\n -webkit-user-drag: 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 onCancel?: () => void\n onImageLoad?: (naturalWidth: number, naturalHeight: number) => void\n showCircleGuide?: boolean\n minWidth?: number\n minHeight?: number\n /**\n * Nome do arquivo atual, usado na mensagem da variante \"imagem muito pequena\".\n * Opcional — se ausente, a mensagem cai num texto genérico.\n */\n fileName?: string\n /**\n * Callback disparado quando o usuário clica em \"Enviar outra\" na variante\n * \"imagem muito pequena\". Normalmente abre um file input. Se não for passado,\n * o botão \"Enviar outra\" é escondido.\n */\n onSelectAnother?: () => void\n}\n\ntype ImageStatus = 'loading' | 'too-small' | 'ok'\n\nexport function ImageCropModal({\n open,\n onOpenChange,\n imageSrc,\n aspectRatio,\n onConfirm,\n onCancel,\n onImageLoad: onImageLoadCallback,\n showCircleGuide,\n minWidth,\n minHeight,\n fileName,\n onSelectAnother,\n}: ImageCropModalProps) {\n const imgRef = useRef<HTMLImageElement>(null)\n const [crop, setCrop] = useState<Crop>()\n const [imageSize, setImageSize] = useState({ width: 0, height: 0 })\n const [imageStatus, setImageStatus] = useState<ImageStatus>('loading')\n\n // Ref pra callback externo — evita re-rodar o effect de preload toda vez que\n // a identidade do callback muda.\n const onImageLoadCallbackRef = useRef(onImageLoadCallback)\n useEffect(() => {\n onImageLoadCallbackRef.current = onImageLoadCallback\n })\n\n // Pre-carrega a imagem fora do DOM pra medir dimensões ANTES de montar\n // o ReactCrop. Assim conseguimos decidir entre a variante de crop e a\n // variante \"imagem muito pequena\" sem flash de UI.\n useEffect(() => {\n if (!open || !imageSrc) {\n setImageStatus('loading')\n return\n }\n\n setImageStatus('loading')\n let cancelled = false\n\n const img = new window.Image()\n img.onload = () => {\n if (cancelled) return\n const { naturalWidth, naturalHeight } = img\n setImageSize({ width: naturalWidth, height: naturalHeight })\n onImageLoadCallbackRef.current?.(naturalWidth, naturalHeight)\n\n const tooSmall =\n (minWidth !== undefined && naturalWidth < minWidth) ||\n (minHeight !== undefined && naturalHeight < minHeight)\n setImageStatus(tooSmall ? 'too-small' : 'ok')\n }\n img.onerror = () => {\n if (cancelled) return\n setImageStatus('too-small')\n }\n img.src = imageSrc\n\n return () => {\n cancelled = true\n }\n }, [open, imageSrc, minWidth, minHeight])\n\n // Inicializa o crop quando o <img> do ReactCrop monta. A imagem já está em\n // cache vinda do preload, então carrega instantâneo.\n const onCropImgLoad = (e: React.SyntheticEvent<HTMLImageElement>) => {\n const { naturalWidth, naturalHeight } = e.currentTarget\n const initialCrop = centerCrop(\n makeAspectCrop(\n { unit: '%', width: 90 },\n aspectRatio,\n naturalWidth,\n naturalHeight\n ),\n naturalWidth,\n naturalHeight\n )\n setCrop(initialCrop)\n }\n\n // Calcula tamanho mínimo em porcentagem baseado nas dimensões mínimas em pixels\n const minCropWidth = minWidth && imageSize.width > 0\n ? Math.min((minWidth / imageSize.width) * 100, 100)\n : undefined\n const minCropHeight = minHeight && imageSize.height > 0\n ? Math.min((minHeight / imageSize.height) * 100, 100)\n : undefined\n\n const handleConfirm = () => {\n if (imgRef.current && crop) {\n onConfirm(imgRef.current, crop)\n }\n }\n\n const handleCancel = () => {\n onCancel?.()\n onOpenChange(false)\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, círculo e bolinhas 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 const dimensionText =\n minWidth !== undefined && minHeight !== undefined\n ? `${minWidth}x${minHeight}px`\n : null\n\n const isTooSmall = imageStatus === 'too-small'\n\n return (\n <Dialog open={open} onOpenChange={onOpenChange}>\n <style>{baseCropStyles}</style>\n <DialogContent\n className={\n isTooSmall\n ? 'w-full md:w-[480px]! lg:w-[480px]! max-w-[480px]! max-[480px]:w-[calc(100%-32px)] p-0 gap-0 z-[1003]!'\n : 'w-full md:w-[650px]! lg:w-[650px]! max-w-[650px]! max-[650px]:w-[calc(100%-32px)] p-0 gap-0 z-[1003]!'\n }\n overlayClassName=\"z-[1002]!\"\n >\n {isTooSmall ? (\n <>\n <DialogHeader className=\"p-5\">\n <DialogTitle className=\"text-center text-[16px]!\">\n {dimensionText ? `Foto menor que ${dimensionText}` : 'Foto muito pequena'}\n </DialogTitle>\n </DialogHeader>\n\n <div className=\"px-5 pb-5\">\n <p className=\"paragraph-small-regular text-zinc-600 text-center\">\n {fileName ? `O arquivo \"${fileName}\"` : 'A imagem selecionada'} é menor\n {dimensionText ? ` que ${dimensionText}` : ' que o tamanho mínimo exigido'}.\n {' '}Faça envio de uma imagem {dimensionText ? `com pelo menos ${dimensionText}` : 'maior'} para continuar.\n </p>\n </div>\n\n <DialogFooter className=\"flex-row! items-center justify-start! gap-3 p-5 border-t border-zinc-200\">\n {onSelectAnother && <Button onClick={onSelectAnother}>Enviar outra</Button>}\n <Button variant=\"secondary\" onClick={handleCancel}>\n Cancelar\n </Button>\n </DialogFooter>\n </>\n ) : (\n <>\n <DialogHeader className=\"p-5\">\n <DialogTitle className=\"text-center text-[16px]!\">\n Ajustar imagem\n </DialogTitle>\n </DialogHeader>\n\n <div className=\"flex flex-col gap-4 px-5 pb-5\">\n <div className=\"flex justify-center overflow-hidden rounded-lg min-h-[200px]\">\n {imageStatus === 'ok' && (\n <ReactCrop\n crop={crop}\n onChange={(c: Crop) => setCrop(c)}\n aspect={aspectRatio}\n className={`max-h-[50vh] custom-crop-area ${shouldShowCircle ? 'with-circle' : ''}`}\n keepSelection\n minWidth={minCropWidth}\n minHeight={minCropHeight}\n renderSelectionAddon={renderSelectionAddon}\n >\n <img\n ref={imgRef}\n src={imageSrc}\n alt=\"Crop\"\n onLoad={onCropImgLoad}\n draggable={false}\n className=\"max-h-[50vh] object-contain select-none\"\n />\n </ReactCrop>\n )}\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 </div>\n\n <DialogFooter className=\"flex-row! items-center justify-start! gap-3 p-5 border-t border-zinc-200\">\n <Button onClick={handleConfirm} disabled={imageStatus !== 'ok' || !crop}>\n Confirmar\n </Button>\n <Button variant=\"secondary\" onClick={handleCancel}>\n Cancelar\n </Button>\n </DialogFooter>\n </>\n )}\n </DialogContent>\n </Dialog>\n )\n}\n"],"mappings":"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","names":[]}
@@ -18,6 +18,7 @@ function ImageUpload({
18
18
  croppedPreview,
19
19
  showCropModal,
20
20
  isProcessing,
21
+ fileName,
21
22
  handleFileSelect,
22
23
  handleCropConfirm,
23
24
  setShowCropModal,
@@ -111,7 +112,11 @@ function ImageUpload({
111
112
  onOpenChange: setShowCropModal,
112
113
  imageSrc: originalImage,
113
114
  aspectRatio: config.aspectRatio,
114
- onConfirm: handleCropConfirm
115
+ onConfirm: handleCropConfirm,
116
+ minWidth: config.width,
117
+ minHeight: config.height,
118
+ fileName,
119
+ onSelectAnother: () => inputRef.current?.click()
115
120
  }
116
121
  )
117
122
  ] });
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../src/components/widgets/ImageUpload/ImageUpload.tsx"],"sourcesContent":["'use client'\n\nimport { useRef } from 'react'\nimport { useImageUpload } from '../../../modules/images/hooks/use-image-upload.hook'\nimport { ACCEPTED_IMAGE_FORMATS_STRING } from '../../../modules/images/constants/image.constants'\nimport { ImageCropModal } from './ImageCropModal'\nimport { Button } from '../../ui/buttons/Button'\nimport type { ImageConfig } from '../../../modules/images/types/image.type'\n\ninterface ImageUploadProps {\n config: ImageConfig\n value?: string\n onChange?: (value: string) => void\n onError?: (error: string) => void\n className?: string\n}\n\nexport function ImageUpload({\n config,\n value,\n onChange,\n onError,\n className,\n}: ImageUploadProps) {\n const inputRef = useRef<HTMLInputElement>(null)\n\n const {\n originalImage,\n croppedPreview,\n showCropModal,\n isProcessing,\n handleFileSelect,\n handleCropConfirm,\n setShowCropModal,\n clear,\n } = useImageUpload({\n config,\n onSuccess: (base64) => onChange?.(base64),\n onError,\n })\n\n const displayImage = croppedPreview || value\n\n return (\n <div className={className}>\n <input\n ref={inputRef}\n type=\"file\"\n accept={ACCEPTED_IMAGE_FORMATS_STRING}\n className=\"hidden\"\n onChange={(e) => {\n const file = e.target.files?.[0]\n if (file) handleFileSelect(file)\n e.target.value = ''\n }}\n />\n\n {displayImage ? (\n <div className=\"relative\">\n <div\n className=\"overflow-hidden rounded-lg border border-gray-200\"\n style={{ aspectRatio: config.aspectRatio }}\n >\n <img\n src={displayImage}\n alt=\"Preview\"\n className=\"w-full h-full object-cover\"\n />\n </div>\n\n <div className=\"flex gap-2 mt-2\">\n <Button\n variant=\"secondary\"\n size=\"sm\"\n onClick={() => inputRef.current?.click()}\n disabled={isProcessing}\n >\n Trocar\n </Button>\n <Button\n variant=\"secondary\"\n size=\"sm\"\n onClick={() => {\n clear()\n onChange?.('')\n }}\n disabled={isProcessing}\n >\n Remover\n </Button>\n </div>\n\n {isProcessing && (\n <p className=\"paragraph-small-regular text-gray-500 mt-2\">\n Processando...\n </p>\n )}\n </div>\n ) : (\n <button\n type=\"button\"\n onClick={() => inputRef.current?.click()}\n className=\"w-full border-2 border-dashed border-gray-300 rounded-lg p-8 text-center hover:border-gray-400 transition-colors\"\n style={{ aspectRatio: config.aspectRatio }}\n >\n <p className=\"paragraph-medium-regular text-gray-600\">\n Clique para enviar imagem\n </p>\n <p className=\"paragraph-small-regular text-gray-400 mt-1\">\n Tamanho recomendado: {config.width} x {config.height} pixels\n </p>\n </button>\n )}\n\n {originalImage && (\n <ImageCropModal\n open={showCropModal}\n onOpenChange={setShowCropModal}\n imageSrc={originalImage}\n aspectRatio={config.aspectRatio}\n onConfirm={handleCropConfirm}\n />\n )}\n </div>\n )\n}\n"],"mappings":";AA6CM,cAyBI,YAzBJ;AA3CN,SAAS,cAAc;AACvB,SAAS,sBAAsB;AAC/B,SAAS,qCAAqC;AAC9C,SAAS,sBAAsB;AAC/B,SAAS,cAAc;AAWhB,SAAS,YAAY;AAAA,EAC1B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,GAAqB;AACnB,QAAM,WAAW,OAAyB,IAAI;AAE9C,QAAM;AAAA,IACJ;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,EACF,IAAI,eAAe;AAAA,IACjB;AAAA,IACA,WAAW,CAAC,WAAW,WAAW,MAAM;AAAA,IACxC;AAAA,EACF,CAAC;AAED,QAAM,eAAe,kBAAkB;AAEvC,SACE,qBAAC,SAAI,WACH;AAAA;AAAA,MAAC;AAAA;AAAA,QACC,KAAK;AAAA,QACL,MAAK;AAAA,QACL,QAAQ;AAAA,QACR,WAAU;AAAA,QACV,UAAU,CAAC,MAAM;AACf,gBAAM,OAAO,EAAE,OAAO,QAAQ,CAAC;AAC/B,cAAI,KAAM,kBAAiB,IAAI;AAC/B,YAAE,OAAO,QAAQ;AAAA,QACnB;AAAA;AAAA,IACF;AAAA,IAEC,eACC,qBAAC,SAAI,WAAU,YACb;AAAA;AAAA,QAAC;AAAA;AAAA,UACC,WAAU;AAAA,UACV,OAAO,EAAE,aAAa,OAAO,YAAY;AAAA,UAEzC;AAAA,YAAC;AAAA;AAAA,cACC,KAAK;AAAA,cACL,KAAI;AAAA,cACJ,WAAU;AAAA;AAAA,UACZ;AAAA;AAAA,MACF;AAAA,MAEA,qBAAC,SAAI,WAAU,mBACb;AAAA;AAAA,UAAC;AAAA;AAAA,YACC,SAAQ;AAAA,YACR,MAAK;AAAA,YACL,SAAS,MAAM,SAAS,SAAS,MAAM;AAAA,YACvC,UAAU;AAAA,YACX;AAAA;AAAA,QAED;AAAA,QACA;AAAA,UAAC;AAAA;AAAA,YACC,SAAQ;AAAA,YACR,MAAK;AAAA,YACL,SAAS,MAAM;AACb,oBAAM;AACN,yBAAW,EAAE;AAAA,YACf;AAAA,YACA,UAAU;AAAA,YACX;AAAA;AAAA,QAED;AAAA,SACF;AAAA,MAEC,gBACC,oBAAC,OAAE,WAAU,8CAA6C,4BAE1D;AAAA,OAEJ,IAEA;AAAA,MAAC;AAAA;AAAA,QACC,MAAK;AAAA,QACL,SAAS,MAAM,SAAS,SAAS,MAAM;AAAA,QACvC,WAAU;AAAA,QACV,OAAO,EAAE,aAAa,OAAO,YAAY;AAAA,QAEzC;AAAA,8BAAC,OAAE,WAAU,0CAAyC,uCAEtD;AAAA,UACA,qBAAC,OAAE,WAAU,8CAA6C;AAAA;AAAA,YAClC,OAAO;AAAA,YAAM;AAAA,YAAI,OAAO;AAAA,YAAO;AAAA,aACvD;AAAA;AAAA;AAAA,IACF;AAAA,IAGD,iBACC;AAAA,MAAC;AAAA;AAAA,QACC,MAAM;AAAA,QACN,cAAc;AAAA,QACd,UAAU;AAAA,QACV,aAAa,OAAO;AAAA,QACpB,WAAW;AAAA;AAAA,IACb;AAAA,KAEJ;AAEJ;","names":[]}
1
+ {"version":3,"sources":["../../../../src/components/widgets/ImageUpload/ImageUpload.tsx"],"sourcesContent":["'use client'\n\nimport { useRef } from 'react'\nimport { useImageUpload } from '../../../modules/images/hooks/use-image-upload.hook'\nimport { ACCEPTED_IMAGE_FORMATS_STRING } from '../../../modules/images/constants/image.constants'\nimport { ImageCropModal } from './ImageCropModal'\nimport { Button } from '../../ui/buttons/Button'\nimport type { ImageConfig } from '../../../modules/images/types/image.type'\n\ninterface ImageUploadProps {\n config: ImageConfig\n value?: string\n onChange?: (value: string) => void\n onError?: (error: string) => void\n className?: string\n}\n\nexport function ImageUpload({\n config,\n value,\n onChange,\n onError,\n className,\n}: ImageUploadProps) {\n const inputRef = useRef<HTMLInputElement>(null)\n\n const {\n originalImage,\n croppedPreview,\n showCropModal,\n isProcessing,\n fileName,\n handleFileSelect,\n handleCropConfirm,\n setShowCropModal,\n clear,\n } = useImageUpload({\n config,\n onSuccess: (base64) => onChange?.(base64),\n onError,\n })\n\n const displayImage = croppedPreview || value\n\n return (\n <div className={className}>\n <input\n ref={inputRef}\n type=\"file\"\n accept={ACCEPTED_IMAGE_FORMATS_STRING}\n className=\"hidden\"\n onChange={(e) => {\n const file = e.target.files?.[0]\n if (file) handleFileSelect(file)\n e.target.value = ''\n }}\n />\n\n {displayImage ? (\n <div className=\"relative\">\n <div\n className=\"overflow-hidden rounded-lg border border-gray-200\"\n style={{ aspectRatio: config.aspectRatio }}\n >\n <img\n src={displayImage}\n alt=\"Preview\"\n className=\"w-full h-full object-cover\"\n />\n </div>\n\n <div className=\"flex gap-2 mt-2\">\n <Button\n variant=\"secondary\"\n size=\"sm\"\n onClick={() => inputRef.current?.click()}\n disabled={isProcessing}\n >\n Trocar\n </Button>\n <Button\n variant=\"secondary\"\n size=\"sm\"\n onClick={() => {\n clear()\n onChange?.('')\n }}\n disabled={isProcessing}\n >\n Remover\n </Button>\n </div>\n\n {isProcessing && (\n <p className=\"paragraph-small-regular text-gray-500 mt-2\">\n Processando...\n </p>\n )}\n </div>\n ) : (\n <button\n type=\"button\"\n onClick={() => inputRef.current?.click()}\n className=\"w-full border-2 border-dashed border-gray-300 rounded-lg p-8 text-center hover:border-gray-400 transition-colors\"\n style={{ aspectRatio: config.aspectRatio }}\n >\n <p className=\"paragraph-medium-regular text-gray-600\">\n Clique para enviar imagem\n </p>\n <p className=\"paragraph-small-regular text-gray-400 mt-1\">\n Tamanho recomendado: {config.width} x {config.height} pixels\n </p>\n </button>\n )}\n\n {originalImage && (\n <ImageCropModal\n open={showCropModal}\n onOpenChange={setShowCropModal}\n imageSrc={originalImage}\n aspectRatio={config.aspectRatio}\n onConfirm={handleCropConfirm}\n minWidth={config.width}\n minHeight={config.height}\n fileName={fileName}\n onSelectAnother={() => inputRef.current?.click()}\n />\n )}\n </div>\n )\n}\n"],"mappings":";AA8CM,cAyBI,YAzBJ;AA5CN,SAAS,cAAc;AACvB,SAAS,sBAAsB;AAC/B,SAAS,qCAAqC;AAC9C,SAAS,sBAAsB;AAC/B,SAAS,cAAc;AAWhB,SAAS,YAAY;AAAA,EAC1B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,GAAqB;AACnB,QAAM,WAAW,OAAyB,IAAI;AAE9C,QAAM;AAAA,IACJ;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,EACF,IAAI,eAAe;AAAA,IACjB;AAAA,IACA,WAAW,CAAC,WAAW,WAAW,MAAM;AAAA,IACxC;AAAA,EACF,CAAC;AAED,QAAM,eAAe,kBAAkB;AAEvC,SACE,qBAAC,SAAI,WACH;AAAA;AAAA,MAAC;AAAA;AAAA,QACC,KAAK;AAAA,QACL,MAAK;AAAA,QACL,QAAQ;AAAA,QACR,WAAU;AAAA,QACV,UAAU,CAAC,MAAM;AACf,gBAAM,OAAO,EAAE,OAAO,QAAQ,CAAC;AAC/B,cAAI,KAAM,kBAAiB,IAAI;AAC/B,YAAE,OAAO,QAAQ;AAAA,QACnB;AAAA;AAAA,IACF;AAAA,IAEC,eACC,qBAAC,SAAI,WAAU,YACb;AAAA;AAAA,QAAC;AAAA;AAAA,UACC,WAAU;AAAA,UACV,OAAO,EAAE,aAAa,OAAO,YAAY;AAAA,UAEzC;AAAA,YAAC;AAAA;AAAA,cACC,KAAK;AAAA,cACL,KAAI;AAAA,cACJ,WAAU;AAAA;AAAA,UACZ;AAAA;AAAA,MACF;AAAA,MAEA,qBAAC,SAAI,WAAU,mBACb;AAAA;AAAA,UAAC;AAAA;AAAA,YACC,SAAQ;AAAA,YACR,MAAK;AAAA,YACL,SAAS,MAAM,SAAS,SAAS,MAAM;AAAA,YACvC,UAAU;AAAA,YACX;AAAA;AAAA,QAED;AAAA,QACA;AAAA,UAAC;AAAA;AAAA,YACC,SAAQ;AAAA,YACR,MAAK;AAAA,YACL,SAAS,MAAM;AACb,oBAAM;AACN,yBAAW,EAAE;AAAA,YACf;AAAA,YACA,UAAU;AAAA,YACX;AAAA;AAAA,QAED;AAAA,SACF;AAAA,MAEC,gBACC,oBAAC,OAAE,WAAU,8CAA6C,4BAE1D;AAAA,OAEJ,IAEA;AAAA,MAAC;AAAA;AAAA,QACC,MAAK;AAAA,QACL,SAAS,MAAM,SAAS,SAAS,MAAM;AAAA,QACvC,WAAU;AAAA,QACV,OAAO,EAAE,aAAa,OAAO,YAAY;AAAA,QAEzC;AAAA,8BAAC,OAAE,WAAU,0CAAyC,uCAEtD;AAAA,UACA,qBAAC,OAAE,WAAU,8CAA6C;AAAA;AAAA,YAClC,OAAO;AAAA,YAAM;AAAA,YAAI,OAAO;AAAA,YAAO;AAAA,aACvD;AAAA;AAAA;AAAA,IACF;AAAA,IAGD,iBACC;AAAA,MAAC;AAAA;AAAA,QACC,MAAM;AAAA,QACN,cAAc;AAAA,QACd,UAAU;AAAA,QACV,aAAa,OAAO;AAAA,QACpB,WAAW;AAAA,QACX,UAAU,OAAO;AAAA,QACjB,WAAW,OAAO;AAAA,QAClB;AAAA,QACA,iBAAiB,MAAM,SAAS,SAAS,MAAM;AAAA;AAAA,IACjD;AAAA,KAEJ;AAEJ;","names":[]}
@@ -16,6 +16,7 @@ function useImageUpload(options) {
16
16
  const [showTooSmallModal, setShowTooSmallModal] = useState(false);
17
17
  const [tooSmallError, setTooSmallError] = useState(null);
18
18
  const [isProcessing, setIsProcessing] = useState(false);
19
+ const [fileName, setFileName] = useState("");
19
20
  const fileInputRef = useRef(null);
20
21
  const currentFileName = useRef("");
21
22
  const handleFileSelect = useCallback(
@@ -26,6 +27,7 @@ function useImageUpload(options) {
26
27
  return;
27
28
  }
28
29
  currentFileName.current = file.name;
30
+ setFileName(file.name);
29
31
  const objectUrl = URL.createObjectURL(file);
30
32
  setOriginalImage(objectUrl);
31
33
  const img = new Image();
@@ -94,6 +96,7 @@ function useImageUpload(options) {
94
96
  tooSmallError,
95
97
  config,
96
98
  isProcessing,
99
+ fileName,
97
100
  fileInputRef,
98
101
  handleFileSelect,
99
102
  handleCropConfirm,
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../src/modules/images/hooks/use-image-upload.hook.ts"],"sourcesContent":["'use client'\n\nimport { useState, useCallback, useRef, useEffect } from 'react'\nimport type { Crop } from 'react-image-crop'\nimport { cropImageToCanvas } from '../utils/crop-image'\nimport { validateImage } from '../utils/validate-image'\nimport type { ImageConfig } from '../types/image.type'\n\nexport interface ImageTooSmallError {\n fileName: string\n minWidth: number\n minHeight: number\n}\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 // Refs para manter callbacks sempre atualizados (evita stale closures)\n const onSuccessRef = useRef(onSuccess)\n const onErrorRef = useRef(onError)\n useEffect(() => {\n onSuccessRef.current = onSuccess\n onErrorRef.current = onError\n }, [onSuccess, onError])\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 [showTooSmallModal, setShowTooSmallModal] = useState(false)\n const [tooSmallError, setTooSmallError] = useState<ImageTooSmallError | null>(null)\n const [isProcessing, setIsProcessing] = useState(false)\n const fileInputRef = useRef<HTMLInputElement>(null)\n const currentFileName = useRef<string>('')\n\n const handleFileSelect = useCallback(\n (file: File) => {\n const validation = validateImage(file)\n if (!validation.valid) {\n onErrorRef.current?.(validation.error || 'Arquivo inválido')\n return\n }\n\n currentFileName.current = file.name\n\n // Usa object URL em vez de base64 — mais eficiente para imagens grandes\n const objectUrl = URL.createObjectURL(file)\n setOriginalImage(objectUrl)\n\n // Carrega imagem em memória para garantir que é válida antes de abrir modal\n const img = new Image()\n img.onload = () => {\n setShowCropModal(true)\n }\n img.onerror = () => {\n URL.revokeObjectURL(objectUrl)\n setOriginalImage(null)\n onErrorRef.current?.('Erro ao carregar a imagem. Tente uma imagem menor ou em outro formato.')\n }\n img.src = objectUrl\n },\n []\n )\n\n const handleImageLoadInModal = useCallback(\n (_naturalWidth: number, _naturalHeight: number) => {\n // Validação agora é feita antes de abrir a modal\n },\n []\n )\n\n const handleSelectAnotherFile = useCallback(() => {\n setShowTooSmallModal(false)\n setTooSmallError(null)\n fileInputRef.current?.click()\n }, [])\n\n const handleCancelTooSmall = useCallback(() => {\n setShowTooSmallModal(false)\n setShowCropModal(false)\n setTooSmallError(null)\n if (originalImage) URL.revokeObjectURL(originalImage)\n setOriginalImage(null)\n }, [originalImage])\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 if (originalImage) URL.revokeObjectURL(originalImage)\n setOriginalImage(null)\n onSuccessRef.current?.(croppedBase64)\n } catch {\n onErrorRef.current?.('Erro ao aplicar crop')\n } finally {\n setIsProcessing(false)\n }\n },\n [config]\n )\n\n const clear = useCallback(() => {\n if (originalImage) URL.revokeObjectURL(originalImage)\n setOriginalImage(null)\n setCroppedPreview(null)\n }, [originalImage])\n\n return {\n originalImage,\n croppedPreview,\n showCropModal,\n showTooSmallModal,\n tooSmallError,\n config,\n isProcessing,\n fileInputRef,\n handleFileSelect,\n handleCropConfirm,\n handleImageLoadInModal,\n handleSelectAnotherFile,\n handleCancelTooSmall,\n setShowCropModal,\n setShowTooSmallModal,\n clear,\n }\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../../../src/modules/images/hooks/use-image-upload.hook.ts"],"sourcesContent":["'use client'\n\nimport { useState, useCallback, useRef, useEffect } from 'react'\nimport type { Crop } from 'react-image-crop'\nimport { cropImageToCanvas } from '../utils/crop-image'\nimport { validateImage } from '../utils/validate-image'\nimport type { ImageConfig } from '../types/image.type'\n\nexport interface ImageTooSmallError {\n fileName: string\n minWidth: number\n minHeight: number\n}\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 // Refs para manter callbacks sempre atualizados (evita stale closures)\n const onSuccessRef = useRef(onSuccess)\n const onErrorRef = useRef(onError)\n useEffect(() => {\n onSuccessRef.current = onSuccess\n onErrorRef.current = onError\n }, [onSuccess, onError])\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 [showTooSmallModal, setShowTooSmallModal] = useState(false)\n const [tooSmallError, setTooSmallError] = useState<ImageTooSmallError | null>(null)\n const [isProcessing, setIsProcessing] = useState(false)\n const [fileName, setFileName] = useState<string>('')\n const fileInputRef = useRef<HTMLInputElement>(null)\n const currentFileName = useRef<string>('')\n\n const handleFileSelect = useCallback(\n (file: File) => {\n const validation = validateImage(file)\n if (!validation.valid) {\n onErrorRef.current?.(validation.error || 'Arquivo inválido')\n return\n }\n\n currentFileName.current = file.name\n setFileName(file.name)\n\n // Usa object URL em vez de base64 — mais eficiente para imagens grandes\n const objectUrl = URL.createObjectURL(file)\n setOriginalImage(objectUrl)\n\n // Carrega imagem em memória para garantir que é válida antes de abrir modal\n const img = new Image()\n img.onload = () => {\n setShowCropModal(true)\n }\n img.onerror = () => {\n URL.revokeObjectURL(objectUrl)\n setOriginalImage(null)\n onErrorRef.current?.('Erro ao carregar a imagem. Tente uma imagem menor ou em outro formato.')\n }\n img.src = objectUrl\n },\n []\n )\n\n const handleImageLoadInModal = useCallback(\n (_naturalWidth: number, _naturalHeight: number) => {\n // Validação agora é feita antes de abrir a modal\n },\n []\n )\n\n const handleSelectAnotherFile = useCallback(() => {\n setShowTooSmallModal(false)\n setTooSmallError(null)\n fileInputRef.current?.click()\n }, [])\n\n const handleCancelTooSmall = useCallback(() => {\n setShowTooSmallModal(false)\n setShowCropModal(false)\n setTooSmallError(null)\n if (originalImage) URL.revokeObjectURL(originalImage)\n setOriginalImage(null)\n }, [originalImage])\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 if (originalImage) URL.revokeObjectURL(originalImage)\n setOriginalImage(null)\n onSuccessRef.current?.(croppedBase64)\n } catch {\n onErrorRef.current?.('Erro ao aplicar crop')\n } finally {\n setIsProcessing(false)\n }\n },\n [config]\n )\n\n const clear = useCallback(() => {\n if (originalImage) URL.revokeObjectURL(originalImage)\n setOriginalImage(null)\n setCroppedPreview(null)\n }, [originalImage])\n\n return {\n originalImage,\n croppedPreview,\n showCropModal,\n showTooSmallModal,\n tooSmallError,\n config,\n isProcessing,\n fileName,\n fileInputRef,\n handleFileSelect,\n handleCropConfirm,\n handleImageLoadInModal,\n handleSelectAnotherFile,\n handleCancelTooSmall,\n setShowCropModal,\n setShowTooSmallModal,\n clear,\n }\n}\n"],"mappings":"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","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@greatapps/common",
3
- "version": "1.1.547",
3
+ "version": "1.1.551",
4
4
  "description": "Shared library for GreatApps frontend applications",
5
5
  "main": "./dist/index.mjs",
6
6
  "types": "./src/index.ts",
@@ -1,6 +1,6 @@
1
1
  'use client'
2
2
 
3
- import { useRef, useState } from 'react'
3
+ import { useEffect, useRef, useState } from 'react'
4
4
  import ReactCrop, {
5
5
  type Crop,
6
6
  centerCrop,
@@ -149,8 +149,21 @@ interface ImageCropModalProps {
149
149
  showCircleGuide?: boolean
150
150
  minWidth?: number
151
151
  minHeight?: number
152
+ /**
153
+ * Nome do arquivo atual, usado na mensagem da variante "imagem muito pequena".
154
+ * Opcional — se ausente, a mensagem cai num texto genérico.
155
+ */
156
+ fileName?: string
157
+ /**
158
+ * Callback disparado quando o usuário clica em "Enviar outra" na variante
159
+ * "imagem muito pequena". Normalmente abre um file input. Se não for passado,
160
+ * o botão "Enviar outra" é escondido.
161
+ */
162
+ onSelectAnother?: () => void
152
163
  }
153
164
 
165
+ type ImageStatus = 'loading' | 'too-small' | 'ok'
166
+
154
167
  export function ImageCropModal({
155
168
  open,
156
169
  onOpenChange,
@@ -162,18 +175,60 @@ export function ImageCropModal({
162
175
  showCircleGuide,
163
176
  minWidth,
164
177
  minHeight,
178
+ fileName,
179
+ onSelectAnother,
165
180
  }: ImageCropModalProps) {
166
181
  const imgRef = useRef<HTMLImageElement>(null)
167
182
  const [crop, setCrop] = useState<Crop>()
168
183
  const [imageSize, setImageSize] = useState({ width: 0, height: 0 })
184
+ const [imageStatus, setImageStatus] = useState<ImageStatus>('loading')
169
185
 
170
- const onImageLoad = (e: React.SyntheticEvent<HTMLImageElement>) => {
171
- const { naturalWidth, naturalHeight } = e.currentTarget
172
- setImageSize({ width: naturalWidth, height: naturalHeight })
186
+ // Ref pra callback externo evita re-rodar o effect de preload toda vez que
187
+ // a identidade do callback muda.
188
+ const onImageLoadCallbackRef = useRef(onImageLoadCallback)
189
+ useEffect(() => {
190
+ onImageLoadCallbackRef.current = onImageLoadCallback
191
+ })
192
+
193
+ // Pre-carrega a imagem fora do DOM pra medir dimensões ANTES de montar
194
+ // o ReactCrop. Assim conseguimos decidir entre a variante de crop e a
195
+ // variante "imagem muito pequena" sem flash de UI.
196
+ useEffect(() => {
197
+ if (!open || !imageSrc) {
198
+ setImageStatus('loading')
199
+ return
200
+ }
201
+
202
+ setImageStatus('loading')
203
+ let cancelled = false
173
204
 
174
- // Notifica o hook para verificar dimensões mínimas
175
- onImageLoadCallback?.(naturalWidth, naturalHeight)
205
+ const img = new window.Image()
206
+ img.onload = () => {
207
+ if (cancelled) return
208
+ const { naturalWidth, naturalHeight } = img
209
+ setImageSize({ width: naturalWidth, height: naturalHeight })
210
+ onImageLoadCallbackRef.current?.(naturalWidth, naturalHeight)
211
+
212
+ const tooSmall =
213
+ (minWidth !== undefined && naturalWidth < minWidth) ||
214
+ (minHeight !== undefined && naturalHeight < minHeight)
215
+ setImageStatus(tooSmall ? 'too-small' : 'ok')
216
+ }
217
+ img.onerror = () => {
218
+ if (cancelled) return
219
+ setImageStatus('too-small')
220
+ }
221
+ img.src = imageSrc
222
+
223
+ return () => {
224
+ cancelled = true
225
+ }
226
+ }, [open, imageSrc, minWidth, minHeight])
176
227
 
228
+ // Inicializa o crop quando o <img> do ReactCrop monta. A imagem já está em
229
+ // cache vinda do preload, então carrega instantâneo.
230
+ const onCropImgLoad = (e: React.SyntheticEvent<HTMLImageElement>) => {
231
+ const { naturalWidth, naturalHeight } = e.currentTarget
177
232
  const initialCrop = centerCrop(
178
233
  makeAspectCrop(
179
234
  { unit: '%', width: 90 },
@@ -184,7 +239,6 @@ export function ImageCropModal({
184
239
  naturalWidth,
185
240
  naturalHeight
186
241
  )
187
-
188
242
  setCrop(initialCrop)
189
243
  }
190
244
 
@@ -225,53 +279,95 @@ export function ImageCropModal({
225
279
  </>
226
280
  )
227
281
 
282
+ const dimensionText =
283
+ minWidth !== undefined && minHeight !== undefined
284
+ ? `${minWidth}x${minHeight}px`
285
+ : null
286
+
287
+ const isTooSmall = imageStatus === 'too-small'
288
+
228
289
  return (
229
290
  <Dialog open={open} onOpenChange={onOpenChange}>
230
291
  <style>{baseCropStyles}</style>
231
292
  <DialogContent
232
- className="w-full md:w-[650px]! lg:w-[650px]! max-w-[650px]! max-[650px]:w-[calc(100%-32px)] p-0 gap-0 z-[1003]!"
293
+ className={
294
+ isTooSmall
295
+ ? 'w-full md:w-[480px]! lg:w-[480px]! max-w-[480px]! max-[480px]:w-[calc(100%-32px)] p-0 gap-0 z-[1003]!'
296
+ : 'w-full md:w-[650px]! lg:w-[650px]! max-w-[650px]! max-[650px]:w-[calc(100%-32px)] p-0 gap-0 z-[1003]!'
297
+ }
233
298
  overlayClassName="z-[1002]!"
234
299
  >
235
- <DialogHeader className="p-5">
236
- <DialogTitle className="text-center text-[16px]!">
237
- Ajustar imagem
238
- </DialogTitle>
239
- </DialogHeader>
240
-
241
- <div className="flex flex-col gap-4 px-5 pb-5">
242
- <div className="flex justify-center overflow-hidden rounded-lg">
243
- <ReactCrop
244
- crop={crop}
245
- onChange={(c) => setCrop(c)}
246
- aspect={aspectRatio}
247
- className={`max-h-[50vh] custom-crop-area ${shouldShowCircle ? 'with-circle' : ''}`}
248
- keepSelection
249
- minWidth={minCropWidth}
250
- minHeight={minCropHeight}
251
- renderSelectionAddon={renderSelectionAddon}
252
- >
253
- <img
254
- ref={imgRef}
255
- src={imageSrc}
256
- alt="Crop"
257
- onLoad={onImageLoad}
258
- draggable={false}
259
- className="max-h-[50vh] object-contain select-none"
260
- />
261
- </ReactCrop>
262
- </div>
263
-
264
- <p className="paragraph-small-regular text-zinc-500 text-center">
265
- Arraste para ajustar a área de corte
266
- </p>
267
- </div>
268
-
269
- <DialogFooter className="flex-row! items-center justify-start! gap-3 p-5 border-t border-zinc-200">
270
- <Button onClick={handleConfirm}>Confirmar</Button>
271
- <Button variant="secondary" onClick={handleCancel}>
272
- Cancelar
273
- </Button>
274
- </DialogFooter>
300
+ {isTooSmall ? (
301
+ <>
302
+ <DialogHeader className="p-5">
303
+ <DialogTitle className="text-center text-[16px]!">
304
+ {dimensionText ? `Foto menor que ${dimensionText}` : 'Foto muito pequena'}
305
+ </DialogTitle>
306
+ </DialogHeader>
307
+
308
+ <div className="px-5 pb-5">
309
+ <p className="paragraph-small-regular text-zinc-600 text-center">
310
+ {fileName ? `O arquivo "${fileName}"` : 'A imagem selecionada'} é menor
311
+ {dimensionText ? ` que ${dimensionText}` : ' que o tamanho mínimo exigido'}.
312
+ {' '}Faça envio de uma imagem {dimensionText ? `com pelo menos ${dimensionText}` : 'maior'} para continuar.
313
+ </p>
314
+ </div>
315
+
316
+ <DialogFooter className="flex-row! items-center justify-start! gap-3 p-5 border-t border-zinc-200">
317
+ {onSelectAnother && <Button onClick={onSelectAnother}>Enviar outra</Button>}
318
+ <Button variant="secondary" onClick={handleCancel}>
319
+ Cancelar
320
+ </Button>
321
+ </DialogFooter>
322
+ </>
323
+ ) : (
324
+ <>
325
+ <DialogHeader className="p-5">
326
+ <DialogTitle className="text-center text-[16px]!">
327
+ Ajustar imagem
328
+ </DialogTitle>
329
+ </DialogHeader>
330
+
331
+ <div className="flex flex-col gap-4 px-5 pb-5">
332
+ <div className="flex justify-center overflow-hidden rounded-lg min-h-[200px]">
333
+ {imageStatus === 'ok' && (
334
+ <ReactCrop
335
+ crop={crop}
336
+ onChange={(c: Crop) => setCrop(c)}
337
+ aspect={aspectRatio}
338
+ className={`max-h-[50vh] custom-crop-area ${shouldShowCircle ? 'with-circle' : ''}`}
339
+ keepSelection
340
+ minWidth={minCropWidth}
341
+ minHeight={minCropHeight}
342
+ renderSelectionAddon={renderSelectionAddon}
343
+ >
344
+ <img
345
+ ref={imgRef}
346
+ src={imageSrc}
347
+ alt="Crop"
348
+ onLoad={onCropImgLoad}
349
+ draggable={false}
350
+ className="max-h-[50vh] object-contain select-none"
351
+ />
352
+ </ReactCrop>
353
+ )}
354
+ </div>
355
+
356
+ <p className="paragraph-small-regular text-zinc-500 text-center">
357
+ Arraste para ajustar a área de corte
358
+ </p>
359
+ </div>
360
+
361
+ <DialogFooter className="flex-row! items-center justify-start! gap-3 p-5 border-t border-zinc-200">
362
+ <Button onClick={handleConfirm} disabled={imageStatus !== 'ok' || !crop}>
363
+ Confirmar
364
+ </Button>
365
+ <Button variant="secondary" onClick={handleCancel}>
366
+ Cancelar
367
+ </Button>
368
+ </DialogFooter>
369
+ </>
370
+ )}
275
371
  </DialogContent>
276
372
  </Dialog>
277
373
  )
@@ -29,6 +29,7 @@ export function ImageUpload({
29
29
  croppedPreview,
30
30
  showCropModal,
31
31
  isProcessing,
32
+ fileName,
32
33
  handleFileSelect,
33
34
  handleCropConfirm,
34
35
  setShowCropModal,
@@ -119,6 +120,10 @@ export function ImageUpload({
119
120
  imageSrc={originalImage}
120
121
  aspectRatio={config.aspectRatio}
121
122
  onConfirm={handleCropConfirm}
123
+ minWidth={config.width}
124
+ minHeight={config.height}
125
+ fileName={fileName}
126
+ onSelectAnother={() => inputRef.current?.click()}
122
127
  />
123
128
  )}
124
129
  </div>
@@ -35,6 +35,7 @@ export function useImageUpload(options: UseImageUploadOptions) {
35
35
  const [showTooSmallModal, setShowTooSmallModal] = useState(false)
36
36
  const [tooSmallError, setTooSmallError] = useState<ImageTooSmallError | null>(null)
37
37
  const [isProcessing, setIsProcessing] = useState(false)
38
+ const [fileName, setFileName] = useState<string>('')
38
39
  const fileInputRef = useRef<HTMLInputElement>(null)
39
40
  const currentFileName = useRef<string>('')
40
41
 
@@ -47,6 +48,7 @@ export function useImageUpload(options: UseImageUploadOptions) {
47
48
  }
48
49
 
49
50
  currentFileName.current = file.name
51
+ setFileName(file.name)
50
52
 
51
53
  // Usa object URL em vez de base64 — mais eficiente para imagens grandes
52
54
  const objectUrl = URL.createObjectURL(file)
@@ -129,6 +131,7 @@ export function useImageUpload(options: UseImageUploadOptions) {
129
131
  tooSmallError,
130
132
  config,
131
133
  isProcessing,
134
+ fileName,
132
135
  fileInputRef,
133
136
  handleFileSelect,
134
137
  handleCropConfirm,