From 3006dbaabd2271d9add1db91add271ae6af40aa3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A1=B0=EC=98=81=EC=9A=B0?= Date: Sun, 7 Sep 2025 18:19:42 +0900 Subject: [PATCH 1/8] =?UTF-8?q?feat:=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20?= =?UTF-8?q?=EC=97=85=EB=A1=9C=EB=93=9C=20API=20=ED=98=B8=EC=B6=9C=20?= =?UTF-8?q?=EB=A9=94=EC=84=9C=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- gitmon-client/src/lib/upload.ts | 76 +++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 gitmon-client/src/lib/upload.ts diff --git a/gitmon-client/src/lib/upload.ts b/gitmon-client/src/lib/upload.ts new file mode 100644 index 0000000..25b9632 --- /dev/null +++ b/gitmon-client/src/lib/upload.ts @@ -0,0 +1,76 @@ +// 이미지 업로드 유틸리티: 멀티파트 업로드 후 이미지 URL을 반환합니다. + +const MAX_BYTES = 10 * 1024 * 1024 // 10MB +const ALLOWED_EXTENSIONS = ['jpg', 'jpeg', 'png', 'webp', 'gif'] + +function getExtension(filename: string): string { + const parts = filename.split('.') + return parts.length > 1 ? parts.pop()!.toLowerCase() : '' +} + +function assertPreconditions(file: File) { + const ext = getExtension(file.name) + if (!file.type.startsWith('image/')) { + throw new Error('이미지 파일만 업로드할 수 있습니다.') + } + if (!ALLOWED_EXTENSIONS.includes(ext)) { + throw new Error('허용되지 않은 이미지 확장자입니다.') + } + if (file.size > MAX_BYTES) { + throw new Error('이미지 용량이 너무 큽니다. 최대 10MB까지 허용됩니다.') + } +} + +export async function uploadImage(file: File, token: string): Promise { + assertPreconditions(file) + + const baseUrl = process.env.NEXT_PUBLIC_API_DOMAIN + if (!baseUrl) { + throw new Error('이미지 업로드 설정이 올바르지 않습니다(NEXT_PUBLIC_API_DOMAIN).') + } + + const path = '/api/v1/posting/images' + const url = `${baseUrl}${path}` + + const form = new FormData() + form.append('image', file) + + const res = await fetch(url, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + }, + body: form, + }) + + let payload: any = await res.json() + + if (res.status === 201) { + const imageUrl = payload?.data + if (typeof imageUrl === 'string' && imageUrl.length > 0) { + return imageUrl + } + throw new Error('이미지 업로드 응답이 올바르지 않습니다.') + } + + const serverMessage = payload?.errorMessage as string | undefined + + if (res.status === 400) { + throw new Error(serverMessage || '허용되지 않은 이미지 확장자입니다.') + } + if (res.status === 409) { + throw new Error(serverMessage || '레포지토리가 아직 설정되지 않았습니다.') + } + if (res.status === 401 || res.status === 403) { + throw new Error(serverMessage || '인증이 만료되었거나 권한이 없습니다.') + } + + throw new Error(serverMessage || '이미지 업로드에 실패했습니다.') +} + +export const ImageUploadPolicy = { + maxBytes: MAX_BYTES, + allowedExtensions: ALLOWED_EXTENSIONS, +} + + From 581ebb52005ff3c162adaaa9f2e4dc7f1e44919d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A1=B0=EC=98=81=EC=9A=B0?= Date: Sun, 7 Sep 2025 18:21:35 +0900 Subject: [PATCH 2/8] =?UTF-8?q?feat:=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20?= =?UTF-8?q?=EC=97=85=EB=A1=9C=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 업로드 케이스: 이미지 드롭, 이미지 붙여넣기, 이미지 업로더 컴포넌트 --- .../[id]/[repo]/[slug]/update/UpdatePost.tsx | 2 +- gitmon-client/src/app/create-post/AddPost.tsx | 2 +- .../src/components/Post/PostForm.tsx | 55 ++++++++++++++++++- .../src/components/Post/image-uploader.tsx | 34 +++++++++--- 4 files changed, 80 insertions(+), 13 deletions(-) diff --git a/gitmon-client/src/app/[id]/[repo]/[slug]/update/UpdatePost.tsx b/gitmon-client/src/app/[id]/[repo]/[slug]/update/UpdatePost.tsx index f521154..48a23af 100644 --- a/gitmon-client/src/app/[id]/[repo]/[slug]/update/UpdatePost.tsx +++ b/gitmon-client/src/app/[id]/[repo]/[slug]/update/UpdatePost.tsx @@ -99,7 +99,7 @@ export default function UpdatePost({ token, post }: UpdatePostProps) { return (
- +
) } diff --git a/gitmon-client/src/app/create-post/AddPost.tsx b/gitmon-client/src/app/create-post/AddPost.tsx index 04863ae..68164c3 100644 --- a/gitmon-client/src/app/create-post/AddPost.tsx +++ b/gitmon-client/src/app/create-post/AddPost.tsx @@ -91,7 +91,7 @@ export default function AddPost({ token }: { token: string | null }) { return (
- +
) } diff --git a/gitmon-client/src/components/Post/PostForm.tsx b/gitmon-client/src/components/Post/PostForm.tsx index 9621840..e3257a7 100644 --- a/gitmon-client/src/components/Post/PostForm.tsx +++ b/gitmon-client/src/components/Post/PostForm.tsx @@ -9,6 +9,8 @@ import { Card } from '@components/ui/card' import MarkdownPreview from './markdown-preview' import ToolbarButton from './toolbar-button' import ImageUploader from './image-uploader' +import { uploadImage } from '@lib/upload' +import { toast } from 'sonner' import { Bold, Italic, @@ -31,13 +33,15 @@ import { Post } from '@lib/types' interface PostFormProps { onPostSaved: (post: { title: string; content: string }) => void post: Omit | null + token?: string | null } -export function PostForm({ onPostSaved, post }: PostFormProps) { +export function PostForm({ onPostSaved, post, token }: PostFormProps) { const params = useParams() const [title, setTitle] = useState(post?.title || '') const [content, setContent] = useState(post?.content || '') const [showImageUploader, setShowImageUploader] = useState(false) + const [isUploading, setIsUploading] = useState(false) const textareaRef = useRef(null) const insertTextAtCursor = useCallback( @@ -106,10 +110,53 @@ export function PostForm({ onPostSaved, post }: PostFormProps) { } const handleImageInsert = (imageUrl: string) => { - insertTextAtCursor(`![Image](${imageUrl})`) + insertTextAtCursor(`![](${imageUrl})`) setShowImageUploader(false) } + const handleFilesToUpload = async (files: FileList | File[]) => { + if (!files || files.length === 0) return + const fileArray = Array.from(files) + const imageFile = fileArray.find(f => f.type.startsWith('image/')) + if (!imageFile) return + if (!token) { + toast('로그인이 필요하거나 토큰이 없습니다.') + return + } + try { + setIsUploading(true) + const url = await uploadImage(imageFile, token) + insertTextAtCursor(`![](${url})`) + } catch (e: any) { + toast(e?.message || '이미지 업로드에 실패했습니다.') + } finally { + setIsUploading(false) + } + } + + const onDragOver = (e: React.DragEvent) => { + e.preventDefault() + } + + const onDrop = async (e: React.DragEvent) => { + e.preventDefault() + const dt = e.dataTransfer + if (dt?.files && dt.files.length > 0) { + await handleFilesToUpload(dt.files) + } + } + + const onPaste = async (e: React.ClipboardEvent) => { + const items = e.clipboardData?.files + if (items && items.length > 0) { + const hasImage = Array.from(items).some(f => f.type.startsWith('image/')) + if (hasImage) { + e.preventDefault() + await handleFilesToUpload(items) + } + } + } + return (
@@ -190,6 +237,9 @@ export function PostForm({ onPostSaved, post }: PostFormProps) { placeholder="Write your post content in markdown..." value={content} onChange={e => setContent(e.target.value)} + onDragOver={onDragOver} + onDrop={onDrop} + onPaste={onPaste} className="min-h-[400px] font-mono text-sm resize-y" /> @@ -218,6 +268,7 @@ export function PostForm({ onPostSaved, post }: PostFormProps) { {showImageUploader && ( setShowImageUploader(false)} /> diff --git a/gitmon-client/src/components/Post/image-uploader.tsx b/gitmon-client/src/components/Post/image-uploader.tsx index a9bd323..67e9ec6 100644 --- a/gitmon-client/src/components/Post/image-uploader.tsx +++ b/gitmon-client/src/components/Post/image-uploader.tsx @@ -16,17 +16,20 @@ import { import { toast } from 'sonner' import { Upload, LinkIcon } from 'lucide-react' import Image from 'next/image' +import { uploadImage } from '@lib/upload' interface ImageUploaderProps { onImageInsert: (imageUrl: string) => void onCancel: () => void + token: string | null } -export default function ImageUploader({ onImageInsert, onCancel }: ImageUploaderProps) { +export default function ImageUploader({ onImageInsert, onCancel, token }: ImageUploaderProps) { const [uploadType, setUploadType] = useState<'file' | 'url'>('file') const [imageUrl, setImageUrl] = useState('') const [file, setFile] = useState(null) const [previewUrl, setPreviewUrl] = useState(null) + const [isUploading, setIsUploading] = useState(false) const handleFileChange = (e: React.ChangeEvent) => { if (e.target.files && e.target.files[0]) { @@ -42,7 +45,7 @@ export default function ImageUploader({ onImageInsert, onCancel }: ImageUploader } } - const handleInsert = () => { + const handleInsert = async () => { if (uploadType === 'url') { if (!imageUrl.trim()) { toast('Please enter an image URL') @@ -54,11 +57,18 @@ export default function ImageUploader({ onImageInsert, onCancel }: ImageUploader toast('Please select an image file') return } - - // In a real application, you would upload the file to a server - // and get back a URL. For this demo, we'll use the preview URL. - if (previewUrl) { - onImageInsert(previewUrl) + if (!token) { + toast('로그인이 필요하거나 토큰이 없습니다.') + return + } + try { + setIsUploading(true) + const url = await uploadImage(file, token) + onImageInsert(url) + } catch (e: any) { + toast(e?.message || '이미지 업로드에 실패했습니다.') + } finally { + setIsUploading(false) } } } @@ -100,6 +110,8 @@ export default function ImageUploader({ onImageInsert, onCancel }: ImageUploader Preview
@@ -121,6 +133,8 @@ export default function ImageUploader({ onImageInsert, onCancel }: ImageUploader Preview { toast('Failed to load image from URL') @@ -133,10 +147,12 @@ export default function ImageUploader({ onImageInsert, onCancel }: ImageUploader
- - + From 66e6e2fce0e3e159fe692d6e3f9d22587e4e57ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A1=B0=EC=98=81=EC=9A=B0?= Date: Sun, 7 Sep 2025 18:28:23 +0900 Subject: [PATCH 3/8] =?UTF-8?q?fix:=20=EC=9E=85=EB=A0=A5=EA=B0=92=EC=9D=84?= =?UTF-8?q?=20=EB=B9=88=20=EB=AC=B8=EC=9E=90=EC=97=B4=EB=A1=9C=20=EC=B4=88?= =?UTF-8?q?=EA=B8=B0=ED=99=94=ED=95=98=EC=97=AC=20uncontrolled=20=EC=A0=84?= =?UTF-8?q?=ED=99=98=20=EB=B0=A9=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/Post/image-uploader.tsx | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/gitmon-client/src/components/Post/image-uploader.tsx b/gitmon-client/src/components/Post/image-uploader.tsx index 67e9ec6..0a0d9d4 100644 --- a/gitmon-client/src/components/Post/image-uploader.tsx +++ b/gitmon-client/src/components/Post/image-uploader.tsx @@ -84,7 +84,10 @@ export default function ImageUploader({ onImageInsert, onCancel, token }: ImageU
{uploadType === 'file' ? ( -
+
{previewUrl && ( @@ -118,7 +125,7 @@ export default function ImageUploader({ onImageInsert, onCancel, token }: ImageU )}
) : ( -
+
Date: Sun, 7 Sep 2025 18:30:50 +0900 Subject: [PATCH 4/8] =?UTF-8?q?feat:=20=EC=99=B8=EB=B6=80=20=EB=8F=84?= =?UTF-8?q?=EB=A9=94=EC=9D=B8=20=EB=A0=8C=EB=8D=94=EB=A7=81=20=ED=97=88?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- gitmon-client/src/components/Post/image-uploader.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/gitmon-client/src/components/Post/image-uploader.tsx b/gitmon-client/src/components/Post/image-uploader.tsx index 0a0d9d4..69478fa 100644 --- a/gitmon-client/src/components/Post/image-uploader.tsx +++ b/gitmon-client/src/components/Post/image-uploader.tsx @@ -119,6 +119,7 @@ export default function ImageUploader({ onImageInsert, onCancel, token }: ImageU alt="Preview" width={200} height={200} + unoptimized className="max-h-[200px] max-w-full object-contain border rounded" />
@@ -142,6 +143,7 @@ export default function ImageUploader({ onImageInsert, onCancel, token }: ImageU alt="Preview" width={200} height={200} + unoptimized className="max-h-[200px] max-w-full object-contain border rounded" onError={() => { toast('Failed to load image from URL') From 2b84fee6a547a234165192b36b72f79c271eee2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A1=B0=EC=98=81=EC=9A=B0?= Date: Sun, 7 Sep 2025 19:18:55 +0900 Subject: [PATCH 5/8] =?UTF-8?q?feat:=20Preview=20=ED=83=AD=EC=9D=B8=20?= =?UTF-8?q?=EC=83=81=ED=99=A9=EC=97=90=EC=84=9C=20=EC=9D=B4=EB=AF=B8?= =?UTF-8?q?=EC=A7=80=20=EC=97=85=EB=A1=9C=EB=93=9C=EA=B0=80=20=EA=B0=80?= =?UTF-8?q?=EB=8A=A5=ED=95=98=EB=8F=84=EB=A1=9D=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Write 탭인 상황에서는 이미 가능함 --- gitmon-client/src/components/Post/PostForm.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/gitmon-client/src/components/Post/PostForm.tsx b/gitmon-client/src/components/Post/PostForm.tsx index e3257a7..026c55b 100644 --- a/gitmon-client/src/components/Post/PostForm.tsx +++ b/gitmon-client/src/components/Post/PostForm.tsx @@ -46,7 +46,11 @@ export function PostForm({ onPostSaved, post, token }: PostFormProps) { const insertTextAtCursor = useCallback( (textBefore: string, textAfter = '') => { - if (!textareaRef.current) return + if (!textareaRef.current) { + // Textarea가 언마운트된 경우(예: Preview 탭): 본문 끝에 추가 + setContent(prev => prev + textBefore + textAfter) + return + } const textarea = textareaRef.current const start = textarea.selectionStart From 4f1fcd64801115f78d49c5b6b38f8d52d0ece76a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A1=B0=EC=98=81=EC=9A=B0?= Date: Sun, 7 Sep 2025 21:28:38 +0900 Subject: [PATCH 6/8] =?UTF-8?q?chore:=20=EB=B9=8C=EB=93=9C=EC=98=A4?= =?UTF-8?q?=EB=A5=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- gitmon-client/src/components/Post/PostForm.tsx | 4 ++-- gitmon-client/src/components/Post/image-uploader.tsx | 4 ++-- gitmon-client/src/lib/upload.ts | 11 +++++++---- 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/gitmon-client/src/components/Post/PostForm.tsx b/gitmon-client/src/components/Post/PostForm.tsx index 026c55b..de0eea4 100644 --- a/gitmon-client/src/components/Post/PostForm.tsx +++ b/gitmon-client/src/components/Post/PostForm.tsx @@ -131,8 +131,8 @@ export function PostForm({ onPostSaved, post, token }: PostFormProps) { setIsUploading(true) const url = await uploadImage(imageFile, token) insertTextAtCursor(`![](${url})`) - } catch (e: any) { - toast(e?.message || '이미지 업로드에 실패했습니다.') + } catch (e: unknown) { + toast((e as Error)?.message || '이미지 업로드에 실패했습니다.') } finally { setIsUploading(false) } diff --git a/gitmon-client/src/components/Post/image-uploader.tsx b/gitmon-client/src/components/Post/image-uploader.tsx index 69478fa..ecfb851 100644 --- a/gitmon-client/src/components/Post/image-uploader.tsx +++ b/gitmon-client/src/components/Post/image-uploader.tsx @@ -65,8 +65,8 @@ export default function ImageUploader({ onImageInsert, onCancel, token }: ImageU setIsUploading(true) const url = await uploadImage(file, token) onImageInsert(url) - } catch (e: any) { - toast(e?.message || '이미지 업로드에 실패했습니다.') + } catch (e: unknown) { + toast((e as Error)?.message || '이미지 업로드에 실패했습니다.') } finally { setIsUploading(false) } diff --git a/gitmon-client/src/lib/upload.ts b/gitmon-client/src/lib/upload.ts index 25b9632..3905906 100644 --- a/gitmon-client/src/lib/upload.ts +++ b/gitmon-client/src/lib/upload.ts @@ -3,6 +3,11 @@ const MAX_BYTES = 10 * 1024 * 1024 // 10MB const ALLOWED_EXTENSIONS = ['jpg', 'jpeg', 'png', 'webp', 'gif'] +interface UploadResponse { + data?: string + errorMessage?: string +} + function getExtension(filename: string): string { const parts = filename.split('.') return parts.length > 1 ? parts.pop()!.toLowerCase() : '' @@ -43,7 +48,7 @@ export async function uploadImage(file: File, token: string): Promise { body: form, }) - let payload: any = await res.json() + const payload = (await res.json()) as UploadResponse if (res.status === 201) { const imageUrl = payload?.data @@ -53,7 +58,7 @@ export async function uploadImage(file: File, token: string): Promise { throw new Error('이미지 업로드 응답이 올바르지 않습니다.') } - const serverMessage = payload?.errorMessage as string | undefined + const serverMessage = payload?.errorMessage if (res.status === 400) { throw new Error(serverMessage || '허용되지 않은 이미지 확장자입니다.') @@ -72,5 +77,3 @@ export const ImageUploadPolicy = { maxBytes: MAX_BYTES, allowedExtensions: ALLOWED_EXTENSIONS, } - - From 69b2e0d182273ac5cf649d6d4aa6a1670707aaf6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A1=B0=EC=98=81=EC=9A=B0?= Date: Sun, 7 Sep 2025 21:29:12 +0900 Subject: [PATCH 7/8] =?UTF-8?q?feat:=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20?= =?UTF-8?q?=EC=97=85=EB=A1=9C=EB=93=9C=20=EC=A4=91=EC=9E=84=EC=9D=84=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=EC=97=90=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- gitmon-client/src/components/Post/PostForm.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/gitmon-client/src/components/Post/PostForm.tsx b/gitmon-client/src/components/Post/PostForm.tsx index de0eea4..acd578c 100644 --- a/gitmon-client/src/components/Post/PostForm.tsx +++ b/gitmon-client/src/components/Post/PostForm.tsx @@ -238,12 +238,13 @@ export function PostForm({ onPostSaved, post, token }: PostFormProps) {