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
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
1.1.6
1.2.6
Binary file added e2e/fixtures/test-video.mp4
Binary file not shown.
20 changes: 15 additions & 5 deletions e2e/pages/banner-detail.page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,10 +56,14 @@ export class BannerDetailPage extends BasePage {

readonly endDateInput = () => this.page.locator('input#endDate');

readonly imageFileInput = () => this.page.locator('input[type="file"][accept="image/*"]');
readonly mediaFileInput = () => this.page.locator('input[type="file"]');

readonly uploadedImage = () => this.page.locator('img[alt="업로드된 이미지"]');

readonly uploadedVideo = () => this.page.locator('video');

readonly uploadedMedia = () => this.page.locator('img[alt="업로드된 이미지"], video');

readonly deleteDialog = () => this.page.getByRole('alertdialog');

readonly confirmDeleteButton = () => this.deleteDialog().getByRole('button', { name: '삭제' });
Expand Down Expand Up @@ -148,13 +152,19 @@ export class BannerDetailPage extends BasePage {

async uploadTestImage() {
const testImagePath = path.resolve(__dirname, '../fixtures/test-image.png');
await this.imageFileInput().setInputFiles(testImagePath);
await this.uploadedImage().waitFor({ state: 'visible', timeout: 10000 });
await this.mediaFileInput().setInputFiles(testImagePath);
await this.uploadedMedia().waitFor({ state: 'visible', timeout: 10000 });
}

async uploadTestVideo() {
const testVideoPath = path.resolve(__dirname, '../fixtures/test-video.mp4');
await this.mediaFileInput().setInputFiles(testVideoPath);
await this.uploadedVideo().waitFor({ state: 'visible', timeout: 10000 });
}

async ensureImage() {
const hasImage = await this.uploadedImage().isVisible().catch(() => false);
if (!hasImage) {
const hasMedia = await this.uploadedMedia().isVisible().catch(() => false);
if (!hasMedia) {
await this.uploadTestImage();
}
}
Expand Down
30 changes: 30 additions & 0 deletions e2e/specs/banners.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,36 @@ test.describe('배너 CRUD 플로우', () => {
});
});

test.describe('배너 동영상 업로드', () => {
test('동영상 파일 업로드 시 비디오 미리보기가 표시된다', async ({ page }) => {
const detailPage = new BannerDetailPage(page);

await detailPage.gotoNew();

// 동영상 업로드
await detailPage.uploadTestVideo();

// <video> 태그로 미리보기가 표시되는지 확인
await expect(detailPage.uploadedVideo()).toBeVisible();
// <img> 태그는 표시되지 않아야 함
await expect(detailPage.uploadedImage()).not.toBeVisible();
});

test('이미지 파일 업로드 시 이미지 미리보기가 표시된다', async ({ page }) => {
const detailPage = new BannerDetailPage(page);

await detailPage.gotoNew();

// 이미지 업로드
await detailPage.uploadTestImage();

// <img> 태그로 미리보기가 표시되는지 확인
await expect(detailPage.uploadedImage()).toBeVisible();
// <video> 태그는 표시되지 않아야 함
await expect(detailPage.uploadedVideo()).not.toBeVisible();
});
});

test.describe('배너 순서 변경', () => {
let listPage: BannerListPage;

Expand Down
93 changes: 84 additions & 9 deletions src/components/common/ImageUpload.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,38 +9,97 @@ import { useState, useEffect, useRef, useCallback } from 'react';
import { Upload, X } from 'lucide-react';
import { Button } from '@/components/ui/button';

/** 허용 파일 타입 */
const DEFAULT_ACCEPT = 'image/*';

/**
* 파일 MIME 타입이 허용 목록에 포함되는지 확인
* @param fileType - 파일의 MIME 타입 (예: 'image/webp', 'video/mp4')
* @param accept - accept 속성 문자열 (예: 'image/*,video/mp4')
*/
export function isFileTypeAllowed(fileType: string, accept: string): boolean {
return accept.split(',').some((pattern) => {
const trimmed = pattern.trim();
if (trimmed.endsWith('/*')) {
return fileType.startsWith(trimmed.replace('/*', '/'));
}
return fileType === trimmed;
});
}

/** 파일이 비디오 타입인지 확인 */
export function isVideoFile(file: File | null): boolean;
export function isVideoFile(url: string | null): boolean;
export function isVideoFile(input: File | string | null): boolean {
if (!input) return false;
if (typeof input === 'string') {
return input.match(/\.(mp4|webm|mov)(\?|$)/i) !== null;
}
return input.type.startsWith('video/');
}

/**
* ImageUpload 컴포넌트의 props
* @param imageUrl - 현재 이미지 URL (초기값 또는 서버에서 로드된 값)
* @param onImageChange - 이미지 변경 시 호출되는 콜백
* @param imageUrl - 현재 미디어 URL (초기값 또는 서버에서 로드된 값)
* @param onImageChange - 미디어 변경 시 호출되는 콜백
* @param minHeight - 최소 높이 (기본: 200px)
* @param accept - 허용 파일 타입 (기본: 'image/*')
* @param onFileRejected - 허용되지 않은 파일 업로드 시 콜백
* @param description - 업로드 영역 안내 텍스트
* @param supportText - 지원 포맷 안내 텍스트
*/
export interface ImageUploadProps {
imageUrl: string | null;
onImageChange: (file: File | null, previewUrl: string | null) => void;
minHeight?: number;
accept?: string;
onFileRejected?: (file: File) => void;
description?: string;
supportText?: string;
}

export function ImageUpload({ imageUrl, onImageChange, minHeight = 200 }: ImageUploadProps) {
export function ImageUpload({
imageUrl,
onImageChange,
minHeight = 200,
accept = DEFAULT_ACCEPT,
onFileRejected,
description = '이미지를 드래그하거나 클릭하여 업로드',
supportText = 'PNG, JPG, WEBP 지원',
}: ImageUploadProps) {
const [isDragging, setIsDragging] = useState(false);
const [previewUrl, setPreviewUrl] = useState<string | null>(imageUrl);
const [isVideo, setIsVideo] = useState(() => isVideoFile(imageUrl));
const fileInputRef = useRef<HTMLInputElement>(null);
const blobUrlRef = useRef<string | null>(null);

useEffect(() => {
setPreviewUrl(imageUrl);
if (!imageUrl) {
setIsVideo(false);
} else if (!imageUrl.startsWith('blob:')) {
// CDN URL은 확장자로 판별, blob URL은 handleFile에서 설정한 값을 유지
setIsVideo(isVideoFile(imageUrl));
}
}, [imageUrl]);

const handleFile = useCallback(
(file: File) => {
if (!file.type.startsWith('image/')) {
if (!isFileTypeAllowed(file.type, accept)) {
onFileRejected?.(file);
return;
}

if (blobUrlRef.current) {
URL.revokeObjectURL(blobUrlRef.current);
}
setIsVideo(file.type.startsWith('video/'));
const url = URL.createObjectURL(file);
blobUrlRef.current = url;
setPreviewUrl(url);
onImageChange(file, url);
},
[onImageChange]
[accept, onFileRejected, onImageChange]
);

const handleDragOver = (e: React.DragEvent) => {
Expand Down Expand Up @@ -71,7 +130,12 @@ export function ImageUpload({ imageUrl, onImageChange, minHeight = 200 }: ImageU
};

const handleRemove = () => {
if (blobUrlRef.current) {
URL.revokeObjectURL(blobUrlRef.current);
blobUrlRef.current = null;
}
setPreviewUrl(null);
setIsVideo(false);
onImageChange(null, null);
if (fileInputRef.current) {
fileInputRef.current.value = '';
Expand All @@ -82,7 +146,18 @@ export function ImageUpload({ imageUrl, onImageChange, minHeight = 200 }: ImageU
<div className="space-y-4">
{previewUrl ? (
<div className="relative">
<img src={previewUrl} alt="업로드된 이미지" className="w-full rounded-lg border" />
{isVideo ? (
<video
src={previewUrl}
className="w-full rounded-lg border"
controls
muted
loop
playsInline
/>
) : (
<img src={previewUrl} alt="업로드된 이미지" className="w-full rounded-lg border" />
)}
<Button
type="button"
variant="destructive"
Expand All @@ -107,14 +182,14 @@ export function ImageUpload({ imageUrl, onImageChange, minHeight = 200 }: ImageU
onClick={() => fileInputRef.current?.click()}
>
<Upload className="mb-2 h-10 w-10 text-muted-foreground" />
<p className="text-sm text-muted-foreground">이미지를 드래그하거나 클릭하여 업로드</p>
<p className="mt-1 text-xs text-muted-foreground">PNG, JPG, WEBP 지원</p>
<p className="text-sm text-muted-foreground">{description}</p>
<p className="mt-1 text-xs text-muted-foreground">{supportText}</p>
</div>
)}
<input
ref={fileInputRef}
type="file"
accept="image/*"
accept={accept}
className="hidden"
onChange={handleFileSelect}
/>
Expand Down
Loading
Loading