feat(F-059): completed feature
This commit is contained in:
@@ -158,11 +158,13 @@ export default function ProductsPage() {
|
||||
<td className="px-4 py-3.5">
|
||||
<div className="flex items-center gap-3">
|
||||
{p.images?.[0]?.url ? (
|
||||
<img
|
||||
src={p.images[0].url.replace('/uploads/', '/uploads/40/')}
|
||||
alt={p.name}
|
||||
className="w-10 h-10 rounded-lg object-cover bg-gray-100 flex-shrink-0"
|
||||
/>
|
||||
<div className="w-10 h-14 rounded-lg bg-gray-100 flex items-center justify-center flex-shrink-0 overflow-hidden">
|
||||
<img
|
||||
src={p.images[0].url.replace('/uploads/', '/uploads/40/')}
|
||||
alt={p.name}
|
||||
className="max-w-full max-h-full w-auto h-auto object-contain"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-10 h-10 rounded-lg bg-gray-100 flex items-center justify-center text-lg flex-shrink-0">
|
||||
🌿
|
||||
|
||||
@@ -16,6 +16,14 @@ const PEER_UPLOAD_DIRS = [
|
||||
|
||||
/** Thumbnail widths pre-generated for lists (40px) and previews (200px). */
|
||||
const THUMBNAIL_WIDTHS = [40, 200];
|
||||
/**
|
||||
* Maximum height ratio (h/w) for cached thumbnails. The thumbnail is scaled
|
||||
* to fit inside a `width × width * MAX_HEIGHT_RATIO` bounding box without
|
||||
* cropping. A non-square source produces a non-square thumbnail that preserves
|
||||
* the original aspect ratio, so the frontend can render it with
|
||||
* `object-contain` inside a matching box.
|
||||
*/
|
||||
const MAX_HEIGHT_RATIO = 1.4;
|
||||
|
||||
async function mirrorToPeers(filePath: string): Promise<void> {
|
||||
await Promise.allSettled(
|
||||
@@ -47,7 +55,10 @@ async function generateThumbnails(buffer: Buffer, filename: string): Promise<voi
|
||||
THUMBNAIL_WIDTHS.map(async (width) => {
|
||||
const thumbDir = path.join(dir, String(width));
|
||||
await mkdir(thumbDir, { recursive: true });
|
||||
const resized = await sharp(buffer).resize({ width, withoutEnlargement: true }).toBuffer();
|
||||
const maxHeight = Math.round(width * MAX_HEIGHT_RATIO);
|
||||
const resized = await sharp(buffer)
|
||||
.resize({ width, height: maxHeight, fit: 'inside', withoutEnlargement: true })
|
||||
.toBuffer();
|
||||
await writeFile(path.join(thumbDir, filename), resized);
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -17,6 +17,14 @@ import path from 'node:path';
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const THUMB_SIZES: Readonly<Record<string, number>> = { '40': 40, '200': 200 };
|
||||
/**
|
||||
* Maximum height ratio (h/w) for cached thumbnails. The thumbnail is scaled
|
||||
* to fit inside a `width × width * MAX_HEIGHT_RATIO` bounding box without
|
||||
* cropping, so a non-square source produces a non-square thumbnail that
|
||||
* preserves the original aspect ratio. The frontend renders the result with
|
||||
* `object-contain`.
|
||||
*/
|
||||
const MAX_HEIGHT_RATIO = 1.4;
|
||||
const SAFE_SEGMENT = /^[A-Za-z0-9._-]+$/;
|
||||
const CONTENT_TYPE_BY_EXTENSION: Readonly<Record<string, string>> = {
|
||||
'.jpg': 'image/jpeg',
|
||||
@@ -54,7 +62,10 @@ async function findOriginal(filename: string): Promise<{ root: string; buffer: B
|
||||
async function buildThumbnail(source: Buffer, width: number): Promise<Buffer | null> {
|
||||
try {
|
||||
const { default: sharp } = await import('sharp');
|
||||
return await sharp(source).resize({ width, withoutEnlargement: true }).toBuffer();
|
||||
const maxHeight = Math.round(width * MAX_HEIGHT_RATIO);
|
||||
return await sharp(source)
|
||||
.resize({ width, height: maxHeight, fit: 'inside', withoutEnlargement: true })
|
||||
.toBuffer();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -173,12 +173,17 @@ export function ImagesSection({ productId }: ImagesSectionProps) {
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
{images.map((img, idx) => (
|
||||
<div key={img.id} className="relative group">
|
||||
{/* Use 200px thumbnail for editor preview to reduce bandwidth */}
|
||||
<img
|
||||
src={img.url.replace('/uploads/', '/uploads/200/')}
|
||||
alt={img.altText ?? img.url}
|
||||
className="w-full aspect-square object-cover rounded-xl bg-gray-100"
|
||||
/>
|
||||
{/* Use 200px thumbnail for editor preview to reduce bandwidth.
|
||||
Container caps the bounding box (max 280px tall to match the
|
||||
1.4 aspect ratio used by the backend thumbnail); the image
|
||||
preserves its source aspect ratio via `object-contain`. */}
|
||||
<div className="w-full max-h-72 aspect-[5/7] bg-gray-100 rounded-xl overflow-hidden flex items-center justify-center">
|
||||
<img
|
||||
src={img.url.replace('/uploads/', '/uploads/200/')}
|
||||
alt={img.altText ?? img.url}
|
||||
className="max-w-full max-h-full w-auto h-auto object-contain"
|
||||
/>
|
||||
</div>
|
||||
{/* Main badge */}
|
||||
{idx === 0 && (
|
||||
<span className="absolute top-2 left-2 px-2 py-0.5 bg-[#2D6A4F] text-white text-xs font-medium rounded-full">
|
||||
|
||||
File diff suppressed because one or more lines are too long
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 127 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
@@ -79,9 +79,9 @@ export default async function BrandPage({ params }: Props) {
|
||||
{products.map((product) => (
|
||||
<Link key={product.id} href={`/products/${product.slug}`} className="group block">
|
||||
<div className="bg-gray-50 rounded-xl overflow-hidden border border-gray-100 hover:border-[#70ad47] transition-all hover:shadow-md">
|
||||
<div className="aspect-square relative bg-white flex items-center justify-center">
|
||||
<div className="relative aspect-[5/7] max-h-72 bg-white flex items-center justify-center overflow-hidden">
|
||||
{product.images?.[0] ? (
|
||||
<Image src={product.images[0].url} alt={product.name} fill className="object-cover" sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 25vw" />
|
||||
<Image src={product.images[0].url} alt={product.name} fill className="object-contain" sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 25vw" />
|
||||
) : (
|
||||
<span className="text-5xl">🌿</span>
|
||||
)}
|
||||
|
||||
@@ -87,9 +87,9 @@ export default async function CategoryPage({ params }: Props) {
|
||||
{products.map((product) => (
|
||||
<Link key={product.id} href={`/products/${product.slug}`} className="group block">
|
||||
<div className="bg-gray-50 rounded-xl overflow-hidden border border-gray-100 hover:border-[#70ad47] transition-all hover:shadow-md">
|
||||
<div className="aspect-square relative bg-white flex items-center justify-center">
|
||||
<div className="relative aspect-[5/7] max-h-72 bg-white flex items-center justify-center overflow-hidden">
|
||||
{product.images?.[0] ? (
|
||||
<Image src={product.images[0].url} alt={product.name} fill className="object-cover" sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 25vw" />
|
||||
<Image src={product.images[0].url} alt={product.name} fill className="object-contain" sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 25vw" />
|
||||
) : (
|
||||
<span className="text-5xl">🌿</span>
|
||||
)}
|
||||
|
||||
@@ -100,13 +100,16 @@ export default async function ProductPage({ params }: Props) {
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12">
|
||||
{/* Image */}
|
||||
<div>
|
||||
<div className="relative aspect-square max-h-[500px] bg-gray-50 rounded-2xl border border-gray-100 flex items-center justify-center overflow-hidden">
|
||||
{/* Detail image: bound the box at max-h 500px and let the image keep
|
||||
its natural aspect ratio with `object-contain`. Non-square photos
|
||||
render fully inside the box, never cropped. */}
|
||||
<div className="relative aspect-[5/7] max-h-[500px] bg-gray-50 rounded-2xl border border-gray-100 flex items-center justify-center overflow-hidden">
|
||||
{product.images?.[0] ? (
|
||||
<Image
|
||||
src={product.images[0].url}
|
||||
alt={product.name}
|
||||
fill
|
||||
className="object-cover"
|
||||
className="object-contain"
|
||||
priority
|
||||
sizes="(max-width: 1024px) 100vw, 50vw"
|
||||
/>
|
||||
|
||||
@@ -48,9 +48,19 @@ export default async function ProductsPage() {
|
||||
return (
|
||||
<Link key={product.id} href={`/products/${product.slug}`} className="group block">
|
||||
<div className="bg-gray-50 rounded-xl overflow-hidden border border-gray-100 hover:border-[#70ad47] transition-all hover:shadow-md">
|
||||
<div className="aspect-square relative bg-white flex items-center justify-center">
|
||||
{/* Image container: bounded box (max 5:7 aspect ratio to match
|
||||
backend thumbnail sizing). The image preserves its source
|
||||
aspect ratio via `object-contain`, so non-square images
|
||||
display without cropping. */}
|
||||
<div className="relative aspect-[5/7] max-h-72 bg-white flex items-center justify-center overflow-hidden">
|
||||
{product.images?.[0] ? (
|
||||
<Image src={product.images[0].url} alt={product.name} fill className="object-cover" sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 25vw" />
|
||||
<Image
|
||||
src={product.images[0].url}
|
||||
alt={product.name}
|
||||
fill
|
||||
className="object-contain"
|
||||
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 25vw"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-5xl">🌿</span>
|
||||
)}
|
||||
|
||||
@@ -91,9 +91,9 @@ export default async function SearchPage({ searchParams }: Props) {
|
||||
return (
|
||||
<Link key={product.id} href={`/products/${product.slug}`} className="group block">
|
||||
<div className="bg-gray-50 rounded-xl overflow-hidden border border-gray-100 hover:border-[#70ad47] transition-all hover:shadow-md">
|
||||
<div className="aspect-square relative bg-white flex items-center justify-center">
|
||||
<div className="relative aspect-[5/7] max-h-72 bg-white flex items-center justify-center overflow-hidden">
|
||||
{product.images?.[0] ? (
|
||||
<Image src={product.images[0].url} alt={product.name} fill className="object-cover" sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 25vw" />
|
||||
<Image src={product.images[0].url} alt={product.name} fill className="object-contain" sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 25vw" />
|
||||
) : (
|
||||
<span className="text-5xl">🌿</span>
|
||||
)}
|
||||
|
||||
@@ -17,6 +17,14 @@ import path from 'node:path';
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const THUMB_SIZES: Readonly<Record<string, number>> = { '40': 40, '200': 200 };
|
||||
/**
|
||||
* Maximum height ratio (h/w) for cached thumbnails. The thumbnail is scaled
|
||||
* to fit inside a `width × width * MAX_HEIGHT_RATIO` bounding box without
|
||||
* cropping, so a non-square source produces a non-square thumbnail that
|
||||
* preserves the original aspect ratio. The frontend renders the result with
|
||||
* `object-contain`.
|
||||
*/
|
||||
const MAX_HEIGHT_RATIO = 1.4;
|
||||
const SAFE_SEGMENT = /^[A-Za-z0-9._-]+$/;
|
||||
const CONTENT_TYPE_BY_EXTENSION: Readonly<Record<string, string>> = {
|
||||
'.jpg': 'image/jpeg',
|
||||
@@ -55,7 +63,10 @@ async function findOriginal(filename: string): Promise<{ root: string; buffer: B
|
||||
async function buildThumbnail(source: Buffer, width: number): Promise<Buffer | null> {
|
||||
try {
|
||||
const { default: sharp } = await import('sharp');
|
||||
return await sharp(source).resize({ width, withoutEnlargement: true }).toBuffer();
|
||||
const maxHeight = Math.round(width * MAX_HEIGHT_RATIO);
|
||||
return await sharp(source)
|
||||
.resize({ width, height: maxHeight, fit: 'inside', withoutEnlargement: true })
|
||||
.toBuffer();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -12,10 +12,18 @@ function CartItemRow({ item }: { item: CartItem }) {
|
||||
|
||||
return (
|
||||
<div className="flex gap-4 py-4 border-b border-gray-100 last:border-0">
|
||||
{/* Image */}
|
||||
<div className="w-20 h-20 bg-gray-50 rounded-lg overflow-hidden flex-shrink-0 flex items-center justify-center">
|
||||
{/* Image: bounded box (80x112 max, 5:7 ratio matching the backend
|
||||
thumbnail). The image preserves its source aspect ratio via
|
||||
`object-contain` so non-square images are not cropped. */}
|
||||
<div className="w-20 max-h-28 bg-gray-50 rounded-lg overflow-hidden flex-shrink-0 flex items-center justify-center">
|
||||
{item.imageUrl ? (
|
||||
<Image src={item.imageUrl} alt={item.productName} width={80} height={80} className="object-cover" />
|
||||
<Image
|
||||
src={item.imageUrl.replace('/uploads/', '/uploads/40/')}
|
||||
alt={item.productName}
|
||||
width={80}
|
||||
height={112}
|
||||
className="object-contain"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-3xl">🌿</span>
|
||||
)}
|
||||
|
||||
@@ -244,7 +244,7 @@ export default function CheckoutClient() {
|
||||
<div key={item.variantId} className="flex gap-3">
|
||||
<div className="w-12 h-12 bg-white rounded-lg overflow-hidden flex-shrink-0 flex items-center justify-center">
|
||||
{item.imageUrl
|
||||
? <Image src={item.imageUrl} alt={item.productName} width={48} height={48} className="object-cover" />
|
||||
? <Image src={item.imageUrl.replace('/uploads/', '/uploads/40/')} alt={item.productName} width={40} height={56} className="object-contain" />
|
||||
: <span className="text-2xl">🌿</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -34,9 +34,9 @@ export default async function FeaturedProducts() {
|
||||
{products.map((product) => (
|
||||
<Link key={product.id} href={`/products/${product.slug}`} className="group block">
|
||||
<div className="bg-gray-50 rounded-xl overflow-hidden border border-gray-100 hover:border-[#70ad47] transition-colors">
|
||||
<div className="aspect-square relative bg-white flex items-center justify-center">
|
||||
<div className="relative aspect-[5/7] max-h-72 bg-white flex items-center justify-center overflow-hidden">
|
||||
{product.images?.[0] ? (
|
||||
<Image src={product.images[0].url} alt={product.name} fill className="object-cover" sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 25vw" />
|
||||
<Image src={product.images[0].url} alt={product.name} fill className="object-contain" sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 25vw" />
|
||||
) : (
|
||||
<span className="text-5xl">🌿</span>
|
||||
)}
|
||||
|
||||
@@ -40,14 +40,14 @@ function LiveSearchResults({
|
||||
onClick={onClose}
|
||||
className="flex items-center gap-3 px-4 py-3 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<div className="w-10 h-10 bg-gray-100 rounded-lg overflow-hidden flex-shrink-0 flex items-center justify-center">
|
||||
<div className="w-10 h-14 bg-gray-100 rounded-lg overflow-hidden flex-shrink-0 flex items-center justify-center">
|
||||
{product.images?.[0] ? (
|
||||
<Image
|
||||
src={product.images[0].url}
|
||||
src={product.images[0].url.replace('/uploads/', '/uploads/40/')}
|
||||
alt={product.name}
|
||||
width={40}
|
||||
height={40}
|
||||
className="object-cover w-full h-full"
|
||||
height={56}
|
||||
className="object-contain"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-lg">🌿</span>
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 127 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
@@ -69,7 +69,7 @@ export default async function ProductPage({ params }: PageProps) {
|
||||
<img
|
||||
src={image.url}
|
||||
alt={image.altText}
|
||||
className="aspect-[4/3] w-full max-h-[600px] object-cover"
|
||||
className="block w-full max-h-[600px] object-contain"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex aspect-[4/3] items-center justify-center text-emerald-900">
|
||||
|
||||
@@ -17,6 +17,14 @@ import path from 'node:path';
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const THUMB_SIZES: Readonly<Record<string, number>> = { '40': 40, '200': 200 };
|
||||
/**
|
||||
* Maximum height ratio (h/w) for cached thumbnails. The thumbnail is scaled
|
||||
* to fit inside a `width × width * MAX_HEIGHT_RATIO` bounding box without
|
||||
* cropping, so a non-square source produces a non-square thumbnail that
|
||||
* preserves the original aspect ratio. The frontend renders the result with
|
||||
* `object-contain`.
|
||||
*/
|
||||
const MAX_HEIGHT_RATIO = 1.4;
|
||||
const SAFE_SEGMENT = /^[A-Za-z0-9._-]+$/;
|
||||
const CONTENT_TYPE_BY_EXTENSION: Readonly<Record<string, string>> = {
|
||||
'.jpg': 'image/jpeg',
|
||||
@@ -55,7 +63,10 @@ async function findOriginal(filename: string): Promise<{ root: string; buffer: B
|
||||
async function buildThumbnail(source: Buffer, width: number): Promise<Buffer | null> {
|
||||
try {
|
||||
const { default: sharp } = await import('sharp');
|
||||
return await sharp(source).resize({ width, withoutEnlargement: true }).toBuffer();
|
||||
const maxHeight = Math.round(width * MAX_HEIGHT_RATIO);
|
||||
return await sharp(source)
|
||||
.resize({ width, height: maxHeight, fit: 'inside', withoutEnlargement: true })
|
||||
.toBuffer();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -8,11 +8,15 @@ export function ProductCard({ product }: Readonly<{ product: ProductSummaryDto }
|
||||
href={product.url}
|
||||
className="group block overflow-hidden rounded-3xl border border-emerald-900/10 bg-white shadow-sm transition hover:-translate-y-0.5 hover:border-emerald-700"
|
||||
>
|
||||
<div className="flex aspect-[4/3] items-center justify-center bg-emerald-50 text-sm text-emerald-900">
|
||||
<div className="flex aspect-[5/7] max-h-72 items-center justify-center bg-emerald-50 text-sm text-emerald-900 overflow-hidden">
|
||||
{mainImage ? (
|
||||
// Keep plain img for remote/local URL compatibility until image pipeline configuration exists.
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={mainImage.url} alt={mainImage.altText} className="h-full w-full object-cover" />
|
||||
<img
|
||||
src={mainImage.url}
|
||||
alt={mainImage.altText}
|
||||
className="max-h-full max-w-full w-auto h-auto object-contain"
|
||||
/>
|
||||
) : (
|
||||
<span>Producto ecológico</span>
|
||||
)}
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user