/** * 검증 이슈 첨부 — 화면 캡처·파일 선택·드래그·클립보드 붙여넣기 * 이미지 + zip·hwp·hwpx */ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import ScreenRegionCaptureOverlay from '../ScreenRegionCaptureOverlay'; import { deleteVerificationIssueImage, loadVerificationIssueImages, uploadVerificationIssueImages, verificationIssueImageUrl, type VerificationIssueImageDto, } from '../../utils/verificationBoardApi'; import { filesFromClipboardRead, filesFromDataTransfer, } from '../../utils/clipboardImageFiles'; import { ATTACHMENT_TYPE_HINT, VERIFICATION_ATTACHMENT_ACCEPT, isImageAttachment, pickAttachmentFiles, prepareAttachmentsForUpload, } from '../../utils/verificationAttachmentFiles'; function uploadErrorMessage(e: unknown): string { if (e instanceof Error) { if (e.message === 'IMAGE_TOO_LARGE' || e.message === 'FILE_TOO_LARGE') { return '파일이 너무 큽니다. 5MB 이하로 줄인 뒤 다시 시도하세요.'; } if (e.message === 'IMAGE_READ_FAILED') { return '이미지를 읽을 수 없습니다.'; } if (e.message) return e.message; } return '첨부 파일 업로드에 실패했습니다.'; } function fileKindLabel(fileName?: string | null, contentType?: string | null): string { const lower = (fileName ?? '').toLowerCase(); if (lower.endsWith('.zip')) return 'ZIP'; if (lower.endsWith('.hwp')) return 'HWP'; if (lower.endsWith('.hwpx')) return 'HWPX'; const mime = (contentType ?? '').toLowerCase(); if (mime.includes('zip')) return 'ZIP'; if (mime.includes('hwp')) return 'HWP'; return 'FILE'; } interface Props { /** 저장된 이슈 ID — 수정·상세 화면 */ issueId?: number | null; /** 등록 draft — 저장 전 로컬 파일 */ draftFiles?: File[]; onDraftFilesChange?: (files: File[]) => void; /** true: 새 첨부 업로드만 저장 시 반영 (삭제는 즉시 API) */ deferUpload?: boolean; disabled?: boolean; /** compact: 모달 등 좁은 영역 */ compact?: boolean; /** 첨부 추가·삭제 후 (목록 갱신 등) */ onAttachmentsChanged?: () => void; } interface CtxMenuState { x: number; y: number; } const VerificationIssueAttachmentsSection: React.FC = ({ issueId = null, draftFiles = [], onDraftFilesChange, deferUpload = false, disabled = false, compact = false, onAttachmentsChanged, }) => { const isDraft = issueId == null; const saveOnSubmit = deferUpload && issueId != null; const [images, setImages] = useState([]); const [loading, setLoading] = useState(false); const [uploading, setUploading] = useState(false); const [dragOver, setDragOver] = useState(false); const [ctxMenu, setCtxMenu] = useState(null); const [screenCaptureOpen, setScreenCaptureOpen] = useState(false); const fileInputRef = useRef(null); const zoneRef = useRef(null); const reload = useCallback(async () => { if (issueId == null) return; setLoading(true); try { setImages(await loadVerificationIssueImages(issueId)); } catch (e) { console.warn('[VerificationBoard] images load', e); } finally { setLoading(false); } }, [issueId]); useEffect(() => { if (issueId != null) void reload(); else setImages([]); }, [issueId, reload]); const draftPreviews = useMemo( () => draftFiles.map((file, index) => { const isImage = isImageAttachment(file.type, file.name); return { key: `${file.name}-${file.size}-${file.lastModified}-${index}`, index, fileName: file.name, isImage, src: isImage ? URL.createObjectURL(file) : undefined, kind: fileKindLabel(file.name, file.type), }; }), [draftFiles], ); useEffect(() => () => { draftPreviews.forEach(p => { if (p.src) URL.revokeObjectURL(p.src); }); }, [draftPreviews]); useEffect(() => { const close = () => setCtxMenu(null); window.addEventListener('click', close); window.addEventListener('scroll', close, true); return () => { window.removeEventListener('click', close); window.removeEventListener('scroll', close, true); }; }, []); const addDraftFiles = useCallback(async (files: File[]) => { const picked = pickAttachmentFiles(files); if (picked.length === 0) { window.alert(`첨부 가능: ${ATTACHMENT_TYPE_HINT}`); return; } setUploading(true); try { const prepared = await prepareAttachmentsForUpload(picked); onDraftFilesChange?.([...draftFiles, ...prepared]); } catch (e) { window.alert(uploadErrorMessage(e)); } finally { setUploading(false); } }, [draftFiles, onDraftFilesChange]); const uploadFiles = useCallback(async (files: File[]) => { if (issueId == null || saveOnSubmit) { await addDraftFiles(files); return; } const picked = pickAttachmentFiles(files); if (picked.length === 0) { window.alert(`첨부 가능: ${ATTACHMENT_TYPE_HINT}`); return; } setUploading(true); try { const prepared = await prepareAttachmentsForUpload(picked); await uploadVerificationIssueImages(issueId, prepared); await reload(); onAttachmentsChanged?.(); } catch (e) { console.warn('[VerificationBoard] attachment upload', e); window.alert(uploadErrorMessage(e)); } finally { setUploading(false); } }, [issueId, saveOnSubmit, addDraftFiles, reload]); const pasteFromClipboard = useCallback(async () => { setCtxMenu(null); try { const files = await filesFromClipboardRead(); if (files.length === 0) { window.alert('클립보드에 붙여넣을 수 있는 이미지(png, jpeg 등)가 없습니다.'); return; } await uploadFiles(files); } catch (e) { if (e instanceof Error && e.message === 'CLIPBOARD_UNSUPPORTED') { window.alert('이 브라우저에서는 클립보드 이미지 읽기를 지원하지 않습니다.'); return; } window.alert('클립보드 접근이 거부되었습니다. 브라우저 권한을 확인하세요.'); } }, [uploadFiles]); const handleScreenCapture = useCallback((file: File) => { setScreenCaptureOpen(false); void uploadFiles([file]); }, [uploadFiles]); const handleDeleteServer = async (imageId: number) => { if (issueId == null) return; if (!window.confirm('이 첨부 파일을 삭제할까요?')) return; setUploading(true); try { await deleteVerificationIssueImage(issueId, imageId); await reload(); onAttachmentsChanged?.(); } catch (e) { console.warn('[VerificationBoard] attachment delete', e); window.alert('첨부 파일 삭제에 실패했습니다.'); } finally { setUploading(false); } }; const handleDeleteDraft = (index: number) => { onDraftFilesChange?.(draftFiles.filter((_, i) => i !== index)); }; const openAttachment = (src: string, fileName: string, isImage: boolean) => { if (isImage) { window.open(src, '_blank', 'noopener,noreferrer'); return; } const a = document.createElement('a'); a.href = src; a.download = fileName; a.rel = 'noopener noreferrer'; document.body.appendChild(a); a.click(); a.remove(); }; const renderAttachmentThumb = ( src: string, fileName: string, isImage: boolean, kind: string, onOpen: () => void, ) => ( ); const handleContextMenu = (e: React.MouseEvent) => { if (disabled) return; e.preventDefault(); e.stopPropagation(); setCtxMenu({ x: e.clientX, y: e.clientY }); }; const handlePaste = (e: React.ClipboardEvent) => { if (disabled) return; const fromItems = filesFromDataTransfer(e.clipboardData); const fromFiles = pickAttachmentFiles(e.clipboardData.files); const merged = fromFiles.length > 0 ? fromFiles : fromItems; if (merged.length === 0) return; e.preventDefault(); void uploadFiles(merged); }; const handleDrop = (e: React.DragEvent) => { if (disabled) return; e.preventDefault(); setDragOver(false); const picked = pickAttachmentFiles(e.dataTransfer.files); if (picked.length > 0) void uploadFiles(picked); }; const visibleServerImages = useMemo( () => images.filter(img => img.id != null), [images], ); const totalCount = isDraft || saveOnSubmit ? visibleServerImages.length + draftFiles.length : images.length; const showEmptyHint = !loading && totalCount === 0; return (

첨부 파일 {totalCount > 0 && `(${totalCount})`}

{ const files = e.target.files; if (files?.length) void uploadFiles(pickAttachmentFiles(files)); e.target.value = ''; }} />
{ if (disabled) return; e.preventDefault(); setDragOver(true); }} onDragOver={e => { if (disabled) return; e.preventDefault(); setDragOver(true); }} onDragLeave={e => { if (zoneRef.current && !zoneRef.current.contains(e.relatedTarget as Node)) { setDragOver(false); } }} onDrop={handleDrop} > {loading && issueId != null && !isDraft && images.length === 0 ? (

첨부 파일 불러오는 중…

) : showEmptyHint ? (

{isDraft || saveOnSubmit ? '저장 시 함께 반영됩니다. 화면 캡처·드래그·우클릭 붙여넣기·파일 선택' : '화면 캡처·드래그·우클릭 붙여넣기·파일 선택'}

) : (
{(isDraft || saveOnSubmit) && visibleServerImages.map(img => { if (img.id == null) return null; const src = verificationIssueImageUrl(issueId!, img.id); const isImage = isImageAttachment(img.contentType, img.fileName); const kind = fileKindLabel(img.fileName, img.contentType); return (
{renderAttachmentThumb( src, img.fileName ?? '첨부 파일', isImage, kind, () => openAttachment(src, img.fileName ?? '첨부 파일', isImage), )}
{img.fileName}
); })} {(isDraft || saveOnSubmit) ? draftPreviews.map(p => (
{p.isImage && p.src ? renderAttachmentThumb( p.src, p.fileName, true, p.kind, () => openAttachment(p.src!, p.fileName, true), ) : (
{p.kind}
)}
{p.fileName}
)) : !saveOnSubmit && images.map(img => { if (img.id == null) return null; const src = verificationIssueImageUrl(issueId!, img.id); const isImage = isImageAttachment(img.contentType, img.fileName); const kind = fileKindLabel(img.fileName, img.contentType); return (
{renderAttachmentThumb( src, img.fileName ?? '첨부 파일', isImage, kind, () => openAttachment(src, img.fileName ?? '첨부 파일', isImage), )}
{img.fileName}
); })}
)} {uploading &&
업로드 중…
}
{ctxMenu && (
e.stopPropagation()} >
)} {screenCaptureOpen && ( setScreenCaptureOpen(false)} /> )}
); }; export default VerificationIssueAttachmentsSection;