이미지 페이징, 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:
parent
921ce9676b
commit
35df389141
12 changed files with 633 additions and 159 deletions
|
|
@ -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: '이미지 목록 조회 실패' });
|
||||
|
|
|
|||
27
frontend/package-lock.json
generated
27
frontend/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
BIN
frontend/public/default.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
로그아웃
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
145
frontend/src/features/admin/components/ImagePicker.jsx
Normal file
145
frontend/src/features/admin/components/ImagePicker.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue