임동욱 임동욱 08-19
merge: 썸네일이 폼에 실리지 않던 문제
@361b354aa305795d9d4ab3f18d3bbeb541511454
@fox/core/components/fox-file-upload/fox-file-upload.tsx
--- @fox/core/components/fox-file-upload/fox-file-upload.tsx
+++ @fox/core/components/fox-file-upload/fox-file-upload.tsx
@@ -156,13 +156,18 @@
   return `${(bytes / 1024 / 1024).toFixed(1)}MB`;
 }
 
+/** 목록 항목의 id. 삭제할 때 같은 규칙으로 원본 `File`을 되찾으므로 한곳에 둔다. */
+function fileKey(file: File): string {
+  return `${file.name}:${file.size}:${file.lastModified}`;
+}
+
 /** 비제어로 쓸 때 원본 `File`을 목록 한 줄로 바꾼다. 시안의 `이름` + `.PDF (20KB)` 모양이다. */
 function toItem(file: File, previewUrl?: string): FoxFileItem {
   const dot = file.name.lastIndexOf(".");
   const base = dot > 0 ? file.name.slice(0, dot) : file.name;
   const ext = dot > 0 ? file.name.slice(dot + 1).toUpperCase() : "";
   return {
-    id: `${file.name}:${file.size}:${file.lastModified}`,
+    id: fileKey(file),
     name: base,
     meta: ext ? `.${ext} (${formatBytes(file.size)})` : `(${formatBytes(file.size)})`,
     previewUrl,
@@ -215,6 +220,8 @@
   const autoId = useId();
   const pickerRef = useRef<HTMLInputElement>(null);
   const objectUrls = useRef<string[]>([]);
+  /** 지금 고른 원본 파일들. 네이티브 입력의 `files`를 다시 세우는 근거다(아래 `syncPicker`). */
+  const picked = useRef<File[]>([]);
   const [inner, setInner] = useState<FoxFileItem[]>(defaultFiles ?? []);
   const [over, setOver] = useState(false);
 
@@ -242,16 +249,41 @@
     pickerRef.current?.click();
   };
 
-  const takeFiles = (picked: File[]) => {
-    if (!picked.length) {
+  /**
+   * 고른 파일을 네이티브 입력에 **다시 심는다.**
+   *
+   * 폼 제출에 실리는 것은 React 상태가 아니라 이 입력의 `files`다. 끌어다 놓은 파일은 애초에
+   * 입력을 거치지 않고, 고른 파일도 예전에는 "같은 파일을 다시 골라도 이벤트가 오게" 바로
+   * 비웠다 — 그래서 `name`을 줘도 폼에는 아무것도 실리지 않았다(그 prop의 설명과 반대였다).
+   * 비우는 대신 지금 목록으로 덮으면 두 가지가 같이 해결된다: 제출에 실리고, 목록을 비웠을 때는
+   * 입력도 비어 같은 파일을 다시 고를 수 있다.
+   */
+  const syncPicker = (nextFiles: File[]) => {
+    const picker = pickerRef.current;
+    // jsdom 등 DataTransfer가 없는 환경에서는 조용히 건너뛴다 — 화면 동작은 그대로다.
+    if (!picker || typeof DataTransfer === "undefined") {
       return;
     }
-    onSelect?.(picked);
+    const transfer = new DataTransfer();
+    nextFiles.forEach((file) => transfer.items.add(file));
+    picker.files = transfer.files;
+  };
+
+  const takeFiles = (chosen: File[]) => {
+    if (!chosen.length) {
+      return;
+    }
+    onSelect?.(chosen);
+
+    // 제어·비제어를 가리지 않고 입력은 늘 맞춘다 — `name`은 목록을 누가 들고 있든 "고른 파일이
+    // 폼에 실린다"는 약속이다.
+    picked.current = multiple ? [...picked.current, ...chosen] : chosen.slice(0, 1);
+    syncPicker(picked.current);
 
     if (files !== undefined) {
       return;
     }
-    const made = picked.map((file) => {
+    const made = chosen.map((file) => {
       if (!file.type.startsWith("image/")) {
         return toItem(file);
       }
@@ -266,12 +298,15 @@
 
   const handlePicked = (event: ChangeEvent<HTMLInputElement>) => {
     takeFiles(Array.from(event.target.files ?? []));
-    // 같은 파일을 다시 골라도 이벤트가 오게 비운다.
-    event.target.value = "";
   };
 
   const handleRemove = (id: string) => {
     onRemove?.(id);
+
+    // 밖에서 받은 기존 파일(수정 화면의 등록된 이미지)은 여기 없어 그대로 지나간다.
+    picked.current = picked.current.filter((file) => fileKey(file) !== id);
+    syncPicker(picked.current);
+
     if (files !== undefined) {
       return;
     }
app/(protected)/(basic)/decoration-items/_actions.ts
--- app/(protected)/(basic)/decoration-items/_actions.ts
+++ app/(protected)/(basic)/decoration-items/_actions.ts
@@ -13,6 +13,7 @@
 import type { DecorationItemType } from '@/lib/domain/decoration-item';
 import {
   validateDecorationItemCreate,
+  validateDecorationItemImage,
   validateDecorationItemUpdate,
   type DecorationItemEditableValues,
   type DecorationItemFormState,
@@ -131,6 +132,12 @@
 
   let imageFileId = currentImageFileId;
   if (newFile) {
+    // 백엔드 저장소 설정에도 확장자·크기 제한이 있지만 걸리면 사유 없이 실패한다 — 시안이
+    // 못박은 한도를 여기서 먼저 재서 어느 항목이 왜 잘못됐는지 알려 준다.
+    const imageError = validateDecorationItemImage(newFile);
+    if (imageError) {
+      return { status: 'error', errors: { imageFileId: imageError } };
+    }
     try {
       imageFileId = await uploadDecorationItemImage(newFile);
     } catch {
@@ -175,6 +182,12 @@
 
   let imageFileId = currentImageFileId;
   if (newFile) {
+    // 백엔드 저장소 설정에도 확장자·크기 제한이 있지만 걸리면 사유 없이 실패한다 — 시안이
+    // 못박은 한도를 여기서 먼저 재서 어느 항목이 왜 잘못됐는지 알려 준다.
+    const imageError = validateDecorationItemImage(newFile);
+    if (imageError) {
+      return { status: 'error', errors: { imageFileId: imageError } };
+    }
     try {
       imageFileId = await uploadDecorationItemImage(newFile);
     } catch {
app/(protected)/(basic)/decoration-items/_components/decoration-item-form-fields.tsx
--- app/(protected)/(basic)/decoration-items/_components/decoration-item-form-fields.tsx
+++ app/(protected)/(basic)/decoration-items/_components/decoration-item-form-fields.tsx
@@ -16,7 +16,10 @@
   type DecorationItem,
   type DecorationItemType,
 } from '@/lib/domain/decoration-item';
-import type { DecorationItemFormErrors } from '@/lib/domain/decoration-item-form';
+import {
+  IMAGE_HELP_TEXT,
+  type DecorationItemFormErrors,
+} from '@/lib/domain/decoration-item-form';
 import styles from './decoration-item-form-fields.module.scss';
 
 interface DecorationItemFormFieldsProps {
@@ -32,10 +35,6 @@
   /** 아이템ID 줄 — 등록은 자동발급 안내, 수정은 발급된 값이라 팝업마다 달라 밖에서 받는다. */
   itemIdField: ReactNode;
 }
-
-/** 시안의 안내 문구 그대로. */
-const IMAGE_DESCRIPTION =
-  'JPG, PNG 파일만 가능 (권장 사이즈: 1280x720px, 최대 5MB)';
 
 /**
  * 등록·수정 팝업이 공유하는 입력 항목 — 시안 ADM_ITM_102_p(5227:4629).
@@ -150,7 +149,7 @@
             requirement="required"
             accept="image/jpeg,image/png"
             selectLabel="파일선택"
-            description={IMAGE_DESCRIPTION}
+            description={IMAGE_HELP_TEXT}
             thumbnailWidth={120}
             thumbnailHeight={120}
             defaultFiles={
lib/domain/decoration-item-form.ts
--- lib/domain/decoration-item-form.ts
+++ lib/domain/decoration-item-form.ts
@@ -22,6 +22,31 @@
 const NAME_MAX_LENGTH = 100;
 const DESCRIPTION_MAX_LENGTH = 500;
 
+/**
+ * 썸네일 규칙 — **시안 안내문과 검사가 같은 값을 본다.** 화면은 `IMAGE_HELP_TEXT`를 그대로
+ * 보여주고, Server Action은 업로드 직전에 아래 `validateDecorationItemImage`로 같은 값을 잰다.
+ *
+ * 백엔드에도 저장소 설정(`FileStrgStng`)에 확장자·크기 제한이 있지만 그 값은 DB에 있어 코드에서
+ * 알 수 없고, 걸리면 "이미지를 업로드하지 못했습니다."라는 뭉뚱그린 문구만 남는다. 시안이 이미
+ * 한도를 못박아 두었으니 화면에서 먼저 잡아 어디가 잘못됐는지 알려 준다.
+ */
+const IMAGE_MAX_BYTES = 5 * 1024 * 1024;
+const IMAGE_ALLOWED_TYPES = ['image/jpeg', 'image/png'];
+
+export const IMAGE_HELP_TEXT =
+  'JPG, PNG 파일만 가능 (권장 사이즈: 1280x720px, 최대 5MB)';
+
+/** 고른 파일이 규칙에 맞는지. 문제가 있으면 안내 문구, 없으면 null. */
+export function validateDecorationItemImage(file: File): string | null {
+  if (!IMAGE_ALLOWED_TYPES.includes(file.type)) {
+    return 'JPG 또는 PNG 파일만 등록할 수 있습니다.';
+  }
+  if (file.size > IMAGE_MAX_BYTES) {
+    return `이미지는 ${IMAGE_MAX_BYTES / 1024 / 1024}MB 이하만 등록할 수 있습니다.`;
+  }
+  return null;
+}
+
 /** 등록·수정 양쪽에서 실제로 바뀔 수 있는 항목(= 백엔드 쓰기 API가 받는 항목). */
 export type DecorationItemEditableValues = {
   itemType: DecorationItemType;
Add a comment
List