메뉴 CRUD 백엔드 + 폼 개선

백엔드:
- 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) <noreply@anthropic.com>
This commit is contained in:
caadiq 2026-04-13 15:20:46 +09:00
parent 35df389141
commit a0fd5a2dbb
9 changed files with 331 additions and 57 deletions

15
backend/models/Menu.js Normal file
View file

@ -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'] }],
});

View file

@ -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 };

View file

@ -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;

34
backend/routes/menus.js Normal file
View file

@ -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;

View file

@ -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) => {

View file

@ -0,0 +1,46 @@
export default function ConfirmDialog({
open,
onClose,
onConfirm,
title,
description,
confirmText = '확인',
cancelText = '취소',
destructive = false,
loading = false,
}) {
if (!open) return null
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm" onClick={onClose}>
<div className="w-full max-w-md rounded-2xl bg-gray-900 border border-white/10 shadow-2xl" onClick={(e) => e.stopPropagation()}>
<div className="px-6 py-4 border-b border-white/5 flex items-center justify-between">
<h3 className="font-semibold">{title}</h3>
<button onClick={onClose} className="text-gray-500 hover:text-white transition text-xl leading-none">×</button>
</div>
<div className="p-6">
<p className="text-sm text-gray-300 leading-relaxed whitespace-pre-line">{description}</p>
</div>
<div className="flex gap-2 px-6 py-4 border-t border-white/5">
<button
onClick={onClose}
className="flex-1 rounded-lg border border-white/10 px-4 py-2 text-sm hover:bg-white/5 transition"
>
{cancelText}
</button>
<button
onClick={onConfirm}
disabled={loading}
className={`flex-1 rounded-lg px-4 py-2 text-sm font-medium transition disabled:opacity-50 ${
destructive
? 'bg-red-600 hover:bg-red-500 shadow-lg shadow-red-500/20'
: 'bg-emerald-600 hover:bg-emerald-500'
}`}
>
{loading ? '처리 중...' : confirmText}
</button>
</div>
</div>
</div>
)
}

View file

@ -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 (
<Modal open={open} onClose={onClose} title={title}>
<div className="p-6">
<p className="text-sm text-gray-300 leading-relaxed whitespace-pre-line">{description}</p>
</div>
<div className="flex gap-2 px-6 py-4 border-t border-white/5">
<button onClick={onClose} className="flex-1 rounded-lg border border-white/10 px-4 py-2 text-sm hover:bg-white/5 transition">
취소
</button>
<button
onClick={onConfirm}
disabled={loading}
className={`flex-1 rounded-lg px-4 py-2 text-sm font-medium transition disabled:opacity-50 ${
destructive
? 'bg-red-600 hover:bg-red-500 shadow-lg shadow-red-500/20'
: 'bg-emerald-600 hover:bg-emerald-500'
}`}
>
{loading ? '처리 중...' : confirmText}
</button>
</div>
</Modal>
)
}
/* ── 이미지 카드 ── */
function ImageCard({ image, selected, selectMode, onToggle, onCopyUrl, copied }) {
return (

View file

@ -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 (
<div className="space-y-6 max-w-2xl">
<div>
@ -136,14 +162,25 @@ export default function AdminMenuForm() {
/>
</Field>
<Field label="URL" required hint="/로 시작하는 라우트 경로" error={errors.url}>
<input
type="text"
value={form.url}
onChange={(e) => update({ url: e.target.value })}
placeholder="예: /boss"
className={inputCls}
/>
<Field label="경로" required error={errors.slug}>
<div className={`flex items-stretch rounded-lg border bg-gray-950 transition focus-within:border-emerald-500/50 ${
errors.slug ? 'border-red-500/40' : 'border-white/10'
}`}>
<span className="flex items-center px-3 text-sm text-gray-500 border-r border-white/10 select-none">/</span>
<input
type="text"
value={form.slug}
onChange={(e) => handleSlugChange(e.target.value)}
placeholder="boss-crystal"
className="flex-1 min-w-0 bg-transparent px-3 py-2 text-sm outline-none"
/>
</div>
{form.slug.trim() && !errors.slug && (
<div className="text-xs text-gray-500 mt-1.5 flex items-center gap-1.5">
<span>전체 URL:</span>
<code className="text-emerald-400 bg-gray-950/50 px-1.5 py-0.5 rounded">https://maple.caadiq.co.kr{fullUrl}</code>
</div>
)}
</Field>
<Field label="아이콘 이미지" hint="선택사항">
@ -178,24 +215,45 @@ export default function AdminMenuForm() {
</div>
</Field>
<div className="flex gap-2 pt-2">
<div className="flex items-center gap-2 pt-2">
{isEdit && (
<button
type="button"
onClick={() => setConfirmDelete(true)}
className="rounded-lg border border-red-500/30 text-red-400 hover:bg-red-500/10 hover:border-red-500/50 px-4 py-2.5 text-sm transition"
>
삭제
</button>
)}
<div className="flex-1" />
<button
type="button"
onClick={() => navigate('/admin')}
className="flex-1 rounded-lg border border-white/10 px-4 py-2.5 text-sm hover:bg-white/5 transition"
className="rounded-lg border border-white/10 px-5 py-2.5 text-sm hover:bg-white/5 transition"
>
취소
</button>
<button
type="submit"
disabled={saveMutation.isPending}
className="flex-1 rounded-lg bg-emerald-600 hover:bg-emerald-500 px-4 py-2.5 text-sm font-medium disabled:opacity-50 transition shadow-lg shadow-emerald-500/20"
className="rounded-lg bg-emerald-600 hover:bg-emerald-500 px-5 py-2.5 text-sm font-medium disabled:opacity-50 transition shadow-lg shadow-emerald-500/20"
>
{saveMutation.isPending ? '저장 중...' : (isEdit ? '저장' : '추가')}
</button>
</div>
</form>
<ConfirmDialog
open={confirmDelete}
onClose={() => setConfirmDelete(false)}
onConfirm={() => deleteMutation.mutate()}
title="메뉴 삭제"
description={`"${form.title}" 메뉴를 삭제하시겠습니까?\n\n이 작업은 되돌릴 수 없습니다.`}
confirmText="삭제"
destructive
loading={deleteMutation.isPending}
/>
<ImagePicker
open={pickerOpen}
onClose={() => setPickerOpen(false)}

View file

@ -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;
}