feat: 아이콘 업로드 UI 개선 - 번역과 동일한 패턴 적용
- 파일 추가 → 대기열 목록 → 시작 버튼 패턴 - 개별 파일 상태 표시 (pending/processing/success/error) - 파일 제거 및 완료 항목 지우기 기능 - 에러 메시지 표시
This commit is contained in:
parent
7c2b887884
commit
b511f374b5
1 changed files with 176 additions and 45 deletions
|
|
@ -155,6 +155,8 @@ export default function Admin({ isMobile = false }) {
|
|||
const [isIconDragging, setIsIconDragging] = useState(false);
|
||||
const [deleteIconDialog, setDeleteIconDialog] = useState({ show: false, modId: null });
|
||||
const [isIconListExpanded, setIsIconListExpanded] = useState(false);
|
||||
const [pendingIconFiles, setPendingIconFiles] = useState([]); // 아이콘 파일 대기열
|
||||
const [clearingIconFiles, setClearingIconFiles] = useState(false);
|
||||
|
||||
// 권한 확인
|
||||
useEffect(() => {
|
||||
|
|
@ -324,21 +326,57 @@ export default function Admin({ isMobile = false }) {
|
|||
}
|
||||
}, []);
|
||||
|
||||
// 아이콘 ZIP 업로드 (여러 파일 지원)
|
||||
const handleIconZipUpload = async (files) => {
|
||||
// 아이콘 ZIP 파일 추가 (대기열에만 추가)
|
||||
const addIconFiles = (files) => {
|
||||
const fileArray = Array.isArray(files) ? files : [files];
|
||||
const zipFiles = fileArray.filter(f => f && f.name.endsWith('.zip'));
|
||||
|
||||
if (zipFiles.length === 0) {
|
||||
setToast('ZIP 파일만 업로드 가능합니다', true);
|
||||
setToast('ZIP 파일만 추가 가능합니다', true);
|
||||
return;
|
||||
}
|
||||
|
||||
// 중복 체크 후 대기열에 추가
|
||||
const newFiles = zipFiles
|
||||
.filter(f => !pendingIconFiles.some(pf => pf.name === f.name))
|
||||
.map(f => ({ name: f.name, file: f, status: 'pending' }));
|
||||
|
||||
if (newFiles.length > 0) {
|
||||
setPendingIconFiles(prev => [...prev, ...newFiles]);
|
||||
}
|
||||
};
|
||||
|
||||
// 아이콘 파일 제거
|
||||
const removeIconFile = (fileName) => {
|
||||
setPendingIconFiles(prev => prev.filter(f => f.name !== fileName));
|
||||
};
|
||||
|
||||
// 완료된 아이콘 파일 지우기
|
||||
const clearCompletedIconFiles = () => {
|
||||
setClearingIconFiles(true);
|
||||
setTimeout(() => {
|
||||
setPendingIconFiles(prev => prev.filter(f => f.status === 'pending'));
|
||||
setClearingIconFiles(false);
|
||||
}, 300);
|
||||
};
|
||||
|
||||
// 아이콘 업로드 시작
|
||||
const startIconUpload = async () => {
|
||||
const filesToUpload = pendingIconFiles.filter(f => f.status === 'pending');
|
||||
if (filesToUpload.length === 0) return;
|
||||
|
||||
setIconUploading(true);
|
||||
|
||||
for (const fileObj of filesToUpload) {
|
||||
// 상태를 'processing'으로 변경
|
||||
setPendingIconFiles(prev =>
|
||||
prev.map(f => f.name === fileObj.name ? { ...f, status: 'processing' } : f)
|
||||
);
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const formData = new FormData();
|
||||
zipFiles.forEach(file => formData.append('files', file));
|
||||
formData.append('files', fileObj.file);
|
||||
|
||||
const res = await fetch('/api/admin/icons/upload', {
|
||||
method: 'POST',
|
||||
|
|
@ -347,27 +385,30 @@ export default function Admin({ isMobile = false }) {
|
|||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
const totalUploaded = data.results?.reduce((sum, r) => sum + r.uploaded, 0) || 0;
|
||||
const totalUpdated = data.results?.reduce((sum, r) => sum + r.updated, 0) || 0;
|
||||
const modIds = data.results?.map(r => r.modId).join(', ') || '';
|
||||
setToast(`아이콘 업로드 완료: ${modIds} (${totalUploaded}개 업로드, ${totalUpdated}개 DB 업데이트)`);
|
||||
|
||||
// 에러가 있는 경우 추가 알림
|
||||
if (data.errors?.length > 0) {
|
||||
console.warn('아이콘 업로드 일부 실패:', data.errors);
|
||||
}
|
||||
fetchIconMods();
|
||||
if (data.success && data.results?.length > 0) {
|
||||
const result = data.results[0];
|
||||
setPendingIconFiles(prev =>
|
||||
prev.map(f => f.name === fileObj.name ? {
|
||||
...f,
|
||||
status: 'success',
|
||||
result: `${result.uploaded}개 업로드, ${result.updated}개 DB 업데이트`
|
||||
} : f)
|
||||
);
|
||||
} else {
|
||||
const errorMsg = data.errors?.map(e => `${e.file}: ${e.error}`).join('\n') || data.error || '업로드 실패';
|
||||
setToast(errorMsg, true);
|
||||
const errorMsg = data.errors?.[0]?.error || data.error || '업로드 실패';
|
||||
setPendingIconFiles(prev =>
|
||||
prev.map(f => f.name === fileObj.name ? { ...f, status: 'error', error: errorMsg } : f)
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('아이콘 업로드 실패:', error);
|
||||
setToast('업로드 중 오류 발생', true);
|
||||
} finally {
|
||||
setIconUploading(false);
|
||||
setPendingIconFiles(prev =>
|
||||
prev.map(f => f.name === fileObj.name ? { ...f, status: 'error', error: error.message } : f)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
setIconUploading(false);
|
||||
fetchIconMods();
|
||||
};
|
||||
|
||||
// 아이콘 모드 삭제
|
||||
|
|
@ -2136,14 +2177,14 @@ export default function Admin({ isMobile = false }) {
|
|||
isIconDragging
|
||||
? 'border-emerald-500 bg-emerald-500/10'
|
||||
: 'border-zinc-700 hover:border-emerald-500 hover:bg-zinc-800/50'
|
||||
} ${iconUploading ? 'pointer-events-none opacity-50' : ''}`}
|
||||
}`}
|
||||
onDragOver={(e) => { e.preventDefault(); setIsIconDragging(true); }}
|
||||
onDragLeave={() => setIsIconDragging(false)}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
setIsIconDragging(false);
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
if (files.length > 0) handleIconZipUpload(files);
|
||||
if (files.length > 0) addIconFiles(files);
|
||||
}}
|
||||
>
|
||||
<input
|
||||
|
|
@ -2153,23 +2194,113 @@ export default function Admin({ isMobile = false }) {
|
|||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const files = Array.from(e.target.files);
|
||||
if (files.length > 0) handleIconZipUpload(files);
|
||||
if (files.length > 0) addIconFiles(files);
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
{iconUploading ? (
|
||||
<Loader2 className="w-8 h-8 mb-2 text-emerald-400 animate-spin" />
|
||||
) : (
|
||||
<Upload className={`w-8 h-8 mb-2 ${isIconDragging ? 'text-emerald-400' : 'text-zinc-500'}`} />
|
||||
)}
|
||||
<span className="text-zinc-400 text-sm text-center">
|
||||
{iconUploading ? '업로드 중...' : 'IconExporter ZIP 파일을 드래그하거나 클릭하여 추가'}
|
||||
IconExporter ZIP 파일을 드래그하거나 클릭하여 추가
|
||||
</span>
|
||||
<span className="text-zinc-600 text-xs mt-1">
|
||||
metadata.json이 포함된 ZIP 파일
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{/* 아이콘 파일 대기열 */}
|
||||
{pendingIconFiles.length > 0 && (
|
||||
<div className="mt-4 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-zinc-400 text-sm">
|
||||
파일 목록 ({pendingIconFiles.length}개)
|
||||
</span>
|
||||
<button
|
||||
onClick={clearCompletedIconFiles}
|
||||
className="text-zinc-500 hover:text-red-400 text-xs"
|
||||
>
|
||||
완료된 항목 지우기
|
||||
</button>
|
||||
</div>
|
||||
<div className="max-h-48 overflow-y-auto custom-scrollbar space-y-1">
|
||||
{pendingIconFiles.map((fileObj) => {
|
||||
const statusStyles = {
|
||||
pending: 'bg-zinc-800/50',
|
||||
processing: 'bg-emerald-600/30 border border-emerald-500',
|
||||
success: 'bg-green-600/20 border border-green-500/50',
|
||||
error: 'bg-red-600/20 border border-red-500/50'
|
||||
};
|
||||
const textStyles = {
|
||||
pending: 'text-white',
|
||||
processing: 'text-emerald-300',
|
||||
success: 'text-green-400',
|
||||
error: 'text-red-400'
|
||||
};
|
||||
const statusIcons = {
|
||||
pending: null,
|
||||
processing: <Loader2 size={14} className="animate-spin text-emerald-400" />,
|
||||
success: <Check size={14} className="text-green-400" />,
|
||||
error: <X size={14} className="text-red-400" />
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
key={fileObj.name}
|
||||
className={`relative p-2 rounded-lg overflow-hidden ${statusStyles[fileObj.status] || 'bg-zinc-800/50'}`}
|
||||
>
|
||||
{fileObj.status === 'processing' && (
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-emerald-600/20 via-emerald-500/30 to-emerald-600/20 animate-pulse" />
|
||||
)}
|
||||
<div className="flex items-center justify-between relative z-10">
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
{statusIcons[fileObj.status]}
|
||||
<span className={`text-sm truncate ${textStyles[fileObj.status] || 'text-zinc-400'}`}>
|
||||
{fileObj.name}
|
||||
</span>
|
||||
</div>
|
||||
{fileObj.status === 'pending' && (
|
||||
<button
|
||||
onClick={() => removeIconFile(fileObj.name)}
|
||||
className="text-zinc-500 hover:text-red-400 p-1"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
)}
|
||||
{fileObj.status === 'error' && (
|
||||
<span className="text-red-400 text-xs ml-2 truncate max-w-[150px]" title={fileObj.error}>
|
||||
{fileObj.error}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 업로드 시작 버튼 */}
|
||||
{pendingIconFiles.some(f => f.status === 'pending') && (
|
||||
<motion.button
|
||||
onClick={startIconUpload}
|
||||
disabled={iconUploading}
|
||||
className="w-full py-3 mt-2 bg-gradient-to-r from-emerald-600 to-emerald-500 hover:from-emerald-500 hover:to-emerald-400 disabled:from-zinc-700 disabled:to-zinc-600 text-white rounded-xl font-medium flex items-center justify-center gap-2 transition-all"
|
||||
whileHover={{ scale: 1.01 }}
|
||||
whileTap={{ scale: 0.99 }}
|
||||
>
|
||||
{iconUploading ? (
|
||||
<>
|
||||
<Loader2 size={18} className="animate-spin" />
|
||||
업로드 중...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Upload size={18} />
|
||||
아이콘 업로드 시작 ({pendingIconFiles.filter(f => f.status === 'pending').length}개)
|
||||
</>
|
||||
)}
|
||||
</motion.button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 업로드된 모드 목록 */}
|
||||
{iconMods.length > 0 && (
|
||||
<div className="mt-4 border border-zinc-800 rounded-xl overflow-hidden">
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue