summaryrefslogtreecommitdiff
path: root/lib/basic-contract/gtc-vendor/markdown-image-editor.tsx
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-09-01 09:12:09 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-09-01 09:12:09 +0000
commit18954df6565108a469fb1608ea3715dd9bb1b02d (patch)
tree2675d254c547861a903a32459d89283a324e0e0d /lib/basic-contract/gtc-vendor/markdown-image-editor.tsx
parentf91cd16a872d9cda04aeb5c4e31538e3e2bd1895 (diff)
(대표님) 구매 기본계약, gtc 개발
Diffstat (limited to 'lib/basic-contract/gtc-vendor/markdown-image-editor.tsx')
-rw-r--r--lib/basic-contract/gtc-vendor/markdown-image-editor.tsx360
1 files changed, 360 insertions, 0 deletions
diff --git a/lib/basic-contract/gtc-vendor/markdown-image-editor.tsx b/lib/basic-contract/gtc-vendor/markdown-image-editor.tsx
new file mode 100644
index 00000000..422d8475
--- /dev/null
+++ b/lib/basic-contract/gtc-vendor/markdown-image-editor.tsx
@@ -0,0 +1,360 @@
+"use client"
+
+import * as React from "react"
+import { Button } from "@/components/ui/button"
+import { Textarea } from "@/components/ui/textarea"
+import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
+import { Upload, X, Image as ImageIcon, Eye, EyeOff } from "lucide-react"
+import { toast } from "sonner"
+import { cn } from "@/lib/utils"
+
+interface ClauseImage {
+ id: string
+ url: string
+ fileName: string
+ size: number
+}
+
+interface MarkdownImageEditorProps {
+ content: string
+ images: ClauseImage[]
+ onChange: (content: string, images: ClauseImage[]) => void
+ placeholder?: string
+ rows?: number
+ className?: string
+}
+
+export function MarkdownImageEditor({
+ content,
+ images,
+ onChange,
+ placeholder = "텍스트를 입력하고, 이미지를 삽입하려면 '이미지 추가' 버튼을 클릭하세요.",
+ rows = 6,
+ className
+}: MarkdownImageEditorProps) {
+ const [imageUploadOpen, setImageUploadOpen] = React.useState(false)
+ const [showPreview, setShowPreview] = React.useState(false)
+ const [uploading, setUploading] = React.useState(false)
+ const textareaRef = React.useRef<HTMLTextAreaElement>(null)
+
+ // 이미지 업로드 핸들러
+ const handleImageUpload = async (file: File) => {
+ if (!file.type.startsWith('image/')) {
+ toast.error('이미지 파일만 업로드 가능합니다.')
+ return
+ }
+
+ if (file.size > 5 * 1024 * 1024) { // 5MB 제한
+ toast.error('파일 크기는 5MB 이하여야 합니다.')
+ return
+ }
+
+ setUploading(true)
+ try {
+ // 실제 구현에서는 서버로 업로드
+ const uploadedUrl = await uploadImage(file)
+ const imageId = `image${Date.now()}`
+
+ // 이미지 배열에 추가
+ const newImages = [...images, {
+ id: imageId,
+ url: uploadedUrl,
+ fileName: file.name,
+ size: file.size,
+ }]
+
+ // 커서 위치에 이미지 참조 삽입
+ const imageRef = `![${imageId}]`
+ const textarea = textareaRef.current
+
+ if (textarea) {
+ const start = textarea.selectionStart
+ const end = textarea.selectionEnd
+ const newContent = content.substring(0, start) + imageRef + content.substring(end)
+
+ onChange(newContent, newImages)
+
+ // 커서 위치를 이미지 참조 뒤로 이동
+ setTimeout(() => {
+ textarea.focus()
+ textarea.setSelectionRange(start + imageRef.length, start + imageRef.length)
+ }, 0)
+ } else {
+ // 텍스트 끝에 추가
+ const newContent = content + (content ? '\n\n' : '') + imageRef
+ onChange(newContent, newImages)
+ }
+
+ toast.success('이미지가 추가되었습니다.')
+ setImageUploadOpen(false)
+ } catch (error) {
+ toast.error('이미지 업로드에 실패했습니다.')
+ console.error('Image upload error:', error)
+ } finally {
+ setUploading(false)
+ }
+ }
+
+ // 이미지 제거
+ const removeImage = (imageId: string) => {
+ // 이미지 배열에서 제거
+ const newImages = images.filter(img => img.id !== imageId)
+
+ // 텍스트에서 이미지 참조 제거
+ const imageRef = `![${imageId}]`
+ const newContent = content.replace(new RegExp(`\\!\\[${imageId}\\]`, 'g'), '')
+
+ onChange(newContent, newImages)
+ toast.success('이미지가 제거되었습니다.')
+ }
+
+ // 커서 위치에 텍스트 삽입
+ const insertAtCursor = (text: string) => {
+ const textarea = textareaRef.current
+ if (!textarea) return
+
+ const start = textarea.selectionStart
+ const end = textarea.selectionEnd
+ const newContent = content.substring(0, start) + text + content.substring(end)
+
+ onChange(newContent, images)
+
+ // 커서 위치 조정
+ setTimeout(() => {
+ textarea.focus()
+ textarea.setSelectionRange(start + text.length, start + text.length)
+ }, 0)
+ }
+
+ return (
+ <div className={cn("space-y-3", className)}>
+ {/* 에디터 툴바 */}
+ <div className="flex items-center justify-between">
+ <div className="flex items-center gap-2">
+ <Button
+ type="button"
+ variant="outline"
+ size="sm"
+ onClick={() => setImageUploadOpen(true)}
+ disabled={uploading}
+ >
+ <Upload className="h-4 w-4 mr-1" />
+ {uploading ? '업로드 중...' : '이미지 추가'}
+ </Button>
+
+ <Button
+ type="button"
+ variant="outline"
+ size="sm"
+ onClick={() => setShowPreview(!showPreview)}
+ >
+ {showPreview ? (
+ <>
+ <EyeOff className="h-4 w-4 mr-1" />
+ 편집
+ </>
+ ) : (
+ <>
+ <Eye className="h-4 w-4 mr-1" />
+ 미리보기
+ </>
+ )}
+ </Button>
+ </div>
+
+ {images.length > 0 && (
+ <span className="text-xs text-muted-foreground">
+ {images.length}개 이미지 첨부됨
+ </span>
+ )}
+ </div>
+
+ {/* 에디터 영역 */}
+ {showPreview ? (
+ /* 미리보기 모드 */
+ <div className="border rounded-lg p-4 bg-muted/10 min-h-[200px]">
+ <div className="space-y-3">
+ {renderMarkdownPreview(content, images)}
+ </div>
+ </div>
+ ) : (
+ /* 편집 모드 */
+ <div className="relative">
+ <Textarea
+ ref={textareaRef}
+ value={content}
+ onChange={(e) => onChange(e.target.value, images)}
+ placeholder={placeholder}
+ rows={rows}
+ className="font-mono text-sm resize-none"
+ />
+
+ {/* 이미지 참조 안내 */}
+ {content.includes('![image') && (
+ <div className="absolute bottom-2 right-2 text-xs text-muted-foreground bg-background/80 px-2 py-1 rounded">
+ ![imageXXX] = 이미지 삽입 위치
+ </div>
+ )}
+ </div>
+ )}
+
+ {/* 첨부된 이미지 목록 */}
+ {images.length > 0 && !showPreview && (
+ <div className="space-y-2">
+ <div className="text-sm font-medium">첨부된 이미지:</div>
+ <div className="grid grid-cols-2 md:grid-cols-4 gap-2">
+ {images.map((img) => (
+ <div key={img.id} className="relative group">
+ <img
+ src={img.url}
+ alt={img.fileName}
+ className="w-full h-16 object-cover rounded border"
+ />
+ <div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity rounded flex items-center justify-center">
+ <div className="text-white text-xs text-center">
+ <div className="font-medium">![{img.id}]</div>
+ <div className="truncate max-w-16" title={img.fileName}>
+ {img.fileName}
+ </div>
+ </div>
+ </div>
+ <Button
+ type="button"
+ variant="destructive"
+ size="sm"
+ className="absolute -top-1 -right-1 h-5 w-5 p-0 opacity-0 group-hover:opacity-100"
+ onClick={() => removeImage(img.id)}
+ >
+ <X className="h-3 w-3" />
+ </Button>
+ </div>
+ ))}
+ </div>
+ </div>
+ )}
+
+ {/* 이미지 업로드 다이얼로그 */}
+ <Dialog open={imageUploadOpen} onOpenChange={setImageUploadOpen}>
+ <DialogContent className="max-w-md">
+ <DialogHeader>
+ <DialogTitle>이미지 추가</DialogTitle>
+ </DialogHeader>
+ <div className="space-y-4">
+ <div className="border-2 border-dashed border-muted-foreground/25 rounded-lg p-6">
+ <div className="flex flex-col items-center justify-center text-center">
+ <ImageIcon className="h-8 w-8 text-muted-foreground mb-2" />
+ <p className="text-sm text-muted-foreground mb-4">
+ 이미지를 선택하거나 드래그해서 업로드하세요
+ </p>
+ <input
+ type="file"
+ accept="image/*"
+ onChange={(e) => {
+ const file = e.target.files?.[0]
+ if (file) handleImageUpload(file)
+ }}
+ className="hidden"
+ id="image-upload"
+ disabled={uploading}
+ />
+ <label
+ htmlFor="image-upload"
+ className={cn(
+ "cursor-pointer inline-flex items-center gap-2 text-sm bg-primary text-primary-foreground hover:bg-primary/90 px-4 py-2 rounded-md",
+ uploading && "opacity-50 cursor-not-allowed"
+ )}
+ >
+ <Upload className="h-4 w-4" />
+ 파일 선택
+ </label>
+ </div>
+ </div>
+ <div className="text-xs text-muted-foreground space-y-1">
+ <div>• 지원 형식: JPG, PNG, GIF, WebP</div>
+ <div>• 최대 크기: 5MB</div>
+ <div>• 현재 커서 위치에 삽입됩니다</div>
+ </div>
+ </div>
+ </DialogContent>
+ </Dialog>
+ </div>
+ )
+}
+
+// 마크다운 미리보기 렌더링
+function renderMarkdownPreview(content: string, images: ClauseImage[]) {
+ if (!content.trim()) {
+ return (
+ <p className="text-muted-foreground italic">
+ 내용을 입력하세요...
+ </p>
+ )
+ }
+
+ const parts = content.split(/(\![a-zA-Z0-9_]+\])/)
+
+ return parts.map((part, index) => {
+ // 이미지 참조인지 확인
+ const imageMatch = part.match(/^!\[(.+)\]$/)
+
+ if (imageMatch) {
+ const imageId = imageMatch[1]
+ const image = images.find(img => img.id === imageId)
+
+ if (image) {
+ return (
+ <div key={index} className="my-3">
+ <img
+ src={image.url}
+ alt={image.fileName}
+ className="max-w-full h-auto rounded border shadow-sm"
+ style={{ maxHeight: '400px' }}
+ />
+ <p className="text-xs text-muted-foreground mt-1 text-center">
+ {image.fileName} ({formatFileSize(image.size)})
+ </p>
+ </div>
+ )
+ } else {
+ return (
+ <div key={index} className="my-2 p-2 bg-yellow-50 border border-yellow-200 rounded">
+ <p className="text-xs text-yellow-700">
+ ⚠️ 이미지를 찾을 수 없음: {part}
+ </p>
+ </div>
+ )
+ }
+ } else {
+ // 일반 텍스트 (줄바꿈 처리)
+ return part.split('\n').map((line, lineIndex) => (
+ <p key={`${index}-${lineIndex}`} className="text-sm">
+ {line || '\u00A0'} {/* 빈 줄 처리 */}
+ </p>
+ ))
+ }
+ })
+}
+
+// 파일 크기 포맷팅
+function formatFileSize(bytes: number): string {
+ if (bytes === 0) return '0 Bytes'
+
+ const k = 1024
+ const sizes = ['Bytes', 'KB', 'MB', 'GB']
+ const i = Math.floor(Math.log(bytes) / Math.log(k))
+
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
+}
+
+// 임시 이미지 업로드 함수 (실제 구현 필요)
+async function uploadImage(file: File): Promise<string> {
+ // TODO: 실제 서버 업로드 로직 구현
+ // 현재는 임시로 ObjectURL 반환
+ return new Promise((resolve) => {
+ const reader = new FileReader()
+ reader.onload = (e) => {
+ resolve(e.target?.result as string)
+ }
+ reader.readAsDataURL(file)
+ })
+} \ No newline at end of file