import { useState, useEffect } from 'react' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { api } from '../../api/client' /* ── 공용 모달 ── */ 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">
{/* 파일 추가 영역 */} {/* 선택된 파일 리스트 */} {items.length > 0 && (
{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 (
{item.preview ? ( ) : (
)}
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 &&
{errorMsg}
}
) })}
)}
{/* 버튼 */}
) } /* ── 삭제 확인 다이얼로그 ── */ function ConfirmDialog({ open, onClose, onConfirm, title, description, confirmText = '삭제', destructive = false, loading = false }) { return (

{description}

) } /* ── 이미지 카드 ── */ 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 && }
)}
{image.name} {!selectMode && (
)}
{image.name}
) } /* ── 페이지네이션 ── */ 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} />
) }