import { useState, useEffect } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { api } from '../../api/client'
import ConfirmDialog from '../../components/ConfirmDialog'
/* ── 공용 모달 ── */
function Modal({ open, onClose, title, children, maxWidth = 'max-w-md' }) {
if (!open) return null
return (
e.stopPropagation()}>
{title}
{children}
)
}
/* ── 업로드 모달 (다중 지원) ── */
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 (
0 ? ` (${items.length})` : ''}`} maxWidth="max-w-2xl">
)
}
/* ── 이미지 카드 ── */
function ImageCard({ image, selected, selectMode, onToggle, onCopyUrl, copied }) {
return (
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 && (
{selected && ✓}
)}

{!selectMode && (
)}
)
}
/* ── 페이지네이션 ── */
function Pagination({ page, totalPages, onChange }) {
if (totalPages <= 1) return null
const pages = []
const maxButtons = 7
let start = Math.max(1, page - Math.floor(maxButtons / 2))
let end = Math.min(totalPages, start + maxButtons - 1)
if (end - start + 1 < maxButtons) start = Math.max(1, end - maxButtons + 1)
for (let i = start; i <= end; i++) pages.push(i)
const btn = "min-w-9 h-9 px-3 rounded-lg text-sm transition flex items-center justify-center"
return (
{start > 1 && (
<>
{start > 2 && …}
>
)}
{pages.map((p) => (
))}
{end < totalPages && (
<>
{end < totalPages - 1 && …}
>
)}
)
}
const PAGE_SIZE = 24
/* ── 메인 ── */
export default function AdminImages() {
const queryClient = useQueryClient()
const [page, setPage] = useState(1)
const [search, setSearch] = useState('')
const [debouncedSearch, setDebouncedSearch] = useState('')
const [uploadOpen, setUploadOpen] = useState(false)
const [selectMode, setSelectMode] = useState(false)
const [selectedIds, setSelectedIds] = useState(new Set())
const [confirmDelete, setConfirmDelete] = useState(null) // {ids, names}
const [copiedId, setCopiedId] = useState(null)
// 검색어 디바운싱
useEffect(() => {
const t = setTimeout(() => {
setDebouncedSearch(search)
setPage(1)
}, 300)
return () => clearTimeout(t)
}, [search])
// 이미지 목록 (페이징 + 검색)
const { data: imagesData, isLoading } = useQuery({
queryKey: ['admin', 'images', { page, search: debouncedSearch }],
queryFn: async () => {
const params = new URLSearchParams({
page,
limit: PAGE_SIZE,
...(debouncedSearch && { search: debouncedSearch }),
})
return api(`/api/admin/images?${params}`)
},
placeholderData: (prev) => prev,
})
const images = imagesData?.items || []
const totalPages = imagesData?.total_pages || 1
// 전체 이름 (중복 체크용)
const { data: allNamesArray = [] } = useQuery({
queryKey: ['admin', 'images', 'names'],
queryFn: () => api('/api/admin/images/names'),
})
const allNames = new Set(allNamesArray)
const invalidateImages = () => {
queryClient.invalidateQueries({ queryKey: ['admin', 'images'] })
}
// 업로드
const uploadMutation = useMutation({
mutationFn: async (items) => {
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 || '업로드 실패')
return result
},
onSuccess: (result) => {
if (result.errors?.length > 0) {
alert(`일부 업로드 실패:\n${result.errors.map((e) => `- ${e.name}: ${e.error}`).join('\n')}`)
}
setUploadOpen(false)
invalidateImages()
},
onError: (err) => alert(err.message),
})
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 selectAll = () => {
if (selectedIds.size === images.length) {
setSelectedIds(new Set())
} else {
setSelectedIds(new Set(images.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 deleteMutation = useMutation({
mutationFn: (ids) => api('/api/admin/images/delete', { method: 'POST', body: { ids } }),
onSuccess: () => {
setConfirmDelete(null)
setSelectedIds(new Set())
setSelectMode(false)
invalidateImages()
},
onError: (err) => alert(err.message),
})
const copyUrl = (image) => {
navigator.clipboard.writeText(image.url)
setCopiedId(image.id)
setTimeout(() => setCopiedId(null), 1500)
}
return (
이미지 관리
공용 이미지를 업로드하고 관리합니다
{selectMode ? (
<>
{selectedIds.size}개 선택
>
) : (
<>
{images.length > 0 && (
)}
>
)}
{/* 검색 */}
{images.length > 0 && (
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"
/>
🔍
)}
{/* 이미지 그리드 */}
{isLoading ? (
{Array.from({ length: 8 }).map((_, i) => (
))}
) : images.length === 0 ? (
🖼️
{debouncedSearch ? '검색 결과가 없습니다' : '업로드된 이미지가 없습니다'}
{!debouncedSearch && (
)}
) : (
<>
{images.map((image) => (
))}
>
)}
setUploadOpen(false)}
onUpload={(items) => uploadMutation.mutate(items)}
uploading={uploadMutation.isPending}
existingNames={allNames}
/>
setConfirmDelete(null)}
onConfirm={() => deleteMutation.mutate(confirmDelete.ids)}
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={deleteMutation.isPending}
/>
)
}