From 17c852e2bf9272e6fa85c5bdd5e0a7dfc9f1a612 Mon Sep 17 00:00:00 2001 From: Jandl-hostacny Date: Wed, 21 Oct 2026 13:29:29 +0200 Subject: [PATCH] fix: nahravanie velkych obrazkov - priamo do Supabase cez podpisanu URL, zmensenie na 2000 px Vercel odmietal poziadavky nad 4,5 MB (Request Entity Too Large) a bucket berie najviac 10 MB. Co-Authored-By: Claude Opus 5.5 --- src/app/api/upload/route.ts | 40 ++++++---------- src/components/editor/ImageUploadInput.tsx | 19 ++------ src/lib/cardImages.ts | 10 +--- src/lib/upload.ts | 56 ++++++++++++++++++++++ 4 files changed, 76 insertions(+), 49 deletions(-) create mode 100644 src/lib/upload.ts diff --git a/src/app/api/upload/route.ts b/src/app/api/upload/route.ts index 5fc99a5..18b450b 100644 --- a/src/app/api/upload/route.ts +++ b/src/app/api/upload/route.ts @@ -1,42 +1,30 @@ import { NextRequest, NextResponse } from 'next/server'; import { getSupabaseAdmin } from '@/lib/supabase'; +const BUCKET = 'newsletter-assets'; + +// Vydá podpísanú adresu na nahratie súboru priamo z prehliadača do Supabase Storage. +// Samotný súbor cez server nejde – Vercel by odmietol požiadavky nad 4,5 MB („Request Entity Too Large“). export async function POST(req: NextRequest) { try { - const formData = await req.formData(); - const file = formData.get('file') as File | null; - - if (!file) { - return NextResponse.json({ error: 'Súbor nebol poskytnutý.' }, { status: 400 }); + const { name } = (await req.json()) as { name?: string }; + if (!name) { + return NextResponse.json({ error: 'Chýba názov súboru.' }, { status: 400 }); } const admin = getSupabaseAdmin(); - const safeName = file.name.replace(/[^a-zA-Z0-9._-]/g, '_'); + const safeName = name.replace(/[^a-zA-Z0-9._-]/g, '_'); const path = `uploads/${Date.now()}-${safeName}`; - const arrayBuffer = await file.arrayBuffer(); - const buffer = Buffer.from(arrayBuffer); - - const { error: uploadError } = await admin.storage - .from('newsletter-assets') - .upload(path, buffer, { - contentType: file.type || 'image/png', - upsert: true - }); - - if (uploadError) { - console.error('Upload to Supabase Storage error:', uploadError); - return NextResponse.json({ error: uploadError.message }, { status: 500 }); + const { data, error } = await admin.storage.from(BUCKET).createSignedUploadUrl(path); + if (error || !data) { + console.error('Signed upload URL error:', error); + return NextResponse.json({ error: error?.message || 'Nepodarilo sa pripraviť nahratie' }, { status: 500 }); } - const { data: pubData } = admin.storage - .from('newsletter-assets') - .getPublicUrl(path); + const { data: pubData } = admin.storage.from(BUCKET).getPublicUrl(path); - return NextResponse.json({ - success: true, - url: pubData.publicUrl - }); + return NextResponse.json({ success: true, path, token: data.token, url: pubData.publicUrl }); } catch (err: any) { console.error('API /upload error:', err); return NextResponse.json({ error: err.message || 'Chyba servera pri nahrávaní súboru' }, { status: 500 }); diff --git a/src/components/editor/ImageUploadInput.tsx b/src/components/editor/ImageUploadInput.tsx index 3fa0239..897c4c7 100644 --- a/src/components/editor/ImageUploadInput.tsx +++ b/src/components/editor/ImageUploadInput.tsx @@ -2,6 +2,7 @@ import React, { useRef, useState } from 'react'; import { Upload, Loader2, Image as ImageIcon } from 'lucide-react'; +import { shrinkImage, uploadFile } from '@/lib/upload'; interface ImageUploadInputProps { label?: string; @@ -32,22 +33,10 @@ export const ImageUploadInput: React.FC = ({ setIsUploading(true); try { - const formData = new FormData(); - formData.append('file', file); - - const res = await fetch('/api/upload', { - method: 'POST', - body: formData - }); - - const data = await res.json(); - if (data.success && data.url) { - onChange(data.url); - } else { - alert(`Chyba pri nahrávaní obrázka: ${data.error || 'Neznáma chyba'}`); - } + const small = await shrinkImage(file); + onChange(await uploadFile(small, small.name)); } catch (err: any) { - alert(`Chyba: ${err.message}`); + alert(`Chyba pri nahrávaní obrázka: ${err.message}`); } finally { setIsUploading(false); if (fileInputRef.current) { diff --git a/src/lib/cardImages.ts b/src/lib/cardImages.ts index 7beecf8..d28ea77 100644 --- a/src/lib/cardImages.ts +++ b/src/lib/cardImages.ts @@ -4,17 +4,11 @@ import { toJpeg } from 'html-to-image'; import { CardProduct, NewsletterCampaign } from '@/types/newsletter'; import { cardContextFor, cardVisualKey, cardVisualHeight } from './productCard'; +import { uploadFile } from './upload'; async function uploadJpeg(dataUrl: string, fileName: string): Promise { const blob = await (await fetch(dataUrl)).blob(); - const formData = new FormData(); - formData.append('file', new File([blob], fileName, { type: 'image/jpeg' })); - const res = await fetch('/api/upload', { method: 'POST', body: formData }); - const data = await res.json(); - if (!data.success || !data.url) { - throw new Error(data.error || 'Nahratie obrázka karty zlyhalo'); - } - return data.url; + return uploadFile(blob, fileName); } export async function prepareCardImages( diff --git a/src/lib/upload.ts b/src/lib/upload.ts new file mode 100644 index 0000000..ed2118e --- /dev/null +++ b/src/lib/upload.ts @@ -0,0 +1,56 @@ +// Nahratie súboru do úložiska: server vydá podpísanú adresu, súbor ide z prehliadača priamo do Supabase +// (cez Vercel by väčšie súbory neprešli – limit 4,5 MB na požiadavku). +import { supabase } from './supabase'; + +export async function uploadFile(file: Blob, name: string): Promise { + const res = await fetch('/api/upload', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name }) + }); + const data = await res.json().catch(() => ({ error: `Server vrátil chybu ${res.status}` })); + if (!data.success || !data.path || !data.token) { + throw new Error(data.error || 'Nahratie súboru zlyhalo'); + } + + const { error } = await supabase.storage + .from('newsletter-assets') + .uploadToSignedUrl(data.path, data.token, file, { contentType: file.type || 'image/png' }); + if (error) throw new Error(error.message); + + return data.url; +} + +// Zmenšenie veľkej fotky pred nahratím: v e-maile má obrázok najviac 600 px (×2 pre ostré displeje), +// tlačové podklady s tisíckami px a desiatkami MB sú zbytočné (a bucket berie najviac 10 MB). +// PNG ostáva PNG kvôli priehľadnosti; SVG a GIF (animácie) sa nechávajú tak. +const MAX_SIDE = 2000; +const MAX_BYTES = 9 * 1024 * 1024; + +export async function shrinkImage(file: File): Promise { + if (!/^image\/(png|jpeg|webp)$/.test(file.type)) return file; + const bitmap = await createImageBitmap(file).catch(() => null); + if (!bitmap) return file; + if (Math.max(bitmap.width, bitmap.height) <= MAX_SIDE && file.size <= MAX_BYTES) { + bitmap.close(); + return file; + } + + const type = file.type === 'image/png' ? 'image/png' : 'image/jpeg'; + let side = Math.min(MAX_SIDE, Math.max(bitmap.width, bitmap.height)); + let blob: Blob | null = null; + for (let i = 0; i < 4; i++) { + const k = side / Math.max(bitmap.width, bitmap.height); + const canvas = document.createElement('canvas'); + canvas.width = Math.round(bitmap.width * k); + canvas.height = Math.round(bitmap.height * k); + canvas.getContext('2d')!.drawImage(bitmap, 0, 0, canvas.width, canvas.height); + blob = await new Promise((res) => canvas.toBlob(res, type, 0.9)); + if (!blob || blob.size <= MAX_BYTES) break; + side = Math.round(side * 0.75); + } + bitmap.close(); + if (!blob) return file; + const name = type === 'image/jpeg' ? file.name.replace(/\.\w+$/, '.jpg') : file.name; + return new File([blob], name, { type }); +}