From ded54ed845cbd8d6ab9277fc68af210f9b65a10b Mon Sep 17 00:00:00 2001 From: JeongwooSeo Date: Tue, 2 Dec 2025 00:21:57 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EC=9E=90?= =?UTF-8?q?=EB=8F=99=EC=B5=9C=EC=A0=81=ED=99=94=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/upload/route.ts | 74 ++++++++++--------- app/entities/post/write/BlogForm.tsx | 9 ++- .../post/write/UploadImageContainer.tsx | 27 ++++--- app/entities/post/write/UploadedImage.tsx | 16 ++-- 4 files changed, 73 insertions(+), 53 deletions(-) diff --git a/app/api/upload/route.ts b/app/api/upload/route.ts index 13c2651..7397d1e 100644 --- a/app/api/upload/route.ts +++ b/app/api/upload/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from 'next/server'; import { getServerSession } from 'next-auth'; -import { handleUpload, type HandleUploadBody } from '@vercel/blob/client'; +import sharp from 'sharp'; +import { put } from '@vercel/blob'; export async function POST(request: Request): Promise { const session = await getServerSession(); @@ -9,47 +10,48 @@ export async function POST(request: Request): Promise { return new NextResponse('Unauthorized', { status: 401 }); } - const body = (await request.json()) as HandleUploadBody; - try { - const jsonResponse = await handleUpload({ - body, - request, - onBeforeGenerateToken: async ( - pathname - /* clientPayload */ - ) => { - return { - allowedContentTypes: ['image/*'], - tokenPayload: JSON.stringify({ - // optional, sent to your server on upload completion - // you could pass a user id from auth, or a value from clientPayload - }), - }; - }, - onUploadCompleted: async ({ blob, tokenPayload }) => { - // Get notified of client upload completion - // ⚠️ This will not work on `localhost` websites, - // Use ngrok or similar to get the full upload flow - - console.log('blob upload completed', blob, tokenPayload); - - try { - // Run any logic after the file upload completed - // const { userId } = JSON.parse(tokenPayload); - // await db.update({ avatar: blob.url, userId }); - return; - } catch (error) { - throw new Error('Could not update user'); - } - }, + const formData = await request.formData(); + const file = formData.get('file') as File; + + if (!file) { + return NextResponse.json({ error: 'No file provided' }, { status: 400 }); + } + + if (!file.type.startsWith('image/')) { + return NextResponse.json({ error: 'File must be an image' }, { status: 400 }); + } + + const arrayBuffer = await file.arrayBuffer(); + const buffer = Buffer.from(arrayBuffer); + + const timestamp = Date.now(); + const baseFilename = file.name.replace(/\.[^/.]+$/, ''); + const pathname = `/images/${timestamp}-${baseFilename}.webp`; + + // Convert to WebP with max 1920px width + const webpBuffer = await sharp(buffer) + .resize(1920, null, { + withoutEnlargement: true, + fit: 'inside', + }) + .webp({ quality: 85 }) + .toBuffer(); + + const blob = await put(pathname, webpBuffer, { + access: 'public', + contentType: 'image/webp', }); - return NextResponse.json(jsonResponse); + return NextResponse.json({ + success: true, + url: blob.url, + }); } catch (error) { + console.error('Upload error:', error); return NextResponse.json( { error: (error as Error).message }, - { status: 400 } // The webhook will retry 5 times waiting for a 200 + { status: 500 } ); } } diff --git a/app/entities/post/write/BlogForm.tsx b/app/entities/post/write/BlogForm.tsx index 4080bee..5c7ed57 100644 --- a/app/entities/post/write/BlogForm.tsx +++ b/app/entities/post/write/BlogForm.tsx @@ -37,10 +37,13 @@ const BlogForm = () => { const [createSeriesOpen, setCreateSeriesOpen] = useState(false); useBlockNavigate({ title: formData.title, content: formData.content || '' }); - - const handleFieldChange = (field: string, value: string | boolean | string[]) => { + + const handleFieldChange = ( + field: string, + value: string | boolean | string[] + ) => { setFormData({ [field]: value }); - } + }; return (
diff --git a/app/entities/post/write/UploadImageContainer.tsx b/app/entities/post/write/UploadImageContainer.tsx index 6073b52..f5f5be6 100644 --- a/app/entities/post/write/UploadImageContainer.tsx +++ b/app/entities/post/write/UploadImageContainer.tsx @@ -2,7 +2,6 @@ import { ChangeEvent, Dispatch, SetStateAction } from 'react'; import { FaImage } from 'react-icons/fa'; import UploadedImage from '@/app/entities/post/write/UploadedImage'; -import { upload } from '@vercel/blob/client'; interface UploadImageContainerProps { onClick: (link: string) => void; @@ -34,14 +33,24 @@ const UploadImageContainer = ({ throw new Error('이미지 파일만 업로드할 수 있습니다.'); } - const timestamp = new Date().getTime(); - const pathname = `/images/${timestamp}-${file.name}`; - const newBlob = await upload(pathname, file, { - access: 'public', - handleUploadUrl: '/api/upload', + const formData = new FormData(); + formData.append('file', file); + + const response = await fetch('/api/upload', { + method: 'POST', + body: formData, }); - setUploadedImages((prev) => [...prev, newBlob.url]); + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || '업로드 실패'); + } + + const data = await response.json(); + + if (data.success && data.url) { + setUploadedImages((prev) => [...prev, data.url]); + } } return; @@ -86,8 +95,8 @@ const UploadImageContainer = ({ 'w-full border px-4 py-4 bg-gray-100 whitespace-nowrap space-x-4 overflow-x-scroll gap-2 min-h-40' } > - {uploadedImages.map((image, index) => ( - + {uploadedImages.map((imageUrl, index) => ( + ))}
diff --git a/app/entities/post/write/UploadedImage.tsx b/app/entities/post/write/UploadedImage.tsx index d10f815..6828ff3 100644 --- a/app/entities/post/write/UploadedImage.tsx +++ b/app/entities/post/write/UploadedImage.tsx @@ -2,16 +2,22 @@ import Image from 'next/image'; interface UploadedImageProps { onClick: (link: string) => void; - image: string; + imageUrl: string; } -const UploadedImage = ({ onClick, image }: UploadedImageProps) => { +const UploadedImage = ({ onClick, imageUrl }: UploadedImageProps) => { + const markdownSyntax = `![이미지](${imageUrl})`; + + const handleClick = () => { + onClick(markdownSyntax); + }; + return (
  • onClick(image)} + onClick={handleClick} >

    {

    {'이미지'}
  • );