Skip to content

Commit 18f8a30

Browse files
Merge pull request #63 from ShipFriend0516/feature/image-multisize-optimization
feat: 이미지 업로드시 자동 webp 최적화 기능 구현
2 parents c33fc20 + ded54ed commit 18f8a30

4 files changed

Lines changed: 73 additions & 53 deletions

File tree

app/api/upload/route.ts

Lines changed: 38 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { NextResponse } from 'next/server';
22
import { getServerSession } from 'next-auth';
3-
import { handleUpload, type HandleUploadBody } from '@vercel/blob/client';
3+
import sharp from 'sharp';
4+
import { put } from '@vercel/blob';
45

56
export async function POST(request: Request): Promise<NextResponse> {
67
const session = await getServerSession();
@@ -9,47 +10,48 @@ export async function POST(request: Request): Promise<NextResponse> {
910
return new NextResponse('Unauthorized', { status: 401 });
1011
}
1112

12-
const body = (await request.json()) as HandleUploadBody;
13-
1413
try {
15-
const jsonResponse = await handleUpload({
16-
body,
17-
request,
18-
onBeforeGenerateToken: async (
19-
pathname
20-
/* clientPayload */
21-
) => {
22-
return {
23-
allowedContentTypes: ['image/*'],
24-
tokenPayload: JSON.stringify({
25-
// optional, sent to your server on upload completion
26-
// you could pass a user id from auth, or a value from clientPayload
27-
}),
28-
};
29-
},
30-
onUploadCompleted: async ({ blob, tokenPayload }) => {
31-
// Get notified of client upload completion
32-
// ⚠️ This will not work on `localhost` websites,
33-
// Use ngrok or similar to get the full upload flow
34-
35-
console.log('blob upload completed', blob, tokenPayload);
36-
37-
try {
38-
// Run any logic after the file upload completed
39-
// const { userId } = JSON.parse(tokenPayload);
40-
// await db.update({ avatar: blob.url, userId });
41-
return;
42-
} catch (error) {
43-
throw new Error('Could not update user');
44-
}
45-
},
14+
const formData = await request.formData();
15+
const file = formData.get('file') as File;
16+
17+
if (!file) {
18+
return NextResponse.json({ error: 'No file provided' }, { status: 400 });
19+
}
20+
21+
if (!file.type.startsWith('image/')) {
22+
return NextResponse.json({ error: 'File must be an image' }, { status: 400 });
23+
}
24+
25+
const arrayBuffer = await file.arrayBuffer();
26+
const buffer = Buffer.from(arrayBuffer);
27+
28+
const timestamp = Date.now();
29+
const baseFilename = file.name.replace(/\.[^/.]+$/, '');
30+
const pathname = `/images/${timestamp}-${baseFilename}.webp`;
31+
32+
// Convert to WebP with max 1920px width
33+
const webpBuffer = await sharp(buffer)
34+
.resize(1920, null, {
35+
withoutEnlargement: true,
36+
fit: 'inside',
37+
})
38+
.webp({ quality: 85 })
39+
.toBuffer();
40+
41+
const blob = await put(pathname, webpBuffer, {
42+
access: 'public',
43+
contentType: 'image/webp',
4644
});
4745

48-
return NextResponse.json(jsonResponse);
46+
return NextResponse.json({
47+
success: true,
48+
url: blob.url,
49+
});
4950
} catch (error) {
51+
console.error('Upload error:', error);
5052
return NextResponse.json(
5153
{ error: (error as Error).message },
52-
{ status: 400 } // The webhook will retry 5 times waiting for a 200
54+
{ status: 500 }
5355
);
5456
}
5557
}

app/entities/post/write/BlogForm.tsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,10 +37,13 @@ const BlogForm = () => {
3737

3838
const [createSeriesOpen, setCreateSeriesOpen] = useState(false);
3939
useBlockNavigate({ title: formData.title, content: formData.content || '' });
40-
41-
const handleFieldChange = (field: string, value: string | boolean | string[]) => {
40+
41+
const handleFieldChange = (
42+
field: string,
43+
value: string | boolean | string[]
44+
) => {
4245
setFormData({ [field]: value });
43-
}
46+
};
4447

4548
return (
4649
<div className={'px-16'}>

app/entities/post/write/UploadImageContainer.tsx

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
import { ChangeEvent, Dispatch, SetStateAction } from 'react';
33
import { FaImage } from 'react-icons/fa';
44
import UploadedImage from '@/app/entities/post/write/UploadedImage';
5-
import { upload } from '@vercel/blob/client';
65

76
interface UploadImageContainerProps {
87
onClick: (link: string) => void;
@@ -34,14 +33,24 @@ const UploadImageContainer = ({
3433
throw new Error('이미지 파일만 업로드할 수 있습니다.');
3534
}
3635

37-
const timestamp = new Date().getTime();
38-
const pathname = `/images/${timestamp}-${file.name}`;
39-
const newBlob = await upload(pathname, file, {
40-
access: 'public',
41-
handleUploadUrl: '/api/upload',
36+
const formData = new FormData();
37+
formData.append('file', file);
38+
39+
const response = await fetch('/api/upload', {
40+
method: 'POST',
41+
body: formData,
4242
});
4343

44-
setUploadedImages((prev) => [...prev, newBlob.url]);
44+
if (!response.ok) {
45+
const error = await response.json();
46+
throw new Error(error.error || '업로드 실패');
47+
}
48+
49+
const data = await response.json();
50+
51+
if (data.success && data.url) {
52+
setUploadedImages((prev) => [...prev, data.url]);
53+
}
4554
}
4655

4756
return;
@@ -86,8 +95,8 @@ const UploadImageContainer = ({
8695
'w-full border px-4 py-4 bg-gray-100 whitespace-nowrap space-x-4 overflow-x-scroll gap-2 min-h-40'
8796
}
8897
>
89-
{uploadedImages.map((image, index) => (
90-
<UploadedImage key={index} onClick={onClick} image={image} />
98+
{uploadedImages.map((imageUrl, index) => (
99+
<UploadedImage key={index} onClick={onClick} imageUrl={imageUrl} />
91100
))}
92101
</ul>
93102
</div>

app/entities/post/write/UploadedImage.tsx

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,22 @@ import Image from 'next/image';
22

33
interface UploadedImageProps {
44
onClick: (link: string) => void;
5-
image: string;
5+
imageUrl: string;
66
}
77

8-
const UploadedImage = ({ onClick, image }: UploadedImageProps) => {
8+
const UploadedImage = ({ onClick, imageUrl }: UploadedImageProps) => {
9+
const markdownSyntax = `![이미지](${imageUrl})`;
10+
11+
const handleClick = () => {
12+
onClick(markdownSyntax);
13+
};
14+
915
return (
1016
<li
1117
className={
1218
'relative rounded-md overflow-hidden max-w-[240px] w-full h-full aspect-video inline-block hover:opacity-80 cursor-pointer hover:shadow-lg group'
1319
}
14-
onClick={() => onClick(image)}
20+
onClick={handleClick}
1521
>
1622
<p
1723
className={
@@ -22,10 +28,10 @@ const UploadedImage = ({ onClick, image }: UploadedImageProps) => {
2228
</p>
2329
<Image
2430
className={'group object-cover'}
25-
src={image}
31+
src={imageUrl}
2632
alt={'이미지'}
2733
fill={true}
28-
sizes={'400'}
34+
sizes={'240px'}
2935
/>
3036
</li>
3137
);

0 commit comments

Comments
 (0)