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.icon || '📋'
- )}
+
+
-
{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.length === 0 ? (
+ ) : images.length === 0 ? (
🖼️
- {images.length === 0 ? '업로드된 이미지가 없습니다' : '검색 결과가 없습니다'}
+ {debouncedSearch ? '검색 결과가 없습니다' : '업로드된 이미지가 없습니다'}
- {images.length === 0 && (
+ {!debouncedSearch && (
setUploadOpen(true)}
className="text-sm text-emerald-400 hover:text-emerald-300 transition"
@@ -437,38 +511,41 @@ export default function AdminImages() {
)}
) : (
-
- {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() {
{ localStorage.removeItem('maple-admin-key'); setVerified(false) }}
+ onClick={handleLogout}
className="text-sm text-gray-500 hover:text-gray-300 transition"
>
로그아웃
diff --git a/frontend/src/features/admin/AdminMenuForm.jsx b/frontend/src/features/admin/AdminMenuForm.jsx
index f0c194a..3723d5c 100644
--- a/frontend/src/features/admin/AdminMenuForm.jsx
+++ b/frontend/src/features/admin/AdminMenuForm.jsx
@@ -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 (
-
-
-
메뉴 항목 추가
-
새 기능 카드를 추가합니다
-
-
- 준비 중
+
+
+
+ {hint && {hint}}
+ {children}
+ {error &&
{error}
}
+
+ )
+}
+
+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 (
+
+
+
{isEdit ? '메뉴 항목 편집' : '메뉴 항목 추가'}
+
홈 화면에 표시되는 카드의 정보를 설정합니다
+
+
+
+
+
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) => (
+
{ 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}
+ >
+
+

+
+
+
+ ))}
+
+ )}
+
+
+ {/* 페이지네이션 + 액션 */}
+
+ {totalPages > 1 ? (
+
+ 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"
+ >
+ ‹
+
+ {page} / {totalPages}
+ 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"
+ >
+ ›
+
+
+ ) :
}
+
+ {currentImageId && (
+
{ onSelect(null); onClose() }}
+ className="text-sm text-red-400 hover:text-red-300 transition"
+ >
+ 이미지 제거
+
+ )}
+
+
+
+ )
+}
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.icon || '📋'
- )}
+
+
{menu.title}