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 0000000..26ff958 Binary files /dev/null and b/frontend/public/default.png differ diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 69d29b1..a19ebae 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -15,6 +15,7 @@ export default function App() { } /> } /> } /> + } /> 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}