feat(F-059): completed feature

This commit is contained in:
chattie
2026-08-19 15:32:56 +02:00
parent 4cdb5fb487
commit 72ba84456c
32 changed files with 369 additions and 57 deletions

View File

@@ -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">
🌿

View File

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

View File

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

View File

@@ -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