diff --git a/docs/features/region-management/design.md b/docs/features/region-management/design.md index 1f8614f..53f3e90 100644 --- a/docs/features/region-management/design.md +++ b/docs/features/region-management/design.md @@ -139,6 +139,13 @@ - Textarea - Placeholder: `지역 설명을 입력하세요.` - 3-5줄 높이 + - `대표 이미지`: + - Optional + - 기존 저장 URL 또는 저장 전 WebP 결과를 미리보기로 표시한다. + - JPG/PNG/WebP를 선택하면 자유 비율, 1:1, 4:3, 16:9 중 하나를 선택해 크롭한다. 크롭 영역을 드래그하고 모서리 핸들로 크기를 조절하며, 선택 영역 모서리에 원본 기준 가로×세로 픽셀을 표시한다. + - 기본 WebP 품질은 70이며 40~95 범위에서 5 단위로 직접 조절한다. + - 변환 완료 후 원본/결과 해상도·실제 파일 크기·증감률을 표시한다. 결과는 저장 클릭 전까지 로컬 `PreparedImage`로 유지한다. + - 최대 입력 20 MiB, 최대 결과 5 MiB, 최대 출력 긴 변 1600px을 적용한다. - Validation messages: - `한글명은 필수입니다` - `영문명은 필수입니다` diff --git a/docs/features/region-management/plan.md b/docs/features/region-management/plan.md index 81edf0a..784b10f 100644 --- a/docs/features/region-management/plan.md +++ b/docs/features/region-management/plan.md @@ -82,6 +82,22 @@ Add region management under the whisky/tasting tag admin section. The implementa - If reorder save fails, restore local rows to the original order and refetch. - Bulk reorder API is not available. Track a follow-up issue for `PATCH /admin/api/v1/regions/reorder` with `regionIds` if product wants atomic full-order saves. +## Region Representative Image Follow-up + +10. Add image-only preprocessing UI without changing shared `ImageUpload` or Banner video behavior. + +- Add `PreparedImageField` and its `ImageCropDialog` child component. +- Allow JPG/PNG/WebP selection, free-form or fixed ratio selection (1:1, 4:3, 16:9), crop-area drag/resize, WebP quality selection, and actual result metadata display. +- Keep `PreparedImage.file` and its object URL local to the field. The shared component must not know an S3 root path or Region/RHF API types. + +11. Compose the field in `RegionDetailPage`. + +- Keep existing `form.imageUrl` until a new image is successfully saved; use local `pendingImage` and `stagedImageUrl` states for a replacement. +- On save only, upload `pendingImage.file` to `S3UploadPath.REGION`, reuse a staged URL after a Region mutation failure, and send the resulting URL in the Region request. +- Image removal sends `imageUrl: null`; do not add a client-side S3 delete flow. + +12. Surface region representative thumbnails in `RegionListPage` and add focused tests for image field state and deferred WebP upload behavior. + ## Verification Checklist - [ ] `pnpm test:run src/hooks/__tests__/useRegions.test.ts` diff --git a/docs/features/region-management/spec.md b/docs/features/region-management/spec.md index 35b9b2a..a0dd1e2 100644 --- a/docs/features/region-management/spec.md +++ b/docs/features/region-management/spec.md @@ -114,6 +114,12 @@ - `DELETE /admin/api/v1/regions/{regionId}` - 존재하지 않는 ID는 `REGION_NOT_FOUND`로 실패한다. - Response는 프로젝트의 일반 mutation 응답 형식인 `{ code, message, targetId, responseAt }`로 본다. +- Region 대표 이미지 후속 연동: + - `imageUrl: string | null`은 Region 목록/상세 응답 및 생성/수정 요청에서 사용한다. + - 지역 등록/수정 화면은 이미지 선택 → 비율 선택 크롭 → WebP 전처리 → 저장 시 S3 업로드 → Region API `imageUrl` 전달 순서를 따른다. + - 이미지 선택·크롭만으로는 S3 또는 Region API를 호출하지 않는다. + - 지원 입력은 JPG/PNG/WebP, 최대 20 MiB이며 WebP 결과는 최대 긴 변 1600px·최대 5 MiB로 제한한다. + - 운영자는 자유 비율, 1:1, 4:3, 16:9 중 크롭 비율을 선택하고 크롭 영역을 드래그·리사이즈해 조절한다. 선택 영역 모서리에는 원본 기준 크롭 가로×세로 픽셀을 표시한다. 기본 비율은 1:1, 기본 품질은 70이다. - `PATCH /admin/api/v1/regions/{regionId}/sort-order` - Request: - `sortOrder: number` diff --git a/package.json b/package.json index 6ad2d47..58e7418 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,7 @@ "react-daum-postcode": "^4.0.0", "react-dom": "^18.3.1", "react-hook-form": "^7.69.0", + "react-image-crop": "11.1.2", "react-router": "^7.11.0", "tailwind-merge": "^3.4.0", "tailwindcss": "3.4.17", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b542fee..ad94501 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -95,6 +95,9 @@ importers: react-hook-form: specifier: ^7.69.0 version: 7.69.0(react@18.3.1) + react-image-crop: + specifier: 11.1.2 + version: 11.1.2(react@18.3.1) react-router: specifier: ^7.11.0 version: 7.11.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -2535,6 +2538,11 @@ packages: peerDependencies: react: ^16.8.0 || ^17 || ^18 || ^19 + react-image-crop@11.1.2: + resolution: {integrity: sha512-+0Pc2fxpwKL4u4oLmdKBw8XSwUceFbXbKEHvFOlsl/MGB1OVNic4uBlAPmEHGXYgoJIq+b63xHbc/aJMG0AVkA==} + peerDependencies: + react: '>=16.13.1' + react-is@17.0.2: resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} @@ -5321,6 +5329,10 @@ snapshots: dependencies: react: 18.3.1 + react-image-crop@11.1.2(react@18.3.1): + dependencies: + react: 18.3.1 + react-is@17.0.2: {} react-refresh@0.17.0: {} diff --git a/src/components/common/ImageCropDialog.tsx b/src/components/common/ImageCropDialog.tsx new file mode 100644 index 0000000..7640bf5 --- /dev/null +++ b/src/components/common/ImageCropDialog.tsx @@ -0,0 +1,277 @@ +import { useEffect, useRef, useState } from 'react'; +import ReactCrop, { centerCrop, makeAspectCrop, type Crop, type PixelCrop } from 'react-image-crop'; +import 'react-image-crop/dist/ReactCrop.css'; + +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { + ImagePreprocessingError, + preprocessImageToWebP, + type PreparedImage, +} from '@/lib/image-preprocessing'; + +import type { ImageProcessingPolicy } from './PreparedImageField'; + +interface ImageCropDialogProps { + file: File | null; + open: boolean; + policy: ImageProcessingPolicy; + onOpenChange: (open: boolean) => void; + onPrepared: (preparedImage: PreparedImage) => void; +} + +function createInitialCrop(image: HTMLImageElement, aspectRatio: number | null): Crop { + if (aspectRatio === null) { + return { unit: '%', x: 5, y: 5, width: 90, height: 90 }; + } + + const width = image.width || image.naturalWidth; + const height = image.height || image.naturalHeight; + + return centerCrop( + makeAspectCrop({ unit: '%', width: 90 }, aspectRatio, width, height), + width, + height + ); +} + +function toPixelCrop(crop: Crop, image: HTMLImageElement): PixelCrop { + if (crop.unit === 'px') return crop as PixelCrop; + + const width = image.width || image.naturalWidth; + const height = image.height || image.naturalHeight; + + return { + unit: 'px', + x: (crop.x ?? 0) * (width / 100), + y: (crop.y ?? 0) * (height / 100), + width: crop.width * (width / 100), + height: crop.height * (height / 100), + }; +} + +function toSourceCrop(crop: PixelCrop, image: HTMLImageElement) { + const renderedWidth = image.width || image.naturalWidth; + const renderedHeight = image.height || image.naturalHeight; + + if (!renderedWidth || !renderedHeight || !image.naturalWidth || !image.naturalHeight) return null; + + const scaleX = image.naturalWidth / renderedWidth; + const scaleY = image.naturalHeight / renderedHeight; + + return { + x: Math.round(crop.x * scaleX), + y: Math.round(crop.y * scaleY), + width: Math.round(crop.width * scaleX), + height: Math.round(crop.height * scaleY), + }; +} + +export function ImageCropDialog({ + file, + open, + policy, + onOpenChange, + onPrepared, +}: ImageCropDialogProps) { + const imageRef = useRef(null); + const [sourceUrl, setSourceUrl] = useState(null); + const [crop, setCrop] = useState(); + const [cropPixels, setCropPixels] = useState(null); + const [quality, setQuality] = useState(policy.defaultQuality); + const [aspectRatio, setAspectRatio] = useState(policy.defaultAspectRatio); + const [errorMessage, setErrorMessage] = useState(null); + const [isProcessing, setIsProcessing] = useState(false); + + useEffect(() => { + if (!file || !open) return; + + const nextSourceUrl = URL.createObjectURL(file); + setSourceUrl(nextSourceUrl); + setCrop(undefined); + setCropPixels(null); + setQuality(policy.defaultQuality); + setAspectRatio(policy.defaultAspectRatio); + setErrorMessage(null); + + return () => URL.revokeObjectURL(nextSourceUrl); + }, [file, open, policy.defaultAspectRatio, policy.defaultQuality]); + + const handleCropComplete = (nextCrop: PixelCrop) => { + setCropPixels(nextCrop); + }; + + const handleImageLoad = (event: React.SyntheticEvent) => { + const initialCrop = createInitialCrop(event.currentTarget, aspectRatio); + setCrop(initialCrop); + setCropPixels(toPixelCrop(initialCrop, event.currentTarget)); + }; + + const handleAspectRatioChange = (value: string) => { + const nextAspectRatio = value === 'free' ? null : Number(value); + setAspectRatio(nextAspectRatio); + setCropPixels(null); + + if (imageRef.current) { + const initialCrop = createInitialCrop(imageRef.current, nextAspectRatio); + setCrop(initialCrop); + setCropPixels(toPixelCrop(initialCrop, imageRef.current)); + } + }; + + const handleApply = async () => { + const image = imageRef.current; + if (!file || !image || !cropPixels) { + setErrorMessage('크롭 영역을 준비하지 못했습니다. 이미지를 다시 선택해주세요.'); + return; + } + + const sourceCrop = toSourceCrop(cropPixels, image); + if (!sourceCrop) { + setErrorMessage('이미지 크기를 확인하지 못했습니다. 이미지를 다시 선택해주세요.'); + return; + } + + setIsProcessing(true); + setErrorMessage(null); + + try { + const preparedImage = await preprocessImageToWebP(file, { + crop: sourceCrop, + quality, + allowedMimeTypes: policy.allowedMimeTypes, + maxInputBytes: policy.maxInputBytes, + maxOutputBytes: policy.maxOutputBytes, + maxOutputLongEdge: policy.maxOutputLongEdge, + }); + + onPrepared(preparedImage); + onOpenChange(false); + } catch (error) { + setErrorMessage( + error instanceof ImagePreprocessingError + ? error.message + : '이미지를 처리하지 못했습니다. 다른 이미지를 선택해주세요.' + ); + } finally { + setIsProcessing(false); + } + }; + + const sourceCrop = + cropPixels && imageRef.current ? toSourceCrop(cropPixels, imageRef.current) : null; + const cropSizeLabel = sourceCrop ? `크롭 ${sourceCrop.width} × ${sourceCrop.height}px` : null; + + return ( + + + + 이미지 크롭 및 변환 + + 자유 비율 또는 고정 비율을 선택한 뒤, 크롭 영역을 드래그하거나 모서리 핸들로 크기를 + 조절하세요. + + + +
+
+ {sourceUrl && ( + { + setCrop(percentCrop); + setCropPixels(pixelCrop); + }} + onComplete={handleCropComplete} + renderSelectionAddon={() => + cropSizeLabel ? ( + + {cropSizeLabel} + + ) : null + } + > + 크롭할 원본 이미지 + + )} +
+ +
+ + + +
+ + {errorMessage &&

{errorMessage}

} +
+ + + + + +
+
+ ); +} diff --git a/src/components/common/PreparedImageField.tsx b/src/components/common/PreparedImageField.tsx new file mode 100644 index 0000000..e33bbba --- /dev/null +++ b/src/components/common/PreparedImageField.tsx @@ -0,0 +1,308 @@ +import { + type ChangeEvent, + type DragEvent, + type KeyboardEvent, + useEffect, + useRef, + useState, +} from 'react'; +import { ImagePlus, RotateCcw, Upload, X } from 'lucide-react'; + +import { Button } from '@/components/ui/button'; +import type { PreparedImage } from '@/lib/image-preprocessing'; + +import { ImageCropDialog } from './ImageCropDialog'; + +export interface ImageAspectRatioOption { + label: string; + /** null이면 크롭 영역의 가로·세로 비율을 고정하지 않는다. */ + value: number | null; +} + +export interface ImageProcessingPolicy { + allowedMimeTypes: readonly string[]; + aspectRatios: readonly ImageAspectRatioOption[]; + defaultAspectRatio: number | null; + defaultQuality: number; + maxInputBytes: number; + maxOutputBytes: number; + maxOutputLongEdge: number; +} + +interface PreparedImageFieldProps { + /** 서버에 저장된 기존 이미지 URL. local blob URL은 전달하지 않는다. */ + currentImageUrl: string | null; + /** 아직 저장되지 않은 WebP 전처리 결과. */ + preparedImage: PreparedImage | null; + policy: ImageProcessingPolicy; + onPreparedImageChange: (preparedImage: PreparedImage) => void; + onCancelPreparedImage: () => void; + onRemoveImage: () => void; + disabled?: boolean; + errorMessage?: string; +} + +function formatByteSize(byteSize: number) { + if (byteSize < 1000) return `${byteSize} B`; + + const units = ['KB', 'MB', 'GB']; + let value = byteSize; + let unitIndex = -1; + + while (value >= 1000 && unitIndex < units.length - 1) { + value /= 1000; + unitIndex += 1; + } + + const formattedValue = + value >= 10 ? value.toFixed(0) : Number.isInteger(value) ? String(value) : value.toFixed(1); + return `${formattedValue} ${units[unitIndex]}`; +} + +function formatSizeDelta(originalByteSize: number, outputByteSize: number) { + if (originalByteSize <= 0) return '-'; + + const percentage = ((outputByteSize - originalByteSize) / originalByteSize) * 100; + return `${percentage > 0 ? '+' : ''}${percentage.toFixed(1)}%`; +} + +export function PreparedImageField({ + currentImageUrl, + preparedImage, + policy, + onPreparedImageChange, + onCancelPreparedImage, + onRemoveImage, + disabled = false, + errorMessage, +}: PreparedImageFieldProps) { + const inputRef = useRef(null); + const [isDragging, setIsDragging] = useState(false); + const [selectionError, setSelectionError] = useState(null); + const [selectedFile, setSelectedFile] = useState(null); + const [isCropDialogOpen, setIsCropDialogOpen] = useState(false); + const preparedPreviewUrlRef = useRef(null); + const [preparedPreviewUrl, setPreparedPreviewUrl] = useState(null); + + const clearPreparedPreview = () => { + const currentPreviewUrl = preparedPreviewUrlRef.current; + if (currentPreviewUrl) URL.revokeObjectURL(currentPreviewUrl); + + preparedPreviewUrlRef.current = null; + setPreparedPreviewUrl(null); + }; + + useEffect(() => { + return () => { + const currentPreviewUrl = preparedPreviewUrlRef.current; + if (currentPreviewUrl) URL.revokeObjectURL(currentPreviewUrl); + }; + }, []); + + useEffect(() => { + if (preparedImage !== null) return; + + const currentPreviewUrl = preparedPreviewUrlRef.current; + if (currentPreviewUrl) URL.revokeObjectURL(currentPreviewUrl); + preparedPreviewUrlRef.current = null; + }, [preparedImage]); + + const handleFile = (file: File | undefined) => { + if (!file || disabled) return; + + if (!policy.allowedMimeTypes.includes(file.type)) { + setSelectionError('JPG, PNG, WEBP 이미지만 선택할 수 있습니다.'); + return; + } + + if (file.size > policy.maxInputBytes) { + setSelectionError(`원본 이미지는 ${formatByteSize(policy.maxInputBytes)} 이하여야 합니다.`); + return; + } + + setSelectionError(null); + setSelectedFile(file); + setIsCropDialogOpen(true); + }; + + const handleInputChange = (event: ChangeEvent) => { + handleFile(event.target.files?.[0]); + event.target.value = ''; + }; + + const handleDrop = (event: DragEvent) => { + event.preventDefault(); + setIsDragging(false); + handleFile(event.dataTransfer.files[0]); + }; + + const handleKeyDown = (event: KeyboardEvent) => { + if (disabled || (event.key !== 'Enter' && event.key !== ' ')) return; + + event.preventDefault(); + inputRef.current?.click(); + }; + + const handleCropDialogOpenChange = (open: boolean) => { + setIsCropDialogOpen(open); + if (!open) setSelectedFile(null); + }; + + const handlePrepared = (nextPreparedImage: PreparedImage) => { + clearPreparedPreview(); + const nextPreviewUrl = URL.createObjectURL(nextPreparedImage.file); + preparedPreviewUrlRef.current = nextPreviewUrl; + setPreparedPreviewUrl(nextPreviewUrl); + setSelectionError(null); + onPreparedImageChange(nextPreparedImage); + }; + + const handleCancelPrepared = () => { + clearPreparedPreview(); + setSelectionError(null); + onCancelPreparedImage(); + }; + + const handleRemove = () => { + clearPreparedPreview(); + setSelectionError(null); + onRemoveImage(); + }; + + const visibleImageUrl = (preparedImage ? preparedPreviewUrl : null) ?? currentImageUrl; + const isPendingImage = preparedImage !== null; + const hasImage = visibleImageUrl !== null; + const accept = policy.allowedMimeTypes.join(','); + + return ( +
+ {hasImage ? ( +
+ 선택한 이미지 미리보기 +
+
+

+ {isPendingImage ? '새 이미지 (저장 전)' : '현재 저장된 이미지'} +

+ {isPendingImage && ( + + 저장 시 업로드 + + )} +
+ + {preparedImage && ( +
+

+ 원본 {preparedImage.metadata.original.width} ×{' '} + {preparedImage.metadata.original.height} ·{' '} + {formatByteSize(preparedImage.metadata.original.byteSize)} +

+

+ 결과 {preparedImage.metadata.output.width} ×{' '} + {preparedImage.metadata.output.height} ·{' '} + {formatByteSize(preparedImage.metadata.output.byteSize)} ·{' '} + {formatSizeDelta( + preparedImage.metadata.original.byteSize, + preparedImage.metadata.output.byteSize + )} +

+

WebP 품질 {preparedImage.metadata.output.quality}%

+
+ )} + +
+ + {isPendingImage && ( + + )} + +
+
+
+ ) : ( +
!disabled && inputRef.current?.click()} + onKeyDown={handleKeyDown} + onDragOver={(event) => { + event.preventDefault(); + if (!disabled) setIsDragging(true); + }} + onDragLeave={() => setIsDragging(false)} + onDrop={handleDrop} + > +
+ )} + + + + {(selectionError || errorMessage) && ( +

{selectionError ?? errorMessage}

+ )} + + +
+ ); +} diff --git a/src/components/common/__tests__/ImageCropDialog.test.tsx b/src/components/common/__tests__/ImageCropDialog.test.tsx new file mode 100644 index 0000000..d6c9c54 --- /dev/null +++ b/src/components/common/__tests__/ImageCropDialog.test.tsx @@ -0,0 +1,127 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { fireEvent, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { render } from '@/test/test-utils'; + +import { ImageCropDialog } from '../ImageCropDialog'; + +const { preprocessImageToWebP } = vi.hoisted(() => ({ + preprocessImageToWebP: vi.fn(), +})); + +vi.mock('react-image-crop', () => ({ + default: ({ + aspect, + children, + onComplete, + renderSelectionAddon, + }: { + aspect?: number; + children: React.ReactNode; + onComplete: (crop: { unit: 'px'; x: number; y: number; width: number; height: number }) => void; + renderSelectionAddon?: () => React.ReactNode; + }) => ( +
+ + {children} + {renderSelectionAddon?.()} +
+ ), + centerCrop: (crop: T) => crop, + makeAspectCrop: (crop: T) => crop, +})); + +vi.mock('@/lib/image-preprocessing', () => ({ + ImagePreprocessingError: class ImagePreprocessingError extends Error {}, + preprocessImageToWebP, +})); + +const policy = { + allowedMimeTypes: ['image/jpeg', 'image/png', 'image/webp'], + aspectRatios: [ + { label: '자유 비율', value: null }, + { label: '1:1', value: 1 }, + { label: '4:3', value: 4 / 3 }, + { label: '16:9', value: 16 / 9 }, + ], + defaultAspectRatio: 1, + defaultQuality: 70, + maxInputBytes: 20 * 1024 * 1024, + maxOutputBytes: 5 * 1024 * 1024, + maxOutputLongEdge: 1600, +} as const; + +afterEach(() => { + vi.restoreAllMocks(); + preprocessImageToWebP.mockReset(); +}); + +describe('ImageCropDialog', () => { + it('자유 비율에서 크롭 영역을 드래그·리사이즈한 픽셀 좌표를 원본 좌표계로 변환한다', async () => { + const user = userEvent.setup(); + const file = new File(['source'], 'region.jpg', { type: 'image/jpeg' }); + const preparedImage = { + file: new File(['output'], 'region.webp', { type: 'image/webp' }), + metadata: { + original: { width: 1000, height: 500, byteSize: 1_000_000, mimeType: 'image/jpeg' }, + output: { + width: 1000, + height: 500, + byteSize: 250_000, + mimeType: 'image/webp' as const, + quality: 70, + }, + }, + }; + const onPrepared = vi.fn(); + const onOpenChange = vi.fn(); + preprocessImageToWebP.mockResolvedValue(preparedImage); + + render( + + ); + + const image = await screen.findByAltText('크롭할 원본 이미지'); + Object.defineProperties(image, { + width: { configurable: true, value: 500 }, + height: { configurable: true, value: 250 }, + naturalWidth: { configurable: true, value: 1000 }, + naturalHeight: { configurable: true, value: 500 }, + }); + fireEvent.load(image); + + expect(screen.getByTestId('crop-editor')).toHaveAttribute('data-aspect', '1'); + + await user.selectOptions(screen.getByLabelText('크롭 비율'), 'free'); + expect(screen.getByTestId('crop-editor')).toHaveAttribute('data-aspect', 'free'); + expect(await screen.findByLabelText('원본 기준 크롭 900 × 450px')).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: '크롭 영역 변경' })); + expect(await screen.findByLabelText('원본 기준 크롭 600 × 300px')).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: '크롭 적용' })); + + await waitFor(() => { + expect(preprocessImageToWebP).toHaveBeenCalledWith( + file, + expect.objectContaining({ + crop: { x: 20, y: 40, width: 600, height: 300 }, + quality: 70, + }) + ); + }); + expect(onPrepared).toHaveBeenCalledWith(preparedImage); + expect(onOpenChange).toHaveBeenCalledWith(false); + }); +}); diff --git a/src/components/common/__tests__/PreparedImageField.test.tsx b/src/components/common/__tests__/PreparedImageField.test.tsx new file mode 100644 index 0000000..8e8a516 --- /dev/null +++ b/src/components/common/__tests__/PreparedImageField.test.tsx @@ -0,0 +1,168 @@ +import { describe, expect, it, vi } from 'vitest'; +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { render } from '@/test/test-utils'; +import type { PreparedImage } from '@/lib/image-preprocessing'; + +import { PreparedImageField, type ImageProcessingPolicy } from '../PreparedImageField'; + +vi.mock('../ImageCropDialog', () => ({ + ImageCropDialog: ({ + open, + onPrepared, + }: { + open: boolean; + onPrepared: (preparedImage: PreparedImage) => void; + }) => + open ? ( + + ) : null, +})); + +const policy: ImageProcessingPolicy = { + allowedMimeTypes: ['image/jpeg', 'image/png', 'image/webp'], + aspectRatios: [ + { label: '1:1', value: 1 }, + { label: '4:3', value: 4 / 3 }, + { label: '16:9', value: 16 / 9 }, + ], + defaultAspectRatio: 1, + defaultQuality: 70, + maxInputBytes: 20 * 1024 * 1024, + maxOutputBytes: 5 * 1024 * 1024, + maxOutputLongEdge: 1600, +}; + +describe('PreparedImageField', () => { + it('기존 서버 이미지를 미리보기로 표시한다', () => { + render( + + ); + + expect(screen.getByAltText('선택한 이미지 미리보기')).toHaveAttribute( + 'src', + 'https://cdn.example.com/regions/scotland.webp' + ); + expect(screen.getByText('현재 저장된 이미지')).toBeInTheDocument(); + }); + + it('허용되지 않은 MIME 타입은 크롭 다이얼로그를 열지 않는다', async () => { + const user = userEvent.setup({ applyAccept: false }); + + render( + + ); + + await user.upload( + screen.getByLabelText('이미지 파일 선택'), + new File(['video'], 'region.mp4', { type: 'video/mp4' }) + ); + + expect(screen.getByText('JPG, PNG, WEBP 이미지만 선택할 수 있습니다.')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: '크롭 적용' })).not.toBeInTheDocument(); + }); + + it('크롭 적용 후 실제 결과 metadata를 표시하고 PreparedImage를 부모에 전달한다', async () => { + const user = userEvent.setup(); + const onPreparedImageChange = vi.fn(); + + const { rerender } = render( + + ); + + await user.upload( + screen.getByLabelText('이미지 파일 선택'), + new File(['source-image'], 'region.jpg', { type: 'image/jpeg' }) + ); + await user.click(screen.getByRole('button', { name: '크롭 적용' })); + + expect(onPreparedImageChange).toHaveBeenCalledWith( + expect.objectContaining({ + file: expect.objectContaining({ name: 'region.webp', type: 'image/webp' }), + }) + ); + + const preparedImage = onPreparedImageChange.mock.calls[0]![0] as PreparedImage; + rerender( + + ); + + expect(screen.getByText('새 이미지 (저장 전)')).toBeInTheDocument(); + expect(screen.getByText(/원본 2400 × 1600 · 1 MB/)).toBeInTheDocument(); + expect(screen.getByText(/결과 1600 × 1067 · 250 KB · -75.0%/)).toBeInTheDocument(); + expect(screen.getByText('WebP 품질 70%')).toBeInTheDocument(); + }); + + it('삭제 의도는 부모에 위임한다', async () => { + const user = userEvent.setup(); + const onRemoveImage = vi.fn(); + + render( + + ); + + await user.click(screen.getByRole('button', { name: '이미지 삭제' })); + + expect(onRemoveImage).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/hooks/__tests__/useRegions.test.ts b/src/hooks/__tests__/useRegions.test.ts index 4e3dc17..ef5fea4 100644 --- a/src/hooks/__tests__/useRegions.test.ts +++ b/src/hooks/__tests__/useRegions.test.ts @@ -23,6 +23,10 @@ describe('useRegions hooks', () => { await waitFor(() => expect(result.current.isSuccess).toBe(true)); expect(result.current.data!.items.length).toBeGreaterThan(0); + expect(result.current.data!.items[0]!.imageUrl).toBe( + 'https://example.com/regions/scotland.webp' + ); + expect(result.current.data!.items.some((item) => item.imageUrl === null)).toBe(true); expect(result.current.data!.meta.totalElements).toBeGreaterThan(0); }); @@ -71,6 +75,7 @@ describe('useRegions hooks', () => { expect(result.current.data!.id).toBe(1); expect(result.current.data!.korName).toBe('스코틀랜드'); + expect(result.current.data!.imageUrl).toBe('https://example.com/regions/scotland.webp'); expect(result.current.data!.sortOrder).toBe(0); expect(result.current.data!.hasChildren).toBe(true); }); @@ -85,6 +90,7 @@ describe('useRegions hooks', () => { engName: '-', continent: null, description: null, + imageUrl: null, sortOrder: 21, parentId: null, parentKorName: null, @@ -120,6 +126,7 @@ describe('useRegions hooks', () => { engName: 'Japan', continent: '아시아', description: null, + imageUrl: null, parentId: null, sortOrder: 9999, }); @@ -159,6 +166,7 @@ describe('useRegions hooks', () => { data: { korName: '스코틀랜드 수정', engName: 'Scotland Updated', + imageUrl: 'https://example.com/regions/scotland-updated.webp', sortOrder: 0, }, }); diff --git a/src/lib/__tests__/image-preprocessing.test.ts b/src/lib/__tests__/image-preprocessing.test.ts new file mode 100644 index 0000000..01cc80e --- /dev/null +++ b/src/lib/__tests__/image-preprocessing.test.ts @@ -0,0 +1,307 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + ImagePreprocessingError, + preprocessImageToWebP, + type ImagePreprocessingErrorCode, +} from '../image-preprocessing'; + +const createBitmap = (width = 1200, height = 800) => ({ + width, + height, + close: vi.fn(), +}); + +async function expectPreprocessingError( + promise: Promise, + code: ImagePreprocessingErrorCode +) { + try { + await promise; + throw new Error('이미지 전처리가 실패해야 합니다.'); + } catch (error) { + expect(error).toBeInstanceOf(ImagePreprocessingError); + expect((error as ImagePreprocessingError).code).toBe(code); + } +} + +describe('preprocessImageToWebP', () => { + const drawImage = vi.fn(); + + beforeEach(() => { + drawImage.mockReset(); + vi.stubGlobal('createImageBitmap', vi.fn().mockResolvedValue(createBitmap())); + vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({ + drawImage, + } as unknown as CanvasRenderingContext2D); + vi.spyOn(HTMLCanvasElement.prototype, 'toBlob').mockImplementation((callback, type) => { + callback(new Blob(['encoded-webp'], { type: type ?? 'image/webp' })); + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it('크롭 영역을 긴 변 기준으로 축소하고 기본 품질 70의 WebP File을 반환한다', async () => { + const bitmap = createBitmap(); + vi.mocked(createImageBitmap).mockResolvedValue(bitmap as unknown as ImageBitmap); + const input = new File(['source'], 'region.png', { type: 'image/png' }); + + const result = await preprocessImageToWebP(input, { + crop: { x: 100, y: 50, width: 800, height: 400 }, + maxOutputLongEdge: 400, + }); + + expect(createImageBitmap).toHaveBeenCalledWith(input, { + imageOrientation: 'from-image', + }); + expect(drawImage).toHaveBeenCalledWith(bitmap, 100, 50, 800, 400, 0, 0, 400, 200); + expect(HTMLCanvasElement.prototype.toBlob).toHaveBeenCalledWith( + expect.any(Function), + 'image/webp', + 0.7 + ); + expect(result.file).toBeInstanceOf(File); + expect(result.file.name).toBe('region.webp'); + expect(result.file.type).toBe('image/webp'); + expect(result.metadata).toEqual({ + original: { + width: 1200, + height: 800, + byteSize: input.size, + mimeType: 'image/png', + }, + output: { + width: 400, + height: 200, + byteSize: result.file.size, + mimeType: 'image/webp', + quality: 70, + }, + }); + expect(bitmap.close).toHaveBeenCalledOnce(); + }); + + it('원본 크롭 영역이 최대 긴 변보다 작으면 확대하지 않고 지정 품질을 적용한다', async () => { + const result = await preprocessImageToWebP( + new File(['source'], 'small.jpeg', { type: 'image/jpeg' }), + { + crop: { x: 0, y: 0, width: 200, height: 100 }, + maxOutputLongEdge: 500, + quality: 85, + outputFileName: 'prepared.webp', + } + ); + + expect(drawImage).toHaveBeenCalledWith(expect.anything(), 0, 0, 200, 100, 0, 0, 200, 100); + expect(HTMLCanvasElement.prototype.toBlob).toHaveBeenCalledWith( + expect.any(Function), + 'image/webp', + 0.85 + ); + expect(result.file.name).toBe('prepared.webp'); + expect(result.metadata.output.width).toBe(200); + expect(result.metadata.output.height).toBe(100); + }); + + it('createImageBitmap 실패 시 HTMLImageElement.decode로 폴백하고 내부 object URL을 정리한다', async () => { + vi.mocked(createImageBitmap).mockRejectedValue(new Error('bitmap decode failed')); + const decode = vi.fn().mockResolvedValue(undefined); + const createObjectURL = vi.fn().mockReturnValue('blob:fallback-source'); + const revokeObjectURL = vi.fn(); + const OriginalURL = URL; + + class MockImage { + naturalWidth = 640; + naturalHeight = 480; + src = ''; + decode = decode; + } + + vi.stubGlobal('Image', MockImage); + vi.stubGlobal( + 'URL', + class extends OriginalURL { + static createObjectURL = createObjectURL; + static revokeObjectURL = revokeObjectURL; + } + ); + + await preprocessImageToWebP(new Blob(['source'], { type: 'image/png' }), { + crop: { x: 0, y: 0, width: 320, height: 240 }, + maxOutputLongEdge: 320, + }); + + expect(decode).toHaveBeenCalledOnce(); + expect(createObjectURL).toHaveBeenCalledOnce(); + expect(revokeObjectURL).toHaveBeenCalledWith('blob:fallback-source'); + }); + + it('두 decode 경로가 모두 실패하면 object URL을 정리하고 DECODE_FAILED를 반환한다', async () => { + vi.mocked(createImageBitmap).mockRejectedValue(new Error('bitmap decode failed')); + const decode = vi.fn().mockRejectedValue(new Error('image decode failed')); + const revokeObjectURL = vi.fn(); + const OriginalURL = URL; + + class MockImage { + naturalWidth = 0; + naturalHeight = 0; + src = ''; + decode = decode; + } + + vi.stubGlobal('Image', MockImage); + vi.stubGlobal( + 'URL', + class extends OriginalURL { + static createObjectURL = vi.fn().mockReturnValue('blob:invalid-source'); + static revokeObjectURL = revokeObjectURL; + } + ); + + await expectPreprocessingError( + preprocessImageToWebP(new Blob(['invalid'], { type: 'image/png' }), { + crop: { x: 0, y: 0, width: 100, height: 100 }, + maxOutputLongEdge: 100, + }), + 'DECODE_FAILED' + ); + + expect(revokeObjectURL).toHaveBeenCalledWith('blob:invalid-source'); + expect(drawImage).not.toHaveBeenCalled(); + }); + + it.each([39, 42, 100])('허용 단계가 아닌 품질 %s를 거부한다', async (quality) => { + await expectPreprocessingError( + preprocessImageToWebP(new Blob(['source'], { type: 'image/png' }), { + crop: { x: 0, y: 0, width: 100, height: 100 }, + maxOutputLongEdge: 100, + quality, + }), + 'INVALID_QUALITY' + ); + expect(createImageBitmap).not.toHaveBeenCalled(); + }); + + it('소스 범위를 벗어난 크롭 영역을 거부하고 ImageBitmap을 정리한다', async () => { + const bitmap = createBitmap(300, 200); + vi.mocked(createImageBitmap).mockResolvedValue(bitmap as unknown as ImageBitmap); + + await expectPreprocessingError( + preprocessImageToWebP(new Blob(['source'], { type: 'image/png' }), { + crop: { x: 250, y: 0, width: 100, height: 100 }, + maxOutputLongEdge: 100, + }), + 'INVALID_CROP' + ); + expect(bitmap.close).toHaveBeenCalledOnce(); + }); + + it('0 이하인 크롭 크기와 최대 긴 변을 decode 전에 거부한다', async () => { + await expectPreprocessingError( + preprocessImageToWebP(new Blob(['source'], { type: 'image/png' }), { + crop: { x: 0, y: 0, width: 0, height: 100 }, + maxOutputLongEdge: 100, + }), + 'INVALID_CROP' + ); + await expectPreprocessingError( + preprocessImageToWebP(new Blob(['source'], { type: 'image/png' }), { + crop: { x: 0, y: 0, width: 100, height: 100 }, + maxOutputLongEdge: 0, + }), + 'INVALID_MAX_OUTPUT_LONG_EDGE' + ); + expect(createImageBitmap).not.toHaveBeenCalled(); + }); + + it('caller가 제공한 MIME과 입력 byte 제한을 적용한다', async () => { + const input = new Blob(['12345'], { type: 'image/gif' }); + + await expectPreprocessingError( + preprocessImageToWebP(input, { + crop: { x: 0, y: 0, width: 100, height: 100 }, + maxOutputLongEdge: 100, + allowedMimeTypes: ['image/png', 'image/jpeg'], + }), + 'UNSUPPORTED_MIME_TYPE' + ); + await expectPreprocessingError( + preprocessImageToWebP(input, { + crop: { x: 0, y: 0, width: 100, height: 100 }, + maxOutputLongEdge: 100, + maxInputBytes: 4, + }), + 'INPUT_TOO_LARGE' + ); + expect(createImageBitmap).not.toHaveBeenCalled(); + }); + + it('caller가 제공한 출력 byte 제한을 초과하면 실패한다', async () => { + await expectPreprocessingError( + preprocessImageToWebP(new Blob(['source'], { type: 'image/png' }), { + crop: { x: 0, y: 0, width: 100, height: 100 }, + maxOutputLongEdge: 100, + maxOutputBytes: 3, + }), + 'OUTPUT_TOO_LARGE' + ); + }); + + it('Canvas context 생성 실패를 구분 가능한 에러로 반환하고 ImageBitmap을 정리한다', async () => { + const bitmap = createBitmap(); + vi.mocked(createImageBitmap).mockResolvedValue(bitmap as unknown as ImageBitmap); + vi.mocked(HTMLCanvasElement.prototype.getContext).mockReturnValue(null); + + await expectPreprocessingError( + preprocessImageToWebP(new Blob(['source'], { type: 'image/png' }), { + crop: { x: 0, y: 0, width: 100, height: 100 }, + maxOutputLongEdge: 100, + }), + 'CANVAS_UNAVAILABLE' + ); + expect(bitmap.close).toHaveBeenCalledOnce(); + }); + + it('Canvas draw와 WebP encode 실패를 각각 구분 가능한 에러로 반환한다', async () => { + drawImage.mockImplementationOnce(() => { + throw new Error('draw failed'); + }); + + await expectPreprocessingError( + preprocessImageToWebP(new Blob(['source'], { type: 'image/png' }), { + crop: { x: 0, y: 0, width: 100, height: 100 }, + maxOutputLongEdge: 100, + }), + 'CANVAS_DRAW_FAILED' + ); + + vi.mocked(HTMLCanvasElement.prototype.toBlob).mockImplementationOnce((callback) => { + callback(null); + }); + + await expectPreprocessingError( + preprocessImageToWebP(new Blob(['source'], { type: 'image/png' }), { + crop: { x: 0, y: 0, width: 100, height: 100 }, + maxOutputLongEdge: 100, + }), + 'ENCODING_FAILED' + ); + }); + + it('Canvas가 WebP가 아닌 MIME으로 encode하면 WEBP_UNSUPPORTED를 반환한다', async () => { + vi.mocked(HTMLCanvasElement.prototype.toBlob).mockImplementationOnce((callback) => { + callback(new Blob(['encoded-png'], { type: 'image/png' })); + }); + + await expectPreprocessingError( + preprocessImageToWebP(new Blob(['source'], { type: 'image/png' }), { + crop: { x: 0, y: 0, width: 100, height: 100 }, + maxOutputLongEdge: 100, + }), + 'WEBP_UNSUPPORTED' + ); + }); +}); diff --git a/src/lib/image-preprocessing.ts b/src/lib/image-preprocessing.ts new file mode 100644 index 0000000..95d74b5 --- /dev/null +++ b/src/lib/image-preprocessing.ts @@ -0,0 +1,355 @@ +export const DEFAULT_IMAGE_QUALITY = 70; +export const MIN_IMAGE_QUALITY = 40; +export const MAX_IMAGE_QUALITY = 95; +export const IMAGE_QUALITY_STEP = 5; +export const WEBP_MIME_TYPE = 'image/webp'; + +export interface CropPixelArea { + x: number; + y: number; + width: number; + height: number; +} + +export interface ImagePreprocessingOptions { + /** 소스 이미지 좌표계 기준 크롭 영역 */ + crop: CropPixelArea; + /** 결과 이미지의 최대 긴 변. 작은 이미지는 확대하지 않는다. */ + maxOutputLongEdge: number; + /** 40~95 범위에서 5 단위로 지정하는 WebP 품질 */ + quality?: number; + /** 호출자가 허용할 입력 MIME 목록. 생략하면 decode 가능한 모든 형식을 허용한다. */ + allowedMimeTypes?: readonly string[]; + /** 호출자가 정하는 최대 입력 크기(byte) */ + maxInputBytes?: number; + /** 호출자가 정하는 최대 결과 크기(byte) */ + maxOutputBytes?: number; + /** 결과 파일명. 확장자는 항상 .webp로 정규화한다. */ + outputFileName?: string; +} + +export interface PreparedImageMetadata { + original: { + width: number; + height: number; + byteSize: number; + mimeType: string; + }; + output: { + width: number; + height: number; + byteSize: number; + mimeType: typeof WEBP_MIME_TYPE; + quality: number; + }; +} + +/** 향후 presign 업로드에 직접 전달할 수 있는 UI 비종속 전처리 결과 */ +export interface PreparedImage { + file: File; + metadata: PreparedImageMetadata; +} + +export type ImagePreprocessingErrorCode = + | 'INVALID_QUALITY' + | 'INVALID_CROP' + | 'INVALID_MAX_OUTPUT_LONG_EDGE' + | 'INVALID_BYTE_LIMIT' + | 'UNSUPPORTED_MIME_TYPE' + | 'INPUT_TOO_LARGE' + | 'DECODE_FAILED' + | 'CANVAS_UNAVAILABLE' + | 'CANVAS_DRAW_FAILED' + | 'ENCODING_FAILED' + | 'WEBP_UNSUPPORTED' + | 'OUTPUT_TOO_LARGE'; + +export class ImagePreprocessingError extends Error { + readonly code: ImagePreprocessingErrorCode; + readonly cause?: unknown; + + constructor(code: ImagePreprocessingErrorCode, message: string, cause?: unknown) { + super(message); + this.name = 'ImagePreprocessingError'; + this.code = code; + this.cause = cause; + } +} + +interface DecodedImage { + source: CanvasImageSource; + width: number; + height: number; + cleanup: () => void; +} + +function validateQuality(quality: number) { + if ( + !Number.isInteger(quality) || + quality < MIN_IMAGE_QUALITY || + quality > MAX_IMAGE_QUALITY || + quality % IMAGE_QUALITY_STEP !== 0 + ) { + throw new ImagePreprocessingError( + 'INVALID_QUALITY', + `이미지 품질은 ${MIN_IMAGE_QUALITY}~${MAX_IMAGE_QUALITY} 범위에서 ${IMAGE_QUALITY_STEP} 단위여야 합니다.` + ); + } +} + +function validateCropShape(crop: CropPixelArea) { + const values = [crop.x, crop.y, crop.width, crop.height]; + if ( + values.some((value) => !Number.isFinite(value)) || + crop.x < 0 || + crop.y < 0 || + crop.width <= 0 || + crop.height <= 0 + ) { + throw new ImagePreprocessingError( + 'INVALID_CROP', + '크롭 좌표는 0 이상이고 크롭 너비와 높이는 0보다 큰 유한수여야 합니다.' + ); + } +} + +function validatePositiveInteger(value: number | undefined, label: string) { + if (value !== undefined && (!Number.isInteger(value) || value <= 0)) { + throw new ImagePreprocessingError('INVALID_BYTE_LIMIT', `${label}은 0보다 큰 정수여야 합니다.`); + } +} + +function validateOptions(input: Blob, options: ImagePreprocessingOptions) { + const quality = options.quality ?? DEFAULT_IMAGE_QUALITY; + validateQuality(quality); + validateCropShape(options.crop); + + if (!Number.isInteger(options.maxOutputLongEdge) || options.maxOutputLongEdge <= 0) { + throw new ImagePreprocessingError( + 'INVALID_MAX_OUTPUT_LONG_EDGE', + '최대 출력 긴 변은 0보다 큰 정수여야 합니다.' + ); + } + + validatePositiveInteger(options.maxInputBytes, '최대 입력 byte'); + validatePositiveInteger(options.maxOutputBytes, '최대 출력 byte'); + + if ( + options.allowedMimeTypes && + !options.allowedMimeTypes.some( + (mimeType) => mimeType.toLowerCase() === input.type.toLowerCase() + ) + ) { + throw new ImagePreprocessingError( + 'UNSUPPORTED_MIME_TYPE', + `허용되지 않은 이미지 MIME 타입입니다: ${input.type || '(없음)'}` + ); + } + + if (options.maxInputBytes !== undefined && input.size > options.maxInputBytes) { + throw new ImagePreprocessingError( + 'INPUT_TOO_LARGE', + `입력 이미지가 최대 크기(${options.maxInputBytes} byte)를 초과했습니다.` + ); + } + + return quality; +} + +function assertCropInsideSource(crop: CropPixelArea, sourceWidth: number, sourceHeight: number) { + if (crop.x + crop.width > sourceWidth || crop.y + crop.height > sourceHeight) { + throw new ImagePreprocessingError( + 'INVALID_CROP', + `크롭 영역이 원본 이미지 범위(${sourceWidth}x${sourceHeight})를 벗어났습니다.` + ); + } +} + +function calculateOutputDimensions(crop: CropPixelArea, maxOutputLongEdge: number) { + const scale = Math.min(1, maxOutputLongEdge / Math.max(crop.width, crop.height)); + return { + width: Math.max(1, Math.round(crop.width * scale)), + height: Math.max(1, Math.round(crop.height * scale)), + }; +} + +async function decodeWithHtmlImage(input: Blob, bitmapError?: unknown): Promise { + let objectUrl: string | undefined; + + try { + objectUrl = URL.createObjectURL(input); + const image = new Image(); + image.src = objectUrl; + await image.decode(); + + if (image.naturalWidth <= 0 || image.naturalHeight <= 0) { + throw new Error('decode된 이미지의 크기가 유효하지 않습니다.'); + } + + return { + source: image, + width: image.naturalWidth, + height: image.naturalHeight, + // 이 함수에서 생성한 object URL만 이 함수가 소유하고 정리한다. + cleanup: () => URL.revokeObjectURL(objectUrl!), + }; + } catch (fallbackError) { + if (objectUrl) URL.revokeObjectURL(objectUrl); + throw new ImagePreprocessingError( + 'DECODE_FAILED', + '이미지 파일을 decode하지 못했습니다.', + bitmapError ?? fallbackError + ); + } +} + +async function decodeImage(input: Blob): Promise { + let bitmapError: unknown; + + if (typeof createImageBitmap === 'function') { + try { + const bitmap = await createImageBitmap(input, { imageOrientation: 'from-image' }); + if (bitmap.width > 0 && bitmap.height > 0) { + return { + source: bitmap, + width: bitmap.width, + height: bitmap.height, + cleanup: () => bitmap.close(), + }; + } + bitmap.close(); + bitmapError = new Error('decode된 ImageBitmap 크기가 유효하지 않습니다.'); + } catch (error) { + bitmapError = error; + } + } + + return decodeWithHtmlImage(input, bitmapError); +} + +function encodeCanvasToWebP(canvas: HTMLCanvasElement, quality: number) { + return new Promise((resolve, reject) => { + try { + canvas.toBlob( + (blob) => { + if (!blob) { + reject( + new ImagePreprocessingError( + 'ENCODING_FAILED', + 'Canvas에서 WebP Blob을 생성하지 못했습니다.' + ) + ); + return; + } + if (blob.type.toLowerCase() !== WEBP_MIME_TYPE) { + reject( + new ImagePreprocessingError( + 'WEBP_UNSUPPORTED', + '현재 브라우저가 Canvas WebP encoding을 지원하지 않습니다.' + ) + ); + return; + } + resolve(blob); + }, + WEBP_MIME_TYPE, + quality / 100 + ); + } catch (error) { + reject( + new ImagePreprocessingError( + 'ENCODING_FAILED', + 'Canvas WebP encoding 중 오류가 발생했습니다.', + error + ) + ); + } + }); +} + +function toWebPFileName(input: Blob, outputFileName?: string) { + const requestedName = outputFileName ?? (input instanceof File ? input.name : 'image'); + const baseName = requestedName.replace(/\.[^./\\]+$/, '') || 'image'; + return `${baseName}.webp`; +} + +/** + * Blob/File을 decode한 뒤 지정 픽셀 영역을 crop·resize하고 WebP File로 반환한다. + * 입력 Blob/File의 생명주기는 호출자가 소유하며, 내부 폴백용 object URL은 함수가 정리한다. + */ +export async function preprocessImageToWebP( + input: File | Blob, + options: ImagePreprocessingOptions +): Promise { + const quality = validateOptions(input, options); + const decoded = await decodeImage(input); + + try { + assertCropInsideSource(options.crop, decoded.width, decoded.height); + const dimensions = calculateOutputDimensions(options.crop, options.maxOutputLongEdge); + const canvas = document.createElement('canvas'); + canvas.width = dimensions.width; + canvas.height = dimensions.height; + + const context = canvas.getContext('2d'); + if (!context) { + throw new ImagePreprocessingError( + 'CANVAS_UNAVAILABLE', + '이미지 전처리를 위한 2D Canvas context를 생성하지 못했습니다.' + ); + } + + try { + context.drawImage( + decoded.source, + options.crop.x, + options.crop.y, + options.crop.width, + options.crop.height, + 0, + 0, + dimensions.width, + dimensions.height + ); + } catch (error) { + throw new ImagePreprocessingError( + 'CANVAS_DRAW_FAILED', + '이미지를 Canvas에 그리지 못했습니다.', + error + ); + } + + const encodedBlob = await encodeCanvasToWebP(canvas, quality); + if (options.maxOutputBytes !== undefined && encodedBlob.size > options.maxOutputBytes) { + throw new ImagePreprocessingError( + 'OUTPUT_TOO_LARGE', + `결과 이미지가 최대 크기(${options.maxOutputBytes} byte)를 초과했습니다.` + ); + } + + const file = new File([encodedBlob], toWebPFileName(input, options.outputFileName), { + type: WEBP_MIME_TYPE, + }); + + return { + file, + metadata: { + original: { + width: decoded.width, + height: decoded.height, + byteSize: input.size, + mimeType: input.type, + }, + output: { + width: dimensions.width, + height: dimensions.height, + byteSize: encodedBlob.size, + mimeType: WEBP_MIME_TYPE, + quality, + }, + }, + }; + } finally { + decoded.cleanup(); + } +} diff --git a/src/pages/regions/RegionDetail.tsx b/src/pages/regions/RegionDetail.tsx index 802eea5..b245ca5 100644 --- a/src/pages/regions/RegionDetail.tsx +++ b/src/pages/regions/RegionDetail.tsx @@ -17,8 +17,13 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com import { DetailPageHeader } from '@/components/common/DetailPageHeader'; import { DeleteConfirmDialog } from '@/components/common/DeleteConfirmDialog'; import { FormField } from '@/components/common/FormField'; +import { + PreparedImageField, + type ImageProcessingPolicy, +} from '@/components/common/PreparedImageField'; import { RelatedWhiskyLookupCard } from '@/components/common/RelatedWhiskyLookupCard'; import { SearchableSelect } from '@/components/common/SearchableSelect'; +import { useImageUpload, S3UploadPath } from '@/hooks/useImageUpload'; import { useRegionCreate, useRegionDelete, @@ -26,8 +31,24 @@ import { useRegionList, useRegionUpdate, } from '@/hooks/useRegions'; +import type { PreparedImage } from '@/lib/image-preprocessing'; import { regionDefaultValues, regionFormSchema, type RegionFormValues } from './region.schema'; +const REGION_IMAGE_POLICY: ImageProcessingPolicy = { + allowedMimeTypes: ['image/jpeg', 'image/png', 'image/webp'], + aspectRatios: [ + { label: '자유 비율', value: null }, + { label: '1:1', value: 1 }, + { label: '4:3', value: 4 / 3 }, + { label: '16:9', value: 16 / 9 }, + ], + defaultAspectRatio: 1, + defaultQuality: 70, + maxInputBytes: 20 * 1024 * 1024, + maxOutputBytes: 5 * 1024 * 1024, + maxOutputLongEdge: 1600, +}; + function toNullableText(value: string | null | undefined) { const trimmed = value?.trim(); return trimmed ? trimmed : null; @@ -61,11 +82,20 @@ export function RegionDetailPage() { }); const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + const [pendingImage, setPendingImage] = useState(null); + const [stagedImageUrl, setStagedImageUrl] = useState(null); const form = useForm({ resolver: zodResolver(regionFormSchema), defaultValues: regionDefaultValues, }); + const { + upload: uploadRegionImage, + isUploading: isImageUploading, + error: imageUploadError, + } = useImageUpload({ + rootPath: S3UploadPath.REGION, + }); useEffect(() => { if (isNewMode) { @@ -76,6 +106,7 @@ export function RegionDetailPage() { engName: detailData.engName, continent: detailData.continent, description: detailData.description, + imageUrl: detailData.imageUrl, parentId: detailData.parentId, sortOrder: detailData.sortOrder, }); @@ -97,20 +128,54 @@ export function RegionDetailPage() { ? Math.max(...parentRegionData.items.map((region) => region.sortOrder)) + 1 : regionDefaultValues.sortOrder; - const onSubmit = (data: RegionFormValues) => { + const handlePreparedImageChange = (preparedImage: PreparedImage) => { + setPendingImage(preparedImage); + setStagedImageUrl(null); + }; + + const handleCancelPreparedImage = () => { + setPendingImage(null); + setStagedImageUrl(null); + }; + + const handleRemoveImage = () => { + setPendingImage(null); + setStagedImageUrl(null); + form.setValue('imageUrl', null, { shouldDirty: true, shouldValidate: true }); + }; + + const onSubmit = async (data: RegionFormValues) => { + let imageUrl = data.imageUrl; + + if (pendingImage) { + imageUrl = stagedImageUrl ?? (await uploadRegionImage(pendingImage.file)); + if (!imageUrl) return; + + setStagedImageUrl(imageUrl); + } + const formData = { korName: data.korName, engName: data.engName, continent: toNullableText(data.continent), description: toNullableText(data.description), + imageUrl, parentId: data.parentId, sortOrder: isNewMode ? nextCreateSortOrder : data.sortOrder, }; - if (isNewMode) { - createMutation.mutate(formData); - } else if (regionId !== undefined) { - updateMutation.mutate({ id: regionId, data: formData }); + try { + if (isNewMode) { + await createMutation.mutateAsync(formData); + } else if (regionId !== undefined) { + await updateMutation.mutateAsync({ id: regionId, data: formData }); + } + + form.setValue('imageUrl', imageUrl); + setPendingImage(null); + setStagedImageUrl(null); + } catch { + // 업로드가 성공했더라도 Region 저장이 실패하면 staged URL과 pending 파일을 보존해 재시도한다. } }; @@ -122,7 +187,7 @@ export function RegionDetailPage() { const handleBack = () => navigate('/regions'); - const isMutating = createMutation.isPending || updateMutation.isPending; + const isMutating = createMutation.isPending || updateMutation.isPending || isImageUploading; return (
@@ -201,40 +266,63 @@ export function RegionDetailPage() { - {!isNewMode && detailData && ( - +
+ - 참고 정보 - 지역의 연결 상태와 순서를 확인합니다. + 대표 이미지 + + 비율을 선택하고 원하는 영역으로 크롭한 WebP 이미지를 저장합니다. + - -
-

정렬 순서

-

{detailData.sortOrder + 1}

-
-
-

상위 지역

-

{detailData.parentKorName ?? '-'}

-
-
-

하위 지역

-

{detailData.hasChildren ? '있음' : '없음'}

-
-
-

연결 위스키

-

{detailData.alcoholCount.toLocaleString()}개

-
-
-

생성일

-

{formatDateTime(detailData.createAt)}

-
-
-

수정일

-

{formatDateTime(detailData.lastModifyAt)}

-
+ +
- )} + + {!isNewMode && detailData && ( + + + 참고 정보 + 지역의 연결 상태와 순서를 확인합니다. + + +
+

정렬 순서

+

{detailData.sortOrder + 1}

+
+
+

상위 지역

+

{detailData.parentKorName ?? '-'}

+
+
+

하위 지역

+

{detailData.hasChildren ? '있음' : '없음'}

+
+
+

연결 위스키

+

{detailData.alcoholCount.toLocaleString()}개

+
+
+

생성일

+

{formatDateTime(detailData.createAt)}

+
+
+

수정일

+

{formatDateTime(detailData.lastModifyAt)}

+
+
+
+ )} +
{!isNewMode && regionId !== undefined && ( diff --git a/src/pages/regions/RegionList.tsx b/src/pages/regions/RegionList.tsx index d3c26a9..c6836db 100644 --- a/src/pages/regions/RegionList.tsx +++ b/src/pages/regions/RegionList.tsx @@ -335,6 +335,7 @@ export function RegionListPage() { 순서 + 이미지 한글명 영문명 대륙 @@ -346,13 +347,13 @@ export function RegionListPage() { {isLoading ? ( - + 로딩 중... ) : displayedItems.length === 0 ? ( - + {keyword ? '검색 결과가 없습니다.' : '등록된 지역이 없습니다.'} @@ -375,6 +376,19 @@ export function RegionListPage() { {isReorderMode ? index + 1 : item.sortOrder + 1} + + {item.imageUrl ? ( + {`${item.korName} + ) : ( +
+ - +
+ )} +
{item.korName} {item.engName} {item.continent || '-'} diff --git a/src/pages/regions/__tests__/RegionDetail.test.tsx b/src/pages/regions/__tests__/RegionDetail.test.tsx index 3550d13..f0bd3df 100644 --- a/src/pages/regions/__tests__/RegionDetail.test.tsx +++ b/src/pages/regions/__tests__/RegionDetail.test.tsx @@ -1,13 +1,59 @@ import { describe, expect, it, vi } from 'vitest'; -import { screen } from '@testing-library/react'; +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { http, HttpResponse } from 'msw'; import { server } from '@/test/mocks/server'; import { mockAlcoholLookupItems, wrapApiResponse } from '@/test/mocks/data'; import { render } from '@/test/test-utils'; +import type { RegionFormData } from '@/types/api'; import { RegionDetailPage } from '../RegionDetail'; +vi.mock('@/components/common/ImageCropDialog', () => ({ + ImageCropDialog: ({ + open, + onPrepared, + }: { + open: boolean; + onPrepared: (preparedImage: { + file: File; + metadata: { + original: { width: number; height: number; byteSize: number; mimeType: string }; + output: { + width: number; + height: number; + byteSize: number; + mimeType: 'image/webp'; + quality: number; + }; + }; + }) => void; + }) => + open ? ( + + ) : null, +})); + vi.mock('react-router', async () => { const actual = await vi.importActual('react-router'); @@ -52,4 +98,107 @@ describe('RegionDetailPage', () => { expect(lookupRegionId).toBe('1'); expect(lookupCursor).toBe('0'); }); + + it('이미지가 있는 지역의 텍스트만 수정해도 기존 imageUrl을 보존해 저장한다', async () => { + const user = userEvent.setup(); + let capturedBody: RegionFormData | null = null; + + server.use( + http.put('/admin/api/v1/regions/1', async ({ request }) => { + capturedBody = (await request.json()) as RegionFormData; + return HttpResponse.json( + wrapApiResponse({ + code: 'REGION_UPDATED', + message: '지역이 수정되었습니다.', + targetId: 1, + responseAt: '2024-06-01T00:00:00', + }) + ); + }) + ); + + render(); + + const korNameInput = await screen.findByDisplayValue('스코틀랜드'); + await user.clear(korNameInput); + await user.type(korNameInput, '스코틀랜드 수정'); + await user.click(screen.getByRole('button', { name: '저장' })); + + await waitFor(() => expect(capturedBody).not.toBeNull()); + expect(capturedBody).toMatchObject({ + korName: '스코틀랜드 수정', + imageUrl: 'https://example.com/regions/scotland.webp', + }); + }); + + it('전처리 이미지는 저장을 눌렀을 때만 WebP로 업로드하고 Region 수정 요청에 URL을 넣는다', async () => { + const user = userEvent.setup(); + let presignRootPath: string | null = null; + let presignContentType: string | null = null; + let uploadContentType: string | null = null; + let capturedBody: RegionFormData | null = null; + + server.use( + http.get('/admin/api/v1/s3/presign-url', ({ request }) => { + const url = new URL(request.url); + presignRootPath = url.searchParams.get('rootPath'); + presignContentType = url.searchParams.get('contentType'); + return HttpResponse.json({ + success: true, + code: 200, + data: { + bucketName: 'test-bucket', + expiryTime: 15, + uploadSize: 1, + imageUploadInfo: [ + { + order: 0, + viewUrl: 'https://cdn.example.com/regions/scotland-new.webp', + uploadUrl: 'https://s3.amazonaws.com/test-bucket/admin/region/scotland-new.webp', + }, + ], + }, + errors: [], + meta: {}, + }); + }), + http.put('https://s3.amazonaws.com/*', ({ request }) => { + uploadContentType = request.headers.get('Content-Type'); + return new HttpResponse(null, { status: 200 }); + }), + http.put('/admin/api/v1/regions/1', async ({ request }) => { + capturedBody = (await request.json()) as RegionFormData; + return HttpResponse.json( + wrapApiResponse({ + code: 'REGION_UPDATED', + message: '지역이 수정되었습니다.', + targetId: 1, + responseAt: '2024-06-01T00:00:00', + }) + ); + }) + ); + + render(); + + await screen.findByDisplayValue('스코틀랜드'); + await user.upload( + screen.getByLabelText('이미지 파일 선택'), + new File(['source-image'], 'scotland.jpg', { type: 'image/jpeg' }) + ); + await user.click(screen.getByRole('button', { name: '크롭 적용' })); + + await screen.findByText('새 이미지 (저장 전)'); + expect(presignRootPath).toBeNull(); + + await user.click(screen.getByRole('button', { name: '저장' })); + + await waitFor(() => expect(capturedBody).not.toBeNull()); + expect(presignRootPath).toBe('admin/region'); + expect(presignContentType).toBe('image/webp'); + expect(uploadContentType).toBe('image/webp'); + expect(capturedBody).toMatchObject({ + imageUrl: 'https://cdn.example.com/regions/scotland-new.webp', + }); + }); }); diff --git a/src/pages/regions/__tests__/RegionList.test.tsx b/src/pages/regions/__tests__/RegionList.test.tsx new file mode 100644 index 0000000..550d3aa --- /dev/null +++ b/src/pages/regions/__tests__/RegionList.test.tsx @@ -0,0 +1,74 @@ +import { describe, expect, it, vi } from 'vitest'; +import { screen } from '@testing-library/react'; + +import { render } from '@/test/test-utils'; + +import { RegionListPage } from '../RegionList'; + +const navigate = vi.fn(); +const setSearchParams = vi.fn(); + +vi.mock('react-router', async () => { + const actual = await vi.importActual('react-router'); + + return { + ...actual, + useNavigate: () => navigate, + useSearchParams: () => [new URLSearchParams(), setSearchParams], + }; +}); + +vi.mock('@/hooks/useRegions', () => ({ + useRegionList: () => ({ + data: { + items: [ + { + id: 1, + korName: '스코틀랜드', + engName: 'Scotland', + continent: '유럽', + description: null, + imageUrl: 'https://cdn.example.com/regions/scotland.webp', + createdAt: '2024-01-01T00:00:00', + modifiedAt: '2024-06-01T00:00:00', + parentId: null, + sortOrder: 0, + }, + { + id: 2, + korName: '스페이사이드', + engName: 'Speyside', + continent: '유럽', + description: null, + imageUrl: null, + createdAt: '2024-01-02T00:00:00', + modifiedAt: '2024-06-02T00:00:00', + parentId: 1, + sortOrder: 1, + }, + ], + meta: { + page: 0, + size: 100, + totalElements: 2, + totalPages: 1, + hasNext: false, + }, + }, + isLoading: false, + refetch: vi.fn(), + }), + useRegionBulkReorder: () => ({ mutateAsync: vi.fn() }), +})); + +describe('RegionListPage', () => { + it('대표 이미지가 있으면 썸네일을 표시하고 없으면 빈 상태를 표시한다', () => { + render(); + + expect(screen.getByAltText('스코틀랜드 대표 이미지')).toHaveAttribute( + 'src', + 'https://cdn.example.com/regions/scotland.webp' + ); + expect(screen.getByText('스페이사이드')).toBeInTheDocument(); + }); +}); diff --git a/src/pages/regions/__tests__/region.schema.test.ts b/src/pages/regions/__tests__/region.schema.test.ts new file mode 100644 index 0000000..0ffb53f --- /dev/null +++ b/src/pages/regions/__tests__/region.schema.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; + +import { regionDefaultValues, regionFormSchema } from '../region.schema'; + +describe('regionFormSchema', () => { + const validRegion = { + ...regionDefaultValues, + korName: '스코틀랜드', + engName: 'Scotland', + }; + + it('imageUrl 기본값을 null로 유지한다', () => { + expect(regionDefaultValues.imageUrl).toBeNull(); + expect(regionFormSchema.parse(validRegion).imageUrl).toBeNull(); + }); + + it('imageUrl에 URL 문자열과 null을 허용한다', () => { + expect( + regionFormSchema.parse({ + ...validRegion, + imageUrl: 'https://example.com/regions/scotland.webp', + }).imageUrl + ).toBe('https://example.com/regions/scotland.webp'); + expect(regionFormSchema.parse({ ...validRegion, imageUrl: null }).imageUrl).toBeNull(); + }); +}); diff --git a/src/pages/regions/region.schema.ts b/src/pages/regions/region.schema.ts index fc35987..1f0bdbb 100644 --- a/src/pages/regions/region.schema.ts +++ b/src/pages/regions/region.schema.ts @@ -5,6 +5,7 @@ export const regionFormSchema = z.object({ engName: z.string().min(1, '영문명은 필수입니다'), continent: z.string().nullable(), description: z.string().nullable(), + imageUrl: z.string().nullable(), parentId: z.number().nullable(), sortOrder: z .number({ message: '정렬 순서는 숫자여야 합니다' }) @@ -19,6 +20,7 @@ export const regionDefaultValues: RegionFormValues = { engName: '', continent: null, description: null, + imageUrl: null, parentId: null, sortOrder: 9999, }; diff --git a/src/services/__tests__/region.service.test.ts b/src/services/__tests__/region.service.test.ts index 25a69f6..f953730 100644 --- a/src/services/__tests__/region.service.test.ts +++ b/src/services/__tests__/region.service.test.ts @@ -15,10 +15,77 @@ describe('regionService', () => { const result = await regionService.list(); expect(result.items).toHaveLength(mockRegionListItems.length); expect(result.items[0]!.korName).toBe('스코틀랜드'); + expect(result.items[0]!.imageUrl).toBe('https://example.com/regions/scotland.webp'); + expect(result.items[2]!.imageUrl).toBeNull(); expect(result.meta.totalElements).toBe(mockRegionListItems.length); }); }); + // ========================================== + // detail()/create()/update() + // ========================================== + describe('imageUrl 계약', () => { + it('상세 응답의 imageUrl을 그대로 반환한다', async () => { + const resultWithImage = await regionService.detail(1); + const resultWithoutImage = await regionService.detail(3); + + expect(resultWithImage.imageUrl).toBe('https://example.com/regions/scotland.webp'); + expect(resultWithoutImage.imageUrl).toBeNull(); + }); + + it('생성 요청에 nullable imageUrl을 그대로 전송한다', async () => { + let capturedBody: Record | null = null; + server.use( + http.post(BASE, async ({ request }) => { + capturedBody = (await request.json()) as Record; + return HttpResponse.json( + wrapApiResponse({ + code: 'REGION_CREATED', + message: '지역이 생성되었습니다.', + targetId: 99, + responseAt: '2024-06-01T00:00:00', + }) + ); + }) + ); + + await regionService.create({ + korName: '일본', + engName: 'Japan', + imageUrl: null, + }); + + expect(capturedBody).toMatchObject({ imageUrl: null }); + }); + + it('수정 요청에 imageUrl URL을 그대로 전송한다', async () => { + let capturedBody: Record | null = null; + server.use( + http.put(`${BASE}/1`, async ({ request }) => { + capturedBody = (await request.json()) as Record; + return HttpResponse.json( + wrapApiResponse({ + code: 'REGION_UPDATED', + message: '지역이 수정되었습니다.', + targetId: 1, + responseAt: '2024-06-01T00:00:00', + }) + ); + }) + ); + + await regionService.update(1, { + korName: '스코틀랜드', + engName: 'Scotland', + imageUrl: 'https://example.com/regions/scotland-updated.webp', + }); + + expect(capturedBody).toMatchObject({ + imageUrl: 'https://example.com/regions/scotland-updated.webp', + }); + }); + }); + // ========================================== // bulkReorder() // ========================================== diff --git a/src/test/mocks/data.ts b/src/test/mocks/data.ts index 15f8013..3266318 100644 --- a/src/test/mocks/data.ts +++ b/src/test/mocks/data.ts @@ -596,6 +596,7 @@ export const mockRegionListItems: RegionListItem[] = [ engName: 'Scotland', continent: '유럽', description: '스카치 위스키의 대표 생산 지역입니다.', + imageUrl: 'https://example.com/regions/scotland.webp', createdAt: '2024-01-01T00:00:00', modifiedAt: '2024-06-01T00:00:00', parentId: null, @@ -607,6 +608,7 @@ export const mockRegionListItems: RegionListItem[] = [ engName: 'Speyside', continent: '유럽', description: '스코틀랜드 북동부의 주요 위스키 생산지입니다.', + imageUrl: 'https://example.com/regions/speyside.webp', createdAt: '2024-01-02T00:00:00', modifiedAt: '2024-06-02T00:00:00', parentId: 1, @@ -618,6 +620,7 @@ export const mockRegionListItems: RegionListItem[] = [ engName: 'United States', continent: '북아메리카', description: null, + imageUrl: null, createdAt: '2024-01-03T00:00:00', modifiedAt: '2024-06-03T00:00:00', parentId: null, @@ -631,6 +634,7 @@ export const mockRegionDetail: RegionDetail = { engName: 'Scotland', continent: '유럽', description: '스카치 위스키의 대표 생산 지역입니다.', + imageUrl: 'https://example.com/regions/scotland.webp', sortOrder: 0, parentId: null, parentKorName: null, @@ -640,6 +644,22 @@ export const mockRegionDetail: RegionDetail = { lastModifyAt: '2024-06-01T00:00:00', }; +export const mockRegionDetailWithoutImage: RegionDetail = { + id: 3, + korName: '미국', + engName: 'United States', + continent: '북아메리카', + description: null, + imageUrl: null, + sortOrder: 2, + parentId: null, + parentKorName: null, + hasChildren: false, + alcoholCount: 0, + createAt: '2024-01-03T00:00:00', + lastModifyAt: '2024-06-03T00:00:00', +}; + export const mockRegionFormResponse: RegionFormResponse = { code: 'REGION_CREATED', message: '지역이 생성되었습니다.', diff --git a/src/test/mocks/handlers.ts b/src/test/mocks/handlers.ts index 16f36e4..b3f9986 100644 --- a/src/test/mocks/handlers.ts +++ b/src/test/mocks/handlers.ts @@ -29,6 +29,7 @@ import { mockCurationBulkReorderResponse, mockRegionListItems, mockRegionDetail, + mockRegionDetailWithoutImage, mockRegionFormResponse, mockRegionDeleteResponse, mockRegionBulkReorderResponse, @@ -718,8 +719,11 @@ export const regionHandlers = [ // GET 상세 http.get(`${REGION_BASE}/:id`, ({ params }) => { const id = Number(params.id); - if (id === mockRegionDetail.id) { - return HttpResponse.json(wrapApiResponse(mockRegionDetail)); + const detail = [mockRegionDetail, mockRegionDetailWithoutImage].find( + (region) => region.id === id + ); + if (detail) { + return HttpResponse.json(wrapApiResponse(detail)); } return HttpResponse.json( { diff --git a/src/types/api/region.api.ts b/src/types/api/region.api.ts index c84a1a3..b9a31dd 100644 --- a/src/types/api/region.api.ts +++ b/src/types/api/region.api.ts @@ -70,6 +70,8 @@ export interface RegionApiTypes { continent: string | null; /** 설명 */ description: string | null; + /** 대표 이미지 URL */ + imageUrl: string | null; /** 생성일시 */ createdAt: string; /** 수정일시 */ @@ -107,6 +109,8 @@ export interface RegionApiTypes { continent: string | null; /** 설명 */ description: string | null; + /** 대표 이미지 URL */ + imageUrl: string | null; /** 정렬 순서 */ sortOrder: number; /** 상위 지역 ID */ @@ -135,6 +139,8 @@ export interface RegionApiTypes { continent?: string | null; /** 설명 */ description?: string | null; + /** 대표 이미지 URL */ + imageUrl?: string | null; /** 상위 지역 ID */ parentId?: number | null; /** 정렬 순서 (미지정 시 백엔드 기본값 9999) */ diff --git a/src/types/api/s3.api.ts b/src/types/api/s3.api.ts index c8b3e67..36f080d 100644 --- a/src/types/api/s3.api.ts +++ b/src/types/api/s3.api.ts @@ -71,6 +71,8 @@ export const S3UploadPath = { CURATION: 'admin/curation', /** 증류소 이미지 */ DISTILLERY: 'admin/distillery', + /** 지역 대표 이미지 */ + REGION: 'admin/region', /** 테이스팅 태그 아이콘 */ TASTING_TAG: 'admin/tasting-tag', } as const;