o2o-negosium-original/negodata/front/src/components/ImageDropzone.tsx
Mina Choi 87524bf6e8 [feat] negodata/front: 프론트 초기 구축 (Vite+React, orval API 클라이언트, 견적/상품/협력사 페이지)
- 라우팅 react-router v7, 서버상태 TanStack Query
- orval 자동 생성 API 클라이언트(src/api/generated)
- 견적/상품/협력사/견적설정 페이지, shadcn UI, 토큰(tokens.css)
- 실행: 루트 docker compose (front :3001), README 그에 맞게 정리
- AI Studio 스캐폴드 잔재 제거(metadata.json, 중복 DESIGN_TOKENS.md), 타이틀 NegoData, vite.config 죽은코드 제거

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 17:18:08 +09:00

169 lines
5.4 KiB
TypeScript

import React, { useState, useRef, DragEvent, ChangeEvent } from 'react';
import { Upload, Image as ImageIcon, X, AlertCircle } from 'lucide-react';
interface ImageDropzoneProps {
value: string;
onChange: (base64Url: string) => void;
onClear: () => void;
label?: string;
className?: string;
}
export default function ImageDropzone({
value,
onChange,
onClear,
label = '상품 대표 이미지 업로드',
className = ''
}: ImageDropzoneProps) {
const [isDragActive, setIsDragActive] = useState(false);
const [error, setError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const processFile = (file: File) => {
setError(null);
// Validate file type
if (!file.type.startsWith('image/')) {
setError('이미지 파일(*.png, *.jpg, *.jpeg, *.gif)만 업로드할 수 있습니다.');
return;
}
// Validate size (e.g., max 4MB for high-fidelity base64 storage)
const maxSize = 4 * 1024 * 1024;
if (file.size > maxSize) {
setError('이미지 최댓값은 4MB입니다. 더 작고 최적화된 이미지를 권장합니다.');
return;
}
const reader = new FileReader();
reader.onload = (e) => {
if (e.target?.result && typeof e.target.result === 'string') {
onChange(e.target.result);
} else {
setError('이미지 해독 과정에 이상이 발생했습니다.');
}
};
reader.onerror = () => {
setError('파일을 수집하지 못했습니다.');
};
reader.readAsDataURL(file);
};
const handleDragOver = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDragActive(true);
};
const handleDragLeave = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDragActive(false);
};
const handleDrop = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDragActive(false);
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
processFile(e.dataTransfer.files[0]);
}
};
const handleFileChange = (e: ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files[0]) {
processFile(e.target.files[0]);
}
};
const handleButtonClick = () => {
fileInputRef.current?.click();
};
return (
<div className={`space-y-1.5 font-mono text-xs ${className}`}>
<label className="font-semibold text-foreground flex items-center gap-1.5">
<span className="h-1.5 w-1.5 rounded-full bg-primary" />
{label}
</label>
{value ? (
// Preview Mode with image loaded
<div className="relative group border border-border rounded-lg overflow-hidden bg-background h-44 flex items-center justify-center">
<img
src={value}
alt="Uploaded preview"
referrerPolicy="no-referrer"
className="h-full w-full object-contain"
/>
{/* Hover overlay control */}
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex flex-col items-center justify-center gap-2">
<button
type="button"
onClick={handleButtonClick}
className="px-3 py-1.5 bg-background text-foreground hover:bg-muted font-bold rounded text-[11px] cursor-pointer"
>
다른 이미지로 교체
</button>
<button
type="button"
onClick={onClear}
className="px-3 py-1.5 bg-destructive text-destructive-foreground hover:opacity-90 font-bold rounded text-[11px] cursor-pointer flex items-center gap-1"
>
<X size={12} />
이미지 제거
</button>
</div>
</div>
) : (
// Active Dropzone Area
<div
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={handleButtonClick}
className={`border-2 border-dashed rounded-lg p-6 flex flex-col items-center justify-center text-center cursor-pointer min-h-44 transition-all ${
isDragActive
? 'border-primary bg-primary/5'
: 'border-border hover:border-foreground/30 bg-muted/20'
}`}
>
<input
type="file"
ref={fileInputRef}
onChange={handleFileChange}
accept="image/*"
className="hidden"
/>
<div className="p-2.5 bg-background border border-border rounded-full shadow-xs mb-3 text-muted-foreground">
{isDragActive ? (
<Upload size={22} className="animate-bounce text-primary" />
) : (
<ImageIcon size={22} />
)}
</div>
<div className="space-y-1">
<p className="font-semibold text-foreground text-xs">
{isDragActive ? '여기에 드롭하여 업로드' : '이지지 드래그 앤 드롭 또는 파일 탐색'}
</p>
<p className="text-[10px] text-muted-foreground max-w-[280px] leading-relaxed">
PNG, JPG, JPEG, GIF 파일 지원 (최대 4MB)
</p>
</div>
</div>
)}
{error && (
<div className="flex items-center gap-1.5 text-[10px] text-destructive mt-1">
<AlertCircle size={12} />
<span>{error}</span>
</div>
)}
</div>
);
}