Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions docs/features/region-management/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
- `한글명은 필수입니다`
- `영문명은 필수입니다`
Expand Down
16 changes: 16 additions & 0 deletions docs/features/region-management/plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
6 changes: 6 additions & 0 deletions docs/features/region-management/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
12 changes: 12 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

277 changes: 277 additions & 0 deletions src/components/common/ImageCropDialog.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLImageElement>(null);
const [sourceUrl, setSourceUrl] = useState<string | null>(null);
const [crop, setCrop] = useState<Crop>();
const [cropPixels, setCropPixels] = useState<PixelCrop | null>(null);
const [quality, setQuality] = useState(policy.defaultQuality);
const [aspectRatio, setAspectRatio] = useState<number | null>(policy.defaultAspectRatio);
const [errorMessage, setErrorMessage] = useState<string | null>(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<HTMLImageElement>) => {
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[90vh] max-w-3xl overflow-y-auto">
<DialogHeader>
<DialogTitle>이미지 크롭 및 변환</DialogTitle>
<DialogDescription>
자유 비율 또는 고정 비율을 선택한 뒤, 크롭 영역을 드래그하거나 모서리 핸들로 크기를
조절하세요.
</DialogDescription>
</DialogHeader>

<div className="space-y-5">
<div className="flex min-h-80 items-center justify-center overflow-auto rounded-md bg-muted p-3 sm:min-h-96">
{sourceUrl && (
<ReactCrop
crop={crop}
aspect={aspectRatio ?? undefined}
keepSelection
ruleOfThirds
disabled={isProcessing}
onChange={(pixelCrop, percentCrop) => {
setCrop(percentCrop);
setCropPixels(pixelCrop);
}}
onComplete={handleCropComplete}
renderSelectionAddon={() =>
cropSizeLabel ? (
<span
className="pointer-events-none absolute bottom-1 right-1 rounded bg-black/75 px-1.5 py-0.5 text-xs font-medium text-white"
aria-label={`원본 기준 ${cropSizeLabel}`}
>
{cropSizeLabel}
</span>
) : null
}
>
<img
ref={imageRef}
src={sourceUrl}
alt="크롭할 원본 이미지"
className="max-h-[24rem] w-full object-contain"
onLoad={handleImageLoad}
/>
</ReactCrop>
)}
</div>

<div className="grid gap-4 sm:grid-cols-2">
<label className="space-y-2 text-sm font-medium">
<span>크롭 비율</span>
<select
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
value={aspectRatio === null ? 'free' : String(aspectRatio)}
disabled={isProcessing}
onChange={(event) => handleAspectRatioChange(event.target.value)}
>
{policy.aspectRatios.map((option) => (
<option key={option.label} value={option.value === null ? 'free' : option.value}>
{option.label}
</option>
))}
</select>
</label>

<label className="space-y-2 text-sm font-medium">
<span>WebP 품질: {quality}%</span>
<input
className="w-full accent-primary"
type="range"
min="40"
max="95"
step="5"
value={quality}
disabled={isProcessing}
onChange={(event) => setQuality(Number(event.target.value))}
/>
<span className="block text-xs font-normal text-muted-foreground">
변환 후 실제 해상도와 파일 크기는 적용 결과에서 확인할 수 있습니다.
</span>
</label>
</div>

{errorMessage && <p className="text-sm text-destructive">{errorMessage}</p>}
</div>

<DialogFooter>
<Button
type="button"
variant="outline"
disabled={isProcessing}
onClick={() => onOpenChange(false)}
>
취소
</Button>
<Button
type="button"
disabled={isProcessing || !cropPixels}
onClick={() => void handleApply()}
>
{isProcessing ? '변환 중...' : '크롭 적용'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
Loading