Skip to content

Commit 3b2b36c

Browse files
Merge pull request #46 from ShipFriend0516/feature/image-upload
[Feature] 이미지 업로드 기능 구현
2 parents f1d60b9 + 84b8562 commit 3b2b36c

File tree

5 files changed

+201
-1
lines changed

5 files changed

+201
-1
lines changed

app/api/upload/route.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { handleUpload, type HandleUploadBody } from '@vercel/blob/client';
2+
import { NextResponse } from 'next/server';
3+
4+
export async function POST(request: Request): Promise<NextResponse> {
5+
const body = (await request.json()) as HandleUploadBody;
6+
7+
try {
8+
const jsonResponse = await handleUpload({
9+
body,
10+
request,
11+
onBeforeGenerateToken: async (
12+
pathname
13+
/* clientPayload */
14+
) => {
15+
return {
16+
allowedContentTypes: ['image/jpeg', 'image/png', 'image/gif'],
17+
tokenPayload: JSON.stringify({
18+
// optional, sent to your server on upload completion
19+
// you could pass a user id from auth, or a value from clientPayload
20+
}),
21+
};
22+
},
23+
onUploadCompleted: async ({ blob, tokenPayload }) => {
24+
// Get notified of client upload completion
25+
// ⚠️ This will not work on `localhost` websites,
26+
// Use ngrok or similar to get the full upload flow
27+
28+
console.log('blob upload completed', blob, tokenPayload);
29+
30+
try {
31+
// Run any logic after the file upload completed
32+
// const { userId } = JSON.parse(tokenPayload);
33+
// await db.update({ avatar: blob.url, userId });
34+
return;
35+
} catch (error) {
36+
throw new Error('Could not update user');
37+
}
38+
},
39+
});
40+
41+
return NextResponse.json(jsonResponse);
42+
} catch (error) {
43+
return NextResponse.json(
44+
{ error: (error as Error).message },
45+
{ status: 400 } // The webhook will retry 5 times waiting for a 200
46+
);
47+
}
48+
}

app/entities/post/write/BlogForm.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import Overlay from '@/app/entities/common/Overlay/Overlay';
1717
import { FaPlus } from 'react-icons/fa6';
1818
import CreateSeriesOverlayContainer from '@/app/entities/series/CreateSeriesOverlayContainer';
1919
import { getAllSeriesData } from '@/app/entities/series/api/series';
20-
import LoadingIndicator from '@/app/entities/common/Loading/LoadingIndicator';
20+
import UploadImageContainer from '@/app/entities/post/write/UploadImageContainer';
2121

2222
const MDEditor = dynamic(() => import('@uiw/react-md-editor'), { ssr: false });
2323

@@ -135,6 +135,11 @@ const BlogForm = () => {
135135
}
136136
};
137137

138+
const handleLinkCopy = (image: string) => {
139+
navigator.clipboard.writeText(image);
140+
toast.success('이미지 링크가 복사되었습니다.');
141+
};
142+
138143
return (
139144
<div className={'px-16'}>
140145
<input
@@ -193,6 +198,7 @@ const BlogForm = () => {
193198
height={500}
194199
visibleDragbar={false}
195200
/>
201+
<UploadImageContainer onClick={handleLinkCopy} />
196202
{errors && (
197203
<div className={'mt-2'}>
198204
{errors.slice(0, 3).map((error, index) => (
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
'use client';
2+
import UploadedImage from '@/app/entities/post/write/UploadedImage';
3+
import { FaImage } from 'react-icons/fa';
4+
import { upload } from '@vercel/blob/client';
5+
import { ChangeEvent, useState } from 'react';
6+
7+
interface UploadImageContainerProps {
8+
onClick: (link: string) => void;
9+
}
10+
const UploadImageContainer = ({ onClick }: UploadImageContainerProps) => {
11+
const [uploadedImages, setUploadedImages] = useState<string[]>([]);
12+
const uploadToBlob = async (event: ChangeEvent) => {
13+
try {
14+
event.preventDefault();
15+
const target = event.target as HTMLInputElement;
16+
if (!target.files) {
17+
throw new Error('이미지가 선택되지 않았습니다.');
18+
}
19+
20+
const file = target.files[0];
21+
22+
const timestamp = new Date().getTime();
23+
const pathname = `/images/${timestamp}-${file.name}`;
24+
const newBlob = await upload(pathname, file, {
25+
access: 'public',
26+
handleUploadUrl: '/api/upload',
27+
});
28+
29+
setUploadedImages([...uploadedImages, newBlob.url]);
30+
return;
31+
} catch (error) {
32+
console.error('업로드 실패:', error);
33+
throw error;
34+
}
35+
};
36+
37+
return (
38+
<div className={'w-full mt-4'}>
39+
<div className={'flex justify-between my-1'}>
40+
<div>
41+
<span className={'text-xl font-bold'}>업로드된 이미지</span>
42+
<p>클릭하여 링크 복사</p>
43+
</div>
44+
<div
45+
className={
46+
'cursor-pointer relative w-12 h-12 bg-emerald-500 rounded-md overflow-hidden'
47+
}
48+
>
49+
<FaImage
50+
className={
51+
'absolute top-1/2 left-1/2 -translate-y-1/2 -translate-x-1/2 pointer-events-none'
52+
}
53+
/>
54+
<input
55+
type={'file'}
56+
multiple={true}
57+
placeholder={'이미지 업로드'}
58+
onChange={uploadToBlob}
59+
className={
60+
'w-full h-full file:hidden text-transparent px-2 hover:bg-emerald-600'
61+
}
62+
accept={'image/*'}
63+
></input>
64+
</div>
65+
</div>
66+
67+
<ul
68+
className={
69+
'w-full border-t border-b px-4 py-4 bg-gray-100 whitespace-nowrap space-x-4 overflow-x-scroll gap-2'
70+
}
71+
>
72+
{uploadedImages.map((image, index) => (
73+
<UploadedImage key={index} onClick={onClick} image={image} />
74+
))}
75+
</ul>
76+
</div>
77+
);
78+
};
79+
80+
export default UploadImageContainer;
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import Image from 'next/image';
2+
3+
interface UploadedImageProps {
4+
onClick: (link: string) => void;
5+
image: string;
6+
}
7+
8+
const UploadedImage = ({ onClick, image }: UploadedImageProps) => {
9+
return (
10+
<li
11+
className={
12+
'relative rounded-md overflow-hidden w-1/3 aspect-video inline-block hover:opacity-80 cursor-pointer hover:shadow-lg group'
13+
}
14+
onClick={() => onClick(image)}
15+
>
16+
<p
17+
className={
18+
' z-10 absolute opacity-0 group-hover:opacity-100 top-1/2 left-1/2 -translate-y-1/2 -translate-x-1/2 text-xl font-bold text-black'
19+
}
20+
>
21+
링크 복사
22+
</p>
23+
<Image
24+
className={'group object-cover'}
25+
src={image}
26+
alt={'이미지'}
27+
fill={true}
28+
sizes={'400'}
29+
/>
30+
</li>
31+
);
32+
};
33+
34+
export default UploadedImage;

app/globals.css

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,16 @@ article.post .post-body {
8989
line-height: 150%;
9090
}
9191

92+
article.post .post-body p:has(img) {
93+
text-align: center;
94+
}
95+
96+
article.post .post-body img {
97+
max-width: 100%;
98+
height: auto;
99+
margin: 1em 0;
100+
}
101+
92102
article.post h3 {
93103
font-size: 1.25rem;
94104
font-weight: bold;
@@ -139,3 +149,25 @@ section.footer .disabled {
139149
transition-delay: unset;
140150
transition-timing-function: ease-in-out;
141151
}
152+
153+
/* HR Style */
154+
article.post .post-body hr {
155+
border: 0px solid;
156+
height: 1px;
157+
background-image: linear-gradient(to right, rgba(0, 0, 0, 0), rgba(0, 100, 0, 0.75), rgba(0, 0, 0, 0));
158+
margin: 32px 0px;
159+
display: block;
160+
}
161+
162+
article.post .post-body hr::before {
163+
position: absolute;
164+
background-color: #efefef;
165+
border: 10px solid #006400;
166+
border-top: 10px solid transparent;
167+
border-radius: 2px;
168+
padding: 0px;
169+
transform: rotate(180deg);
170+
left: 50%;
171+
margin: -5px 0px 0px -21px;
172+
content: "";
173+
}

0 commit comments

Comments
 (0)