481 lines
16 KiB
TypeScript
481 lines
16 KiB
TypeScript
/**
|
|
* 검증 이슈 첨부 — 화면 캡처·파일 선택·드래그·클립보드 붙여넣기
|
|
* 이미지 + 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<Props> = ({
|
|
issueId = null,
|
|
draftFiles = [],
|
|
onDraftFilesChange,
|
|
deferUpload = false,
|
|
disabled = false,
|
|
compact = false,
|
|
onAttachmentsChanged,
|
|
}) => {
|
|
const isDraft = issueId == null;
|
|
const saveOnSubmit = deferUpload && issueId != null;
|
|
const [images, setImages] = useState<VerificationIssueImageDto[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [uploading, setUploading] = useState(false);
|
|
const [dragOver, setDragOver] = useState(false);
|
|
const [ctxMenu, setCtxMenu] = useState<CtxMenuState | null>(null);
|
|
const [screenCaptureOpen, setScreenCaptureOpen] = useState(false);
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
const zoneRef = useRef<HTMLDivElement>(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,
|
|
) => (
|
|
<button
|
|
type="button"
|
|
className={`vbd-attach-thumb${isImage ? '' : ' vbd-attach-thumb--file'}`}
|
|
title={`${fileName} — 더블클릭하여 ${isImage ? '열기' : '다운로드'}`}
|
|
onDoubleClick={onOpen}
|
|
>
|
|
{isImage ? (
|
|
<img src={src} alt={fileName} loading="lazy" draggable={false} />
|
|
) : (
|
|
<span className="vbd-attach-file-badge" aria-hidden>{kind}</span>
|
|
)}
|
|
</button>
|
|
);
|
|
|
|
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 (
|
|
<div className={`vbd-attachments${compact ? ' vbd-attachments--compact' : ''}`}>
|
|
<div className="vbd-attachments-head">
|
|
<h3 className="vbd-attachments-title">
|
|
첨부 파일 {totalCount > 0 && `(${totalCount})`}
|
|
</h3>
|
|
<div className="vbd-attachments-actions">
|
|
<button
|
|
type="button"
|
|
className="vbd-btn vbd-btn--sm"
|
|
title="화면 공유 후 드래그로 영역 캡처"
|
|
onClick={() => setScreenCaptureOpen(true)}
|
|
disabled={disabled || uploading}
|
|
>
|
|
화면 캡처
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="vbd-btn vbd-btn--sm"
|
|
onClick={() => fileInputRef.current?.click()}
|
|
disabled={disabled || uploading}
|
|
>
|
|
파일 선택
|
|
</button>
|
|
</div>
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
accept={VERIFICATION_ATTACHMENT_ACCEPT}
|
|
multiple
|
|
hidden
|
|
disabled={disabled}
|
|
onChange={e => {
|
|
const files = e.target.files;
|
|
if (files?.length) void uploadFiles(pickAttachmentFiles(files));
|
|
e.target.value = '';
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
<div
|
|
ref={zoneRef}
|
|
className={`vbd-attach-zone${dragOver ? ' vbd-attach-zone--over' : ''}${disabled ? ' vbd-attach-zone--disabled' : ''}`}
|
|
tabIndex={disabled ? -1 : 0}
|
|
role="region"
|
|
aria-label="첨부 파일 영역"
|
|
onContextMenu={handleContextMenu}
|
|
onPaste={handlePaste}
|
|
onDragEnter={e => { 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 ? (
|
|
<p className="vbd-attach-hint">첨부 파일 불러오는 중…</p>
|
|
) : showEmptyHint ? (
|
|
<p className="vbd-attach-hint">
|
|
{isDraft || saveOnSubmit
|
|
? '저장 시 함께 반영됩니다. 화면 캡처·드래그·우클릭 붙여넣기·파일 선택'
|
|
: '화면 캡처·드래그·우클릭 붙여넣기·파일 선택'}
|
|
</p>
|
|
) : (
|
|
<div className="vbd-attach-grid">
|
|
{(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 (
|
|
<figure key={img.id} className="vbd-attach-item">
|
|
{renderAttachmentThumb(
|
|
src,
|
|
img.fileName ?? '첨부 파일',
|
|
isImage,
|
|
kind,
|
|
() => openAttachment(src, img.fileName ?? '첨부 파일', isImage),
|
|
)}
|
|
<figcaption className="vbd-attach-caption" title={img.fileName}>
|
|
{img.fileName}
|
|
</figcaption>
|
|
<button
|
|
type="button"
|
|
className="vbd-attach-remove"
|
|
title="삭제"
|
|
disabled={disabled}
|
|
onClick={() => void handleDeleteServer(img.id!)}
|
|
>
|
|
✕
|
|
</button>
|
|
</figure>
|
|
);
|
|
})}
|
|
{(isDraft || saveOnSubmit)
|
|
? draftPreviews.map(p => (
|
|
<figure key={p.key} className="vbd-attach-item">
|
|
{p.isImage && p.src
|
|
? renderAttachmentThumb(
|
|
p.src,
|
|
p.fileName,
|
|
true,
|
|
p.kind,
|
|
() => openAttachment(p.src!, p.fileName, true),
|
|
)
|
|
: (
|
|
<div className="vbd-attach-thumb vbd-attach-thumb--file">
|
|
<span className="vbd-attach-file-badge" aria-hidden>{p.kind}</span>
|
|
</div>
|
|
)}
|
|
<figcaption className="vbd-attach-caption" title={p.fileName}>
|
|
{p.fileName}
|
|
</figcaption>
|
|
<button
|
|
type="button"
|
|
className="vbd-attach-remove"
|
|
title="삭제"
|
|
disabled={disabled}
|
|
onClick={() => handleDeleteDraft(p.index)}
|
|
>
|
|
✕
|
|
</button>
|
|
</figure>
|
|
))
|
|
: !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 (
|
|
<figure key={img.id} className="vbd-attach-item">
|
|
{renderAttachmentThumb(
|
|
src,
|
|
img.fileName ?? '첨부 파일',
|
|
isImage,
|
|
kind,
|
|
() => openAttachment(src, img.fileName ?? '첨부 파일', isImage),
|
|
)}
|
|
<figcaption className="vbd-attach-caption" title={img.fileName}>
|
|
{img.fileName}
|
|
</figcaption>
|
|
<button
|
|
type="button"
|
|
className="vbd-attach-remove"
|
|
title="삭제"
|
|
disabled={disabled}
|
|
onClick={() => void handleDeleteServer(img.id!)}
|
|
>
|
|
✕
|
|
</button>
|
|
</figure>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
{uploading && <div className="vbd-attach-uploading">업로드 중…</div>}
|
|
</div>
|
|
|
|
{ctxMenu && (
|
|
<div
|
|
className="vbd-attach-ctx"
|
|
style={{ left: ctxMenu.x, top: ctxMenu.y }}
|
|
onClick={e => e.stopPropagation()}
|
|
>
|
|
<button type="button" onClick={() => { setCtxMenu(null); fileInputRef.current?.click(); }}>
|
|
파일에서 선택…
|
|
</button>
|
|
<button type="button" onClick={() => void pasteFromClipboard()}>
|
|
클립보드 이미지 붙여넣기
|
|
</button>
|
|
<button type="button" onClick={() => { setCtxMenu(null); setScreenCaptureOpen(true); }}>
|
|
화면 영역 캡처…
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{screenCaptureOpen && (
|
|
<ScreenRegionCaptureOverlay
|
|
onCapture={handleScreenCapture}
|
|
onCancel={() => setScreenCaptureOpen(false)}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default VerificationIssueAttachmentsSection;
|