Integrar recorte y optimización a WebP en las subidas de imagen del panel
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.
This commit is contained in:
21
mvp/b2c/package-lock.json
generated
21
mvp/b2c/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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<LogoResult | null, FormData>(
|
||||
@@ -34,16 +35,18 @@ export default function AboutFotoUploader({ fotoUrl }: { fotoUrl: string | null
|
||||
</div>
|
||||
|
||||
<form action={formAction} className="flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
type="file"
|
||||
<ImageCropperField
|
||||
name="aboutFoto"
|
||||
maxDimension={500}
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
className="text-sm file:mr-2 file:rounded-lg file:border-0 file:bg-black file:text-white file:px-3 file:py-1.5 file:text-sm file:font-medium"
|
||||
buttonLabel="Elegir foto"
|
||||
previewClassName="h-24 w-24 rounded-lg"
|
||||
disabled={pending}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={pending}
|
||||
className="bg-black text-white rounded-lg px-4 py-1.5 text-sm font-medium disabled:opacity-50"
|
||||
className="rounded-lg bg-primary-700 px-4 py-1.5 text-sm font-medium text-white transition hover:bg-primary-900 disabled:opacity-50"
|
||||
>
|
||||
{pending ? 'Subiendo…' : 'Subir foto'}
|
||||
</button>
|
||||
@@ -51,7 +54,9 @@ export default function AboutFotoUploader({ fotoUrl }: { fotoUrl: string | null
|
||||
|
||||
{state?.error && <p className="text-sm text-red-600">{state.error}</p>}
|
||||
{state?.ok && <p className="text-sm text-green-600">Foto actualizada ✓</p>}
|
||||
<p className="text-xs text-gray-400">PNG, JPG o WEBP · máx. 1,5 MB.</p>
|
||||
<p className="text-xs text-gray-400">
|
||||
PNG, JPG o WEBP. Se recorta y optimiza a WebP (máx. 500 px).
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
</div>
|
||||
|
||||
<form ref={formRef} action={formAction} className="flex flex-col gap-3">
|
||||
<input
|
||||
type="file"
|
||||
<ImageCropperField
|
||||
key={total}
|
||||
name="foto"
|
||||
maxDimension={1200}
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
buttonLabel="Elegir foto"
|
||||
previewClassName="h-24 w-32 rounded-lg"
|
||||
disabled={lleno || pending}
|
||||
className="text-sm file:mr-2 file:rounded-lg file:border-0 file:bg-primary-700 file:text-white file:px-3 file:py-1.5 file:text-sm file:font-medium disabled:opacity-50"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
@@ -63,7 +66,9 @@ export default function GaleriaUploader({
|
||||
)}
|
||||
{state?.error && <p className="text-sm text-red-600">{state.error}</p>}
|
||||
{state?.ok && <p className="text-sm text-green-600">Foto añadida ✓</p>}
|
||||
<p className="text-xs text-gray-400">PNG, JPG o WEBP · máx. 2 MB.</p>
|
||||
<p className="text-xs text-gray-400">
|
||||
PNG, JPG o WEBP. Se recorta y optimiza a WebP (máx. 1200 px).
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
211
mvp/b2c/src/components/panel/ImageCropperField.tsx
Normal file
211
mvp/b2c/src/components/panel/ImageCropperField.tsx
Normal file
@@ -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 <input type="file"> 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<HTMLInputElement>(null);
|
||||
const outputRef = useRef<HTMLInputElement>(null);
|
||||
const objectUrls = useRef<Set<string>>(new Set());
|
||||
|
||||
const [modalSrc, setModalSrc] = useState<string | null>(null);
|
||||
const [aspect, setAspect] = useState(1);
|
||||
const [crop, setCrop] = useState({ x: 0, y: 0 });
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const [area, setArea] = useState<CropArea | null>(null);
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||
const [working, setWorking] = useState(false);
|
||||
const [error, setError] = useState<string | null>(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<HTMLInputElement>) {
|
||||
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 (
|
||||
<div className="flex flex-col gap-2">
|
||||
<input ref={outputRef} type="file" name={name} accept="image/webp,image/svg+xml" className="hidden" />
|
||||
<input
|
||||
ref={pickerRef}
|
||||
type="file"
|
||||
accept={accept}
|
||||
onChange={onPick}
|
||||
disabled={disabled}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{previewUrl && (
|
||||
<span className={`overflow-hidden border border-gray-200 bg-gray-50 ${previewClassName}`}>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={previewUrl} alt="Previsualización" className={previewImgClassName} />
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => pickerRef.current?.click()}
|
||||
disabled={disabled}
|
||||
className="rounded-lg border border-gray-300 px-4 py-1.5 text-sm font-medium text-gray-700 transition hover:border-primary-700 hover:text-primary-700 disabled:opacity-50"
|
||||
>
|
||||
{previewUrl ? 'Cambiar imagen' : buttonLabel}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||
|
||||
{modalSrc && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4">
|
||||
<div className="flex w-full max-w-lg flex-col gap-4 rounded-2xl bg-white p-5 shadow-xl">
|
||||
<h3 className="font-display text-2xl tracking-tight text-black">Ajusta el encuadre</h3>
|
||||
<div className="relative h-[55vh] w-full overflow-hidden rounded-xl bg-gray-900">
|
||||
<Cropper
|
||||
image={modalSrc}
|
||||
crop={crop}
|
||||
zoom={zoom}
|
||||
aspect={aspect}
|
||||
onCropChange={setCrop}
|
||||
onZoomChange={setZoom}
|
||||
onCropComplete={(_area, areaPixels) => setArea(areaPixels)}
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-3 text-sm text-gray-600">
|
||||
Zoom
|
||||
<input
|
||||
type="range"
|
||||
min={1}
|
||||
max={3}
|
||||
step={0.01}
|
||||
value={zoom}
|
||||
onChange={(e) => setZoom(Number(e.target.value))}
|
||||
className="flex-1 accent-[#2f5c46]"
|
||||
/>
|
||||
</label>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={cerrarModal}
|
||||
disabled={working}
|
||||
className="rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 transition hover:border-gray-500 disabled:opacity-50"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={aplicar}
|
||||
disabled={working || !area}
|
||||
className="rounded-lg bg-primary-700 px-4 py-2 text-sm font-semibold text-white transition hover:bg-primary-900 disabled:opacity-50"
|
||||
>
|
||||
{working ? 'Procesando…' : 'Aplicar'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
});
|
||||
}
|
||||
@@ -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<LogoResult | null, FormData>(subirLogo, null);
|
||||
@@ -27,16 +28,20 @@ export default function LogoUploader({ logoUrl }: { logoUrl: string | null }) {
|
||||
</div>
|
||||
|
||||
<form action={formAction} className="flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
type="file"
|
||||
<ImageCropperField
|
||||
name="logo"
|
||||
maxDimension={500}
|
||||
accept="image/png,image/jpeg,image/webp,image/svg+xml"
|
||||
className="text-sm file:mr-2 file:rounded-lg file:border-0 file:bg-black file:text-white file:px-3 file:py-1.5 file:text-sm file:font-medium"
|
||||
buttonLabel="Elegir logo"
|
||||
previewClassName="h-20 w-32 rounded-lg"
|
||||
previewImgClassName="h-full w-full object-contain"
|
||||
allowSvgPassthrough
|
||||
disabled={pending}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={pending}
|
||||
className="bg-black text-white rounded-lg px-4 py-1.5 text-sm font-medium disabled:opacity-50"
|
||||
className="rounded-lg bg-primary-700 px-4 py-1.5 text-sm font-medium text-white transition hover:bg-primary-900 disabled:opacity-50"
|
||||
>
|
||||
{pending ? 'Subiendo…' : 'Subir logo'}
|
||||
</button>
|
||||
@@ -44,7 +49,9 @@ export default function LogoUploader({ logoUrl }: { logoUrl: string | null }) {
|
||||
|
||||
{state?.error && <p className="text-sm text-red-600">{state.error}</p>}
|
||||
{state?.ok && <p className="text-sm text-green-600">Logo actualizado ✓</p>}
|
||||
<p className="text-xs text-gray-400">PNG, JPG, WEBP o SVG · máx. 500 KB.</p>
|
||||
<p className="text-xs text-gray-400">
|
||||
PNG, JPG, WEBP o SVG. Se recorta y optimiza a WebP (máx. 500 px). El SVG se sube tal cual.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
38
mvp/b2c/src/lib/image/crop.ts
Normal file
38
mvp/b2c/src/lib/image/crop.ts
Normal file
@@ -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<Blob> {
|
||||
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<Blob | null>((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<HTMLImageElement> {
|
||||
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;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user