maplestory/frontend/src/features/admin/pc/components/ImagePicker.jsx
caadiq be548879dc 이미지 관리 다이얼로그 UX 정리
- Modal 공용 컴포넌트에 열기/닫기 애니메이션 추가, 뒷배경 클릭 닫기 제거
- ImagePicker에 동일한 애니메이션 + 뒷배경 클릭 차단 적용
- ImagePicker 이미지 크기를 관리 페이지와 동일하게 (p-4 + w-full h-full object-contain)
- ImagePicker 하단 빈 pagination 영역이 차지하던 여백 제거 (조건부 렌더)
- 그리드 높이를 632px로 고정 + OverlayScrollbars (os-theme-maple) 스크롤
- overscroll-behavior: contain 으로 뒷 페이지 스크롤 전파 방지

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 17:03:10 +09:00

218 lines
8.5 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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 { useQuery } from '@tanstack/react-query'
import { motion, AnimatePresence } from 'framer-motion'
import { OverlayScrollbarsComponent } from 'overlayscrollbars-react'
import { api } from '../../../../api/client'
const PAGE_SIZE = 24
export default function ImagePicker({ open, onClose, onSelect, currentImageId }) {
const [page, setPage] = useState(1)
const [search, setSearch] = useState('')
const [debouncedSearch, setDebouncedSearch] = useState('')
useEffect(() => {
const t = setTimeout(() => {
setDebouncedSearch(search)
setPage(1)
}, 300)
return () => clearTimeout(t)
}, [search])
useEffect(() => {
if (!open) {
setSearch('')
setDebouncedSearch('')
setPage(1)
}
}, [open])
const { data, isLoading } = useQuery({
queryKey: ['admin', 'images', { page, search: debouncedSearch }],
queryFn: () => {
const params = new URLSearchParams({
page,
limit: PAGE_SIZE,
...(debouncedSearch && { search: debouncedSearch }),
})
return api(`/api/admin/images?${params}`)
},
enabled: open,
placeholderData: (prev) => prev,
})
const images = data?.items || []
const totalPages = data?.total_pages || 1
return (
<AnimatePresence>
{open && (
<motion.div
key="backdrop"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.18 }}
className="fixed inset-0 z-50 flex items-center justify-center p-4 backdrop-blur-sm"
style={{ background: 'var(--dialog-backdrop)' }}
>
<motion.div
key="dialog"
initial={{ opacity: 0, scale: 0.94, y: 8 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.96, y: 4 }}
transition={{ duration: 0.2, ease: [0.22, 1, 0.36, 1] }}
className="w-full max-w-3xl 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)',
}}
>
<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)' }}>이미지 선택</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>
</div>
{/* 검색 */}
<div className="px-6 pt-4 shrink-0">
<div className="relative">
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="이미지 이름으로 검색..."
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)',
}}
/>
<span className="absolute left-3 top-1/2 -translate-y-1/2" style={{ color: 'var(--input-icon)' }}>🔍</span>
</div>
</div>
{/* 이미지 그리드 — 6×4 (24개) 높이로 고정, 내부는 OverlayScrollbars */}
<OverlayScrollbarsComponent
className="shrink-0"
style={{ height: '632px', overscrollBehavior: 'contain' }}
options={{
scrollbars: { theme: 'os-theme-maple os-theme-dark', autoHide: 'leave', autoHideDelay: 800 },
overflow: { x: 'hidden', y: 'scroll' },
}}
defer
>
<div className="px-6 pt-4 pb-6">
{isLoading ? (
<div className="grid gap-3 grid-cols-3 sm:grid-cols-4 lg:grid-cols-6">
{Array.from({ length: 12 }).map((_, i) => (
<div
key={i}
className="aspect-square rounded-lg animate-pulse"
style={{ background: 'var(--skeleton-bg)' }}
/>
))}
</div>
) : images.length === 0 ? (
<div className="py-12 text-center text-sm" style={{ color: 'var(--text-dim)' }}>
{debouncedSearch ? '검색 결과가 없습니다' : '업로드된 이미지가 없습니다'}
</div>
) : (
<div className="grid gap-3 grid-cols-3 sm:grid-cols-4 lg:grid-cols-6">
{images.map((image) => {
const isSelected = currentImageId === image.id
return (
<button
key={image.id}
type="button"
onClick={() => { onSelect(image); onClose() }}
className="group rounded-lg border overflow-hidden"
style={{
borderColor: isSelected ? 'var(--selected-border)' : 'var(--panel-border)',
boxShadow: isSelected ? '0 0 0 2px var(--ring-info)' : undefined,
}}
title={image.name}
>
<div
className="aspect-square flex items-center justify-center p-4"
style={{ backgroundImage: 'linear-gradient(to bottom right, var(--icon-box-from), var(--icon-box-to))' }}
>
<img src={image.url} alt={image.name} className="w-full h-full object-contain" style={{ imageRendering: 'pixelated' }} />
</div>
<div
className="px-2 py-1.5 border-t"
style={{
borderColor: 'var(--panel-border)',
background: 'var(--surface-3)',
}}
>
<div className="text-xs truncate">{image.name}</div>
</div>
</button>
)
})}
</div>
)}
</div>
</OverlayScrollbarsComponent>
{/* 페이지네이션 + 액션 (없으면 전체 섹션 숨김) */}
{(totalPages > 1 || currentImageId) && (
<div className="px-6 pb-6 pt-1 flex items-center justify-between shrink-0 gap-3">
{totalPages > 1 ? (
<div className="flex items-center gap-1">
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
className="w-8 h-8 rounded border hover:bg-[var(--btn-bg-hover)] disabled:opacity-30 disabled:cursor-not-allowed flex items-center justify-center text-sm"
style={{
background: 'var(--btn-bg)',
borderColor: 'var(--btn-border)',
color: 'var(--text-emphasis)',
}}
>
</button>
<span className="text-xs px-2" style={{ color: 'var(--text-muted)' }}>{page} / {totalPages}</span>
<button
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page === totalPages}
className="w-8 h-8 rounded border hover:bg-[var(--btn-bg-hover)] disabled:opacity-30 disabled:cursor-not-allowed flex items-center justify-center text-sm"
style={{
background: 'var(--btn-bg)',
borderColor: 'var(--btn-border)',
color: 'var(--text-emphasis)',
}}
>
</button>
</div>
) : <div />}
{currentImageId && (
<button
type="button"
onClick={() => { onSelect(null); onClose() }}
className="text-sm hover:text-[var(--danger-text-strong)]"
style={{ color: 'var(--danger-text)' }}
>
이미지 제거
</button>
)}
</div>
)}
</motion.div>
</motion.div>
)}
</AnimatePresence>
)
}