- 라우팅 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>
169 lines
5.4 KiB
TypeScript
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>
|
|
);
|
|
}
|