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 [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));
|
|
||||||
|
if (newFiles.length > 0) {
|
||||||
const res = await fetch('/api/admin/icons/upload', {
|
setPendingIconFiles(prev => [...prev, ...newFiles]);
|
||||||
method: 'POST',
|
|
||||||
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">
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue