검증게시판, 압축파일, 한글파일 첨부가능
This commit is contained in:
+4
-3
@@ -47,9 +47,10 @@ public class VerificationIssueImageController {
|
|||||||
private static String toUserMessage(String code) {
|
private static String toUserMessage(String code) {
|
||||||
if (code == null) return "업로드할 수 없습니다.";
|
if (code == null) return "업로드할 수 없습니다.";
|
||||||
return switch (code) {
|
return switch (code) {
|
||||||
case "unsupported image type" -> "지원하지 않는 이미지 형식입니다. (jpeg, png, gif, webp, bmp)";
|
case "unsupported file type", "unsupported image type" ->
|
||||||
case "file too large" -> "이미지 크기는 파일당 5MB 이하여야 합니다.";
|
"지원하지 않는 파일 형식입니다. (jpeg, png, gif, webp, bmp, zip, hwp, hwpx)";
|
||||||
case "no files", "no valid image files" -> "업로드할 이미지가 없습니다.";
|
case "file too large" -> "첨부 파일 크기는 파일당 5MB 이하여야 합니다.";
|
||||||
|
case "no files", "no valid image files", "no valid attachment files" -> "업로드할 파일이 없습니다.";
|
||||||
default -> code.startsWith("max images exceeded")
|
default -> code.startsWith("max images exceeded")
|
||||||
? "이슈당 첨부 이미지는 최대 30장까지입니다."
|
? "이슈당 첨부 이미지는 최대 30장까지입니다."
|
||||||
: code.startsWith("issue not found") ? "검증 이슈를 찾을 수 없습니다." : code;
|
: code.startsWith("issue not found") ? "검증 이슈를 찾을 수 없습니다." : code;
|
||||||
|
|||||||
+1
-1
@@ -25,6 +25,6 @@ public class VerificationUploadExceptionHandler {
|
|||||||
public ResponseEntity<Map<String, String>> handleMultipart(MultipartException e) {
|
public ResponseEntity<Map<String, String>> handleMultipart(MultipartException e) {
|
||||||
log.warn("[VerificationUpload] multipart error: {}", e.getMessage());
|
log.warn("[VerificationUpload] multipart error: {}", e.getMessage());
|
||||||
return ResponseEntity.badRequest()
|
return ResponseEntity.badRequest()
|
||||||
.body(Map.of("message", "이미지 파일 형식을 확인할 수 없습니다."));
|
.body(Map.of("message", "첨부 파일 형식을 확인할 수 없습니다."));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,10 +25,21 @@ import java.util.UUID;
|
|||||||
@Slf4j
|
@Slf4j
|
||||||
public class VerificationIssueImageService {
|
public class VerificationIssueImageService {
|
||||||
|
|
||||||
private static final Set<String> ALLOWED_TYPES = Set.of(
|
private static final Set<String> ALLOWED_IMAGE_TYPES = Set.of(
|
||||||
"image/jpeg", "image/png", "image/gif", "image/webp", "image/bmp"
|
"image/jpeg", "image/png", "image/gif", "image/webp", "image/bmp"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
private static final Set<String> ALLOWED_DOCUMENT_TYPES = Set.of(
|
||||||
|
"application/zip",
|
||||||
|
"application/x-zip-compressed",
|
||||||
|
"application/x-hwp",
|
||||||
|
"application/haansoft-hwp",
|
||||||
|
"application/vnd.hancom.hwp",
|
||||||
|
"application/hwp+zip",
|
||||||
|
"application/vnd.hancom.hwpx",
|
||||||
|
"application/x-hwpx"
|
||||||
|
);
|
||||||
|
|
||||||
private final GcVerificationIssueImageRepository imageRepository;
|
private final GcVerificationIssueImageRepository imageRepository;
|
||||||
private final GcVerificationIssueRepository issueRepository;
|
private final GcVerificationIssueRepository issueRepository;
|
||||||
private final Path uploadRoot;
|
private final Path uploadRoot;
|
||||||
@@ -100,7 +111,7 @@ public class VerificationIssueImageService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (saved.isEmpty()) {
|
if (saved.isEmpty()) {
|
||||||
throw new IllegalArgumentException("no valid image files");
|
throw new IllegalArgumentException("no valid attachment files");
|
||||||
}
|
}
|
||||||
return saved;
|
return saved;
|
||||||
}
|
}
|
||||||
@@ -155,14 +166,19 @@ public class VerificationIssueImageService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void validateFile(long sizeBytes, String contentType) {
|
private void validateFile(long sizeBytes, String contentType) {
|
||||||
if (contentType == null) {
|
if (contentType == null || !isAllowedContentType(contentType)) {
|
||||||
throw new IllegalArgumentException("unsupported image type");
|
throw new IllegalArgumentException("unsupported file type");
|
||||||
}
|
}
|
||||||
if (sizeBytes > maxFileSizeBytes) {
|
if (sizeBytes > maxFileSizeBytes) {
|
||||||
throw new IllegalArgumentException("file too large");
|
throw new IllegalArgumentException("file too large");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static boolean isAllowedContentType(String contentType) {
|
||||||
|
return ALLOWED_IMAGE_TYPES.contains(contentType)
|
||||||
|
|| ALLOWED_DOCUMENT_TYPES.contains(contentType);
|
||||||
|
}
|
||||||
|
|
||||||
private String resolveContentType(MultipartFile file, byte[] data) {
|
private String resolveContentType(MultipartFile file, byte[] data) {
|
||||||
String type = file.getContentType();
|
String type = file.getContentType();
|
||||||
if (type != null && !type.isBlank()) {
|
if (type != null && !type.isBlank()) {
|
||||||
@@ -173,12 +189,15 @@ public class VerificationIssueImageService {
|
|||||||
if ("image/x-png".equals(type)) {
|
if ("image/x-png".equals(type)) {
|
||||||
return "image/png";
|
return "image/png";
|
||||||
}
|
}
|
||||||
if (ALLOWED_TYPES.contains(type)) {
|
if (ALLOWED_IMAGE_TYPES.contains(type)) {
|
||||||
return type;
|
return type;
|
||||||
}
|
}
|
||||||
// application/octet-stream 등 — 매직 바이트로 판별
|
if (ALLOWED_DOCUMENT_TYPES.contains(type)) {
|
||||||
|
return type;
|
||||||
|
}
|
||||||
|
// application/octet-stream 등 — 매직 바이트·확장자로 판별
|
||||||
if ("application/octet-stream".equals(type) || type.startsWith("image/")) {
|
if ("application/octet-stream".equals(type) || type.startsWith("image/")) {
|
||||||
String sniffed = sniffImageType(data);
|
String sniffed = sniffFileType(data, file.getOriginalFilename());
|
||||||
if (sniffed != null) return sniffed;
|
if (sniffed != null) return sniffed;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -191,9 +210,35 @@ public class VerificationIssueImageService {
|
|||||||
if (lower.endsWith(".gif")) return "image/gif";
|
if (lower.endsWith(".gif")) return "image/gif";
|
||||||
if (lower.endsWith(".webp")) return "image/webp";
|
if (lower.endsWith(".webp")) return "image/webp";
|
||||||
if (lower.endsWith(".bmp")) return "image/bmp";
|
if (lower.endsWith(".bmp")) return "image/bmp";
|
||||||
|
if (lower.endsWith(".zip")) return "application/zip";
|
||||||
|
if (lower.endsWith(".hwp")) return "application/x-hwp";
|
||||||
|
if (lower.endsWith(".hwpx")) return "application/vnd.hancom.hwpx";
|
||||||
}
|
}
|
||||||
|
|
||||||
return sniffImageType(data);
|
return sniffFileType(data, file.getOriginalFilename());
|
||||||
|
}
|
||||||
|
|
||||||
|
private String sniffFileType(byte[] header, String fileName) {
|
||||||
|
String image = sniffImageType(header);
|
||||||
|
if (image != null) return image;
|
||||||
|
|
||||||
|
if (header != null && header.length >= 4
|
||||||
|
&& header[0] == 0x50 && header[1] == 0x4B
|
||||||
|
&& (header[2] == 0x03 || header[2] == 0x05 || header[2] == 0x07)) {
|
||||||
|
if (fileName != null && fileName.toLowerCase().endsWith(".hwpx")) {
|
||||||
|
return "application/vnd.hancom.hwpx";
|
||||||
|
}
|
||||||
|
return "application/zip";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (header != null && header.length >= 8
|
||||||
|
&& header[0] == (byte) 0xD0 && header[1] == (byte) 0xCF
|
||||||
|
&& header[2] == 0x11 && header[3] == (byte) 0xE0) {
|
||||||
|
if (fileName != null && fileName.toLowerCase().endsWith(".hwp")) {
|
||||||
|
return "application/x-hwp";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private String sniffImageType(byte[] header) {
|
private String sniffImageType(byte[] header) {
|
||||||
@@ -230,7 +275,7 @@ public class VerificationIssueImageService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private String sanitizeFileName(String name) {
|
private String sanitizeFileName(String name) {
|
||||||
if (name == null || name.isBlank()) return "image.png";
|
if (name == null || name.isBlank()) return "attachment.bin";
|
||||||
String base = Paths.get(name).getFileName().toString();
|
String base = Paths.get(name).getFileName().toString();
|
||||||
return base.replaceAll("[^a-zA-Z0-9._\\-가-힣]", "_");
|
return base.replaceAll("[^a-zA-Z0-9._\\-가-힣]", "_");
|
||||||
}
|
}
|
||||||
|
|||||||
+112
-59
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* 검증 이슈 첨부 이미지 — 화면 캡처·파일 선택·드래그·클립보드 붙여넣기
|
* 검증 이슈 첨부 — 화면 캡처·파일 선택·드래그·클립보드 붙여넣기
|
||||||
* issueId 있음: 서버 즉시 업로드 / 없음: draft 로컬 보관(등록 시 저장 후 업로드)
|
* 이미지 + zip·hwp·hwpx
|
||||||
*/
|
*/
|
||||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import ScreenRegionCaptureOverlay from '../ScreenRegionCaptureOverlay';
|
import ScreenRegionCaptureOverlay from '../ScreenRegionCaptureOverlay';
|
||||||
@@ -14,21 +14,37 @@ import {
|
|||||||
import {
|
import {
|
||||||
filesFromClipboardRead,
|
filesFromClipboardRead,
|
||||||
filesFromDataTransfer,
|
filesFromDataTransfer,
|
||||||
pickImageFiles,
|
|
||||||
prepareImagesForUpload,
|
|
||||||
} from '../../utils/clipboardImageFiles';
|
} from '../../utils/clipboardImageFiles';
|
||||||
|
import {
|
||||||
|
ATTACHMENT_TYPE_HINT,
|
||||||
|
VERIFICATION_ATTACHMENT_ACCEPT,
|
||||||
|
isImageAttachment,
|
||||||
|
pickAttachmentFiles,
|
||||||
|
prepareAttachmentsForUpload,
|
||||||
|
} from '../../utils/verificationAttachmentFiles';
|
||||||
|
|
||||||
function uploadErrorMessage(e: unknown): string {
|
function uploadErrorMessage(e: unknown): string {
|
||||||
if (e instanceof Error) {
|
if (e instanceof Error) {
|
||||||
if (e.message === 'IMAGE_TOO_LARGE') {
|
if (e.message === 'IMAGE_TOO_LARGE' || e.message === 'FILE_TOO_LARGE') {
|
||||||
return '이미지가 너무 큽니다. 5MB 이하로 줄인 뒤 다시 시도하세요.';
|
return '파일이 너무 큽니다. 5MB 이하로 줄인 뒤 다시 시도하세요.';
|
||||||
}
|
}
|
||||||
if (e.message === 'IMAGE_READ_FAILED') {
|
if (e.message === 'IMAGE_READ_FAILED') {
|
||||||
return '이미지를 읽을 수 없습니다.';
|
return '이미지를 읽을 수 없습니다.';
|
||||||
}
|
}
|
||||||
if (e.message) return e.message;
|
if (e.message) return e.message;
|
||||||
}
|
}
|
||||||
return '이미지 업로드에 실패했습니다.';
|
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 {
|
interface Props {
|
||||||
@@ -91,17 +107,24 @@ const VerificationIssueAttachmentsSection: React.FC<Props> = ({
|
|||||||
}, [issueId, reload]);
|
}, [issueId, reload]);
|
||||||
|
|
||||||
const draftPreviews = useMemo(
|
const draftPreviews = useMemo(
|
||||||
() => draftFiles.map((file, index) => ({
|
() => draftFiles.map((file, index) => {
|
||||||
key: `${file.name}-${file.size}-${file.lastModified}-${index}`,
|
const isImage = isImageAttachment(file.type, file.name);
|
||||||
index,
|
return {
|
||||||
fileName: file.name,
|
key: `${file.name}-${file.size}-${file.lastModified}-${index}`,
|
||||||
src: URL.createObjectURL(file),
|
index,
|
||||||
})),
|
fileName: file.name,
|
||||||
|
isImage,
|
||||||
|
src: isImage ? URL.createObjectURL(file) : undefined,
|
||||||
|
kind: fileKindLabel(file.name, file.type),
|
||||||
|
};
|
||||||
|
}),
|
||||||
[draftFiles],
|
[draftFiles],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => () => {
|
useEffect(() => () => {
|
||||||
draftPreviews.forEach(p => URL.revokeObjectURL(p.src));
|
draftPreviews.forEach(p => {
|
||||||
|
if (p.src) URL.revokeObjectURL(p.src);
|
||||||
|
});
|
||||||
}, [draftPreviews]);
|
}, [draftPreviews]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -115,14 +138,14 @@ const VerificationIssueAttachmentsSection: React.FC<Props> = ({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const addDraftFiles = useCallback(async (files: File[]) => {
|
const addDraftFiles = useCallback(async (files: File[]) => {
|
||||||
const imgs = pickImageFiles(files);
|
const picked = pickAttachmentFiles(files);
|
||||||
if (imgs.length === 0) {
|
if (picked.length === 0) {
|
||||||
window.alert('이미지 파일(jpeg, png, gif, webp, bmp)만 첨부할 수 있습니다.');
|
window.alert(`첨부 가능: ${ATTACHMENT_TYPE_HINT}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setUploading(true);
|
setUploading(true);
|
||||||
try {
|
try {
|
||||||
const prepared = await prepareImagesForUpload(imgs);
|
const prepared = await prepareAttachmentsForUpload(picked);
|
||||||
onDraftFilesChange?.([...draftFiles, ...prepared]);
|
onDraftFilesChange?.([...draftFiles, ...prepared]);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
window.alert(uploadErrorMessage(e));
|
window.alert(uploadErrorMessage(e));
|
||||||
@@ -136,18 +159,18 @@ const VerificationIssueAttachmentsSection: React.FC<Props> = ({
|
|||||||
await addDraftFiles(files);
|
await addDraftFiles(files);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const imgs = pickImageFiles(files);
|
const picked = pickAttachmentFiles(files);
|
||||||
if (imgs.length === 0) {
|
if (picked.length === 0) {
|
||||||
window.alert('이미지 파일(jpeg, png, gif, webp, bmp)만 첨부할 수 있습니다.');
|
window.alert(`첨부 가능: ${ATTACHMENT_TYPE_HINT}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setUploading(true);
|
setUploading(true);
|
||||||
try {
|
try {
|
||||||
const prepared = await prepareImagesForUpload(imgs);
|
const prepared = await prepareAttachmentsForUpload(picked);
|
||||||
await uploadVerificationIssueImages(issueId, prepared);
|
await uploadVerificationIssueImages(issueId, prepared);
|
||||||
await reload();
|
await reload();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('[VerificationBoard] image upload', e);
|
console.warn('[VerificationBoard] attachment upload', e);
|
||||||
window.alert(uploadErrorMessage(e));
|
window.alert(uploadErrorMessage(e));
|
||||||
} finally {
|
} finally {
|
||||||
setUploading(false);
|
setUploading(false);
|
||||||
@@ -179,7 +202,7 @@ const VerificationIssueAttachmentsSection: React.FC<Props> = ({
|
|||||||
|
|
||||||
const handleDeleteServer = async (imageId: number) => {
|
const handleDeleteServer = async (imageId: number) => {
|
||||||
if (issueId == null) return;
|
if (issueId == null) return;
|
||||||
if (!window.confirm('이 이미지를 삭제할까요?')) return;
|
if (!window.confirm('이 첨부 파일을 삭제할까요?')) return;
|
||||||
if (saveOnSubmit) {
|
if (saveOnSubmit) {
|
||||||
onPendingDeleteIdsChange?.(
|
onPendingDeleteIdsChange?.(
|
||||||
pendingDeleteIds.includes(imageId)
|
pendingDeleteIds.includes(imageId)
|
||||||
@@ -201,10 +224,31 @@ const VerificationIssueAttachmentsSection: React.FC<Props> = ({
|
|||||||
onDraftFilesChange?.(draftFiles.filter((_, i) => i !== index));
|
onDraftFilesChange?.(draftFiles.filter((_, i) => i !== index));
|
||||||
};
|
};
|
||||||
|
|
||||||
const openImage = (src: string) => {
|
const openAttachment = (src: string) => {
|
||||||
window.open(src, '_blank', 'noopener,noreferrer');
|
window.open(src, '_blank', 'noopener,noreferrer');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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) => {
|
const handleContextMenu = (e: React.MouseEvent) => {
|
||||||
if (disabled) return;
|
if (disabled) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -214,18 +258,20 @@ const VerificationIssueAttachmentsSection: React.FC<Props> = ({
|
|||||||
|
|
||||||
const handlePaste = (e: React.ClipboardEvent) => {
|
const handlePaste = (e: React.ClipboardEvent) => {
|
||||||
if (disabled) return;
|
if (disabled) return;
|
||||||
const files = filesFromDataTransfer(e.clipboardData);
|
const fromItems = filesFromDataTransfer(e.clipboardData);
|
||||||
if (files.length === 0) return;
|
const fromFiles = pickAttachmentFiles(e.clipboardData.files);
|
||||||
|
const merged = fromFiles.length > 0 ? fromFiles : fromItems;
|
||||||
|
if (merged.length === 0) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
void uploadFiles(files);
|
void uploadFiles(merged);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDrop = (e: React.DragEvent) => {
|
const handleDrop = (e: React.DragEvent) => {
|
||||||
if (disabled) return;
|
if (disabled) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setDragOver(false);
|
setDragOver(false);
|
||||||
const fromTransfer = filesFromDataTransfer(e.dataTransfer);
|
const picked = pickAttachmentFiles(e.dataTransfer.files);
|
||||||
if (fromTransfer.length > 0) void uploadFiles(fromTransfer);
|
if (picked.length > 0) void uploadFiles(picked);
|
||||||
};
|
};
|
||||||
|
|
||||||
const visibleServerImages = useMemo(
|
const visibleServerImages = useMemo(
|
||||||
@@ -242,7 +288,7 @@ const VerificationIssueAttachmentsSection: React.FC<Props> = ({
|
|||||||
<div className={`vbd-attachments${compact ? ' vbd-attachments--compact' : ''}`}>
|
<div className={`vbd-attachments${compact ? ' vbd-attachments--compact' : ''}`}>
|
||||||
<div className="vbd-attachments-head">
|
<div className="vbd-attachments-head">
|
||||||
<h3 className="vbd-attachments-title">
|
<h3 className="vbd-attachments-title">
|
||||||
첨부 이미지 {totalCount > 0 && `(${totalCount})`}
|
첨부 파일 {totalCount > 0 && `(${totalCount})`}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="vbd-attachments-actions">
|
<div className="vbd-attachments-actions">
|
||||||
<button
|
<button
|
||||||
@@ -266,13 +312,13 @@ const VerificationIssueAttachmentsSection: React.FC<Props> = ({
|
|||||||
<input
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/jpeg,image/png,image/gif,image/webp,image/bmp"
|
accept={VERIFICATION_ATTACHMENT_ACCEPT}
|
||||||
multiple
|
multiple
|
||||||
hidden
|
hidden
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onChange={e => {
|
onChange={e => {
|
||||||
const files = e.target.files;
|
const files = e.target.files;
|
||||||
if (files?.length) void uploadFiles(pickImageFiles(files));
|
if (files?.length) void uploadFiles(pickAttachmentFiles(files));
|
||||||
e.target.value = '';
|
e.target.value = '';
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -283,7 +329,7 @@ const VerificationIssueAttachmentsSection: React.FC<Props> = ({
|
|||||||
className={`vbd-attach-zone${dragOver ? ' vbd-attach-zone--over' : ''}${disabled ? ' vbd-attach-zone--disabled' : ''}`}
|
className={`vbd-attach-zone${dragOver ? ' vbd-attach-zone--over' : ''}${disabled ? ' vbd-attach-zone--disabled' : ''}`}
|
||||||
tabIndex={disabled ? -1 : 0}
|
tabIndex={disabled ? -1 : 0}
|
||||||
role="region"
|
role="region"
|
||||||
aria-label="이미지 첨부 영역"
|
aria-label="첨부 파일 영역"
|
||||||
onContextMenu={handleContextMenu}
|
onContextMenu={handleContextMenu}
|
||||||
onPaste={handlePaste}
|
onPaste={handlePaste}
|
||||||
onDragEnter={e => { if (disabled) return; e.preventDefault(); setDragOver(true); }}
|
onDragEnter={e => { if (disabled) return; e.preventDefault(); setDragOver(true); }}
|
||||||
@@ -296,7 +342,7 @@ const VerificationIssueAttachmentsSection: React.FC<Props> = ({
|
|||||||
onDrop={handleDrop}
|
onDrop={handleDrop}
|
||||||
>
|
>
|
||||||
{loading && issueId != null && !isDraft && images.length === 0 ? (
|
{loading && issueId != null && !isDraft && images.length === 0 ? (
|
||||||
<p className="vbd-attach-hint">이미지 불러오는 중…</p>
|
<p className="vbd-attach-hint">첨부 파일 불러오는 중…</p>
|
||||||
) : showEmptyHint ? (
|
) : showEmptyHint ? (
|
||||||
<p className="vbd-attach-hint">
|
<p className="vbd-attach-hint">
|
||||||
{isDraft || saveOnSubmit
|
{isDraft || saveOnSubmit
|
||||||
@@ -308,16 +354,17 @@ const VerificationIssueAttachmentsSection: React.FC<Props> = ({
|
|||||||
{(isDraft || saveOnSubmit) && visibleServerImages.map(img => {
|
{(isDraft || saveOnSubmit) && visibleServerImages.map(img => {
|
||||||
if (img.id == null) return null;
|
if (img.id == null) return null;
|
||||||
const src = verificationIssueImageUrl(issueId!, img.id);
|
const src = verificationIssueImageUrl(issueId!, img.id);
|
||||||
|
const isImage = isImageAttachment(img.contentType, img.fileName);
|
||||||
|
const kind = fileKindLabel(img.fileName, img.contentType);
|
||||||
return (
|
return (
|
||||||
<figure key={img.id} className="vbd-attach-item">
|
<figure key={img.id} className="vbd-attach-item">
|
||||||
<button
|
{renderAttachmentThumb(
|
||||||
type="button"
|
src,
|
||||||
className="vbd-attach-thumb"
|
img.fileName ?? '첨부 파일',
|
||||||
title={`${img.fileName ?? '첨부 이미지'} — 더블클릭하여 열기`}
|
isImage,
|
||||||
onDoubleClick={() => openImage(src)}
|
kind,
|
||||||
>
|
() => openAttachment(src),
|
||||||
<img src={src} alt={img.fileName ?? '첨부 이미지'} loading="lazy" draggable={false} />
|
)}
|
||||||
</button>
|
|
||||||
<figcaption className="vbd-attach-caption" title={img.fileName}>
|
<figcaption className="vbd-attach-caption" title={img.fileName}>
|
||||||
{img.fileName}
|
{img.fileName}
|
||||||
</figcaption>
|
</figcaption>
|
||||||
@@ -336,14 +383,19 @@ const VerificationIssueAttachmentsSection: React.FC<Props> = ({
|
|||||||
{(isDraft || saveOnSubmit)
|
{(isDraft || saveOnSubmit)
|
||||||
? draftPreviews.map(p => (
|
? draftPreviews.map(p => (
|
||||||
<figure key={p.key} className="vbd-attach-item">
|
<figure key={p.key} className="vbd-attach-item">
|
||||||
<button
|
{p.isImage && p.src
|
||||||
type="button"
|
? renderAttachmentThumb(
|
||||||
className="vbd-attach-thumb"
|
p.src,
|
||||||
title={`${p.fileName} — 더블클릭하여 열기`}
|
p.fileName,
|
||||||
onDoubleClick={() => openImage(p.src)}
|
true,
|
||||||
>
|
p.kind,
|
||||||
<img src={p.src} alt={p.fileName} loading="lazy" draggable={false} />
|
() => openAttachment(p.src!),
|
||||||
</button>
|
)
|
||||||
|
: (
|
||||||
|
<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}>
|
<figcaption className="vbd-attach-caption" title={p.fileName}>
|
||||||
{p.fileName}
|
{p.fileName}
|
||||||
</figcaption>
|
</figcaption>
|
||||||
@@ -361,16 +413,17 @@ const VerificationIssueAttachmentsSection: React.FC<Props> = ({
|
|||||||
: !saveOnSubmit && images.map(img => {
|
: !saveOnSubmit && images.map(img => {
|
||||||
if (img.id == null) return null;
|
if (img.id == null) return null;
|
||||||
const src = verificationIssueImageUrl(issueId!, img.id);
|
const src = verificationIssueImageUrl(issueId!, img.id);
|
||||||
|
const isImage = isImageAttachment(img.contentType, img.fileName);
|
||||||
|
const kind = fileKindLabel(img.fileName, img.contentType);
|
||||||
return (
|
return (
|
||||||
<figure key={img.id} className="vbd-attach-item">
|
<figure key={img.id} className="vbd-attach-item">
|
||||||
<button
|
{renderAttachmentThumb(
|
||||||
type="button"
|
src,
|
||||||
className="vbd-attach-thumb"
|
img.fileName ?? '첨부 파일',
|
||||||
title={`${img.fileName ?? '첨부 이미지'} — 더블클릭하여 열기`}
|
isImage,
|
||||||
onDoubleClick={() => openImage(src)}
|
kind,
|
||||||
>
|
() => openAttachment(src),
|
||||||
<img src={src} alt={img.fileName ?? '첨부 이미지'} loading="lazy" draggable={false} />
|
)}
|
||||||
</button>
|
|
||||||
<figcaption className="vbd-attach-caption" title={img.fileName}>
|
<figcaption className="vbd-attach-caption" title={img.fileName}>
|
||||||
{img.fileName}
|
{img.fileName}
|
||||||
</figcaption>
|
</figcaption>
|
||||||
|
|||||||
@@ -855,6 +855,31 @@
|
|||||||
cursor: zoom-in;
|
cursor: zoom-in;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.vbd-attach-thumb--file {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100px;
|
||||||
|
cursor: pointer;
|
||||||
|
background: color-mix(in srgb, var(--accent, #3f7ef5) 8%, var(--bg2, #1a1b26));
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vbd-attach-file-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 52px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--accent, #7aa2f7);
|
||||||
|
background: color-mix(in srgb, var(--accent, #3f7ef5) 16%, transparent);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--accent, #3f7ef5) 35%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
.vbd-attach-item img {
|
.vbd-attach-item img {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
/**
|
||||||
|
* 검증게시판 첨부 — 이미지 + zip·hwp·hwpx
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
pickImageFiles,
|
||||||
|
prepareImagesForUpload,
|
||||||
|
} from './clipboardImageFiles';
|
||||||
|
|
||||||
|
export const VERIFICATION_ATTACHMENT_ACCEPT =
|
||||||
|
'image/jpeg,image/png,image/gif,image/webp,image/bmp,.zip,.hwp,.hwpx,application/zip,application/x-hwp,application/vnd.hancom.hwpx';
|
||||||
|
|
||||||
|
const DOCUMENT_EXT = new Set(['.zip', '.hwp', '.hwpx']);
|
||||||
|
|
||||||
|
const DOCUMENT_MIME = new Set([
|
||||||
|
'application/zip',
|
||||||
|
'application/x-zip-compressed',
|
||||||
|
'application/x-hwp',
|
||||||
|
'application/haansoft-hwp',
|
||||||
|
'application/vnd.hancom.hwp',
|
||||||
|
'application/hwp+zip',
|
||||||
|
'application/vnd.hancom.hwpx',
|
||||||
|
'application/x-hwpx',
|
||||||
|
]);
|
||||||
|
|
||||||
|
function extOf(name: string): string {
|
||||||
|
const i = name.lastIndexOf('.');
|
||||||
|
return i >= 0 ? name.slice(i).toLowerCase() : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function mimeForDocumentExt(ext: string): string | null {
|
||||||
|
switch (ext) {
|
||||||
|
case '.zip': return 'application/zip';
|
||||||
|
case '.hwp': return 'application/x-hwp';
|
||||||
|
case '.hwpx': return 'application/vnd.hancom.hwpx';
|
||||||
|
default: return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isImageAttachment(contentType?: string | null, fileName?: string | null): boolean {
|
||||||
|
const mime = (contentType ?? '').split(';')[0]?.trim().toLowerCase();
|
||||||
|
if (mime.startsWith('image/')) return true;
|
||||||
|
const ext = fileName ? extOf(fileName) : '';
|
||||||
|
return ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp'].includes(ext);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isDocumentAttachmentFile(file: File): boolean {
|
||||||
|
const ext = extOf(file.name);
|
||||||
|
if (DOCUMENT_EXT.has(ext)) return true;
|
||||||
|
const mime = file.type.split(';')[0]?.trim().toLowerCase();
|
||||||
|
return DOCUMENT_MIME.has(mime);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDocumentFile(file: File): File | null {
|
||||||
|
if (file.size === 0) return null;
|
||||||
|
const ext = extOf(file.name);
|
||||||
|
if (!DOCUMENT_EXT.has(ext)) {
|
||||||
|
const mime = file.type.split(';')[0]?.trim().toLowerCase();
|
||||||
|
if (!DOCUMENT_MIME.has(mime)) return null;
|
||||||
|
}
|
||||||
|
const mime = mimeForDocumentExt(ext)
|
||||||
|
?? file.type.split(';')[0]?.trim().toLowerCase()
|
||||||
|
?? 'application/octet-stream';
|
||||||
|
if (file.type === mime) return file;
|
||||||
|
return new File([file], file.name, { type: mime, lastModified: file.lastModified });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** FileList / File[] → 허용 첨부만 (이미지·zip·hwp·hwpx) */
|
||||||
|
export function pickAttachmentFiles(list: FileList | File[]): File[] {
|
||||||
|
const out: File[] = [];
|
||||||
|
for (const file of Array.from(list)) {
|
||||||
|
if (isDocumentAttachmentFile(file)) {
|
||||||
|
const doc = normalizeDocumentFile(file);
|
||||||
|
if (doc) out.push(doc);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const img = pickImageFiles([file])[0];
|
||||||
|
if (img) out.push(img);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_BYTES = 5 * 1024 * 1024;
|
||||||
|
|
||||||
|
/** 이미지는 압축·문서는 그대로 업로드 */
|
||||||
|
export async function prepareAttachmentsForUpload(files: File[]): Promise<File[]> {
|
||||||
|
const picked = pickAttachmentFiles(files);
|
||||||
|
const out: File[] = [];
|
||||||
|
for (const f of picked) {
|
||||||
|
if (isDocumentAttachmentFile(f)) {
|
||||||
|
if (f.size > MAX_BYTES) throw new Error('FILE_TOO_LARGE');
|
||||||
|
out.push(f);
|
||||||
|
} else {
|
||||||
|
out.push(...await prepareImagesForUpload([f]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ATTACHMENT_TYPE_HINT =
|
||||||
|
'jpeg, png, gif, webp, bmp, zip, hwp, hwpx (파일당 5MB)';
|
||||||
@@ -195,7 +195,7 @@ function parseVerificationUploadError(status: number, text: string): string {
|
|||||||
if (status === 413) {
|
if (status === 413) {
|
||||||
return '업로드 용량이 너무 큽니다. 이미지를 줄이거나 5MB 이하 파일을 사용하세요.';
|
return '업로드 용량이 너무 큽니다. 이미지를 줄이거나 5MB 이하 파일을 사용하세요.';
|
||||||
}
|
}
|
||||||
if (status === 400) return '이미지 형식 또는 크기를 확인하세요. (jpeg, png, gif, webp, bmp · 파일당 5MB)';
|
if (status === 400) return '파일 형식 또는 크기를 확인하세요. (jpeg, png, gif, webp, bmp, zip, hwp, hwpx · 파일당 5MB)';
|
||||||
return `이미지 업로드에 실패했습니다. (${status})`;
|
return `이미지 업로드에 실패했습니다. (${status})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user