--- @fox/core/components/fox-file-upload/fox-file-upload.tsx
+++ @fox/core/components/fox-file-upload/fox-file-upload.tsx
... | ... | @@ -156,13 +156,18 @@ |
| 156 | 156 |
return `${(bytes / 1024 / 1024).toFixed(1)}MB`;
|
| 157 | 157 |
} |
| 158 | 158 |
|
| 159 |
+/** 목록 항목의 id. 삭제할 때 같은 규칙으로 원본 `File`을 되찾으므로 한곳에 둔다. */ |
|
| 160 |
+function fileKey(file: File): string {
|
|
| 161 |
+ return `${file.name}:${file.size}:${file.lastModified}`;
|
|
| 162 |
+} |
|
| 163 |
+ |
|
| 159 | 164 |
/** 비제어로 쓸 때 원본 `File`을 목록 한 줄로 바꾼다. 시안의 `이름` + `.PDF (20KB)` 모양이다. */ |
| 160 | 165 |
function toItem(file: File, previewUrl?: string): FoxFileItem {
|
| 161 | 166 |
const dot = file.name.lastIndexOf(".");
|
| 162 | 167 |
const base = dot > 0 ? file.name.slice(0, dot) : file.name; |
| 163 | 168 |
const ext = dot > 0 ? file.name.slice(dot + 1).toUpperCase() : ""; |
| 164 | 169 |
return {
|
| 165 |
- id: `${file.name}:${file.size}:${file.lastModified}`,
|
|
| 170 |
+ id: fileKey(file), |
|
| 166 | 171 |
name: base, |
| 167 | 172 |
meta: ext ? `.${ext} (${formatBytes(file.size)})` : `(${formatBytes(file.size)})`,
|
| 168 | 173 |
previewUrl, |
... | ... | @@ -215,6 +220,8 @@ |
| 215 | 220 |
const autoId = useId(); |
| 216 | 221 |
const pickerRef = useRef<HTMLInputElement>(null); |
| 217 | 222 |
const objectUrls = useRef<string[]>([]); |
| 223 |
+ /** 지금 고른 원본 파일들. 네이티브 입력의 `files`를 다시 세우는 근거다(아래 `syncPicker`). */ |
|
| 224 |
+ const picked = useRef<File[]>([]); |
|
| 218 | 225 |
const [inner, setInner] = useState<FoxFileItem[]>(defaultFiles ?? []); |
| 219 | 226 |
const [over, setOver] = useState(false); |
| 220 | 227 |
|
... | ... | @@ -242,16 +249,41 @@ |
| 242 | 249 |
pickerRef.current?.click(); |
| 243 | 250 |
}; |
| 244 | 251 |
|
| 245 |
- const takeFiles = (picked: File[]) => {
|
|
| 246 |
- if (!picked.length) {
|
|
| 252 |
+ /** |
|
| 253 |
+ * 고른 파일을 네이티브 입력에 **다시 심는다.** |
|
| 254 |
+ * |
|
| 255 |
+ * 폼 제출에 실리는 것은 React 상태가 아니라 이 입력의 `files`다. 끌어다 놓은 파일은 애초에 |
|
| 256 |
+ * 입력을 거치지 않고, 고른 파일도 예전에는 "같은 파일을 다시 골라도 이벤트가 오게" 바로 |
|
| 257 |
+ * 비웠다 — 그래서 `name`을 줘도 폼에는 아무것도 실리지 않았다(그 prop의 설명과 반대였다). |
|
| 258 |
+ * 비우는 대신 지금 목록으로 덮으면 두 가지가 같이 해결된다: 제출에 실리고, 목록을 비웠을 때는 |
|
| 259 |
+ * 입력도 비어 같은 파일을 다시 고를 수 있다. |
|
| 260 |
+ */ |
|
| 261 |
+ const syncPicker = (nextFiles: File[]) => {
|
|
| 262 |
+ const picker = pickerRef.current; |
|
| 263 |
+ // jsdom 등 DataTransfer가 없는 환경에서는 조용히 건너뛴다 — 화면 동작은 그대로다. |
|
| 264 |
+ if (!picker || typeof DataTransfer === "undefined") {
|
|
| 247 | 265 |
return; |
| 248 | 266 |
} |
| 249 |
- onSelect?.(picked); |
|
| 267 |
+ const transfer = new DataTransfer(); |
|
| 268 |
+ nextFiles.forEach((file) => transfer.items.add(file)); |
|
| 269 |
+ picker.files = transfer.files; |
|
| 270 |
+ }; |
|
| 271 |
+ |
|
| 272 |
+ const takeFiles = (chosen: File[]) => {
|
|
| 273 |
+ if (!chosen.length) {
|
|
| 274 |
+ return; |
|
| 275 |
+ } |
|
| 276 |
+ onSelect?.(chosen); |
|
| 277 |
+ |
|
| 278 |
+ // 제어·비제어를 가리지 않고 입력은 늘 맞춘다 — `name`은 목록을 누가 들고 있든 "고른 파일이 |
|
| 279 |
+ // 폼에 실린다"는 약속이다. |
|
| 280 |
+ picked.current = multiple ? [...picked.current, ...chosen] : chosen.slice(0, 1); |
|
| 281 |
+ syncPicker(picked.current); |
|
| 250 | 282 |
|
| 251 | 283 |
if (files !== undefined) {
|
| 252 | 284 |
return; |
| 253 | 285 |
} |
| 254 |
- const made = picked.map((file) => {
|
|
| 286 |
+ const made = chosen.map((file) => {
|
|
| 255 | 287 |
if (!file.type.startsWith("image/")) {
|
| 256 | 288 |
return toItem(file); |
| 257 | 289 |
} |
... | ... | @@ -266,12 +298,15 @@ |
| 266 | 298 |
|
| 267 | 299 |
const handlePicked = (event: ChangeEvent<HTMLInputElement>) => {
|
| 268 | 300 |
takeFiles(Array.from(event.target.files ?? [])); |
| 269 |
- // 같은 파일을 다시 골라도 이벤트가 오게 비운다. |
|
| 270 |
- event.target.value = ""; |
|
| 271 | 301 |
}; |
| 272 | 302 |
|
| 273 | 303 |
const handleRemove = (id: string) => {
|
| 274 | 304 |
onRemove?.(id); |
| 305 |
+ |
|
| 306 |
+ // 밖에서 받은 기존 파일(수정 화면의 등록된 이미지)은 여기 없어 그대로 지나간다. |
|
| 307 |
+ picked.current = picked.current.filter((file) => fileKey(file) !== id); |
|
| 308 |
+ syncPicker(picked.current); |
|
| 309 |
+ |
|
| 275 | 310 |
if (files !== undefined) {
|
| 276 | 311 |
return; |
| 277 | 312 |
} |
--- app/(protected)/(basic)/decoration-items/_actions.ts
+++ app/(protected)/(basic)/decoration-items/_actions.ts
... | ... | @@ -13,6 +13,7 @@ |
| 13 | 13 |
import type { DecorationItemType } from '@/lib/domain/decoration-item';
|
| 14 | 14 |
import {
|
| 15 | 15 |
validateDecorationItemCreate, |
| 16 |
+ validateDecorationItemImage, |
|
| 16 | 17 |
validateDecorationItemUpdate, |
| 17 | 18 |
type DecorationItemEditableValues, |
| 18 | 19 |
type DecorationItemFormState, |
... | ... | @@ -131,6 +132,12 @@ |
| 131 | 132 |
|
| 132 | 133 |
let imageFileId = currentImageFileId; |
| 133 | 134 |
if (newFile) {
|
| 135 |
+ // 백엔드 저장소 설정에도 확장자·크기 제한이 있지만 걸리면 사유 없이 실패한다 — 시안이 |
|
| 136 |
+ // 못박은 한도를 여기서 먼저 재서 어느 항목이 왜 잘못됐는지 알려 준다. |
|
| 137 |
+ const imageError = validateDecorationItemImage(newFile); |
|
| 138 |
+ if (imageError) {
|
|
| 139 |
+ return { status: 'error', errors: { imageFileId: imageError } };
|
|
| 140 |
+ } |
|
| 134 | 141 |
try {
|
| 135 | 142 |
imageFileId = await uploadDecorationItemImage(newFile); |
| 136 | 143 |
} catch {
|
... | ... | @@ -175,6 +182,12 @@ |
| 175 | 182 |
|
| 176 | 183 |
let imageFileId = currentImageFileId; |
| 177 | 184 |
if (newFile) {
|
| 185 |
+ // 백엔드 저장소 설정에도 확장자·크기 제한이 있지만 걸리면 사유 없이 실패한다 — 시안이 |
|
| 186 |
+ // 못박은 한도를 여기서 먼저 재서 어느 항목이 왜 잘못됐는지 알려 준다. |
|
| 187 |
+ const imageError = validateDecorationItemImage(newFile); |
|
| 188 |
+ if (imageError) {
|
|
| 189 |
+ return { status: 'error', errors: { imageFileId: imageError } };
|
|
| 190 |
+ } |
|
| 178 | 191 |
try {
|
| 179 | 192 |
imageFileId = await uploadDecorationItemImage(newFile); |
| 180 | 193 |
} catch {
|
--- 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 @@ |
| 16 | 16 |
type DecorationItem, |
| 17 | 17 |
type DecorationItemType, |
| 18 | 18 |
} from '@/lib/domain/decoration-item'; |
| 19 |
-import type { DecorationItemFormErrors } from '@/lib/domain/decoration-item-form';
|
|
| 19 |
+import {
|
|
| 20 |
+ IMAGE_HELP_TEXT, |
|
| 21 |
+ type DecorationItemFormErrors, |
|
| 22 |
+} from '@/lib/domain/decoration-item-form'; |
|
| 20 | 23 |
import styles from './decoration-item-form-fields.module.scss'; |
| 21 | 24 |
|
| 22 | 25 |
interface DecorationItemFormFieldsProps {
|
... | ... | @@ -32,10 +35,6 @@ |
| 32 | 35 |
/** 아이템ID 줄 — 등록은 자동발급 안내, 수정은 발급된 값이라 팝업마다 달라 밖에서 받는다. */ |
| 33 | 36 |
itemIdField: ReactNode; |
| 34 | 37 |
} |
| 35 |
- |
|
| 36 |
-/** 시안의 안내 문구 그대로. */ |
|
| 37 |
-const IMAGE_DESCRIPTION = |
|
| 38 |
- 'JPG, PNG 파일만 가능 (권장 사이즈: 1280x720px, 최대 5MB)'; |
|
| 39 | 38 |
|
| 40 | 39 |
/** |
| 41 | 40 |
* 등록·수정 팝업이 공유하는 입력 항목 — 시안 ADM_ITM_102_p(5227:4629). |
... | ... | @@ -150,7 +149,7 @@ |
| 150 | 149 |
requirement="required" |
| 151 | 150 |
accept="image/jpeg,image/png" |
| 152 | 151 |
selectLabel="파일선택" |
| 153 |
- description={IMAGE_DESCRIPTION}
|
|
| 152 |
+ description={IMAGE_HELP_TEXT}
|
|
| 154 | 153 |
thumbnailWidth={120}
|
| 155 | 154 |
thumbnailHeight={120}
|
| 156 | 155 |
defaultFiles={
|
--- lib/domain/decoration-item-form.ts
+++ lib/domain/decoration-item-form.ts
... | ... | @@ -22,6 +22,31 @@ |
| 22 | 22 |
const NAME_MAX_LENGTH = 100; |
| 23 | 23 |
const DESCRIPTION_MAX_LENGTH = 500; |
| 24 | 24 |
|
| 25 |
+/** |
|
| 26 |
+ * 썸네일 규칙 — **시안 안내문과 검사가 같은 값을 본다.** 화면은 `IMAGE_HELP_TEXT`를 그대로 |
|
| 27 |
+ * 보여주고, Server Action은 업로드 직전에 아래 `validateDecorationItemImage`로 같은 값을 잰다. |
|
| 28 |
+ * |
|
| 29 |
+ * 백엔드에도 저장소 설정(`FileStrgStng`)에 확장자·크기 제한이 있지만 그 값은 DB에 있어 코드에서 |
|
| 30 |
+ * 알 수 없고, 걸리면 "이미지를 업로드하지 못했습니다."라는 뭉뚱그린 문구만 남는다. 시안이 이미 |
|
| 31 |
+ * 한도를 못박아 두었으니 화면에서 먼저 잡아 어디가 잘못됐는지 알려 준다. |
|
| 32 |
+ */ |
|
| 33 |
+const IMAGE_MAX_BYTES = 5 * 1024 * 1024; |
|
| 34 |
+const IMAGE_ALLOWED_TYPES = ['image/jpeg', 'image/png']; |
|
| 35 |
+ |
|
| 36 |
+export const IMAGE_HELP_TEXT = |
|
| 37 |
+ 'JPG, PNG 파일만 가능 (권장 사이즈: 1280x720px, 최대 5MB)'; |
|
| 38 |
+ |
|
| 39 |
+/** 고른 파일이 규칙에 맞는지. 문제가 있으면 안내 문구, 없으면 null. */ |
|
| 40 |
+export function validateDecorationItemImage(file: File): string | null {
|
|
| 41 |
+ if (!IMAGE_ALLOWED_TYPES.includes(file.type)) {
|
|
| 42 |
+ return 'JPG 또는 PNG 파일만 등록할 수 있습니다.'; |
|
| 43 |
+ } |
|
| 44 |
+ if (file.size > IMAGE_MAX_BYTES) {
|
|
| 45 |
+ return `이미지는 ${IMAGE_MAX_BYTES / 1024 / 1024}MB 이하만 등록할 수 있습니다.`;
|
|
| 46 |
+ } |
|
| 47 |
+ return null; |
|
| 48 |
+} |
|
| 49 |
+ |
|
| 25 | 50 |
/** 등록·수정 양쪽에서 실제로 바뀔 수 있는 항목(= 백엔드 쓰기 API가 받는 항목). */ |
| 26 | 51 |
export type DecorationItemEditableValues = {
|
| 27 | 52 |
itemType: DecorationItemType; |
Add a comment
Delete comment
Once you delete this comment, you won't be able to recover it. Are you sure you want to delete this comment?