maplestory/frontend/src/features/admin/AdminImages.jsx
caadiq 921ce9676b 이미지 다중 업로드/삭제 및 중복 방지 추가
- 한 번에 여러 이미지 업로드 (드래그/선택, 개별 이름 수정/제거)
- 다중 선택 삭제 모드 (선택 모드 토글, 전체 선택)
- 커스텀 확인 다이얼로그 (네이티브 confirm 대체)
- 이미지 이름 unique 제약 + 입력 시 실시간 중복/빈 값 검증
- 백엔드 다중 업로드 시 사전 중복 체크
- 카드에서 URL 표시 제거

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 14:42:51 +09:00

475 lines
18 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useEffect } from 'react'
import { api } from '../../api/client'
/* ── 공용 모달 ── */
function Modal({ open, onClose, title, children, maxWidth = 'max-w-md' }) {
if (!open) return null
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm" onClick={onClose}>
<div className={`w-full ${maxWidth} rounded-2xl bg-gray-900 border border-white/10 shadow-2xl max-h-[90vh] flex flex-col`} onClick={(e) => e.stopPropagation()}>
<div className="px-6 py-4 border-b border-white/5 flex items-center justify-between shrink-0">
<h3 className="font-semibold">{title}</h3>
<button onClick={onClose} className="text-gray-500 hover:text-white transition text-xl leading-none">×</button>
</div>
{children}
</div>
</div>
)
}
/* ── 업로드 모달 (다중 지원) ── */
function UploadModal({ open, onClose, onUpload, uploading, existingNames }) {
const [items, setItems] = useState([]) // { file, name, preview, id }
const [dragOver, setDragOver] = useState(false)
useEffect(() => {
if (!open) setItems([])
}, [open])
const addFiles = (fileList) => {
const newItems = []
Array.from(fileList).forEach((file) => {
if (!file.type.startsWith('image/')) return
const id = `${Date.now()}-${Math.random()}`
const reader = new FileReader()
reader.onload = (e) => {
setItems((prev) => prev.map((it) => it.id === id ? { ...it, preview: e.target.result } : it))
}
reader.readAsDataURL(file)
newItems.push({
id,
file,
name: file.name.replace(/\.[^.]+$/, ''),
preview: null,
})
})
setItems((prev) => [...prev, ...newItems])
}
const updateName = (id, name) => {
setItems((prev) => prev.map((it) => it.id === id ? { ...it, name } : it))
}
const removeItem = (id) => {
setItems((prev) => prev.filter((it) => it.id !== id))
}
const trimmedNames = items.map((it) => it.name.trim())
const hasEmpty = trimmedNames.some((n) => !n)
const hasDupExisting = trimmedNames.some((n) => existingNames.has(n))
const hasDupInList = trimmedNames.some((n, i) => trimmedNames.indexOf(n) !== i)
const canSubmit = items.length > 0 && !hasEmpty && !hasDupExisting && !hasDupInList
const handleSubmit = async (e) => {
e.preventDefault()
if (!canSubmit) return
await onUpload(items)
}
return (
<Modal open={open} onClose={onClose} title={`이미지 업로드${items.length > 0 ? ` (${items.length})` : ''}`} maxWidth="max-w-2xl">
<form onSubmit={handleSubmit} className="flex flex-col flex-1 min-h-0">
<div className="p-6 space-y-4 overflow-y-auto flex-1">
{/* 파일 추가 영역 */}
<label
onDragOver={(e) => { e.preventDefault(); setDragOver(true) }}
onDragLeave={() => setDragOver(false)}
onDrop={(e) => {
e.preventDefault()
setDragOver(false)
addFiles(e.dataTransfer.files)
}}
className={`relative rounded-xl border-2 border-dashed transition cursor-pointer min-h-[120px] flex flex-col items-center justify-center ${
dragOver ? 'border-emerald-500 bg-emerald-500/10' : 'border-white/10 hover:border-white/20 bg-white/[0.02]'
}`}
>
<div className="text-2xl mb-1 opacity-50">📥</div>
<p className="text-sm text-gray-400">클릭하거나 이미지를 끌어다 놓으세요</p>
<p className="text-xs text-gray-600 mt-0.5">여러 선택 가능</p>
<input
type="file"
accept="image/*"
multiple
onChange={(e) => { addFiles(e.target.files); e.target.value = '' }}
className="hidden"
/>
</label>
{/* 선택된 파일 리스트 */}
{items.length > 0 && (
<div className="space-y-2">
{items.map((item, idx) => {
const trimmed = item.name.trim()
const dupExisting = trimmed && existingNames.has(trimmed)
const dupInList = trimmed && items.some((it, j) => j !== idx && it.name.trim() === trimmed)
const empty = !trimmed
const errorMsg = empty ? '이름을 입력해주세요'
: dupExisting ? '이미 존재하는 이름입니다'
: dupInList ? '같은 이름이 중복됩니다'
: null
return (
<div key={item.id} className={`flex items-start gap-3 rounded-lg border bg-gray-950/50 p-2 ${
errorMsg ? 'border-red-500/40' : 'border-white/5'
}`}>
<div className="w-12 h-12 rounded bg-gray-900 flex items-center justify-center overflow-hidden shrink-0">
{item.preview ? (
<img src={item.preview} alt="" className="w-full h-full object-contain" />
) : (
<div className="w-4 h-4 border-2 border-emerald-500 border-t-transparent rounded-full animate-spin" />
)}
</div>
<div className="flex-1 min-w-0 space-y-0.5">
<input
type="text"
value={item.name}
onChange={(e) => updateName(item.id, e.target.value)}
className={`w-full rounded border bg-gray-900 px-2 py-1.5 text-sm outline-none transition ${
errorMsg ? 'border-red-500/40 focus:border-red-500/60' : 'border-white/10 focus:border-emerald-500/50'
}`}
/>
{errorMsg && <div className="text-[11px] text-red-400 px-0.5">{errorMsg}</div>}
</div>
<button
type="button"
onClick={() => removeItem(item.id)}
className="w-7 h-7 rounded text-gray-500 hover:text-red-400 hover:bg-red-500/10 transition shrink-0"
>
×
</button>
</div>
)
})}
</div>
)}
</div>
{/* 버튼 */}
<div className="flex gap-2 px-6 py-4 border-t border-white/5 shrink-0">
<button type="button" onClick={onClose} className="flex-1 rounded-lg border border-white/10 px-4 py-2 text-sm hover:bg-white/5 transition">
취소
</button>
<button
type="submit"
disabled={!canSubmit || uploading}
className="flex-1 rounded-lg bg-emerald-600 px-4 py-2 text-sm font-medium hover:bg-emerald-500 disabled:opacity-50 disabled:cursor-not-allowed transition"
>
{uploading ? '업로드 중...' : `${items.length > 0 ? `${items.length}` : ''}업로드`}
</button>
</div>
</form>
</Modal>
)
}
/* ── 삭제 확인 다이얼로그 ── */
function ConfirmDialog({ open, onClose, onConfirm, title, description, confirmText = '삭제', destructive = false, loading = false }) {
return (
<Modal open={open} onClose={onClose} title={title}>
<div className="p-6">
<p className="text-sm text-gray-300 leading-relaxed whitespace-pre-line">{description}</p>
</div>
<div className="flex gap-2 px-6 py-4 border-t border-white/5">
<button onClick={onClose} className="flex-1 rounded-lg border border-white/10 px-4 py-2 text-sm hover:bg-white/5 transition">
취소
</button>
<button
onClick={onConfirm}
disabled={loading}
className={`flex-1 rounded-lg px-4 py-2 text-sm font-medium transition disabled:opacity-50 ${
destructive
? 'bg-red-600 hover:bg-red-500 shadow-lg shadow-red-500/20'
: 'bg-emerald-600 hover:bg-emerald-500'
}`}
>
{loading ? '처리 중...' : confirmText}
</button>
</div>
</Modal>
)
}
/* ── 이미지 카드 ── */
function ImageCard({ image, selected, selectMode, onToggle, onCopyUrl, copied }) {
return (
<div
onClick={() => selectMode && onToggle(image.id)}
className={`group relative rounded-xl border overflow-hidden transition ${
selected
? 'border-emerald-500/60 bg-emerald-500/5 ring-2 ring-emerald-500/30'
: 'border-white/5 bg-gray-900/40 hover:border-white/15'
} ${selectMode ? 'cursor-pointer' : ''}`}
>
{/* 체크박스 (선택모드) */}
{selectMode && (
<div className={`absolute top-2 left-2 z-10 w-5 h-5 rounded border-2 flex items-center justify-center transition ${
selected ? 'border-emerald-500 bg-emerald-500' : 'border-white/30 bg-gray-950/80'
}`}>
{selected && <span className="text-xs text-white"></span>}
</div>
)}
<div className="aspect-square bg-gradient-to-br from-gray-900 to-gray-950 flex items-center justify-center p-4 relative">
<img src={image.url} alt={image.name} className="max-w-full max-h-full object-contain" />
{!selectMode && (
<div className="absolute top-2 right-2 flex gap-1 opacity-0 group-hover:opacity-100 transition">
<button
onClick={(e) => { e.stopPropagation(); onCopyUrl(image) }}
className="w-7 h-7 rounded-md bg-gray-950/80 backdrop-blur-sm border border-white/10 hover:bg-emerald-500/20 hover:border-emerald-500/40 text-xs flex items-center justify-center transition"
title="URL 복사"
>
{copied ? '✓' : '⧉'}
</button>
</div>
)}
</div>
<div className="px-3 py-2 border-t border-white/5">
<div className="text-sm font-medium truncate">{image.name}</div>
</div>
</div>
)
}
/* ── 메인 ── */
export default function AdminImages() {
const [images, setImages] = useState([])
const [loading, setLoading] = useState(true)
const [uploadOpen, setUploadOpen] = useState(false)
const [uploading, setUploading] = useState(false)
const [search, setSearch] = useState('')
const [selectMode, setSelectMode] = useState(false)
const [selectedIds, setSelectedIds] = useState(new Set())
const [confirmDelete, setConfirmDelete] = useState(null) // {ids, names}
const [deleting, setDeleting] = useState(false)
const [copiedId, setCopiedId] = useState(null)
const fetchImages = async () => {
setLoading(true)
try {
const data = await api('/api/admin/images')
setImages(data)
} catch {
setImages([])
} finally {
setLoading(false)
}
}
useEffect(() => { fetchImages() }, [])
const handleUpload = async (items) => {
setUploading(true)
try {
const formData = new FormData()
items.forEach((it) => {
formData.append('files', it.file)
formData.append('names', it.name.trim())
})
const adminKey = localStorage.getItem('maple-admin-key')
const res = await fetch('/api/admin/images', {
method: 'POST',
headers: { 'x-admin-key': adminKey },
body: formData,
})
const result = await res.json()
if (!res.ok) throw new Error(result.error || '업로드 실패')
if (result.errors?.length > 0) {
alert(`일부 업로드 실패:\n${result.errors.map((e) => `- ${e.name}: ${e.error}`).join('\n')}`)
}
setUploadOpen(false)
await fetchImages()
} catch (err) {
alert(err.message)
} finally {
setUploading(false)
}
}
const toggleSelect = (id) => {
setSelectedIds((prev) => {
const next = new Set(prev)
next.has(id) ? next.delete(id) : next.add(id)
return next
})
}
const toggleSelectMode = () => {
setSelectMode((prev) => !prev)
setSelectedIds(new Set())
}
const filtered = images.filter((img) =>
img.name.toLowerCase().includes(search.toLowerCase())
)
const selectAll = () => {
if (selectedIds.size === filtered.length) {
setSelectedIds(new Set())
} else {
setSelectedIds(new Set(filtered.map((img) => img.id)))
}
}
const requestDelete = () => {
const items = images.filter((img) => selectedIds.has(img.id))
setConfirmDelete({
ids: items.map((i) => i.id),
names: items.map((i) => i.name),
})
}
const handleDeleteConfirm = async () => {
setDeleting(true)
try {
await api('/api/admin/images/delete', {
method: 'POST',
body: { ids: confirmDelete.ids },
})
setConfirmDelete(null)
setSelectedIds(new Set())
setSelectMode(false)
await fetchImages()
} catch (err) {
alert(err.message)
} finally {
setDeleting(false)
}
}
const copyUrl = (image) => {
navigator.clipboard.writeText(image.url)
setCopiedId(image.id)
setTimeout(() => setCopiedId(null), 1500)
}
return (
<div className="space-y-6">
<div className="flex items-end justify-between gap-4 flex-wrap">
<div>
<h2 className="text-lg font-semibold">이미지 관리</h2>
<p className="text-sm text-gray-500 mt-0.5">공용 이미지를 업로드하고 관리합니다</p>
</div>
<div className="flex items-center gap-2">
{selectMode ? (
<>
<span className="text-sm text-gray-400">{selectedIds.size} 선택</span>
<button
onClick={selectAll}
className="rounded-lg border border-white/10 px-3 py-2 text-sm hover:bg-white/5 transition"
>
{selectedIds.size === filtered.length && filtered.length > 0 ? '전체 해제' : '전체 선택'}
</button>
<button
onClick={requestDelete}
disabled={selectedIds.size === 0}
className="rounded-lg bg-red-600 hover:bg-red-500 px-3 py-2 text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed transition shadow-lg shadow-red-500/20"
>
삭제
</button>
<button
onClick={toggleSelectMode}
className="rounded-lg border border-white/10 px-3 py-2 text-sm hover:bg-white/5 transition"
>
완료
</button>
</>
) : (
<>
{images.length > 0 && (
<button
onClick={toggleSelectMode}
className="rounded-lg border border-white/10 px-3 py-2 text-sm hover:bg-white/5 transition"
>
선택
</button>
)}
<button
onClick={() => setUploadOpen(true)}
className="flex items-center gap-1.5 rounded-lg bg-emerald-600 hover:bg-emerald-500 px-4 py-2 text-sm font-medium transition shadow-lg shadow-emerald-500/20"
>
<span className="text-base leading-none">+</span>
이미지 업로드
</button>
</>
)}
</div>
</div>
{/* 검색 */}
{images.length > 0 && (
<div className="relative">
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="이미지 이름으로 검색..."
className="w-full rounded-lg border border-white/10 bg-gray-900/50 pl-10 pr-4 py-2.5 text-sm outline-none focus:border-emerald-500/50 transition"
/>
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500">🔍</span>
</div>
)}
{/* 이미지 그리드 */}
{loading ? (
<div className="grid gap-3 grid-cols-2 sm:grid-cols-3 lg:grid-cols-4">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="aspect-square rounded-xl bg-white/[0.02] animate-pulse" />
))}
</div>
) : filtered.length === 0 ? (
<div className="rounded-2xl border border-dashed border-white/10 bg-white/[0.02] p-16 text-center">
<div className="text-5xl mb-3 opacity-30">🖼</div>
<p className="text-gray-400 mb-4">
{images.length === 0 ? '업로드된 이미지가 없습니다' : '검색 결과가 없습니다'}
</p>
{images.length === 0 && (
<button
onClick={() => setUploadOpen(true)}
className="text-sm text-emerald-400 hover:text-emerald-300 transition"
>
이미지 업로드하기
</button>
)}
</div>
) : (
<div className="grid gap-3 grid-cols-2 sm:grid-cols-3 lg:grid-cols-4">
{filtered.map((image) => (
<ImageCard
key={image.id}
image={image}
selected={selectedIds.has(image.id)}
selectMode={selectMode}
onToggle={toggleSelect}
onCopyUrl={copyUrl}
copied={copiedId === image.id}
/>
))}
</div>
)}
<UploadModal
open={uploadOpen}
onClose={() => setUploadOpen(false)}
onUpload={handleUpload}
uploading={uploading}
existingNames={new Set(images.map((img) => img.name))}
/>
<ConfirmDialog
open={!!confirmDelete}
onClose={() => setConfirmDelete(null)}
onConfirm={handleDeleteConfirm}
title="이미지 삭제"
description={confirmDelete ? `${confirmDelete.ids.length}개의 이미지를 삭제하시겠습니까?\n\n${confirmDelete.names.slice(0, 5).map((n) => `· ${n}`).join('\n')}${confirmDelete.names.length > 5 ? `\n· 외 ${confirmDelete.names.length - 5}` : ''}\n\n이 작업은 되돌릴 수 없습니다.` : ''}
confirmText="삭제"
destructive
loading={deleting}
/>
</div>
)
}