이미지 관리 다이얼로그 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:
parent
4720e33f26
commit
be548879dc
2 changed files with 179 additions and 145 deletions
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue