메뉴 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:
parent
35df389141
commit
a0fd5a2dbb
9 changed files with 331 additions and 57 deletions
15
backend/models/Menu.js
Normal file
15
backend/models/Menu.js
Normal 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'] }],
|
||||||
|
});
|
||||||
|
|
@ -1,3 +1,7 @@
|
||||||
import { Image } from './Image.js';
|
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 };
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import multer from 'multer';
|
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 { convertAndUpload, deleteFromS3 } from '../services/image.js';
|
||||||
import { getPublicUrl } from '../lib/s3.js';
|
import { getPublicUrl } from '../lib/s3.js';
|
||||||
|
import { sequelize } from '../lib/db.js';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
const upload = multer({
|
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;
|
export default router;
|
||||||
|
|
|
||||||
34
backend/routes/menus.js
Normal file
34
backend/routes/menus.js
Normal 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;
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
import cors from 'cors';
|
import cors from 'cors';
|
||||||
import adminRoutes from './routes/admin.js';
|
import adminRoutes from './routes/admin.js';
|
||||||
|
import menuRoutes from './routes/menus.js';
|
||||||
import { sequelize } from './lib/db.js';
|
import { sequelize } from './lib/db.js';
|
||||||
import './models/index.js';
|
import './models/index.js';
|
||||||
|
|
||||||
|
|
@ -15,6 +16,7 @@ app.use(cors({
|
||||||
}));
|
}));
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
|
|
||||||
|
app.use('/api/menus', menuRoutes);
|
||||||
app.use('/api/admin', adminRoutes);
|
app.use('/api/admin', adminRoutes);
|
||||||
|
|
||||||
app.get('/api/health', (_req, res) => {
|
app.get('/api/health', (_req, res) => {
|
||||||
|
|
|
||||||
46
frontend/src/components/ConfirmDialog.jsx
Normal file
46
frontend/src/components/ConfirmDialog.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { api } from '../../api/client'
|
import { api } from '../../api/client'
|
||||||
|
import ConfirmDialog from '../../components/ConfirmDialog'
|
||||||
|
|
||||||
/* ── 공용 모달 ── */
|
/* ── 공용 모달 ── */
|
||||||
function Modal({ open, onClose, title, children, maxWidth = 'max-w-md' }) {
|
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 }) {
|
function ImageCard({ image, selected, selectMode, onToggle, onCopyUrl, copied }) {
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
import { useState } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { useNavigate, useParams } from 'react-router-dom'
|
import { useNavigate, useParams } from 'react-router-dom'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { api } from '../../api/client'
|
import { api } from '../../api/client'
|
||||||
import ImagePicker from './components/ImagePicker'
|
import ImagePicker from './components/ImagePicker'
|
||||||
|
import ConfirmDialog from '../../components/ConfirmDialog'
|
||||||
|
|
||||||
function Field({ label, hint, error, required, children }) {
|
function Field({ label, hint, error, required, children }) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -28,39 +29,54 @@ export default function AdminMenuForm() {
|
||||||
const isEdit = !!id
|
const isEdit = !!id
|
||||||
|
|
||||||
const [pickerOpen, setPickerOpen] = useState(false)
|
const [pickerOpen, setPickerOpen] = useState(false)
|
||||||
|
const [confirmDelete, setConfirmDelete] = useState(false)
|
||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
title: '',
|
title: '',
|
||||||
description: '',
|
description: '',
|
||||||
url: '/',
|
slug: '', // 사용자 입력 (앞 / 제외)
|
||||||
image_id: null,
|
image_id: null,
|
||||||
image: null, // 미리보기용 캐시
|
image: null, // 미리보기용 캐시
|
||||||
})
|
})
|
||||||
const [errors, setErrors] = useState({})
|
const [errors, setErrors] = useState({})
|
||||||
|
|
||||||
// 편집 모드일 때 기존 데이터 로드
|
// 편집 모드일 때 기존 데이터 로드
|
||||||
useQuery({
|
const { data: menuData } = useQuery({
|
||||||
queryKey: ['admin', 'menus', id],
|
queryKey: ['admin', 'menus', id],
|
||||||
queryFn: async () => {
|
queryFn: () => api(`/api/admin/menus/${id}`),
|
||||||
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,
|
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 }))
|
const update = (patch) => setForm((prev) => ({ ...prev, ...patch }))
|
||||||
|
|
||||||
|
// slug에서 / 자동 제거 (붙여넣기 등 대비)
|
||||||
|
const handleSlugChange = (value) => {
|
||||||
|
update({ slug: value.replace(/^\/+/, '') })
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullUrl = `/${form.slug.trim()}`
|
||||||
|
|
||||||
const validate = () => {
|
const validate = () => {
|
||||||
const errs = {}
|
const errs = {}
|
||||||
if (!form.title.trim()) errs.title = '제목을 입력해주세요'
|
if (!form.title.trim()) errs.title = '제목을 입력해주세요'
|
||||||
if (!form.url.trim()) errs.url = 'URL을 입력해주세요'
|
if (!form.slug.trim()) errs.slug = '경로를 입력해주세요'
|
||||||
else if (!form.url.startsWith('/')) errs.url = 'URL은 /로 시작해야 합니다'
|
else if (!/^[a-zA-Z0-9\-/]+$/.test(form.slug.trim())) errs.slug = '영문, 숫자, 하이픈(-), 슬래시(/)만 사용할 수 있습니다'
|
||||||
setErrors(errs)
|
setErrors(errs)
|
||||||
return Object.keys(errs).length === 0
|
return Object.keys(errs).length === 0
|
||||||
}
|
}
|
||||||
|
|
@ -70,7 +86,7 @@ export default function AdminMenuForm() {
|
||||||
const payload = {
|
const payload = {
|
||||||
title: form.title.trim(),
|
title: form.title.trim(),
|
||||||
description: form.description.trim(),
|
description: form.description.trim(),
|
||||||
url: form.url.trim(),
|
url: fullUrl,
|
||||||
image_id: form.image_id,
|
image_id: form.image_id,
|
||||||
}
|
}
|
||||||
if (isEdit) {
|
if (isEdit) {
|
||||||
|
|
@ -92,6 +108,16 @@ export default function AdminMenuForm() {
|
||||||
saveMutation.mutate()
|
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 (
|
return (
|
||||||
<div className="space-y-6 max-w-2xl">
|
<div className="space-y-6 max-w-2xl">
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -136,14 +162,25 @@ export default function AdminMenuForm() {
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<Field label="URL" required hint="/로 시작하는 라우트 경로" error={errors.url}>
|
<Field label="경로" required error={errors.slug}>
|
||||||
<input
|
<div className={`flex items-stretch rounded-lg border bg-gray-950 transition focus-within:border-emerald-500/50 ${
|
||||||
type="text"
|
errors.slug ? 'border-red-500/40' : 'border-white/10'
|
||||||
value={form.url}
|
}`}>
|
||||||
onChange={(e) => update({ url: e.target.value })}
|
<span className="flex items-center px-3 text-sm text-gray-500 border-r border-white/10 select-none">/</span>
|
||||||
placeholder="예: /boss"
|
<input
|
||||||
className={inputCls}
|
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>
|
||||||
|
|
||||||
<Field label="아이콘 이미지" hint="선택사항">
|
<Field label="아이콘 이미지" hint="선택사항">
|
||||||
|
|
@ -178,24 +215,45 @@ export default function AdminMenuForm() {
|
||||||
</div>
|
</div>
|
||||||
</Field>
|
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => navigate('/admin')}
|
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>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={saveMutation.isPending}
|
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 ? '저장' : '추가')}
|
{saveMutation.isPending ? '저장 중...' : (isEdit ? '저장' : '추가')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={confirmDelete}
|
||||||
|
onClose={() => setConfirmDelete(false)}
|
||||||
|
onConfirm={() => deleteMutation.mutate()}
|
||||||
|
title="메뉴 삭제"
|
||||||
|
description={`"${form.title}" 메뉴를 삭제하시겠습니까?\n\n이 작업은 되돌릴 수 없습니다.`}
|
||||||
|
confirmText="삭제"
|
||||||
|
destructive
|
||||||
|
loading={deleteMutation.isPending}
|
||||||
|
/>
|
||||||
|
|
||||||
<ImagePicker
|
<ImagePicker
|
||||||
open={pickerOpen}
|
open={pickerOpen}
|
||||||
onClose={() => setPickerOpen(false)}
|
onClose={() => setPickerOpen(false)}
|
||||||
|
|
|
||||||
|
|
@ -14,3 +14,13 @@ body {
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
button:not(:disabled),
|
||||||
|
[role="button"]:not(:disabled),
|
||||||
|
a {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue