diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-07-28 11:44:16 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-07-28 11:44:16 +0000 |
| commit | c228a89c2834ee63b209bad608837c39643f350e (patch) | |
| tree | 39c9a121b556af872072dd80750dedf2d2d62335 /lib/gtc-contract/gtc-clauses/table/markdown-image-editor.tsx | |
| parent | 50ae0b8f02c034e60d4cbb504620dfa1575a836f (diff) | |
(대표님) 의존성 docx 추가, basicContract API, gtc(계약일반조건), 벤더평가 esg 평가데이터 내보내기 개선, S-EDP 피드백 대응(CLS_ID, ITEM NO 등)
Diffstat (limited to 'lib/gtc-contract/gtc-clauses/table/markdown-image-editor.tsx')
| -rw-r--r-- | lib/gtc-contract/gtc-clauses/table/markdown-image-editor.tsx | 360 |
1 files changed, 360 insertions, 0 deletions
diff --git a/lib/gtc-contract/gtc-clauses/table/markdown-image-editor.tsx b/lib/gtc-contract/gtc-clauses/table/markdown-image-editor.tsx new file mode 100644 index 00000000..422d8475 --- /dev/null +++ b/lib/gtc-contract/gtc-clauses/table/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 |
