From 35df389141c3bf2688001f34851641c9b1252291 Mon Sep 17 00:00:00 2001 From: caadiq Date: Mon, 13 Apr 2026 15:11:48 +0900 Subject: [PATCH] =?UTF-8?q?=EC=9D=B4=EB=AF=B8=EC=A7=80=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=95,=20React=20Query=20=EB=8F=84=EC=9E=85,=20?= =?UTF-8?q?=EB=A9=94=EB=89=B4=20=ED=95=AD=EB=AA=A9=20=EC=B6=94=EA=B0=80/?= =?UTF-8?q?=ED=8E=B8=EC=A7=91=20=ED=8F=BC=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 이미지 목록 서버 사이드 페이징 + 검색 디바운싱 - 전역 React Query 도입 (useEffect → useQuery/useMutation) - 메뉴 추가/편집 폼 (제목, 설명, URL, 이미지) - 업로드된 이미지에서 선택하는 ImagePicker 모달 - 미선택 시 default.png를 fallback으로 사용 - AdminHome 카드 클릭 시 편집 페이지로 이동 Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/routes/admin.js | 57 ++++- frontend/package-lock.json | 27 +++ frontend/package.json | 1 + frontend/public/default.png | Bin 0 -> 25768 bytes frontend/src/App.jsx | 1 + frontend/src/features/admin/AdminHome.jsx | 34 +-- frontend/src/features/admin/AdminImages.jsx | 227 ++++++++++++------ frontend/src/features/admin/AdminLayout.jsx | 48 ++-- frontend/src/features/admin/AdminMenuForm.jsx | 210 +++++++++++++++- .../features/admin/components/ImagePicker.jsx | 145 +++++++++++ frontend/src/main.jsx | 19 +- frontend/src/pages/Home.jsx | 23 +- 12 files changed, 633 insertions(+), 159 deletions(-) create mode 100644 frontend/public/default.png create mode 100644 frontend/src/features/admin/components/ImagePicker.jsx diff --git a/backend/routes/admin.js b/backend/routes/admin.js index 9ddbb07..85e51d2 100644 --- a/backend/routes/admin.js +++ b/backend/routes/admin.js @@ -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: '이미지 목록 조회 실패' }); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 9317ab8..f7db7fa 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index 55e58ba..41983c4 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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" diff --git a/frontend/public/default.png b/frontend/public/default.png new file mode 100644 index 0000000000000000000000000000000000000000..26ff958602616235ceff83e982b7b2cb35c55ac8 GIT binary patch literal 25768 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 /> } /> } /> + } /> diff --git a/frontend/src/features/admin/AdminHome.jsx b/frontend/src/features/admin/AdminHome.jsx index 307bed5..812521f 100644 --- a/frontend/src/features/admin/AdminHome.jsx +++ b/frontend/src/features/admin/AdminHome.jsx @@ -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 (
-
- {menu.image_url ? ( - {menu.title} - ) : ( - menu.icon || '📋' - )} +
+ {menu.title}
-

{menu.title}

-

{menu.description}

-
-
- → +

{menu.title}

+

{menu.description}

+

{menu.url}

@@ -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 (
diff --git a/frontend/src/features/admin/AdminImages.jsx b/frontend/src/features/admin/AdminImages.jsx index 52e5761..10455c6 100644 --- a/frontend/src/features/admin/AdminImages.jsx +++ b/frontend/src/features/admin/AdminImages.jsx @@ -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 ( +
+ + + {start > 1 && ( + <> + + {start > 2 && } + + )} + + {pages.map((p) => ( + + ))} + + {end < totalPages && ( + <> + {end < totalPages - 1 && } + + + )} + + +
+ ) +} + +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 ? '전체 해제' : '전체 선택'} )}
) : ( -
- {filtered.map((image) => ( - - ))} -
+ <> +
+ {images.map((image) => ( + + ))} +
+ + )} setUploadOpen(false)} - onUpload={handleUpload} - uploading={uploading} - existingNames={new Set(images.map((img) => img.name))} + onUpload={(items) => uploadMutation.mutate(items)} + uploading={uploadMutation.isPending} + existingNames={allNames} /> 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} />
) diff --git a/frontend/src/features/admin/AdminLayout.jsx b/frontend/src/features/admin/AdminLayout.jsx index 45a97f8..cff5288 100644 --- a/frontend/src/features/admin/AdminLayout.jsx +++ b/frontend/src/features/admin/AdminLayout.jsx @@ -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 (
@@ -38,6 +41,7 @@ export default function AdminLayout() { } if (!verified) { + if (key) localStorage.removeItem('maple-admin-key') return } @@ -60,7 +64,7 @@ export default function AdminLayout() {
+
+ {form.image ? ( + <> +
{form.image.name}
+ + + ) : ( +
이미지 선택
+ )} +
+
+ + +
+ + +
+ + + setPickerOpen(false)} + currentImageId={form.image_id} + onSelect={(img) => update({ image_id: img?.id || null, image: img })} + /> ) } diff --git a/frontend/src/features/admin/components/ImagePicker.jsx b/frontend/src/features/admin/components/ImagePicker.jsx new file mode 100644 index 0000000..e0c5429 --- /dev/null +++ b/frontend/src/features/admin/components/ImagePicker.jsx @@ -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 ( +
+
e.stopPropagation()}> +
+

이미지 선택

+ +
+ + {/* 검색 */} +
+
+ 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" + /> + 🔍 +
+
+ + {/* 이미지 그리드 */} +
+ {isLoading ? ( +
+ {Array.from({ length: 12 }).map((_, i) => ( +
+ ))} +
+ ) : images.length === 0 ? ( +
+ {debouncedSearch ? '검색 결과가 없습니다' : '업로드된 이미지가 없습니다'} +
+ ) : ( +
+ {images.map((image) => ( + + ))} +
+ )} +
+ + {/* 페이지네이션 + 액션 */} +
+ {totalPages > 1 ? ( +
+ + {page} / {totalPages} + +
+ ) :
} + + {currentImageId && ( + + )} +
+
+
+ ) +} diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx index 2898346..1aaee6d 100644 --- a/frontend/src/main.jsx +++ b/frontend/src/main.jsx @@ -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( - - - + + + + + , ) diff --git a/frontend/src/pages/Home.jsx b/frontend/src/pages/Home.jsx index 064176d..821ae0c 100644 --- a/frontend/src/pages/Home.jsx +++ b/frontend/src/pages/Home.jsx @@ -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 (
@@ -52,12 +47,8 @@ export default function Home() { >
-
- {menu.image_url ? ( - {menu.title} - ) : ( - menu.icon || '📋' - )} +
+ {menu.title}

{menu.title}