feat: 아이콘 업로드 UI 개선 - 번역과 동일한 패턴 적용

- 파일 추가 → 대기열 목록 → 시작 버튼 패턴
- 개별 파일 상태 표시 (pending/processing/success/error)
- 파일 제거 및 완료 항목 지우기 기능
- 에러 메시지 표시
This commit is contained in:
caadiq 2025-12-26 20:05:19 +09:00
parent 7c2b887884
commit b511f374b5

View file

@ -155,6 +155,8 @@ export default function Admin({ isMobile = false }) {
const [isIconDragging, setIsIconDragging] = useState(false); const [isIconDragging, setIsIconDragging] = useState(false);
const [deleteIconDialog, setDeleteIconDialog] = useState({ show: false, modId: null }); const [deleteIconDialog, setDeleteIconDialog] = useState({ show: false, modId: null });
const [isIconListExpanded, setIsIconListExpanded] = useState(false); const [isIconListExpanded, setIsIconListExpanded] = useState(false);
const [pendingIconFiles, setPendingIconFiles] = useState([]); //
const [clearingIconFiles, setClearingIconFiles] = useState(false);
// //
useEffect(() => { useEffect(() => {
@ -324,52 +326,91 @@ export default function Admin({ isMobile = false }) {
} }
}, []); }, []);
// ZIP ( ) // ZIP ( )
const handleIconZipUpload = async (files) => { const addIconFiles = (files) => {
const fileArray = Array.isArray(files) ? files : [files]; const fileArray = Array.isArray(files) ? files : [files];
const zipFiles = fileArray.filter(f => f && f.name.endsWith('.zip')); const zipFiles = fileArray.filter(f => f && f.name.endsWith('.zip'));
if (zipFiles.length === 0) { if (zipFiles.length === 0) {
setToast('ZIP 파일만 업로드 가능합니다', true); setToast('ZIP 파일만 추가 가능합니다', true);
return; return;
} }
setIconUploading(true); //
try { const newFiles = zipFiles
const token = localStorage.getItem('token'); .filter(f => !pendingIconFiles.some(pf => pf.name === f.name))
const formData = new FormData(); .map(f => ({ name: f.name, file: f, status: 'pending' }));
zipFiles.forEach(file => formData.append('files', file));
const res = await fetch('/api/admin/icons/upload', { if (newFiles.length > 0) {
method: 'POST', setPendingIconFiles(prev => [...prev, ...newFiles]);
headers: { 'Authorization': `Bearer ${token}` },
body: formData
});
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();
} else {
const errorMsg = data.errors?.map(e => `${e.file}: ${e.error}`).join('\n') || data.error || '업로드 실패';
setToast(errorMsg, true);
}
} catch (error) {
console.error('아이콘 업로드 실패:', error);
setToast('업로드 중 오류 발생', true);
} finally {
setIconUploading(false);
} }
}; };
//
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();
formData.append('files', fileObj.file);
const res = await fetch('/api/admin/icons/upload', {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}` },
body: formData
});
const data = await res.json();
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?.[0]?.error || data.error || '업로드 실패';
setPendingIconFiles(prev =>
prev.map(f => f.name === fileObj.name ? { ...f, status: 'error', error: errorMsg } : f)
);
}
} catch (error) {
setPendingIconFiles(prev =>
prev.map(f => f.name === fileObj.name ? { ...f, status: 'error', error: error.message } : f)
);
}
}
setIconUploading(false);
fetchIconMods();
};
// //
const deleteIconMod = async (modId) => { const deleteIconMod = async (modId) => {
try { try {
@ -2136,14 +2177,14 @@ export default function Admin({ isMobile = false }) {
isIconDragging isIconDragging
? 'border-emerald-500 bg-emerald-500/10' ? 'border-emerald-500 bg-emerald-500/10'
: 'border-zinc-700 hover:border-emerald-500 hover:bg-zinc-800/50' : 'border-zinc-700 hover:border-emerald-500 hover:bg-zinc-800/50'
} ${iconUploading ? 'pointer-events-none opacity-50' : ''}`} }`}
onDragOver={(e) => { e.preventDefault(); setIsIconDragging(true); }} onDragOver={(e) => { e.preventDefault(); setIsIconDragging(true); }}
onDragLeave={() => setIsIconDragging(false)} onDragLeave={() => setIsIconDragging(false)}
onDrop={(e) => { onDrop={(e) => {
e.preventDefault(); e.preventDefault();
setIsIconDragging(false); setIsIconDragging(false);
const files = Array.from(e.dataTransfer.files); const files = Array.from(e.dataTransfer.files);
if (files.length > 0) handleIconZipUpload(files); if (files.length > 0) addIconFiles(files);
}} }}
> >
<input <input
@ -2153,23 +2194,113 @@ export default function Admin({ isMobile = false }) {
className="hidden" className="hidden"
onChange={(e) => { onChange={(e) => {
const files = Array.from(e.target.files); const files = Array.from(e.target.files);
if (files.length > 0) handleIconZipUpload(files); if (files.length > 0) addIconFiles(files);
e.target.value = ''; e.target.value = '';
}} }}
/> />
{iconUploading ? ( <Upload className={`w-8 h-8 mb-2 ${isIconDragging ? 'text-emerald-400' : 'text-zinc-500'}`} />
<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"> <span className="text-zinc-400 text-sm text-center">
{iconUploading ? '업로드 중...' : 'IconExporter ZIP 파일을 드래그하거나 클릭하여 추가'} IconExporter ZIP 파일을 드래그하거나 클릭하여 추가
</span> </span>
<span className="text-zinc-600 text-xs mt-1"> <span className="text-zinc-600 text-xs mt-1">
metadata.json이 포함된 ZIP 파일 metadata.json이 포함된 ZIP 파일
</span> </span>
</label> </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 && ( {iconMods.length > 0 && (
<div className="mt-4 border border-zinc-800 rounded-xl overflow-hidden"> <div className="mt-4 border border-zinc-800 rounded-xl overflow-hidden">