Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ export default function UpdatePost({ token, post }: UpdatePostProps) {

return (
<div className="p-4">
<PostForm onPostSaved={handleSavePost} post={post} />
<PostForm onPostSaved={handleSavePost} post={post} token={token} />
</div>
)
}
2 changes: 1 addition & 1 deletion gitmon-client/src/app/create-post/AddPost.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ export default function AddPost({ token }: { token: string | null }) {

return (
<div className="p-4">
<PostForm onPostSaved={handleSavePost} post={null} />
<PostForm onPostSaved={handleSavePost} post={null} token={token} />
</div>
)
}
74 changes: 68 additions & 6 deletions gitmon-client/src/components/Post/PostForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -31,18 +33,24 @@ import { Post } from '@lib/types'
interface PostFormProps {
onPostSaved: (post: { title: string; content: string }) => void
post: Omit<Post, 'id'> | 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<HTMLTextAreaElement>(null)

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
Expand Down Expand Up @@ -106,10 +114,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: unknown) {
toast((e as Error)?.message || '이미지 업로드에 실패했습니다.')
} finally {
setIsUploading(false)
}
}

const onDragOver = (e: React.DragEvent<HTMLTextAreaElement>) => {
e.preventDefault()
}

const onDrop = async (e: React.DragEvent<HTMLTextAreaElement>) => {
e.preventDefault()
const dt = e.dataTransfer
if (dt?.files && dt.files.length > 0) {
await handleFilesToUpload(dt.files)
}
}

const onPaste = async (e: React.ClipboardEvent<HTMLTextAreaElement>) => {
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 (
<div className="space-y-6">
<div className="flex flex-col space-y-4">
Expand Down Expand Up @@ -187,9 +238,15 @@ export function PostForm({ onPostSaved, post }: PostFormProps) {
<TabsContent value="write" className="mt-2">
<Textarea
ref={textareaRef}
placeholder="Write your post content in markdown..."
placeholder={
isUploading ? '이미지 업로드 중...' : 'Write your post content in markdown...'
}
value={content}
onChange={e => setContent(e.target.value)}
onDragOver={onDragOver}
onDrop={onDrop}
onPaste={onPaste}
disabled={isUploading}
className="min-h-[400px] font-mono text-sm resize-y"
/>
</TabsContent>
Expand All @@ -209,15 +266,20 @@ export function PostForm({ onPostSaved, post }: PostFormProps) {
</Link>
</Button>

<Button onClick={() => onPostSaved({ title, content })} className="gap-2">
<Button
onClick={() => onPostSaved({ title, content })}
disabled={isUploading}
className="gap-2"
>
<Save size={16} />
<p>글 작성</p>
<p>{isUploading ? '업로드 중...' : '글 작성'}</p>
</Button>
</div>
</div>

{showImageUploader && (
<ImageUploader
token={token || null}
onImageInsert={handleImageInsert}
onCancel={() => setShowImageUploader(false)}
/>
Expand Down
51 changes: 38 additions & 13 deletions gitmon-client/src/components/Post/image-uploader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<File | null>(null)
const [previewUrl, setPreviewUrl] = useState<string | null>(null)
const [isUploading, setIsUploading] = useState(false)

const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files[0]) {
Expand All @@ -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')
Expand All @@ -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: unknown) {
toast((e as Error)?.message || '이미지 업로드에 실패했습니다.')
} finally {
setIsUploading(false)
}
}
}
Expand All @@ -74,15 +84,22 @@ export default function ImageUploader({ onImageInsert, onCancel }: ImageUploader
<div className="flex gap-4">
<Button
variant={uploadType === 'file' ? 'default' : 'outline'}
onClick={() => setUploadType('file')}
onClick={() => {
setUploadType('file')
setImageUrl('')
}}
className="flex-1 gap-2"
>
<Upload size={16} />
Upload File
</Button>
<Button
variant={uploadType === 'url' ? 'default' : 'outline'}
onClick={() => setUploadType('url')}
onClick={() => {
setUploadType('url')
setFile(null)
setPreviewUrl(null)
}}
className="flex-1 gap-2"
>
<LinkIcon size={16} />
Expand All @@ -91,7 +108,7 @@ export default function ImageUploader({ onImageInsert, onCancel }: ImageUploader
</div>

{uploadType === 'file' ? (
<div className="grid gap-2">
<div className="grid gap-2" key="file">
<Label htmlFor="image-upload">Select Image</Label>
<Input id="image-upload" type="file" accept="image/*" onChange={handleFileChange} />
{previewUrl && (
Expand All @@ -100,13 +117,16 @@ export default function ImageUploader({ onImageInsert, onCancel }: ImageUploader
<Image
src={previewUrl || '/placeholder.svg'}
alt="Preview"
width={200}
height={200}
unoptimized
className="max-h-[200px] max-w-full object-contain border rounded"
/>
</div>
)}
</div>
) : (
<div className="grid gap-2">
<div className="grid gap-2" key="url">
<Label htmlFor="image-url">Image URL</Label>
<Input
id="image-url"
Expand All @@ -121,6 +141,9 @@ export default function ImageUploader({ onImageInsert, onCancel }: ImageUploader
<Image
src={imageUrl || '/placeholder.svg'}
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')
Expand All @@ -133,10 +156,12 @@ export default function ImageUploader({ onImageInsert, onCancel }: ImageUploader
</div>

<DialogFooter>
<Button variant="outline" onClick={onCancel}>
<Button variant="outline" onClick={onCancel} disabled={isUploading}>
Cancel
</Button>
<Button onClick={handleInsert}>Insert Image</Button>
<Button onClick={handleInsert} disabled={isUploading}>
{isUploading ? 'Uploading...' : 'Insert Image'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
Expand Down
79 changes: 79 additions & 0 deletions gitmon-client/src/lib/upload.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
// 이미지 업로드 유틸리티: 멀티파트 업로드 후 이미지 URL을 반환합니다.

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() : ''
}

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<string> {
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,
})

const payload = (await res.json()) as UploadResponse

if (res.status === 201) {
const imageUrl = payload?.data
if (typeof imageUrl === 'string' && imageUrl.length > 0) {
return imageUrl
}
throw new Error('이미지 업로드 응답이 올바르지 않습니다.')
}

const serverMessage = payload?.errorMessage

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,
}