이미지 관리 다이얼로그 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,40 +1,53 @@
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' }) {
if (!open) return null
return (
<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)' }}
<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)' }}
>
<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)' }}
<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`}
style={{
backgroundImage: 'linear-gradient(to bottom, var(--dialog-bg-from), var(--dialog-bg-to))',
borderColor: 'var(--dialog-border)',
}}
>
×
</button>
</div>
{children}
</div>
</div>
<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)' }}
aria-label="닫기"
>
×
</button>
</div>
{children}
</motion.div>
</motion.div>
)}
</AnimatePresence>
)
}

View file

@ -1,5 +1,7 @@
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
@ -42,22 +44,30 @@ export default function ImagePicker({ open, onClose, onSelect, currentImageId })
const images = data?.items || []
const totalPages = data?.total_pages || 1
if (!open) return null
return (
<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 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)',
}}
onClick={(e) => e.stopPropagation()}
<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)' }}
@ -91,107 +101,118 @@ export default function ImagePicker({ open, onClose, onSelect, currentImageId })
</div>
</div>
{/* 이미지 그리드 */}
<div className="px-6 py-4 overflow-y-auto flex-1">
{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-3"
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" />
</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>
{/* 페이지네이션 + 액션 */}
<div
className="px-6 py-4 border-t flex items-center justify-between shrink-0 gap-3"
style={{ borderColor: 'var(--panel-border)' }}
{/* 이미지 그리드 — 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
>
{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 />}
<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>
{currentImageId && (
<button
type="button"
onClick={() => { onSelect(null); onClose() }}
className="text-sm hover:text-[var(--danger-text-strong)]"
style={{ color: 'var(--danger-text)' }}
>
이미지 제거
</button>
)}
</div>
</div>
</div>
{/* 페이지네이션 + 액션 (없으면 전체 섹션 숨김) */}
{(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>
)
}