이미지 페이징, React Query 도입, 메뉴 항목 추가/편집 폼 구현

- 이미지 목록 서버 사이드 페이징 + 검색 디바운싱
- 전역 React Query 도입 (useEffect → useQuery/useMutation)
- 메뉴 추가/편집 폼 (제목, 설명, URL, 이미지)
- 업로드된 이미지에서 선택하는 ImagePicker 모달
- 미선택 시 default.png를 fallback으로 사용
- AdminHome 카드 클릭 시 편집 페이지로 이동

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
caadiq 2026-04-13 15:11:48 +09:00
parent 921ce9676b
commit 35df389141
12 changed files with 633 additions and 159 deletions

View file

@ -33,19 +33,52 @@ router.use(requireAdmin);
/* ── 이미지 관리 ── */
// 이미지 목록
router.get('/images', async (_req, res) => {
// 전체 이미지 이름 (중복 체크용)
router.get('/images/names', async (_req, res) => {
try {
const images = await Image.findAll({ order: [['created_at', 'DESC']] });
res.json(images.map((img) => ({
id: img.id,
name: img.name,
url: getPublicUrl(img.path),
width: img.width,
height: img.height,
size: img.size,
created_at: img.created_at,
})));
const images = await Image.findAll({ attributes: ['name'] });
res.json(images.map((img) => img.name));
} catch (err) {
console.error('이미지 이름 조회 오류:', err.message);
res.status(500).json({ error: '조회 실패' });
}
});
// 이미지 목록 (페이징 + 검색)
router.get('/images', async (req, res) => {
const page = Math.max(1, parseInt(req.query.page) || 1);
const limit = Math.min(100, Math.max(1, parseInt(req.query.limit) || 24));
const search = (req.query.search || '').trim();
const where = {};
if (search) {
const { Op } = await import('sequelize');
where.name = { [Op.like]: `%${search}%` };
}
try {
const { rows, count } = await Image.findAndCountAll({
where,
order: [['created_at', 'DESC']],
limit,
offset: (page - 1) * limit,
});
res.json({
items: rows.map((img) => ({
id: img.id,
name: img.name,
url: getPublicUrl(img.path),
width: img.width,
height: img.height,
size: img.size,
created_at: img.created_at,
})),
total: count,
page,
limit,
total_pages: Math.ceil(count / limit),
});
} catch (err) {
console.error('이미지 목록 조회 오류:', err.message);
res.status(500).json({ error: '이미지 목록 조회 실패' });

View file

@ -8,6 +8,7 @@
"name": "frontend",
"version": "0.0.0",
"dependencies": {
"@tanstack/react-query": "^5.91.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-router-dom": "^7.14.0"
@ -1122,6 +1123,32 @@
"vite": "^5.2.0 || ^6 || ^7 || ^8"
}
},
"node_modules/@tanstack/query-core": {
"version": "5.99.0",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.99.0.tgz",
"integrity": "sha512-3Jv3WQG0BCcH7G+7lf/bP8QyBfJOXeY+T08Rin3GZ1bshvwlbPt7NrDHMEzGdKIOmOzvIQmxjk28YEQX60k7pQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/react-query": {
"version": "5.99.0",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.99.0.tgz",
"integrity": "sha512-OY2bCqPemT1LlqJ8Y2CUau4KELnIhhG9Ol3ZndPbdnB095pRbPo1cHuXTndg8iIwtoHTgwZjyaDnQ0xD0mYwAw==",
"license": "MIT",
"dependencies": {
"@tanstack/query-core": "5.99.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^18 || ^19"
}
},
"node_modules/@tybys/wasm-util": {
"version": "0.10.1",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",

View file

@ -10,6 +10,7 @@
"preview": "vite preview"
},
"dependencies": {
"@tanstack/react-query": "^5.91.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-router-dom": "^7.14.0"

BIN
frontend/public/default.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View file

@ -15,6 +15,7 @@ export default function App() {
<Route index element={<AdminHome />} />
<Route path="images" element={<AdminImages />} />
<Route path="menus/new" element={<AdminMenuForm />} />
<Route path="menus/:id" element={<AdminMenuForm />} />
</Route>
</Route>
</Routes>

View file

@ -1,29 +1,23 @@
import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { api } from '../../api/client'
function MenuCard({ menu }) {
return (
<Link
to={menu.url}
to={`/admin/menus/${menu.id}`}
className="group relative overflow-hidden rounded-2xl border border-white/5 bg-gradient-to-br from-gray-900/80 to-gray-900/40 p-5 hover:border-emerald-500/30 hover:from-emerald-500/5 hover:to-cyan-500/5 transition-all duration-300"
>
<div className="absolute -top-12 -right-12 w-32 h-32 rounded-full bg-emerald-500/0 group-hover:bg-emerald-500/10 blur-2xl transition-all duration-500" />
<div className="relative flex items-start gap-4">
<div className="shrink-0 w-12 h-12 rounded-xl bg-gradient-to-br from-gray-800 to-gray-900 border border-white/5 flex items-center justify-center text-2xl group-hover:scale-110 group-hover:border-emerald-500/30 transition-all duration-300">
{menu.image_url ? (
<img src={menu.image_url} alt={menu.title} className="w-7 h-7 object-contain" />
) : (
menu.icon || '📋'
)}
<div className="shrink-0 w-12 h-12 rounded-xl bg-gradient-to-br from-gray-800 to-gray-900 border border-white/5 flex items-center justify-center overflow-hidden group-hover:scale-110 group-hover:border-emerald-500/30 transition-all duration-300">
<img src={menu.image?.url || '/default.png'} alt={menu.title} className="w-9 h-9 object-contain" />
</div>
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-white group-hover:text-emerald-300 transition">{menu.title}</h3>
<p className="text-sm text-gray-400 mt-1 leading-relaxed">{menu.description}</p>
</div>
<div className="text-gray-700 group-hover:text-emerald-400 group-hover:translate-x-1 transition-all duration-300">
<h3 className="font-semibold text-white group-hover:text-emerald-300 transition truncate">{menu.title}</h3>
<p className="text-sm text-gray-400 mt-1 leading-relaxed truncate">{menu.description}</p>
<p className="text-xs text-gray-600 mt-1 font-mono truncate">{menu.url}</p>
</div>
</div>
</Link>
@ -45,16 +39,10 @@ function AddCard({ to, icon, label }) {
}
export default function AdminHome() {
const [menus, setMenus] = useState([])
const [loading, setLoading] = useState(true)
useEffect(() => {
// TODO: API
api('/api/admin/menus')
.then(setMenus)
.catch(() => setMenus([]))
.finally(() => setLoading(false))
}, [])
const { data: menus = [], isLoading: loading } = useQuery({
queryKey: ['admin', 'menus'],
queryFn: () => api('/api/admin/menus').catch(() => []),
})
return (
<div className="space-y-8">

View file

@ -1,4 +1,5 @@
import { useState, useEffect } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { api } from '../../api/client'
/* ── 공용 모달 ── */
@ -232,42 +233,127 @@ function ImageCard({ image, selected, selectMode, onToggle, onCopyUrl, copied })
)
}
/* ── 페이지네이션 ── */
function Pagination({ page, totalPages, onChange }) {
if (totalPages <= 1) return null
const pages = []
const maxButtons = 7
let start = Math.max(1, page - Math.floor(maxButtons / 2))
let end = Math.min(totalPages, start + maxButtons - 1)
if (end - start + 1 < maxButtons) start = Math.max(1, end - maxButtons + 1)
for (let i = start; i <= end; i++) pages.push(i)
const btn = "min-w-9 h-9 px-3 rounded-lg text-sm transition flex items-center justify-center"
return (
<div className="flex items-center justify-center gap-1 pt-2">
<button
onClick={() => onChange(page - 1)}
disabled={page === 1}
className={`${btn} border border-white/10 hover:bg-white/5 disabled:opacity-30 disabled:cursor-not-allowed`}
>
</button>
{start > 1 && (
<>
<button onClick={() => onChange(1)} className={`${btn} border border-white/10 hover:bg-white/5`}>1</button>
{start > 2 && <span className="text-gray-600 px-1"></span>}
</>
)}
{pages.map((p) => (
<button
key={p}
onClick={() => onChange(p)}
className={`${btn} ${
p === page
? 'bg-emerald-500/20 border border-emerald-500/40 text-emerald-300 font-medium'
: 'border border-white/10 hover:bg-white/5'
}`}
>
{p}
</button>
))}
{end < totalPages && (
<>
{end < totalPages - 1 && <span className="text-gray-600 px-1"></span>}
<button onClick={() => onChange(totalPages)} className={`${btn} border border-white/10 hover:bg-white/5`}>{totalPages}</button>
</>
)}
<button
onClick={() => onChange(page + 1)}
disabled={page === totalPages}
className={`${btn} border border-white/10 hover:bg-white/5 disabled:opacity-30 disabled:cursor-not-allowed`}
>
</button>
</div>
)
}
const PAGE_SIZE = 24
/* ── 메인 ── */
export default function AdminImages() {
const [images, setImages] = useState([])
const [loading, setLoading] = useState(true)
const [uploadOpen, setUploadOpen] = useState(false)
const [uploading, setUploading] = useState(false)
const queryClient = useQueryClient()
const [page, setPage] = useState(1)
const [search, setSearch] = useState('')
const [debouncedSearch, setDebouncedSearch] = useState('')
const [uploadOpen, setUploadOpen] = useState(false)
const [selectMode, setSelectMode] = useState(false)
const [selectedIds, setSelectedIds] = useState(new Set())
const [confirmDelete, setConfirmDelete] = useState(null) // {ids, names}
const [deleting, setDeleting] = useState(false)
const [copiedId, setCopiedId] = useState(null)
const fetchImages = async () => {
setLoading(true)
try {
const data = await api('/api/admin/images')
setImages(data)
} catch {
setImages([])
} finally {
setLoading(false)
}
//
useEffect(() => {
const t = setTimeout(() => {
setDebouncedSearch(search)
setPage(1)
}, 300)
return () => clearTimeout(t)
}, [search])
// ( + )
const { data: imagesData, isLoading } = useQuery({
queryKey: ['admin', 'images', { page, search: debouncedSearch }],
queryFn: async () => {
const params = new URLSearchParams({
page,
limit: PAGE_SIZE,
...(debouncedSearch && { search: debouncedSearch }),
})
return api(`/api/admin/images?${params}`)
},
placeholderData: (prev) => prev,
})
const images = imagesData?.items || []
const totalPages = imagesData?.total_pages || 1
// ( )
const { data: allNamesArray = [] } = useQuery({
queryKey: ['admin', 'images', 'names'],
queryFn: () => api('/api/admin/images/names'),
})
const allNames = new Set(allNamesArray)
const invalidateImages = () => {
queryClient.invalidateQueries({ queryKey: ['admin', 'images'] })
}
useEffect(() => { fetchImages() }, [])
const handleUpload = async (items) => {
setUploading(true)
try {
//
const uploadMutation = useMutation({
mutationFn: async (items) => {
const formData = new FormData()
items.forEach((it) => {
formData.append('files', it.file)
formData.append('names', it.name.trim())
})
const adminKey = localStorage.getItem('maple-admin-key')
const res = await fetch('/api/admin/images', {
method: 'POST',
@ -276,19 +362,17 @@ export default function AdminImages() {
})
const result = await res.json()
if (!res.ok) throw new Error(result.error || '업로드 실패')
return result
},
onSuccess: (result) => {
if (result.errors?.length > 0) {
alert(`일부 업로드 실패:\n${result.errors.map((e) => `- ${e.name}: ${e.error}`).join('\n')}`)
}
setUploadOpen(false)
await fetchImages()
} catch (err) {
alert(err.message)
} finally {
setUploading(false)
}
}
invalidateImages()
},
onError: (err) => alert(err.message),
})
const toggleSelect = (id) => {
setSelectedIds((prev) => {
@ -303,15 +387,11 @@ export default function AdminImages() {
setSelectedIds(new Set())
}
const filtered = images.filter((img) =>
img.name.toLowerCase().includes(search.toLowerCase())
)
const selectAll = () => {
if (selectedIds.size === filtered.length) {
if (selectedIds.size === images.length) {
setSelectedIds(new Set())
} else {
setSelectedIds(new Set(filtered.map((img) => img.id)))
setSelectedIds(new Set(images.map((img) => img.id)))
}
}
@ -323,23 +403,17 @@ export default function AdminImages() {
})
}
const handleDeleteConfirm = async () => {
setDeleting(true)
try {
await api('/api/admin/images/delete', {
method: 'POST',
body: { ids: confirmDelete.ids },
})
//
const deleteMutation = useMutation({
mutationFn: (ids) => api('/api/admin/images/delete', { method: 'POST', body: { ids } }),
onSuccess: () => {
setConfirmDelete(null)
setSelectedIds(new Set())
setSelectMode(false)
await fetchImages()
} catch (err) {
alert(err.message)
} finally {
setDeleting(false)
}
}
invalidateImages()
},
onError: (err) => alert(err.message),
})
const copyUrl = (image) => {
navigator.clipboard.writeText(image.url)
@ -362,7 +436,7 @@ export default function AdminImages() {
onClick={selectAll}
className="rounded-lg border border-white/10 px-3 py-2 text-sm hover:bg-white/5 transition"
>
{selectedIds.size === filtered.length && filtered.length > 0 ? '전체 해제' : '전체 선택'}
{selectedIds.size === images.length && images.length > 0 ? '전체 해제' : '전체 선택'}
</button>
<button
onClick={requestDelete}
@ -383,9 +457,9 @@ export default function AdminImages() {
{images.length > 0 && (
<button
onClick={toggleSelectMode}
className="rounded-lg border border-white/10 px-3 py-2 text-sm hover:bg-white/5 transition"
className="rounded-lg border border-red-500/30 text-red-400 hover:bg-red-500/10 hover:border-red-500/50 px-3 py-2 text-sm transition"
>
선택
삭제
</button>
)}
<button
@ -415,19 +489,19 @@ export default function AdminImages() {
)}
{/* 이미지 그리드 */}
{loading ? (
{isLoading ? (
<div className="grid gap-3 grid-cols-2 sm:grid-cols-3 lg:grid-cols-4">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="aspect-square rounded-xl bg-white/[0.02] animate-pulse" />
))}
</div>
) : filtered.length === 0 ? (
) : images.length === 0 ? (
<div className="rounded-2xl border border-dashed border-white/10 bg-white/[0.02] p-16 text-center">
<div className="text-5xl mb-3 opacity-30">🖼</div>
<p className="text-gray-400 mb-4">
{images.length === 0 ? '업로드된 이미지가 없습니다' : '검색 결과가 없습니다'}
{debouncedSearch ? '검색 결과가 없습니다' : '업로드된 이미지가 없습니다'}
</p>
{images.length === 0 && (
{!debouncedSearch && (
<button
onClick={() => setUploadOpen(true)}
className="text-sm text-emerald-400 hover:text-emerald-300 transition"
@ -437,38 +511,41 @@ export default function AdminImages() {
)}
</div>
) : (
<div className="grid gap-3 grid-cols-2 sm:grid-cols-3 lg:grid-cols-4">
{filtered.map((image) => (
<ImageCard
key={image.id}
image={image}
selected={selectedIds.has(image.id)}
selectMode={selectMode}
onToggle={toggleSelect}
onCopyUrl={copyUrl}
copied={copiedId === image.id}
/>
))}
</div>
<>
<div className="grid gap-3 grid-cols-2 sm:grid-cols-3 lg:grid-cols-4">
{images.map((image) => (
<ImageCard
key={image.id}
image={image}
selected={selectedIds.has(image.id)}
selectMode={selectMode}
onToggle={toggleSelect}
onCopyUrl={copyUrl}
copied={copiedId === image.id}
/>
))}
</div>
<Pagination page={page} totalPages={totalPages} onChange={setPage} />
</>
)}
<UploadModal
open={uploadOpen}
onClose={() => setUploadOpen(false)}
onUpload={handleUpload}
uploading={uploading}
existingNames={new Set(images.map((img) => img.name))}
onUpload={(items) => uploadMutation.mutate(items)}
uploading={uploadMutation.isPending}
existingNames={allNames}
/>
<ConfirmDialog
open={!!confirmDelete}
onClose={() => setConfirmDelete(null)}
onConfirm={handleDeleteConfirm}
onConfirm={() => deleteMutation.mutate(confirmDelete.ids)}
title="이미지 삭제"
description={confirmDelete ? `${confirmDelete.ids.length}개의 이미지를 삭제하시겠습니까?\n\n${confirmDelete.names.slice(0, 5).map((n) => `· ${n}`).join('\n')}${confirmDelete.names.length > 5 ? `\n· 외 ${confirmDelete.names.length - 5}` : ''}\n\n이 작업은 되돌릴 수 없습니다.` : ''}
confirmText="삭제"
destructive
loading={deleting}
loading={deleteMutation.isPending}
/>
</div>
)

View file

@ -1,35 +1,38 @@
import { useState, useEffect } from 'react'
import { useSearchParams, Outlet, Navigate, Link, useLocation } from 'react-router-dom'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { api } from '../../api/client'
export default function AdminLayout() {
const queryClient = useQueryClient()
const [searchParams] = useSearchParams()
const [verified, setVerified] = useState(null)
const location = useLocation()
const isRoot = location.pathname === '/admin' || location.pathname === '/admin/'
useEffect(() => {
const keyFromUrl = searchParams.get('key')
const keyFromStorage = localStorage.getItem('maple-admin-key')
const key = keyFromUrl || keyFromStorage
const keyFromUrl = searchParams.get('key')
const key = keyFromUrl || localStorage.getItem('maple-admin-key')
if (!key) {
setVerified(false)
return
}
const { data, isLoading } = useQuery({
queryKey: ['admin', 'verify', key],
queryFn: async () => {
if (!key) throw new Error('no key')
await api('/api/admin/verify', { method: 'POST', body: { key } })
localStorage.setItem('maple-admin-key', key)
return true
},
enabled: !!key,
retry: false,
staleTime: Infinity,
})
api('/api/admin/verify', { method: 'POST', body: { key } })
.then(() => {
localStorage.setItem('maple-admin-key', key)
setVerified(true)
})
.catch(() => {
localStorage.removeItem('maple-admin-key')
setVerified(false)
})
}, [searchParams])
const verified = data === true
if (verified === null) {
const handleLogout = () => {
localStorage.removeItem('maple-admin-key')
queryClient.removeQueries({ queryKey: ['admin'] })
window.location.href = '/'
}
if (key && isLoading) {
return (
<div className="flex items-center justify-center pt-20">
<div className="w-6 h-6 border-2 border-emerald-500 border-t-transparent rounded-full animate-spin" />
@ -38,6 +41,7 @@ export default function AdminLayout() {
}
if (!verified) {
if (key) localStorage.removeItem('maple-admin-key')
return <Navigate to="/" replace />
}
@ -60,7 +64,7 @@ export default function AdminLayout() {
</div>
</div>
<button
onClick={() => { localStorage.removeItem('maple-admin-key'); setVerified(false) }}
onClick={handleLogout}
className="text-sm text-gray-500 hover:text-gray-300 transition"
>
로그아웃

View file

@ -1,13 +1,207 @@
export default function AdminMenuForm() {
import { useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { api } from '../../api/client'
import ImagePicker from './components/ImagePicker'
function Field({ label, hint, error, required, children }) {
return (
<div className="space-y-4">
<div>
<h2 className="text-lg font-semibold">메뉴 항목 추가</h2>
<p className="text-sm text-gray-500 mt-0.5"> 기능 카드를 추가합니다</p>
</div>
<div className="rounded-2xl border border-white/5 bg-gray-900/40 p-12 text-center text-gray-500">
준비
<div className="space-y-1.5">
<div className="flex items-baseline justify-between">
<label className="text-sm font-medium text-gray-300">
{label} {required && <span className="text-red-400">*</span>}
</label>
{hint && <span className="text-xs text-gray-500">{hint}</span>}
</div>
{children}
{error && <div className="text-[11px] text-red-400">{error}</div>}
</div>
)
}
const inputCls = 'w-full rounded-lg border border-white/10 bg-gray-950 px-3 py-2 text-sm outline-none focus:border-emerald-500/50 transition'
export default function AdminMenuForm() {
const navigate = useNavigate()
const queryClient = useQueryClient()
const { id } = useParams()
const isEdit = !!id
const [pickerOpen, setPickerOpen] = useState(false)
const [form, setForm] = useState({
title: '',
description: '',
url: '/',
image_id: null,
image: null, //
})
const [errors, setErrors] = useState({})
//
useQuery({
queryKey: ['admin', 'menus', id],
queryFn: async () => {
const data = await api(`/api/admin/menus/${id}`)
setForm({
title: data.title || '',
description: data.description || '',
url: data.url || '/',
image_id: data.image_id,
image: data.image,
})
return data
},
enabled: isEdit,
})
const update = (patch) => setForm((prev) => ({ ...prev, ...patch }))
const validate = () => {
const errs = {}
if (!form.title.trim()) errs.title = '제목을 입력해주세요'
if (!form.url.trim()) errs.url = 'URL을 입력해주세요'
else if (!form.url.startsWith('/')) errs.url = 'URL은 /로 시작해야 합니다'
setErrors(errs)
return Object.keys(errs).length === 0
}
const saveMutation = useMutation({
mutationFn: async () => {
const payload = {
title: form.title.trim(),
description: form.description.trim(),
url: form.url.trim(),
image_id: form.image_id,
}
if (isEdit) {
return api(`/api/admin/menus/${id}`, { method: 'PATCH', body: payload })
}
return api('/api/admin/menus', { method: 'POST', body: payload })
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin', 'menus'] })
queryClient.invalidateQueries({ queryKey: ['menus'] })
navigate('/admin')
},
onError: (err) => alert(err.message),
})
const handleSubmit = (e) => {
e.preventDefault()
if (!validate()) return
saveMutation.mutate()
}
return (
<div className="space-y-6 max-w-2xl">
<div>
<h2 className="text-lg font-semibold">{isEdit ? '메뉴 항목 편집' : '메뉴 항목 추가'}</h2>
<p className="text-sm text-gray-500 mt-0.5"> 화면에 표시되는 카드의 정보를 설정합니다</p>
</div>
<form onSubmit={handleSubmit} className="space-y-5 rounded-2xl border border-white/5 bg-gray-900/40 p-6">
{/* 미리보기 */}
<div className="rounded-xl border border-white/5 bg-gray-950/50 p-4">
<div className="text-xs text-gray-500 mb-3">미리보기</div>
<div className="rounded-xl border border-white/10 bg-gradient-to-br from-gray-900/80 to-gray-900/40 p-5">
<div className="flex items-start gap-4">
<div className="shrink-0 w-12 h-12 rounded-xl bg-gradient-to-br from-gray-800 to-gray-900 border border-white/5 flex items-center justify-center overflow-hidden">
<img src={form.image?.url || '/default.png'} alt="" className="w-9 h-9 object-contain" />
</div>
<div className="flex-1 min-w-0">
<h3 className="font-semibold">{form.title || '제목 없음'}</h3>
<p className="text-sm text-gray-400 mt-1">{form.description || '설명 없음'}</p>
</div>
</div>
</div>
</div>
<Field label="제목" required error={errors.title}>
<input
type="text"
value={form.title}
onChange={(e) => update({ title: e.target.value })}
placeholder="예: 주간 보스 수익 계산기"
className={inputCls}
/>
</Field>
<Field label="설명" hint="카드에 표시되는 부가 설명">
<input
type="text"
value={form.description}
onChange={(e) => update({ description: e.target.value })}
placeholder="예: 캐릭터별 보스 결정석 수익을 계산합니다"
className={inputCls}
/>
</Field>
<Field label="URL" required hint="/로 시작하는 라우트 경로" error={errors.url}>
<input
type="text"
value={form.url}
onChange={(e) => update({ url: e.target.value })}
placeholder="예: /boss"
className={inputCls}
/>
</Field>
<Field label="아이콘 이미지" hint="선택사항">
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => setPickerOpen(true)}
className="w-16 h-16 rounded-lg border border-white/10 hover:border-emerald-500/40 bg-gray-950 flex items-center justify-center overflow-hidden transition shrink-0 cursor-pointer"
>
{form.image?.url ? (
<img src={form.image.url} alt="" className="max-w-[80%] max-h-[80%] object-contain" />
) : (
<span className="text-2xl text-gray-700">+</span>
)}
</button>
<div className="flex-1 min-w-0">
{form.image ? (
<>
<div className="text-sm font-medium truncate">{form.image.name}</div>
<button
type="button"
onClick={() => update({ image_id: null, image: null })}
className="text-xs text-red-400 hover:text-red-300 transition mt-1"
>
이미지 제거
</button>
</>
) : (
<div className="text-sm text-gray-500">이미지 선택</div>
)}
</div>
</div>
</Field>
<div className="flex gap-2 pt-2">
<button
type="button"
onClick={() => navigate('/admin')}
className="flex-1 rounded-lg border border-white/10 px-4 py-2.5 text-sm hover:bg-white/5 transition"
>
취소
</button>
<button
type="submit"
disabled={saveMutation.isPending}
className="flex-1 rounded-lg bg-emerald-600 hover:bg-emerald-500 px-4 py-2.5 text-sm font-medium disabled:opacity-50 transition shadow-lg shadow-emerald-500/20"
>
{saveMutation.isPending ? '저장 중...' : (isEdit ? '저장' : '추가')}
</button>
</div>
</form>
<ImagePicker
open={pickerOpen}
onClose={() => setPickerOpen(false)}
currentImageId={form.image_id}
onSelect={(img) => update({ image_id: img?.id || null, image: img })}
/>
</div>
)
}

View file

@ -0,0 +1,145 @@
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 bg-black/60 backdrop-blur-sm" onClick={onClose}>
<div className="w-full max-w-3xl rounded-2xl bg-gray-900 border border-white/10 shadow-2xl max-h-[90vh] flex flex-col" onClick={(e) => e.stopPropagation()}>
<div className="px-6 py-4 border-b border-white/5 flex items-center justify-between shrink-0">
<h3 className="font-semibold">이미지 선택</h3>
<button onClick={onClose} className="text-gray-500 hover:text-white transition text-xl leading-none">×</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 border-white/10 bg-gray-950 pl-10 pr-4 py-2.5 text-sm outline-none focus:border-emerald-500/50 transition"
/>
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500">🔍</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 bg-white/[0.02] animate-pulse" />
))}
</div>
) : images.length === 0 ? (
<div className="py-12 text-center text-gray-500 text-sm">
{debouncedSearch ? '검색 결과가 없습니다' : '업로드된 이미지가 없습니다'}
</div>
) : (
<div className="grid gap-3 grid-cols-3 sm:grid-cols-4 lg:grid-cols-6">
{images.map((image) => (
<button
key={image.id}
type="button"
onClick={() => { onSelect(image); onClose() }}
className={`group rounded-lg border overflow-hidden transition ${
currentImageId === image.id
? 'border-emerald-500/60 ring-2 ring-emerald-500/30'
: 'border-white/5 hover:border-white/20'
}`}
title={image.name}
>
<div className="aspect-square bg-gradient-to-br from-gray-900 to-gray-950 flex items-center justify-center p-3">
<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 border-white/5 bg-gray-950/50">
<div className="text-xs truncate">{image.name}</div>
</div>
</button>
))}
</div>
)}
</div>
{/* 페이지네이션 + 액션 */}
<div className="px-6 py-4 border-t border-white/5 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 border-white/10 hover:bg-white/5 disabled:opacity-30 disabled:cursor-not-allowed flex items-center justify-center text-sm"
>
</button>
<span className="text-xs text-gray-400 px-2">{page} / {totalPages}</span>
<button
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page === totalPages}
className="w-8 h-8 rounded border border-white/10 hover:bg-white/5 disabled:opacity-30 disabled:cursor-not-allowed flex items-center justify-center text-sm"
>
</button>
</div>
) : <div />}
{currentImageId && (
<button
type="button"
onClick={() => { onSelect(null); onClose() }}
className="text-sm text-red-400 hover:text-red-300 transition"
>
이미지 제거
</button>
)}
</div>
</div>
</div>
)
}

View file

@ -1,13 +1,26 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import './index.css'
import App from './App.jsx'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000,
refetchOnWindowFocus: false,
retry: 1,
},
},
})
createRoot(document.getElementById('root')).render(
<StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<App />
</BrowserRouter>
</QueryClientProvider>
</StrictMode>,
)

View file

@ -1,17 +1,12 @@
import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { api } from '../api/client'
export default function Home() {
const [menus, setMenus] = useState([])
const [loading, setLoading] = useState(true)
useEffect(() => {
api('/api/menus')
.then(setMenus)
.catch(() => setMenus([]))
.finally(() => setLoading(false))
}, [])
const { data: menus = [], isLoading: loading } = useQuery({
queryKey: ['menus'],
queryFn: () => api('/api/menus').catch(() => []),
})
return (
<div className="space-y-12">
@ -52,12 +47,8 @@ export default function Home() {
>
<div className="absolute -top-16 -right-16 w-40 h-40 rounded-full bg-emerald-500/0 group-hover:bg-emerald-500/10 blur-3xl transition-all duration-500" />
<div className="relative space-y-3">
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-gray-800 to-gray-900 border border-white/5 flex items-center justify-center text-2xl group-hover:scale-110 group-hover:border-emerald-500/30 transition-all duration-300">
{menu.image_url ? (
<img src={menu.image_url} alt={menu.title} className="w-7 h-7 object-contain" />
) : (
menu.icon || '📋'
)}
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-gray-800 to-gray-900 border border-white/5 flex items-center justify-center overflow-hidden group-hover:scale-110 group-hover:border-emerald-500/30 transition-all duration-300">
<img src={menu.image?.url || '/default.png'} alt={menu.title} className="w-9 h-9 object-contain" />
</div>
<div>
<h2 className="font-semibold group-hover:text-emerald-300 transition">{menu.title}</h2>