From a0fd5a2dbbda067c0c6694889a8a02f2fedca136 Mon Sep 17 00:00:00 2001 From: caadiq Date: Mon, 13 Apr 2026 15:20:46 +0900 Subject: [PATCH] =?UTF-8?q?=EB=A9=94=EB=89=B4=20CRUD=20=EB=B0=B1=EC=97=94?= =?UTF-8?q?=EB=93=9C=20+=20=ED=8F=BC=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 백엔드: - Menu 모델 + admin/menus CRUD API + 공개 /api/menus 엔드포인트 - 정렬 변경(reorder) API 추가 (드래그앤드롭 대비) 프론트엔드: - 메뉴 삭제 기능 (편집 모드 폼 좌측 빨간 버튼) - ConfirmDialog를 공용 컴포넌트로 추출 - URL 입력을 prefix(/) 형태로 분리, 실제 URL 미리보기 표시 - 캐시 hit 시 폼 동기화 안되던 버그 수정 (useEffect로 데이터 sync) - 전역 button/a 태그에 cursor-pointer 적용 Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/models/Menu.js | 15 ++ backend/models/index.js | 6 +- backend/routes/admin.js | 133 +++++++++++++++++- backend/routes/menus.js | 34 +++++ backend/server.js | 2 + frontend/src/components/ConfirmDialog.jsx | 46 ++++++ frontend/src/features/admin/AdminImages.jsx | 28 +--- frontend/src/features/admin/AdminMenuForm.jsx | 114 +++++++++++---- frontend/src/index.css | 10 ++ 9 files changed, 331 insertions(+), 57 deletions(-) create mode 100644 backend/models/Menu.js create mode 100644 backend/routes/menus.js create mode 100644 frontend/src/components/ConfirmDialog.jsx diff --git a/backend/models/Menu.js b/backend/models/Menu.js new file mode 100644 index 0000000..9a59ad5 --- /dev/null +++ b/backend/models/Menu.js @@ -0,0 +1,15 @@ +import { DataTypes } from 'sequelize'; +import { sequelize } from '../lib/db.js'; + +export const Menu = sequelize.define('Menu', { + id: { type: DataTypes.INTEGER, autoIncrement: true, primaryKey: true }, + title: { type: DataTypes.STRING(100), allowNull: false }, + description: { type: DataTypes.STRING(255) }, + url: { type: DataTypes.STRING(255), allowNull: false }, + image_id: { type: DataTypes.INTEGER, allowNull: true }, + sort_order: { type: DataTypes.INTEGER, defaultValue: 0 }, +}, { + tableName: 'menus', + underscored: true, + indexes: [{ fields: ['sort_order'] }], +}); diff --git a/backend/models/index.js b/backend/models/index.js index f59e636..0c18e09 100644 --- a/backend/models/index.js +++ b/backend/models/index.js @@ -1,3 +1,7 @@ import { Image } from './Image.js'; +import { Menu } from './Menu.js'; -export { Image }; +// Menu <-> Image (선택적 FK) +Menu.belongsTo(Image, { foreignKey: 'image_id', as: 'image', onDelete: 'SET NULL' }); + +export { Image, Menu }; diff --git a/backend/routes/admin.js b/backend/routes/admin.js index 85e51d2..7344570 100644 --- a/backend/routes/admin.js +++ b/backend/routes/admin.js @@ -1,8 +1,9 @@ import { Router } from 'express'; import multer from 'multer'; -import { Image } from '../models/index.js'; +import { Image, Menu } from '../models/index.js'; import { convertAndUpload, deleteFromS3 } from '../services/image.js'; import { getPublicUrl } from '../lib/s3.js'; +import { sequelize } from '../lib/db.js'; const router = Router(); const upload = multer({ @@ -173,4 +174,134 @@ router.post('/images/delete', async (req, res) => { } }); +/* ── 메뉴 관리 ── */ + +function serializeMenu(menu) { + const json = menu.toJSON(); + return { + id: json.id, + title: json.title, + description: json.description, + url: json.url, + sort_order: json.sort_order, + image_id: json.image_id, + image: json.image ? { id: json.image.id, name: json.image.name, url: getPublicUrl(json.image.path) } : null, + }; +} + +// 메뉴 목록 +router.get('/menus', async (_req, res) => { + try { + const menus = await Menu.findAll({ + order: [['sort_order', 'ASC'], ['id', 'ASC']], + include: [{ model: Image, as: 'image' }], + }); + res.json(menus.map(serializeMenu)); + } catch (err) { + console.error('메뉴 목록 조회 오류:', err.message); + res.status(500).json({ error: '메뉴 목록 조회 실패' }); + } +}); + +// 메뉴 단일 조회 +router.get('/menus/:id', async (req, res) => { + try { + const menu = await Menu.findByPk(req.params.id, { + include: [{ model: Image, as: 'image' }], + }); + if (!menu) return res.status(404).json({ error: '메뉴를 찾을 수 없습니다' }); + res.json(serializeMenu(menu)); + } catch (err) { + console.error('메뉴 조회 오류:', err.message); + res.status(500).json({ error: '메뉴 조회 실패' }); + } +}); + +// 메뉴 생성 +router.post('/menus', async (req, res) => { + const { title, description, url, image_id } = req.body; + if (!title?.trim()) return res.status(400).json({ error: '제목을 입력해주세요' }); + if (!url?.trim()) return res.status(400).json({ error: 'URL을 입력해주세요' }); + if (!url.startsWith('/')) return res.status(400).json({ error: 'URL은 /로 시작해야 합니다' }); + + try { + // 새 메뉴는 가장 마지막 순서로 + const max = await Menu.max('sort_order') || 0; + const menu = await Menu.create({ + title: title.trim(), + description: (description || '').trim(), + url: url.trim(), + image_id: image_id || null, + sort_order: max + 1, + }); + const created = await Menu.findByPk(menu.id, { include: [{ model: Image, as: 'image' }] }); + res.json(serializeMenu(created)); + } catch (err) { + console.error('메뉴 생성 오류:', err.message); + res.status(500).json({ error: '메뉴 생성 실패' }); + } +}); + +// 메뉴 수정 +router.patch('/menus/:id', async (req, res) => { + const { title, description, url, image_id } = req.body; + + try { + const menu = await Menu.findByPk(req.params.id); + if (!menu) return res.status(404).json({ error: '메뉴를 찾을 수 없습니다' }); + + if (title !== undefined) { + if (!title.trim()) return res.status(400).json({ error: '제목을 입력해주세요' }); + menu.title = title.trim(); + } + if (description !== undefined) menu.description = description.trim(); + if (url !== undefined) { + if (!url.trim()) return res.status(400).json({ error: 'URL을 입력해주세요' }); + if (!url.startsWith('/')) return res.status(400).json({ error: 'URL은 /로 시작해야 합니다' }); + menu.url = url.trim(); + } + if (image_id !== undefined) menu.image_id = image_id || null; + + await menu.save(); + const updated = await Menu.findByPk(menu.id, { include: [{ model: Image, as: 'image' }] }); + res.json(serializeMenu(updated)); + } catch (err) { + console.error('메뉴 수정 오류:', err.message); + res.status(500).json({ error: '메뉴 수정 실패' }); + } +}); + +// 메뉴 삭제 +router.delete('/menus/:id', async (req, res) => { + try { + const menu = await Menu.findByPk(req.params.id); + if (!menu) return res.status(404).json({ error: '메뉴를 찾을 수 없습니다' }); + await menu.destroy(); + res.json({ success: true }); + } catch (err) { + console.error('메뉴 삭제 오류:', err.message); + res.status(500).json({ error: '메뉴 삭제 실패' }); + } +}); + +// 메뉴 정렬 순서 변경 (드래그 앤 드롭용) +router.post('/menus/reorder', async (req, res) => { + const { ids } = req.body; + if (!Array.isArray(ids) || ids.length === 0) { + return res.status(400).json({ error: '정렬할 메뉴 ID 목록이 필요합니다' }); + } + + try { + await sequelize.transaction(async (tx) => { + for (let i = 0; i < ids.length; i++) { + await Menu.update({ sort_order: i }, { where: { id: ids[i] }, transaction: tx }); + } + }); + res.json({ success: true }); + } catch (err) { + console.error('메뉴 정렬 변경 오류:', err.message); + res.status(500).json({ error: '메뉴 정렬 변경 실패' }); + } +}); + export default router; diff --git a/backend/routes/menus.js b/backend/routes/menus.js new file mode 100644 index 0000000..7295d4d --- /dev/null +++ b/backend/routes/menus.js @@ -0,0 +1,34 @@ +import { Router } from 'express'; +import { Menu, Image } from '../models/index.js'; +import { getPublicUrl } from '../lib/s3.js'; + +const router = Router(); + +function serialize(menu) { + const json = menu.toJSON(); + return { + id: json.id, + title: json.title, + description: json.description, + url: json.url, + sort_order: json.sort_order, + image_id: json.image_id, + image: json.image ? { id: json.image.id, name: json.image.name, url: getPublicUrl(json.image.path) } : null, + }; +} + +// 공개 메뉴 목록 (홈 화면용) +router.get('/', async (_req, res) => { + try { + const menus = await Menu.findAll({ + order: [['sort_order', 'ASC'], ['id', 'ASC']], + include: [{ model: Image, as: 'image' }], + }); + res.json(menus.map(serialize)); + } catch (err) { + console.error('메뉴 목록 조회 오류:', err.message); + res.status(500).json({ error: '메뉴 목록 조회 실패' }); + } +}); + +export default router; diff --git a/backend/server.js b/backend/server.js index 88e0693..f52ac72 100644 --- a/backend/server.js +++ b/backend/server.js @@ -1,6 +1,7 @@ import express from 'express'; import cors from 'cors'; import adminRoutes from './routes/admin.js'; +import menuRoutes from './routes/menus.js'; import { sequelize } from './lib/db.js'; import './models/index.js'; @@ -15,6 +16,7 @@ app.use(cors({ })); app.use(express.json()); +app.use('/api/menus', menuRoutes); app.use('/api/admin', adminRoutes); app.get('/api/health', (_req, res) => { diff --git a/frontend/src/components/ConfirmDialog.jsx b/frontend/src/components/ConfirmDialog.jsx new file mode 100644 index 0000000..7fc49b0 --- /dev/null +++ b/frontend/src/components/ConfirmDialog.jsx @@ -0,0 +1,46 @@ +export default function ConfirmDialog({ + open, + onClose, + onConfirm, + title, + description, + confirmText = '확인', + cancelText = '취소', + destructive = false, + loading = false, +}) { + if (!open) return null + + return ( +
+
e.stopPropagation()}> +
+

{title}

+ +
+
+

{description}

+
+
+ + +
+
+
+ ) +} diff --git a/frontend/src/features/admin/AdminImages.jsx b/frontend/src/features/admin/AdminImages.jsx index 10455c6..35d5258 100644 --- a/frontend/src/features/admin/AdminImages.jsx +++ b/frontend/src/features/admin/AdminImages.jsx @@ -1,6 +1,7 @@ import { useState, useEffect } from 'react' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { api } from '../../api/client' +import ConfirmDialog from '../../components/ConfirmDialog' /* ── 공용 모달 ── */ function Modal({ open, onClose, title, children, maxWidth = 'max-w-md' }) { @@ -163,33 +164,6 @@ function UploadModal({ open, onClose, onUpload, uploading, existingNames }) { ) } -/* ── 삭제 확인 다이얼로그 ── */ -function ConfirmDialog({ open, onClose, onConfirm, title, description, confirmText = '삭제', destructive = false, loading = false }) { - return ( - -
-

{description}

-
-
- - -
-
- ) -} - /* ── 이미지 카드 ── */ function ImageCard({ image, selected, selectMode, onToggle, onCopyUrl, copied }) { return ( diff --git a/frontend/src/features/admin/AdminMenuForm.jsx b/frontend/src/features/admin/AdminMenuForm.jsx index 3723d5c..0ad3012 100644 --- a/frontend/src/features/admin/AdminMenuForm.jsx +++ b/frontend/src/features/admin/AdminMenuForm.jsx @@ -1,8 +1,9 @@ -import { useState } from 'react' +import { useState, useEffect } 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' +import ConfirmDialog from '../../components/ConfirmDialog' function Field({ label, hint, error, required, children }) { return ( @@ -28,39 +29,54 @@ export default function AdminMenuForm() { const isEdit = !!id const [pickerOpen, setPickerOpen] = useState(false) + const [confirmDelete, setConfirmDelete] = useState(false) const [form, setForm] = useState({ title: '', description: '', - url: '/', + slug: '', // 사용자 입력 (앞 / 제외) image_id: null, image: null, // 미리보기용 캐시 }) const [errors, setErrors] = useState({}) // 편집 모드일 때 기존 데이터 로드 - useQuery({ + const { data: menuData } = 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 - }, + queryFn: () => api(`/api/admin/menus/${id}`), enabled: isEdit, }) + // id 변경 또는 데이터 로드 시 폼 동기화 + useEffect(() => { + if (!isEdit) { + setForm({ title: '', description: '', slug: '', image_id: null, image: null }) + return + } + if (menuData) { + setForm({ + title: menuData.title || '', + description: menuData.description || '', + slug: (menuData.url || '').replace(/^\/+/, ''), + image_id: menuData.image_id, + image: menuData.image, + }) + } + }, [isEdit, id, menuData]) + const update = (patch) => setForm((prev) => ({ ...prev, ...patch })) + // slug에서 / 자동 제거 (붙여넣기 등 대비) + const handleSlugChange = (value) => { + update({ slug: value.replace(/^\/+/, '') }) + } + + const fullUrl = `/${form.slug.trim()}` + 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은 /로 시작해야 합니다' + if (!form.slug.trim()) errs.slug = '경로를 입력해주세요' + else if (!/^[a-zA-Z0-9\-/]+$/.test(form.slug.trim())) errs.slug = '영문, 숫자, 하이픈(-), 슬래시(/)만 사용할 수 있습니다' setErrors(errs) return Object.keys(errs).length === 0 } @@ -70,7 +86,7 @@ export default function AdminMenuForm() { const payload = { title: form.title.trim(), description: form.description.trim(), - url: form.url.trim(), + url: fullUrl, image_id: form.image_id, } if (isEdit) { @@ -92,6 +108,16 @@ export default function AdminMenuForm() { saveMutation.mutate() } + const deleteMutation = useMutation({ + mutationFn: () => api(`/api/admin/menus/${id}`, { method: 'DELETE' }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['admin', 'menus'] }) + queryClient.invalidateQueries({ queryKey: ['menus'] }) + navigate('/admin') + }, + onError: (err) => alert(err.message), + }) + return (
@@ -136,14 +162,25 @@ export default function AdminMenuForm() { /> - - update({ url: e.target.value })} - placeholder="예: /boss" - className={inputCls} - /> + +
+ / + handleSlugChange(e.target.value)} + placeholder="boss-crystal" + className="flex-1 min-w-0 bg-transparent px-3 py-2 text-sm outline-none" + /> +
+ {form.slug.trim() && !errors.slug && ( +
+ 전체 URL: + https://maple.caadiq.co.kr{fullUrl} +
+ )}
@@ -178,24 +215,45 @@ export default function AdminMenuForm() {
-
+
+ {isEdit && ( + + )} +
+ setConfirmDelete(false)} + onConfirm={() => deleteMutation.mutate()} + title="메뉴 삭제" + description={`"${form.title}" 메뉴를 삭제하시겠습니까?\n\n이 작업은 되돌릴 수 없습니다.`} + confirmText="삭제" + destructive + loading={deleteMutation.isPending} + /> + setPickerOpen(false)} diff --git a/frontend/src/index.css b/frontend/src/index.css index 15b0904..a4cba8a 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -14,3 +14,13 @@ body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } + +button:not(:disabled), +[role="button"]:not(:disabled), +a { + cursor: pointer; +} + +button:disabled { + cursor: not-allowed; +}