Move uploaded images to disk storage, fix Next 16 async params/searchParams

Homepage was rendering 71MB of HTML because every uploaded image (gallery,
products, proofs, custom requests, design previews, invoices, receipts) was
stored as base64 in Postgres and double-embedded via RSC hydration payloads.
Adds src/lib/storage.ts (sharp-based compression, disk storage under
public/uploads/) and a new /api/design-uploads endpoint for images added
inside the design tool, migrates existing rows, and drops the now-unused
base64 columns and the dead ProductImage table.

Also fixes a systemic bug from the Next.js 16 upgrade where dynamic pages
across the app still destructured params/searchParams synchronously instead
of awaiting them as Promises, which was silently breaking filters/params and
crashing /products/[slug] outright.

Updates README.md and SETUP_AND_BUILD.md to reflect Postgres + Next.js 16 and
document the new image storage architecture and backup requirements.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Andymick
2026-07-22 20:56:40 +01:00
co-authored by Claude Sonnet 5
parent e23f201d3d
commit 396347b982
72 changed files with 1399 additions and 1336 deletions
+7 -12
View File
@@ -3,6 +3,7 @@
import { redirect } from 'next/navigation';
import { prisma } from '@/lib/prisma';
import { DEFAULT_PRINT_AREAS } from '@/lib/mockupDefaults';
import { saveUploadedFile } from '@/lib/storage';
function slugify(input: string) {
return input
@@ -22,15 +23,9 @@ async function uniqueSlug(base: string) {
return slug;
}
async function fileToDataUrl(file: File): Promise<string> {
const bytes = Buffer.from(await file.arrayBuffer());
const mimeType = file.type || 'image/png';
return `data:${mimeType};base64,${bytes.toString('base64')}`;
}
// Reads the per-color photo file inputs the ColorPicker renders (named
// "colorPhoto__<hexWithoutHash>" for front, "colorPhotoBack__<hexWithoutHash>" for
// back) and builds { hex: dataUrl } for whichever colors actually got a file attached.
// back) and builds { hex: url } for whichever colors actually got a file attached.
async function extractColorPhotos(
formData: FormData,
colors: string[],
@@ -41,7 +36,7 @@ async function extractColorPhotos(
for (const hex of colors) {
const file = formData.get(`${prefix}${hex.replace('#', '')}`) as File | null;
if (file && file.size > 0) {
result[hex] = await fileToDataUrl(file);
result[hex] = await saveUploadedFile(file, 'products');
}
}
return result;
@@ -103,8 +98,8 @@ export async function createProduct(formData: FormData) {
const weightKgInput = String(formData.get('weightKg') ?? '').trim();
const weightGrams = weightKgInput ? Math.round(Number(weightKgInput) * 1000) : null;
const imageUrl = imageFile && imageFile.size > 0 ? await fileToDataUrl(imageFile) : null;
const imageUrlBack = imageBackFile && imageBackFile.size > 0 ? await fileToDataUrl(imageBackFile) : null;
const imageUrl = imageFile && imageFile.size > 0 ? await saveUploadedFile(imageFile, 'products') : null;
const imageUrlBack = imageBackFile && imageBackFile.size > 0 ? await saveUploadedFile(imageBackFile, 'products') : null;
const colorPhotos = await extractColorPhotos(formData, colors, 'front');
const colorPhotosBack = await extractColorPhotos(formData, colors, 'back');
const saleFields = parseSaleFields(formData);
@@ -310,7 +305,7 @@ export async function updateProduct(formData: FormData) {
data.imageUrl = null;
data.printArea = JSON.stringify(DEFAULT_PRINT_AREAS[mockup] ?? DEFAULT_PRINT_AREAS.tshirt);
} else if (imageFile && imageFile.size > 0) {
data.imageUrl = await fileToDataUrl(imageFile);
data.imageUrl = await saveUploadedFile(imageFile, 'products');
data.printArea = JSON.stringify(
hasCustomPrintArea
? {
@@ -341,7 +336,7 @@ export async function updateProduct(formData: FormData) {
data.imageUrlBack = null;
data.printAreaBack = null;
} else if (imageBackFile && imageBackFile.size > 0) {
data.imageUrlBack = await fileToDataUrl(imageBackFile);
data.imageUrlBack = await saveUploadedFile(imageBackFile, 'products');
if (hasCustomPrintAreaBack) {
data.printAreaBack = JSON.stringify({
xPct: Number(formData.get('printXBack') ?? 0.3),