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:
Carlos Narro
2026-06-01 22:36:55 +02:00
parent bf9e72064b
commit e9637f77ff
7 changed files with 302 additions and 14 deletions

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View 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;
});
}

View File

@@ -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>
);
}

View 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;
});
}