2026-04-13 14:42:51 +09:00
import { useState , useEffect } from 'react'
2026-04-13 15:11:48 +09:00
import { useQuery , useMutation , useQueryClient } from '@tanstack/react-query'
2026-04-13 14:27:00 +09:00
import { api } from '../../api/client'
2026-04-13 15:20:46 +09:00
import ConfirmDialog from '../../components/ConfirmDialog'
2026-04-19 10:54:12 +09:00
import { useAuthStore } from '../../stores/auth'
2026-04-13 14:27:00 +09:00
2026-04-13 14:42:51 +09:00
/* ── 공용 모달 ── */
function Modal ( { open , onClose , title , children , maxWidth = 'max-w-md' } ) {
if ( ! open ) return null
return (
2026-04-19 10:54:12 +09:00
< div
className = "fixed inset-0 z-50 flex items-center justify-center p-4 backdrop-blur-sm"
style = { { background : 'var(--dialog-backdrop)' } }
onClick = { onClose }
>
< div
className = { ` w-full ${ maxWidth } rounded-2xl border shadow-2xl max-h-[90vh] flex flex-col ` }
style = { {
backgroundImage : 'linear-gradient(to bottom, var(--dialog-bg-from), var(--dialog-bg-to))' ,
borderColor : 'var(--dialog-border)' ,
} }
onClick = { ( e ) => e . stopPropagation ( ) }
>
< div
className = "px-6 py-4 border-b flex items-center justify-between shrink-0"
style = { { borderColor : 'var(--panel-border)' } }
>
< h3 className = "font-semibold" style = { { color : 'var(--text-strong)' } } > { title } < / h3 >
< button
onClick = { onClose }
className = "text-xl leading-none hover:bg-[var(--row-hover-bg)] w-7 h-7 rounded flex items-center justify-center"
style = { { color : 'var(--text-dim)' } }
>
×
< / button >
2026-04-13 14:42:51 +09:00
< / div >
{ children }
< / div >
< / div >
)
}
/* ── 업로드 모달 (다중 지원) ── */
function UploadModal ( { open , onClose , onUpload , uploading , existingNames } ) {
2026-04-19 10:54:12 +09:00
const [ items , setItems ] = useState ( [ ] )
2026-04-13 14:27:00 +09:00
const [ dragOver , setDragOver ] = useState ( false )
useEffect ( ( ) => {
2026-04-13 14:42:51 +09:00
if ( ! open ) setItems ( [ ] )
2026-04-13 14:27:00 +09:00
} , [ open ] )
2026-04-13 14:42:51 +09:00
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 ] )
2026-04-13 14:27:00 +09:00
}
2026-04-13 14:42:51 +09:00
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
2026-04-13 14:27:00 +09:00
const handleSubmit = async ( e ) => {
e . preventDefault ( )
2026-04-13 14:42:51 +09:00
if ( ! canSubmit ) return
await onUpload ( items )
2026-04-13 14:27:00 +09:00
}
2026-04-13 14:20:32 +09:00
return (
2026-04-13 14:42:51 +09:00
< 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" >
{ /* 파일 추가 영역 */ }
2026-04-13 14:27:00 +09:00
< label
onDragOver = { ( e ) => { e . preventDefault ( ) ; setDragOver ( true ) } }
onDragLeave = { ( ) => setDragOver ( false ) }
onDrop = { ( e ) => {
e . preventDefault ( )
setDragOver ( false )
2026-04-13 14:42:51 +09:00
addFiles ( e . dataTransfer . files )
2026-04-13 14:27:00 +09:00
} }
2026-04-19 10:54:12 +09:00
className = "relative rounded-xl border-2 border-dashed cursor-pointer min-h-[120px] flex flex-col items-center justify-center"
style = { dragOver ? {
borderColor : 'var(--selected-border)' ,
background : 'var(--selected-bg)' ,
} : {
borderColor : 'var(--dashed-border)' ,
background : 'var(--skeleton-bg)' ,
} }
2026-04-13 14:27:00 +09:00
>
2026-04-13 14:42:51 +09:00
< div className = "text-2xl mb-1 opacity-50" > 📥 < / div >
2026-04-19 10:54:12 +09:00
< p className = "text-sm" style = { { color : 'var(--text-muted)' } } > 클릭하거나 이미지를 끌어다 놓으세요 < / p >
< p className = "text-xs mt-0.5" style = { { color : 'var(--text-dim)' } } > 여러 개 선택 가능 < / p >
2026-04-13 14:27:00 +09:00
< input
type = "file"
accept = "image/*"
2026-04-13 14:42:51 +09:00
multiple
onChange = { ( e ) => { addFiles ( e . target . files ) ; e . target . value = '' } }
2026-04-13 14:27:00 +09:00
className = "hidden"
/ >
< / label >
2026-04-13 14:42:51 +09:00
{ /* 선택된 파일 리스트 */ }
{ 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
2026-04-13 14:27:00 +09:00
2026-04-13 14:42:51 +09:00
return (
2026-04-19 10:54:12 +09:00
< div
key = { item . id }
className = "flex items-start gap-3 rounded-lg border p-2"
style = { {
background : 'var(--surface-3)' ,
borderColor : errorMsg ? 'var(--icon-danger-border)' : 'var(--panel-border)' ,
} }
>
< div
className = "w-12 h-12 rounded flex items-center justify-center overflow-hidden shrink-0"
style = { { background : 'var(--surface-nested)' } }
>
2026-04-13 14:42:51 +09:00
{ item . preview ? (
< img src = { item . preview } alt = "" className = "w-full h-full object-contain" / >
) : (
2026-04-19 10:54:12 +09:00
< div className = "w-4 h-4 border-2 border-t-transparent rounded-full animate-spin" style = { { borderColor : 'var(--accent)' , borderTopColor : 'transparent' } } / >
2026-04-13 14:42:51 +09:00
) }
< / 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 ) }
2026-04-19 10:54:12 +09:00
className = "w-full rounded border px-2 py-1.5 text-sm outline-none"
style = { {
background : 'var(--input-bg)' ,
borderColor : errorMsg ? 'var(--icon-danger-border)' : 'var(--input-border)' ,
color : 'var(--text-strong)' ,
} }
2026-04-13 14:42:51 +09:00
/ >
2026-04-19 10:54:12 +09:00
{ errorMsg && (
< div className = "text-[11px] px-0.5" style = { { color : 'var(--danger-text)' } } > { errorMsg } < / div >
) }
2026-04-13 14:42:51 +09:00
< / div >
< button
type = "button"
onClick = { ( ) => removeItem ( item . id ) }
2026-04-19 10:54:12 +09:00
className = "w-7 h-7 rounded shrink-0 hover:bg-[var(--danger-bg-hover)] hover:text-[var(--danger-text)]"
style = { { color : 'var(--text-dim)' } }
2026-04-13 14:42:51 +09:00
>
×
< / button >
< / div >
)
} ) }
< / div >
) }
< / div >
{ /* 버튼 */ }
2026-04-19 10:54:12 +09:00
< div
className = "flex gap-2 px-6 py-4 border-t shrink-0"
style = { { borderColor : 'var(--panel-border)' } }
>
< button
type = "button"
onClick = { onClose }
className = "flex-1 rounded-lg border px-4 py-2 text-sm hover:bg-[var(--btn-bg-hover)]"
style = { {
background : 'var(--btn-bg)' ,
borderColor : 'var(--btn-border)' ,
color : 'var(--text-emphasis)' ,
} }
>
2026-04-13 14:42:51 +09:00
취소
< / button >
< button
type = "submit"
disabled = { ! canSubmit || uploading }
2026-04-19 10:54:12 +09:00
className = "flex-1 rounded-lg px-4 py-2 text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed hover:bg-[var(--btn-primary-bg-hover)]"
style = { {
background : 'var(--btn-primary-bg)' ,
color : 'var(--btn-primary-text)' ,
boxShadow : 'var(--btn-primary-shadow)' ,
} }
2026-04-13 14:42:51 +09:00
>
{ uploading ? '업로드 중...' : ` ${ items . length > 0 ? ` ${ items . length } 개 ` : '' } 업로드 ` }
< / button >
< / div >
< / form >
< / Modal >
2026-04-13 14:27:00 +09:00
)
}
2026-04-13 14:42:51 +09:00
/* ── 이미지 카드 ── */
function ImageCard ( { image , selected , selectMode , onToggle , onCopyUrl , copied } ) {
2026-04-13 14:27:00 +09:00
return (
2026-04-13 14:42:51 +09:00
< div
onClick = { ( ) => selectMode && onToggle ( image . id ) }
2026-04-19 10:54:12 +09:00
className = { ` group relative rounded-xl border overflow-hidden ${ selectMode ? 'cursor-pointer' : '' } ` }
style = { {
borderColor : selected ? 'var(--selected-border)' : 'var(--panel-border)' ,
background : selected ? 'var(--selected-bg)' : 'var(--panel-bg)' ,
boxShadow : selected ? '0 0 0 2px var(--ring-info)' : 'var(--panel-shadow)' ,
} }
2026-04-13 14:42:51 +09:00
>
{ selectMode && (
2026-04-19 10:54:12 +09:00
< div
className = "absolute top-2 left-2 z-10 w-5 h-5 rounded border-2 flex items-center justify-center"
style = { selected ? {
borderColor : 'var(--accent)' ,
background : 'var(--accent)' ,
} : {
borderColor : 'var(--panel-border)' ,
background : 'var(--surface-3)' ,
} }
>
{ selected && < span className = "text-xs" style = { { color : 'var(--btn-primary-text)' } } > ✓ < / span > }
2026-04-13 14:42:51 +09:00
< / div >
) }
2026-04-19 10:54:12 +09:00
< div
className = "aspect-square flex items-center justify-center p-4 relative"
style = { { backgroundImage : 'linear-gradient(to bottom right, var(--icon-box-from), var(--icon-box-to))' } }
>
2026-04-14 13:54:48 +09:00
< img
src = { image . url }
alt = { image . name }
className = "w-full h-full object-contain"
style = { { imageRendering : 'pixelated' } }
/ >
2026-04-13 14:27:00 +09:00
2026-04-13 14:42:51 +09:00
{ ! 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 ) } }
2026-04-19 10:54:12 +09:00
className = "w-7 h-7 rounded-md backdrop-blur-sm border text-xs flex items-center justify-center hover:bg-[var(--selected-bg)] hover:border-[var(--selected-border)]"
style = { {
background : 'var(--btn-bg)' ,
borderColor : 'var(--btn-border)' ,
color : 'var(--text-emphasis)' ,
} }
2026-04-13 14:42:51 +09:00
title = "URL 복사"
>
{ copied ? '✓' : '⧉' }
< / button >
< / div >
) }
2026-04-13 14:27:00 +09:00
< / div >
2026-04-19 10:54:12 +09:00
< div
className = "px-3 py-2 border-t"
style = { { borderColor : 'var(--panel-border)' } }
>
2026-04-13 14:27:00 +09:00
< div className = "text-sm font-medium truncate" > { image . name } < / div >
2026-04-13 14:20:32 +09:00
< / div >
2026-04-13 14:27:00 +09:00
< / div >
)
}
2026-04-13 15:11:48 +09:00
/* ── 페이지네이션 ── */
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 )
2026-04-19 10:54:12 +09:00
const baseBtn = "min-w-9 h-9 px-3 rounded-lg text-sm flex items-center justify-center border hover:bg-[var(--btn-bg-hover)]"
const btnStyle = {
background : 'var(--btn-bg)' ,
borderColor : 'var(--btn-border)' ,
color : 'var(--text-emphasis)' ,
}
2026-04-13 15:11:48 +09:00
return (
< div className = "flex items-center justify-center gap-1 pt-2" >
< button
onClick = { ( ) => onChange ( page - 1 ) }
disabled = { page === 1 }
2026-04-19 10:54:12 +09:00
className = { ` ${ baseBtn } disabled:opacity-30 disabled:cursor-not-allowed ` }
style = { btnStyle }
2026-04-13 15:11:48 +09:00
>
‹
< / button >
{ start > 1 && (
< >
2026-04-19 10:54:12 +09:00
< button onClick = { ( ) => onChange ( 1 ) } className = { baseBtn } style = { btnStyle } > 1 < / button >
{ start > 2 && < span className = "px-1" style = { { color : 'var(--text-dim)' } } > … < / span > }
2026-04-13 15:11:48 +09:00
< / >
) }
2026-04-19 10:54:12 +09:00
{ pages . map ( ( p ) => {
const active = p === page
return (
< button
key = { p }
onClick = { ( ) => onChange ( p ) }
className = { ` ${ baseBtn } ${ active ? 'font-medium' : '' } ` }
style = { active ? {
background : 'var(--selected-bg)' ,
borderColor : 'var(--selected-border)' ,
color : 'var(--accent-bright)' ,
} : btnStyle }
>
{ p }
< / button >
)
} ) }
2026-04-13 15:11:48 +09:00
{ end < totalPages && (
< >
2026-04-19 10:54:12 +09:00
{ end < totalPages - 1 && < span className = "px-1" style = { { color : 'var(--text-dim)' } } > … < / span > }
< button onClick = { ( ) => onChange ( totalPages ) } className = { baseBtn } style = { btnStyle } > { totalPages } < / button >
2026-04-13 15:11:48 +09:00
< / >
) }
< button
onClick = { ( ) => onChange ( page + 1 ) }
disabled = { page === totalPages }
2026-04-19 10:54:12 +09:00
className = { ` ${ baseBtn } disabled:opacity-30 disabled:cursor-not-allowed ` }
style = { btnStyle }
2026-04-13 15:11:48 +09:00
>
›
< / button >
< / div >
)
}
const PAGE _SIZE = 24
2026-04-13 14:42:51 +09:00
/* ── 메인 ── */
2026-04-13 14:27:00 +09:00
export default function AdminImages ( ) {
2026-04-13 15:11:48 +09:00
const queryClient = useQueryClient ( )
const [ page , setPage ] = useState ( 1 )
2026-04-13 14:27:00 +09:00
const [ search , setSearch ] = useState ( '' )
2026-04-13 15:11:48 +09:00
const [ debouncedSearch , setDebouncedSearch ] = useState ( '' )
const [ uploadOpen , setUploadOpen ] = useState ( false )
2026-04-13 14:42:51 +09:00
const [ selectMode , setSelectMode ] = useState ( false )
const [ selectedIds , setSelectedIds ] = useState ( new Set ( ) )
2026-04-19 10:54:12 +09:00
const [ confirmDelete , setConfirmDelete ] = useState ( null )
2026-04-13 14:42:51 +09:00
const [ copiedId , setCopiedId ] = useState ( null )
2026-04-13 14:27:00 +09:00
2026-04-13 15:11:48 +09:00
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' ] } )
2026-04-13 14:27:00 +09:00
}
2026-04-13 15:11:48 +09:00
const uploadMutation = useMutation ( {
mutationFn : async ( items ) => {
2026-04-13 14:27:00 +09:00
const formData = new FormData ( )
2026-04-13 14:42:51 +09:00
items . forEach ( ( it ) => {
formData . append ( 'files' , it . file )
formData . append ( 'names' , it . name . trim ( ) )
} )
2026-04-19 10:54:12 +09:00
const adminKey = useAuthStore . getState ( ) . apiKey
2026-04-13 14:27:00 +09:00
const res = await fetch ( '/api/admin/images' , {
method : 'POST' ,
headers : { 'x-admin-key' : adminKey } ,
body : formData ,
} )
2026-04-13 14:42:51 +09:00
const result = await res . json ( )
if ( ! res . ok ) throw new Error ( result . error || '업로드 실패' )
2026-04-13 15:11:48 +09:00
return result
} ,
onSuccess : ( result ) => {
2026-04-13 14:42:51 +09:00
if ( result . errors ? . length > 0 ) {
alert ( ` 일부 업로드 실패: \ n ${ result . errors . map ( ( e ) => ` - ${ e . name } : ${ e . error } ` ) . join ( '\n' ) } ` )
2026-04-13 14:27:00 +09:00
}
2026-04-13 14:42:51 +09:00
setUploadOpen ( false )
2026-04-13 15:11:48 +09:00
invalidateImages ( )
} ,
onError : ( err ) => alert ( err . message ) ,
} )
2026-04-13 14:27:00 +09:00
2026-04-13 14:42:51 +09:00
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 = ( ) => {
2026-04-13 15:11:48 +09:00
if ( selectedIds . size === images . length ) {
2026-04-13 14:42:51 +09:00
setSelectedIds ( new Set ( ) )
} else {
2026-04-13 15:11:48 +09:00
setSelectedIds ( new Set ( images . map ( ( img ) => img . id ) ) )
2026-04-13 14:42:51 +09:00
}
}
const requestDelete = ( ) => {
const items = images . filter ( ( img ) => selectedIds . has ( img . id ) )
setConfirmDelete ( {
ids : items . map ( ( i ) => i . id ) ,
names : items . map ( ( i ) => i . name ) ,
} )
}
2026-04-13 15:11:48 +09:00
const deleteMutation = useMutation ( {
mutationFn : ( ids ) => api ( '/api/admin/images/delete' , { method : 'POST' , body : { ids } } ) ,
onSuccess : ( ) => {
2026-04-13 14:42:51 +09:00
setConfirmDelete ( null )
setSelectedIds ( new Set ( ) )
setSelectMode ( false )
2026-04-13 15:11:48 +09:00
invalidateImages ( )
} ,
onError : ( err ) => alert ( err . message ) ,
} )
2026-04-13 14:27:00 +09:00
2026-04-13 14:42:51 +09:00
const copyUrl = ( image ) => {
navigator . clipboard . writeText ( image . url )
setCopiedId ( image . id )
setTimeout ( ( ) => setCopiedId ( null ) , 1500 )
}
2026-04-13 14:27:00 +09:00
return (
< div className = "space-y-6" >
< div className = "flex items-end justify-between gap-4 flex-wrap" >
< div >
2026-04-19 10:54:12 +09:00
< h2 className = "text-lg font-medium" > 이미지 관리 < / h2 >
< p className = "text-sm mt-0.5" style = { { color : 'var(--text-dim)' } } > 공용 이미지를 업로드하고 관리합니다 < / p >
2026-04-13 14:27:00 +09:00
< / div >
2026-04-13 14:42:51 +09:00
< div className = "flex items-center gap-2" >
{ selectMode ? (
< >
2026-04-19 10:54:12 +09:00
< span className = "text-sm" style = { { color : 'var(--text-muted)' } } > { selectedIds . size } 개 선택 < / span >
2026-04-13 14:42:51 +09:00
< button
onClick = { selectAll }
2026-04-19 10:54:12 +09:00
className = "rounded-lg border px-3 py-2 text-sm hover:bg-[var(--btn-bg-hover)]"
style = { {
background : 'var(--btn-bg)' ,
borderColor : 'var(--btn-border)' ,
color : 'var(--text-emphasis)' ,
} }
2026-04-13 14:42:51 +09:00
>
2026-04-13 15:11:48 +09:00
{ selectedIds . size === images . length && images . length > 0 ? '전체 해제' : '전체 선택' }
2026-04-13 14:42:51 +09:00
< / button >
< button
onClick = { requestDelete }
disabled = { selectedIds . size === 0 }
2026-04-19 10:54:12 +09:00
className = "rounded-lg px-3 py-2 text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed hover:bg-[var(--btn-danger-bg-hover)]"
style = { {
background : 'var(--btn-danger-bg)' ,
color : 'var(--btn-primary-text)' ,
boxShadow : 'var(--btn-danger-shadow)' ,
} }
2026-04-13 14:42:51 +09:00
>
삭제
< / button >
< button
onClick = { toggleSelectMode }
2026-04-19 10:54:12 +09:00
className = "rounded-lg border px-3 py-2 text-sm hover:bg-[var(--btn-bg-hover)]"
style = { {
background : 'var(--btn-bg)' ,
borderColor : 'var(--btn-border)' ,
color : 'var(--text-emphasis)' ,
} }
2026-04-13 14:42:51 +09:00
>
완료
< / button >
< / >
) : (
< >
{ images . length > 0 && (
< button
onClick = { toggleSelectMode }
2026-04-19 10:54:12 +09:00
className = "rounded-lg border px-3 py-2 text-sm hover:bg-[var(--danger-bg-hover)]"
style = { {
borderColor : 'var(--icon-danger-border)' ,
color : 'var(--danger-text)' ,
} }
2026-04-13 14:42:51 +09:00
>
2026-04-13 15:11:48 +09:00
삭제
2026-04-13 14:42:51 +09:00
< / button >
) }
< button
onClick = { ( ) => setUploadOpen ( true ) }
2026-04-19 10:54:12 +09:00
className = "flex items-center gap-1.5 rounded-lg px-4 py-2 text-sm font-medium hover:bg-[var(--btn-primary-bg-hover)]"
style = { {
background : 'var(--btn-primary-bg)' ,
color : 'var(--btn-primary-text)' ,
boxShadow : 'var(--btn-primary-shadow)' ,
} }
2026-04-13 14:42:51 +09:00
>
< span className = "text-base leading-none" > + < / span >
이미지 업로드
< / button >
< / >
) }
< / div >
2026-04-13 14:20:32 +09:00
< / div >
2026-04-13 14:27:00 +09:00
{ /* 검색 */ }
{ images . length > 0 && (
< div className = "relative" >
< input
type = "text"
value = { search }
onChange = { ( e ) => setSearch ( e . target . value ) }
placeholder = "이미지 이름으로 검색..."
2026-04-19 10:54:12 +09:00
className = "w-full rounded-lg border pl-10 pr-4 py-2.5 text-sm outline-none focus:border-[var(--input-border-focus)] hover:border-[var(--input-border-hover)]"
style = { {
background : 'var(--input-bg)' ,
borderColor : 'var(--input-border)' ,
color : 'var(--text-strong)' ,
} }
2026-04-13 14:27:00 +09:00
/ >
2026-04-19 10:54:12 +09:00
< span className = "absolute left-3 top-1/2 -translate-y-1/2" style = { { color : 'var(--input-icon)' } } > 🔍 < / span >
2026-04-13 14:27:00 +09:00
< / div >
) }
{ /* 이미지 그리드 */ }
2026-04-13 15:11:48 +09:00
{ isLoading ? (
2026-04-14 13:54:48 +09:00
< div className = "grid gap-3 grid-cols-3 sm:grid-cols-4 lg:grid-cols-6" >
2026-04-13 14:27:00 +09:00
{ Array . from ( { length : 8 } ) . map ( ( _ , i ) => (
2026-04-19 10:54:12 +09:00
< div
key = { i }
className = "aspect-square rounded-xl animate-pulse"
style = { { background : 'var(--skeleton-bg)' } }
/ >
2026-04-13 14:27:00 +09:00
) ) }
< / div >
2026-04-13 15:11:48 +09:00
) : images . length === 0 ? (
2026-04-19 10:54:12 +09:00
< div
className = "rounded-2xl border border-dashed p-16 text-center"
style = { {
borderColor : 'var(--dashed-border)' ,
background : 'var(--skeleton-bg)' ,
} }
>
2026-04-13 14:27:00 +09:00
< div className = "text-5xl mb-3 opacity-30" > 🖼 ️ < / div >
2026-04-19 10:54:12 +09:00
< p className = "mb-4" style = { { color : 'var(--text-muted)' } } >
2026-04-13 15:11:48 +09:00
{ debouncedSearch ? '검색 결과가 없습니다' : '업로드된 이미지가 없습니다' }
2026-04-13 14:27:00 +09:00
< / p >
2026-04-13 15:11:48 +09:00
{ ! debouncedSearch && (
2026-04-13 14:27:00 +09:00
< button
2026-04-13 14:42:51 +09:00
onClick = { ( ) => setUploadOpen ( true ) }
2026-04-19 10:54:12 +09:00
className = "text-sm hover:text-[var(--accent-hover-text)]"
style = { { color : 'var(--accent)' } }
2026-04-13 14:27:00 +09:00
>
첫 이미지 업로드하기 →
< / button >
) }
< / div >
) : (
2026-04-13 15:11:48 +09:00
< >
2026-04-14 13:54:48 +09:00
< div className = "grid gap-3 grid-cols-3 sm:grid-cols-4 lg:grid-cols-6" >
2026-04-13 15:11:48 +09:00
{ images . map ( ( image ) => (
< ImageCard
key = { image . id }
image = { image }
selected = { selectedIds . has ( image . id ) }
selectMode = { selectMode }
onToggle = { toggleSelect }
onCopyUrl = { copyUrl }
copied = { copiedId === image . id }
/ >
) ) }
< / div >
< Pagination page = { page } totalPages = { totalPages } onChange = { setPage } / >
< / >
2026-04-13 14:27:00 +09:00
) }
< UploadModal
2026-04-13 14:42:51 +09:00
open = { uploadOpen }
onClose = { ( ) => setUploadOpen ( false ) }
2026-04-13 15:11:48 +09:00
onUpload = { ( items ) => uploadMutation . mutate ( items ) }
uploading = { uploadMutation . isPending }
existingNames = { allNames }
2026-04-13 14:42:51 +09:00
/ >
< ConfirmDialog
open = { ! ! confirmDelete }
onClose = { ( ) => setConfirmDelete ( null ) }
2026-04-13 15:11:48 +09:00
onConfirm = { ( ) => deleteMutation . mutate ( confirmDelete . ids ) }
2026-04-13 14:42:51 +09:00
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
2026-04-13 15:11:48 +09:00
loading = { deleteMutation . isPending }
2026-04-13 14:27:00 +09:00
/ >
2026-04-13 14:20:32 +09:00
< / div >
)
}