maplestory/frontend/src/features/admin/pc/components/ImagePicker.jsx
caadiq 444cf8cf85 구조 개편 3단계: features/admin/pc/ + pages/pc/Home.jsx
- features/admin/* → features/admin/pc/* (AdminLayout, AdminHome,
  AdminImages, AdminMenuForm, AdminBoss, AdminFeaturePage, components/)
- pages/Home.jsx → pages/pc/Home.jsx
- App.jsx import path 업데이트

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

197 lines
7.3 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 { 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
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()}
>
<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>
{/* 이미지 그리드 */}
<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)' }}
>
{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>
</div>
</div>
)
}