이미지 관리 다이얼로그 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>
This commit is contained in:
caadiq 2026-04-19 17:03:10 +09:00
parent 4720e33f26
commit be548879dc
2 changed files with 179 additions and 145 deletions

View file

@ -1,24 +1,34 @@
import { motion, AnimatePresence } from 'framer-motion'
/** /**
* 관리자 페이지에서 쓰는 일반 모달 래퍼 * 관리자 페이지에서 쓰는 일반 모달 래퍼
* <Modal open={open} onClose={onClose} title="제목" maxWidth="max-w-md"> * - 열기/닫기 애니메이션 포함
* <div>content</div> * - 뒷배경 클릭으로는 닫히지 않음 (× 버튼만)
* </Modal>
*/ */
export default function Modal({ open, onClose, title, children, maxWidth = 'max-w-md' }) { export default function Modal({ open, onClose, title, children, maxWidth = 'max-w-md' }) {
if (!open) return null
return ( return (
<div <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" className="fixed inset-0 z-50 flex items-center justify-center p-4 backdrop-blur-sm"
style={{ background: 'var(--dialog-backdrop)' }} style={{ background: 'var(--dialog-backdrop)' }}
onClick={onClose}
> >
<div <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 ${maxWidth} rounded-2xl border shadow-2xl max-h-[90vh] flex flex-col`} className={`w-full ${maxWidth} rounded-2xl border shadow-2xl max-h-[90vh] flex flex-col`}
style={{ style={{
backgroundImage: 'linear-gradient(to bottom, var(--dialog-bg-from), var(--dialog-bg-to))', backgroundImage: 'linear-gradient(to bottom, var(--dialog-bg-from), var(--dialog-bg-to))',
borderColor: 'var(--dialog-border)', borderColor: 'var(--dialog-border)',
}} }}
onClick={(e) => e.stopPropagation()}
> >
<div <div
className="px-6 py-4 border-b flex items-center justify-between shrink-0" className="px-6 py-4 border-b flex items-center justify-between shrink-0"
@ -29,12 +39,15 @@ export default function Modal({ open, onClose, title, children, maxWidth = 'max-
onClick={onClose} onClick={onClose}
className="text-xl leading-none hover:bg-[var(--row-hover-bg)] w-7 h-7 rounded flex items-center justify-center" 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)' }} style={{ color: 'var(--text-dim)' }}
aria-label="닫기"
> >
× ×
</button> </button>
</div> </div>
{children} {children}
</div> </motion.div>
</div> </motion.div>
)}
</AnimatePresence>
) )
} }

View file

@ -1,5 +1,7 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { motion, AnimatePresence } from 'framer-motion'
import { OverlayScrollbarsComponent } from 'overlayscrollbars-react'
import { api } from '../../../../api/client' import { api } from '../../../../api/client'
const PAGE_SIZE = 24 const PAGE_SIZE = 24
@ -42,21 +44,29 @@ export default function ImagePicker({ open, onClose, onSelect, currentImageId })
const images = data?.items || [] const images = data?.items || []
const totalPages = data?.total_pages || 1 const totalPages = data?.total_pages || 1
if (!open) return null
return ( return (
<div <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" className="fixed inset-0 z-50 flex items-center justify-center p-4 backdrop-blur-sm"
style={{ background: 'var(--dialog-backdrop)' }} style={{ background: 'var(--dialog-backdrop)' }}
onClick={onClose}
> >
<div <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" className="w-full max-w-3xl rounded-2xl border shadow-2xl max-h-[90vh] flex flex-col"
style={{ style={{
backgroundImage: 'linear-gradient(to bottom, var(--dialog-bg-from), var(--dialog-bg-to))', backgroundImage: 'linear-gradient(to bottom, var(--dialog-bg-from), var(--dialog-bg-to))',
borderColor: 'var(--dialog-border)', borderColor: 'var(--dialog-border)',
}} }}
onClick={(e) => e.stopPropagation()}
> >
<div <div
className="px-6 py-4 border-b flex items-center justify-between shrink-0" className="px-6 py-4 border-b flex items-center justify-between shrink-0"
@ -91,8 +101,17 @@ export default function ImagePicker({ open, onClose, onSelect, currentImageId })
</div> </div>
</div> </div>
{/* 이미지 그리드 */} {/* 이미지 그리드 — 6×4 (24개) 높이로 고정, 내부는 OverlayScrollbars */}
<div className="px-6 py-4 overflow-y-auto flex-1"> <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 ? ( {isLoading ? (
<div className="grid gap-3 grid-cols-3 sm:grid-cols-4 lg:grid-cols-6"> <div className="grid gap-3 grid-cols-3 sm:grid-cols-4 lg:grid-cols-6">
{Array.from({ length: 12 }).map((_, i) => ( {Array.from({ length: 12 }).map((_, i) => (
@ -124,10 +143,10 @@ export default function ImagePicker({ open, onClose, onSelect, currentImageId })
title={image.name} title={image.name}
> >
<div <div
className="aspect-square flex items-center justify-center p-3" 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))' }} style={{ backgroundImage: 'linear-gradient(to bottom right, var(--icon-box-from), var(--icon-box-to))' }}
> >
<img src={image.url} alt={image.name} className="max-w-full max-h-full object-contain" /> <img src={image.url} alt={image.name} className="w-full h-full object-contain" style={{ imageRendering: 'pixelated' }} />
</div> </div>
<div <div
className="px-2 py-1.5 border-t" className="px-2 py-1.5 border-t"
@ -144,12 +163,11 @@ export default function ImagePicker({ open, onClose, onSelect, currentImageId })
</div> </div>
)} )}
</div> </div>
</OverlayScrollbarsComponent>
{/* 페이지네이션 + 액션 */} {/* 페이지네이션 + 액션 (없으면 전체 섹션 숨김) */}
<div {(totalPages > 1 || currentImageId) && (
className="px-6 py-4 border-t flex items-center justify-between shrink-0 gap-3" <div className="px-6 pb-6 pt-1 flex items-center justify-between shrink-0 gap-3">
style={{ borderColor: 'var(--panel-border)' }}
>
{totalPages > 1 ? ( {totalPages > 1 ? (
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<button <button
@ -191,7 +209,10 @@ export default function ImagePicker({ open, onClose, onSelect, currentImageId })
</button> </button>
)} )}
</div> </div>
</div> )}
</div> </motion.div>
</motion.div>
)}
</AnimatePresence>
) )
} }