From e9637f77ffe18b56be683bec299f3592a8f94998 Mon Sep 17 00:00:00 2001 From: Carlos Narro Date: Mon, 1 Jun 2026 22:36:55 +0200 Subject: [PATCH] =?UTF-8?q?Integrar=20recorte=20y=20optimizaci=C3=B3n=20a?= =?UTF-8?q?=20WebP=20en=20las=20subidas=20de=20imagen=20del=20panel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Los tres uploaders del panel (logo, "quiénes somos" y galería) ahora abren un recorte interactivo (zoom + encuadre) y reescalan en el navegador antes de subir: logo y "quiénes somos" a máx. 500 px, galería a máx. 1200 px, reencodando a WebP (calidad 0.82). El SVG del logo se sube tal cual para no rasterizar el vector. Esto reduce el peso de los data URIs base64 que se guardan en Postgres y se inlinean en el funnel. Nueva dependencia react-easy-crop: librería de recorte ligera, sin estado global, compatible con React 19; el reescalado y reencodado se hacen con canvas nativo (lib/image/crop.ts), sin dependencias extra. --- mvp/b2c/package-lock.json | 21 ++ mvp/b2c/package.json | 1 + .../components/panel/AboutFotoUploader.tsx | 15 +- .../src/components/panel/GaleriaUploader.tsx | 13 +- .../components/panel/ImageCropperField.tsx | 211 ++++++++++++++++++ mvp/b2c/src/components/panel/LogoUploader.tsx | 17 +- mvp/b2c/src/lib/image/crop.ts | 38 ++++ 7 files changed, 302 insertions(+), 14 deletions(-) create mode 100644 mvp/b2c/src/components/panel/ImageCropperField.tsx create mode 100644 mvp/b2c/src/lib/image/crop.ts diff --git a/mvp/b2c/package-lock.json b/mvp/b2c/package-lock.json index b885150..568ee9b 100644 --- a/mvp/b2c/package-lock.json +++ b/mvp/b2c/package-lock.json @@ -17,6 +17,7 @@ "postgres": "^3.4.9", "react": "19.2.4", "react-dom": "19.2.4", + "react-easy-crop": "^5.5.7", "tailwindcss": "^4.3.0", "zod": "^4.4.3" }, @@ -7209,6 +7210,12 @@ "svg-arc-to-cubic-bezier": "^3.0.0" } }, + "node_modules/normalize-wheel": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/normalize-wheel/-/normalize-wheel-1.0.1.tgz", + "integrity": "sha512-1OnlAPZ3zgrk8B91HyRj+eVv+kS5u+Z0SCsak6Xil/kmgEia50ga7zfkumayonZrImffAxPU/5WcyGhzetHNPA==", + "license": "BSD-3-Clause" + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -7635,6 +7642,20 @@ "react": "^19.2.4" } }, + "node_modules/react-easy-crop": { + "version": "5.5.7", + "resolved": "https://registry.npmjs.org/react-easy-crop/-/react-easy-crop-5.5.7.tgz", + "integrity": "sha512-kYo4NtMeXFQB7h1U+h5yhUkE46WQbQdq7if54uDlbMdZHdRgNehfvaFrXnFw5NR1PNoUOJIfTwLnWmEx/MaZnA==", + "license": "MIT", + "dependencies": { + "normalize-wheel": "^1.0.1", + "tslib": "^2.0.1" + }, + "peerDependencies": { + "react": ">=16.4.0", + "react-dom": ">=16.4.0" + } + }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", diff --git a/mvp/b2c/package.json b/mvp/b2c/package.json index f4fba54..633a7a3 100644 --- a/mvp/b2c/package.json +++ b/mvp/b2c/package.json @@ -26,6 +26,7 @@ "postgres": "^3.4.9", "react": "19.2.4", "react-dom": "19.2.4", + "react-easy-crop": "^5.5.7", "tailwindcss": "^4.3.0", "zod": "^4.4.3" }, diff --git a/mvp/b2c/src/components/panel/AboutFotoUploader.tsx b/mvp/b2c/src/components/panel/AboutFotoUploader.tsx index 631597d..8f51e5e 100644 --- a/mvp/b2c/src/components/panel/AboutFotoUploader.tsx +++ b/mvp/b2c/src/components/panel/AboutFotoUploader.tsx @@ -6,6 +6,7 @@ import { quitarAboutFoto, type LogoResult, } from '@/app/panel/empresa/actions'; +import ImageCropperField from '@/components/panel/ImageCropperField'; export default function AboutFotoUploader({ fotoUrl }: { fotoUrl: string | null }) { const [state, formAction, pending] = useActionState( @@ -34,16 +35,18 @@ export default function AboutFotoUploader({ fotoUrl }: { fotoUrl: string | null
- @@ -51,7 +54,9 @@ export default function AboutFotoUploader({ fotoUrl }: { fotoUrl: string | null {state?.error &&

{state.error}

} {state?.ok &&

Foto actualizada ✓

} -

PNG, JPG o WEBP · máx. 1,5 MB.

+

+ PNG, JPG o WEBP. Se recorta y optimiza a WebP (máx. 500 px). +

); } diff --git a/mvp/b2c/src/components/panel/GaleriaUploader.tsx b/mvp/b2c/src/components/panel/GaleriaUploader.tsx index a27f5d0..8eadc5b 100644 --- a/mvp/b2c/src/components/panel/GaleriaUploader.tsx +++ b/mvp/b2c/src/components/panel/GaleriaUploader.tsx @@ -2,6 +2,7 @@ import { useActionState, useEffect, useRef } from 'react'; import { subirFotoGaleria, type GaleriaResult } from '@/app/panel/galeria/actions'; +import ImageCropperField from '@/components/panel/ImageCropperField'; export default function GaleriaUploader({ total, @@ -32,12 +33,14 @@ export default function GaleriaUploader({ - {state.error}

} {state?.ok &&

Foto añadida ✓

} -

PNG, JPG o WEBP · máx. 2 MB.

+

+ PNG, JPG o WEBP. Se recorta y optimiza a WebP (máx. 1200 px). +

); } diff --git a/mvp/b2c/src/components/panel/ImageCropperField.tsx b/mvp/b2c/src/components/panel/ImageCropperField.tsx new file mode 100644 index 0000000..e950801 --- /dev/null +++ b/mvp/b2c/src/components/panel/ImageCropperField.tsx @@ -0,0 +1,211 @@ +'use client'; + +import { useCallback, useEffect, useRef, useState } from 'react'; +import Cropper from 'react-easy-crop'; +import { getCroppedWebp, type CropArea } from '@/lib/image/crop'; + +type Props = { + name: string; + maxDimension: number; + accept: string; + buttonLabel?: string; + previewClassName?: string; + previewImgClassName?: string; + allowSvgPassthrough?: boolean; + quality?: number; + disabled?: boolean; +}; + +// Campo de imagen con recorte (zoom + pan) y reescalado a WebP en el navegador. +// El archivo optimizado se inyecta en un oculto con `name`, +// de modo que el formulario padre lo envía como si fuera la selección original. +export default function ImageCropperField({ + name, + maxDimension, + accept, + buttonLabel = 'Elegir imagen', + previewClassName = 'h-24 w-24 rounded-lg', + previewImgClassName = 'h-full w-full object-cover', + allowSvgPassthrough = false, + quality = 0.82, + disabled = false, +}: Props) { + const pickerRef = useRef(null); + const outputRef = useRef(null); + const objectUrls = useRef>(new Set()); + + const [modalSrc, setModalSrc] = useState(null); + const [aspect, setAspect] = useState(1); + const [crop, setCrop] = useState({ x: 0, y: 0 }); + const [zoom, setZoom] = useState(1); + const [area, setArea] = useState(null); + const [previewUrl, setPreviewUrl] = useState(null); + const [working, setWorking] = useState(false); + const [error, setError] = useState(null); + + const track = useCallback((url: string) => { + objectUrls.current.add(url); + return url; + }, []); + + const revoke = useCallback((url: string | null) => { + if (url && objectUrls.current.has(url)) { + URL.revokeObjectURL(url); + objectUrls.current.delete(url); + } + }, []); + + useEffect(() => { + const urls = objectUrls.current; + return () => { + for (const url of urls) URL.revokeObjectURL(url); + urls.clear(); + }; + }, []); + + function setOutputFile(file: File) { + const dt = new DataTransfer(); + dt.items.add(file); + if (outputRef.current) outputRef.current.files = dt.files; + } + + async function onPick(e: React.ChangeEvent) { + setError(null); + const file = e.target.files?.[0]; + e.target.value = ''; // permite re-elegir el mismo archivo + if (!file) return; + + if (allowSvgPassthrough && file.type === 'image/svg+xml') { + setOutputFile(file); + revoke(previewUrl); + setPreviewUrl(track(URL.createObjectURL(file))); + return; + } + + const src = track(URL.createObjectURL(file)); + try { + const dims = await loadDims(src); + setAspect(dims.width / dims.height || 1); + setCrop({ x: 0, y: 0 }); + setZoom(1); + setArea(null); + setModalSrc(src); + } catch { + revoke(src); + setError('No se pudo abrir la imagen.'); + } + } + + function cerrarModal() { + revoke(modalSrc); + setModalSrc(null); + } + + async function aplicar() { + if (!modalSrc || !area) return; + setWorking(true); + setError(null); + try { + const blob = await getCroppedWebp(modalSrc, area, maxDimension, quality); + setOutputFile(new File([blob], `${name}.webp`, { type: 'image/webp' })); + revoke(previewUrl); + setPreviewUrl(track(URL.createObjectURL(blob))); + cerrarModal(); + } catch { + setError('No se pudo procesar la imagen. Prueba con otra.'); + } finally { + setWorking(false); + } + } + + return ( +
+ + + +
+ {previewUrl && ( + + {/* eslint-disable-next-line @next/next/no-img-element */} + Previsualización + + )} + +
+ + {error &&

{error}

} + + {modalSrc && ( +
+
+

Ajusta el encuadre

+
+ setArea(areaPixels)} + /> +
+ +
+ + +
+
+
+ )} +
+ ); +} + +function loadDims(src: string): Promise<{ width: number; height: number }> { + return new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => resolve({ width: img.naturalWidth, height: img.naturalHeight }); + img.onerror = () => reject(new Error('No se pudo cargar la imagen.')); + img.src = src; + }); +} diff --git a/mvp/b2c/src/components/panel/LogoUploader.tsx b/mvp/b2c/src/components/panel/LogoUploader.tsx index cba0f8f..1c9b3bc 100644 --- a/mvp/b2c/src/components/panel/LogoUploader.tsx +++ b/mvp/b2c/src/components/panel/LogoUploader.tsx @@ -2,6 +2,7 @@ import { useActionState } from 'react'; import { subirLogo, quitarLogo, type LogoResult } from '@/app/panel/empresa/actions'; +import ImageCropperField from '@/components/panel/ImageCropperField'; export default function LogoUploader({ logoUrl }: { logoUrl: string | null }) { const [state, formAction, pending] = useActionState(subirLogo, null); @@ -27,16 +28,20 @@ export default function LogoUploader({ logoUrl }: { logoUrl: string | null }) { - @@ -44,7 +49,9 @@ export default function LogoUploader({ logoUrl }: { logoUrl: string | null }) { {state?.error &&

{state.error}

} {state?.ok &&

Logo actualizado ✓

} -

PNG, JPG, WEBP o SVG · máx. 500 KB.

+

+ PNG, JPG, WEBP o SVG. Se recorta y optimiza a WebP (máx. 500 px). El SVG se sube tal cual. +

); } diff --git a/mvp/b2c/src/lib/image/crop.ts b/mvp/b2c/src/lib/image/crop.ts new file mode 100644 index 0000000..d89608e --- /dev/null +++ b/mvp/b2c/src/lib/image/crop.ts @@ -0,0 +1,38 @@ +export type CropArea = { x: number; y: number; width: number; height: number }; + +// Recorta la región indicada (en píxeles del original) y reescala el lado más +// largo a `maxDimension`, devolviendo un WebP ya optimizado listo para subir. +export async function getCroppedWebp( + src: string, + area: CropArea, + maxDimension: number, + quality = 0.82, +): Promise { + const img = await loadImage(src); + const scale = Math.min(1, maxDimension / Math.max(area.width, area.height)); + const outW = Math.max(1, Math.round(area.width * scale)); + const outH = Math.max(1, Math.round(area.height * scale)); + + const canvas = document.createElement('canvas'); + canvas.width = outW; + canvas.height = outH; + const ctx = canvas.getContext('2d'); + if (!ctx) throw new Error('No se pudo crear el lienzo.'); + ctx.imageSmoothingQuality = 'high'; + ctx.drawImage(img, area.x, area.y, area.width, area.height, 0, 0, outW, outH); + + const blob = await new Promise((resolve) => + canvas.toBlob(resolve, 'image/webp', quality), + ); + if (!blob) throw new Error('No se pudo procesar la imagen.'); + return blob; +} + +function loadImage(src: string): Promise { + return new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => resolve(img); + img.onerror = () => reject(new Error('No se pudo cargar la imagen.')); + img.src = src; + }); +}