검증게시판 기능 추가
This commit is contained in:
@@ -0,0 +1,394 @@
|
||||
/**
|
||||
* 검증 이슈 첨부 이미지 — 파일 선택·드래그·클립보드 붙여넣기
|
||||
* issueId 있음: 서버 즉시 업로드 / 없음: draft 로컬 보관(등록 시 저장 후 업로드)
|
||||
*/
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
deleteVerificationIssueImage,
|
||||
loadVerificationIssueImages,
|
||||
uploadVerificationIssueImages,
|
||||
verificationIssueImageUrl,
|
||||
type VerificationIssueImageDto,
|
||||
} from '../../utils/verificationBoardApi';
|
||||
import {
|
||||
filesFromClipboardRead,
|
||||
filesFromDataTransfer,
|
||||
pickImageFiles,
|
||||
prepareImagesForUpload,
|
||||
} from '../../utils/clipboardImageFiles';
|
||||
|
||||
function uploadErrorMessage(e: unknown): string {
|
||||
if (e instanceof Error) {
|
||||
if (e.message === 'IMAGE_TOO_LARGE') {
|
||||
return '이미지가 너무 큽니다. 5MB 이하로 줄인 뒤 다시 시도하세요.';
|
||||
}
|
||||
if (e.message === 'IMAGE_READ_FAILED') {
|
||||
return '이미지를 읽을 수 없습니다.';
|
||||
}
|
||||
if (e.message) return e.message;
|
||||
}
|
||||
return '이미지 업로드에 실패했습니다.';
|
||||
}
|
||||
|
||||
interface Props {
|
||||
/** 저장된 이슈 ID — 수정·상세 화면 */
|
||||
issueId?: number | null;
|
||||
/** 등록 draft — 저장 전 로컬 파일 */
|
||||
draftFiles?: File[];
|
||||
onDraftFilesChange?: (files: File[]) => void;
|
||||
/** 상세 화면 — 저장 시 삭제할 서버 이미지 ID */
|
||||
pendingDeleteIds?: number[];
|
||||
onPendingDeleteIdsChange?: (ids: number[]) => void;
|
||||
/** true: 추가·삭제를 저장 버튼까지 지연 (상세 편집) */
|
||||
deferUpload?: boolean;
|
||||
disabled?: boolean;
|
||||
/** 모달 등 좁은 영역 */
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
interface CtxMenuState {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
const VerificationIssueAttachmentsSection: React.FC<Props> = ({
|
||||
issueId = null,
|
||||
draftFiles = [],
|
||||
onDraftFilesChange,
|
||||
pendingDeleteIds = [],
|
||||
onPendingDeleteIdsChange,
|
||||
deferUpload = false,
|
||||
disabled = false,
|
||||
compact = false,
|
||||
}) => {
|
||||
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 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) => ({
|
||||
key: `${file.name}-${file.size}-${file.lastModified}-${index}`,
|
||||
index,
|
||||
fileName: file.name,
|
||||
src: URL.createObjectURL(file),
|
||||
})),
|
||||
[draftFiles],
|
||||
);
|
||||
|
||||
useEffect(() => () => {
|
||||
draftPreviews.forEach(p => 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 imgs = pickImageFiles(files);
|
||||
if (imgs.length === 0) {
|
||||
window.alert('이미지 파일(jpeg, png, gif, webp, bmp)만 첨부할 수 있습니다.');
|
||||
return;
|
||||
}
|
||||
setUploading(true);
|
||||
try {
|
||||
const prepared = await prepareImagesForUpload(imgs);
|
||||
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 imgs = pickImageFiles(files);
|
||||
if (imgs.length === 0) {
|
||||
window.alert('이미지 파일(jpeg, png, gif, webp, bmp)만 첨부할 수 있습니다.');
|
||||
return;
|
||||
}
|
||||
setUploading(true);
|
||||
try {
|
||||
const prepared = await prepareImagesForUpload(imgs);
|
||||
await uploadVerificationIssueImages(issueId, prepared);
|
||||
await reload();
|
||||
} catch (e) {
|
||||
console.warn('[VerificationBoard] image 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 handleDeleteServer = async (imageId: number) => {
|
||||
if (issueId == null) return;
|
||||
if (!window.confirm('이 이미지를 삭제할까요?')) return;
|
||||
if (saveOnSubmit) {
|
||||
onPendingDeleteIdsChange?.(
|
||||
pendingDeleteIds.includes(imageId)
|
||||
? pendingDeleteIds
|
||||
: [...pendingDeleteIds, imageId],
|
||||
);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await deleteVerificationIssueImage(issueId, imageId);
|
||||
await reload();
|
||||
} catch (e) {
|
||||
console.warn('[VerificationBoard] image delete', e);
|
||||
window.alert('이미지 삭제에 실패했습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteDraft = (index: number) => {
|
||||
onDraftFilesChange?.(draftFiles.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const openImage = (src: string) => {
|
||||
window.open(src, '_blank', 'noopener,noreferrer');
|
||||
};
|
||||
|
||||
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 files = filesFromDataTransfer(e.clipboardData);
|
||||
if (files.length === 0) return;
|
||||
e.preventDefault();
|
||||
void uploadFiles(files);
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
if (disabled) return;
|
||||
e.preventDefault();
|
||||
setDragOver(false);
|
||||
const fromTransfer = filesFromDataTransfer(e.dataTransfer);
|
||||
if (fromTransfer.length > 0) void uploadFiles(fromTransfer);
|
||||
};
|
||||
|
||||
const visibleServerImages = useMemo(
|
||||
() => images.filter(img => img.id != null && !pendingDeleteIds.includes(img.id)),
|
||||
[images, pendingDeleteIds],
|
||||
);
|
||||
|
||||
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>
|
||||
<button
|
||||
type="button"
|
||||
className="vbd-btn vbd-btn--sm"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={disabled || uploading}
|
||||
>
|
||||
파일 선택
|
||||
</button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/gif,image/webp,image/bmp"
|
||||
multiple
|
||||
hidden
|
||||
disabled={disabled}
|
||||
onChange={e => {
|
||||
const files = e.target.files;
|
||||
if (files?.length) void uploadFiles(pickImageFiles(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);
|
||||
return (
|
||||
<figure key={img.id} className="vbd-attach-item">
|
||||
<button
|
||||
type="button"
|
||||
className="vbd-attach-thumb"
|
||||
title={`${img.fileName ?? '첨부 이미지'} — 더블클릭하여 열기`}
|
||||
onDoubleClick={() => openImage(src)}
|
||||
>
|
||||
<img src={src} alt={img.fileName ?? '첨부 이미지'} loading="lazy" draggable={false} />
|
||||
</button>
|
||||
<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">
|
||||
<button
|
||||
type="button"
|
||||
className="vbd-attach-thumb"
|
||||
title={`${p.fileName} — 더블클릭하여 열기`}
|
||||
onDoubleClick={() => openImage(p.src)}
|
||||
>
|
||||
<img src={p.src} alt={p.fileName} draggable={false} />
|
||||
</button>
|
||||
<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);
|
||||
return (
|
||||
<figure key={img.id} className="vbd-attach-item">
|
||||
<button
|
||||
type="button"
|
||||
className="vbd-attach-thumb"
|
||||
title={`${img.fileName ?? '첨부 이미지'} — 더블클릭하여 열기`}
|
||||
onDoubleClick={() => openImage(src)}
|
||||
>
|
||||
<img src={src} alt={img.fileName ?? '첨부 이미지'} loading="lazy" draggable={false} />
|
||||
</button>
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default VerificationIssueAttachmentsSection;
|
||||
Reference in New Issue
Block a user