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 ( +
{description}
+{description}
-https://maple.caadiq.co.kr{fullUrl}
+