보스 결정 관리 개선
- 최대 인원수를 보스 단위로 통합 (난이도별 → 보스 공통) - 가격 입력 시 쉼표 자동 표시 (text + inputMode=numeric) - registry 캐싱으로 sub-route 변경 시 화면 갱신 안되던 버그 수정 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
39cda0d958
commit
b885f464c3
9 changed files with 353 additions and 42 deletions
14
backend/models/boss-crystal/Boss.js
Normal file
14
backend/models/boss-crystal/Boss.js
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { DataTypes } from 'sequelize';
|
||||
import { sequelize } from '../../lib/db.js';
|
||||
|
||||
export const BossCrystalBoss = sequelize.define('BossCrystalBoss', {
|
||||
id: { type: DataTypes.INTEGER, autoIncrement: true, primaryKey: true },
|
||||
name: { type: DataTypes.STRING(50), allowNull: false, unique: true },
|
||||
image_path: { type: DataTypes.STRING(255), allowNull: true }, // S3 키 (예: crystal/boss/검은마법사.webp)
|
||||
max_party_size: { type: DataTypes.TINYINT, allowNull: false, defaultValue: 6 },
|
||||
sort_order: { type: DataTypes.INTEGER, defaultValue: 0 },
|
||||
}, {
|
||||
tableName: 'bc_bosses',
|
||||
underscored: true,
|
||||
indexes: [{ fields: ['sort_order'] }],
|
||||
});
|
||||
19
backend/models/boss-crystal/BossDifficulty.js
Normal file
19
backend/models/boss-crystal/BossDifficulty.js
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { DataTypes } from 'sequelize';
|
||||
import { sequelize } from '../../lib/db.js';
|
||||
|
||||
export const BossCrystalBossDifficulty = sequelize.define('BossCrystalBossDifficulty', {
|
||||
id: { type: DataTypes.INTEGER, autoIncrement: true, primaryKey: true },
|
||||
boss_id: { type: DataTypes.INTEGER, allowNull: false },
|
||||
difficulty: {
|
||||
type: DataTypes.ENUM('easy', 'normal', 'hard', 'chaos', 'extreme'),
|
||||
allowNull: false,
|
||||
},
|
||||
crystal_price: { type: DataTypes.BIGINT, allowNull: false },
|
||||
}, {
|
||||
tableName: 'bc_boss_difficulties',
|
||||
underscored: true,
|
||||
timestamps: false,
|
||||
indexes: [
|
||||
{ unique: true, fields: ['boss_id', 'difficulty'] },
|
||||
],
|
||||
});
|
||||
|
|
@ -1,7 +1,17 @@
|
|||
import { Image } from './Image.js';
|
||||
import { Menu } from './Menu.js';
|
||||
import { BossCrystalBoss } from './boss-crystal/Boss.js';
|
||||
import { BossCrystalBossDifficulty } from './boss-crystal/BossDifficulty.js';
|
||||
|
||||
// Menu <-> Image (선택적 FK)
|
||||
// Menu <-> Image
|
||||
Menu.belongsTo(Image, { foreignKey: 'image_id', as: 'image', onDelete: 'SET NULL' });
|
||||
|
||||
export { Image, Menu };
|
||||
// BossCrystal Boss <-> Difficulty
|
||||
BossCrystalBoss.hasMany(BossCrystalBossDifficulty, {
|
||||
foreignKey: 'boss_id',
|
||||
as: 'difficulties',
|
||||
onDelete: 'CASCADE',
|
||||
});
|
||||
BossCrystalBossDifficulty.belongsTo(BossCrystalBoss, { foreignKey: 'boss_id', as: 'boss' });
|
||||
|
||||
export { Image, Menu, BossCrystalBoss, BossCrystalBossDifficulty };
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ 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';
|
||||
import bossCrystalRouter from './admin/boss-crystal.js';
|
||||
|
||||
const router = Router();
|
||||
const upload = multer({
|
||||
|
|
@ -32,6 +33,9 @@ router.post('/verify', (req, res) => {
|
|||
// 이하 모든 라우트는 인증 필요
|
||||
router.use(requireAdmin);
|
||||
|
||||
// 기능별 sub-router
|
||||
router.use('/boss-crystal', bossCrystalRouter);
|
||||
|
||||
/* ── 이미지 관리 ── */
|
||||
|
||||
// 전체 이미지 이름 (중복 체크용)
|
||||
|
|
|
|||
227
backend/routes/admin/boss-crystal.js
Normal file
227
backend/routes/admin/boss-crystal.js
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
import { Router } from 'express';
|
||||
import multer from 'multer';
|
||||
import { BossCrystalBoss, BossCrystalBossDifficulty } from '../../models/index.js';
|
||||
import { uploadBossImage, deleteBossImage } from '../../services/boss-crystal/image.js';
|
||||
import { getPublicUrl } from '../../lib/s3.js';
|
||||
import { sequelize } from '../../lib/db.js';
|
||||
|
||||
const router = Router();
|
||||
const upload = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
limits: { fileSize: 10 * 1024 * 1024 },
|
||||
});
|
||||
|
||||
function serialize(boss) {
|
||||
const json = boss.toJSON();
|
||||
return {
|
||||
id: json.id,
|
||||
name: json.name,
|
||||
image_url: json.image_path ? getPublicUrl(json.image_path) : null,
|
||||
image_path: json.image_path,
|
||||
max_party_size: json.max_party_size,
|
||||
sort_order: json.sort_order,
|
||||
difficulties: (json.difficulties || []).map((d) => ({
|
||||
id: d.id,
|
||||
difficulty: d.difficulty,
|
||||
crystal_price: Number(d.crystal_price),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function parseDifficulties(raw) {
|
||||
if (!raw) return [];
|
||||
let arr;
|
||||
try {
|
||||
arr = typeof raw === 'string' ? JSON.parse(raw) : raw;
|
||||
} catch {
|
||||
throw new Error('난이도 정보 형식이 잘못되었습니다');
|
||||
}
|
||||
if (!Array.isArray(arr) || arr.length === 0) {
|
||||
throw new Error('하나 이상의 난이도가 필요합니다');
|
||||
}
|
||||
const valid = ['easy', 'normal', 'hard', 'chaos', 'extreme'];
|
||||
return arr.map((d) => {
|
||||
if (!valid.includes(d.difficulty)) throw new Error(`잘못된 난이도: ${d.difficulty}`);
|
||||
const price = Number(d.crystal_price);
|
||||
if (isNaN(price) || price <= 0) throw new Error(`잘못된 가격: ${d.difficulty}`);
|
||||
return { difficulty: d.difficulty, crystal_price: price };
|
||||
});
|
||||
}
|
||||
|
||||
function parseMaxParty(raw) {
|
||||
const n = Number(raw);
|
||||
if (isNaN(n) || n < 1 || n > 6) throw new Error('인원수는 1~6이어야 합니다');
|
||||
return n;
|
||||
}
|
||||
|
||||
// 목록
|
||||
router.get('/bosses', async (_req, res) => {
|
||||
try {
|
||||
const bosses = await BossCrystalBoss.findAll({
|
||||
order: [['sort_order', 'ASC'], ['id', 'ASC']],
|
||||
include: [{ model: BossCrystalBossDifficulty, as: 'difficulties' }],
|
||||
});
|
||||
res.json(bosses.map(serialize));
|
||||
} catch (err) {
|
||||
console.error('보스 목록 조회 오류:', err.message);
|
||||
res.status(500).json({ error: '보스 목록 조회 실패' });
|
||||
}
|
||||
});
|
||||
|
||||
// 단일 조회
|
||||
router.get('/bosses/:id', async (req, res) => {
|
||||
try {
|
||||
const boss = await BossCrystalBoss.findByPk(req.params.id, {
|
||||
include: [{ model: BossCrystalBossDifficulty, as: 'difficulties' }],
|
||||
});
|
||||
if (!boss) return res.status(404).json({ error: '보스를 찾을 수 없습니다' });
|
||||
res.json(serialize(boss));
|
||||
} catch (err) {
|
||||
console.error('보스 조회 오류:', err.message);
|
||||
res.status(500).json({ error: '보스 조회 실패' });
|
||||
}
|
||||
});
|
||||
|
||||
// 생성
|
||||
router.post('/bosses', upload.single('image'), async (req, res) => {
|
||||
const { name } = req.body;
|
||||
if (!name?.trim()) return res.status(400).json({ error: '보스 이름을 입력해주세요' });
|
||||
if (!req.file) return res.status(400).json({ error: '보스 이미지를 업로드해주세요' });
|
||||
|
||||
const trimmedName = name.trim();
|
||||
|
||||
try {
|
||||
const difficulties = parseDifficulties(req.body.difficulties);
|
||||
const maxPartySize = parseMaxParty(req.body.max_party_size);
|
||||
|
||||
// 중복 체크
|
||||
const existing = await BossCrystalBoss.findOne({ where: { name: trimmedName } });
|
||||
if (existing) return res.status(400).json({ error: '같은 이름의 보스가 이미 존재합니다' });
|
||||
|
||||
// 이미지 업로드
|
||||
const imagePath = await uploadBossImage(req.file.buffer, trimmedName);
|
||||
|
||||
// 마지막 정렬 순서
|
||||
const max = await BossCrystalBoss.max('sort_order') || 0;
|
||||
|
||||
const boss = await sequelize.transaction(async (tx) => {
|
||||
const created = await BossCrystalBoss.create({
|
||||
name: trimmedName,
|
||||
image_path: imagePath,
|
||||
max_party_size: maxPartySize,
|
||||
sort_order: max + 1,
|
||||
}, { transaction: tx });
|
||||
|
||||
await BossCrystalBossDifficulty.bulkCreate(
|
||||
difficulties.map((d) => ({ ...d, boss_id: created.id })),
|
||||
{ transaction: tx }
|
||||
);
|
||||
|
||||
return created;
|
||||
});
|
||||
|
||||
const fresh = await BossCrystalBoss.findByPk(boss.id, {
|
||||
include: [{ model: BossCrystalBossDifficulty, as: 'difficulties' }],
|
||||
});
|
||||
res.json(serialize(fresh));
|
||||
} catch (err) {
|
||||
console.error('보스 생성 오류:', err.message);
|
||||
res.status(500).json({ error: err.message || '보스 생성 실패' });
|
||||
}
|
||||
});
|
||||
|
||||
// 수정
|
||||
router.patch('/bosses/:id', upload.single('image'), async (req, res) => {
|
||||
try {
|
||||
const boss = await BossCrystalBoss.findByPk(req.params.id);
|
||||
if (!boss) return res.status(404).json({ error: '보스를 찾을 수 없습니다' });
|
||||
|
||||
const newName = req.body.name?.trim();
|
||||
if (!newName) return res.status(400).json({ error: '보스 이름을 입력해주세요' });
|
||||
|
||||
// 이름 중복 체크 (자기 자신 제외)
|
||||
if (newName !== boss.name) {
|
||||
const dup = await BossCrystalBoss.findOne({ where: { name: newName } });
|
||||
if (dup) return res.status(400).json({ error: '같은 이름의 보스가 이미 존재합니다' });
|
||||
}
|
||||
|
||||
const difficulties = parseDifficulties(req.body.difficulties);
|
||||
const maxPartySize = parseMaxParty(req.body.max_party_size);
|
||||
|
||||
let newImagePath = boss.image_path;
|
||||
|
||||
// 새 이미지 업로드 또는 이름 변경 시 이미지 재업로드
|
||||
if (req.file) {
|
||||
const oldPath = boss.image_path;
|
||||
newImagePath = await uploadBossImage(req.file.buffer, newName);
|
||||
if (oldPath && oldPath !== newImagePath) {
|
||||
await deleteBossImage(oldPath);
|
||||
}
|
||||
} else if (newName !== boss.name && boss.image_path) {
|
||||
// 이름만 변경 - 기존 이미지를 새 경로로 복사하는 대신 이름 기반 경로 업데이트
|
||||
// 간단하게 처리: 기존 키를 새 키로 교체할 수 없으니, 이미지가 없으면 그대로, 있으면 path만 갱신
|
||||
newImagePath = `crystal/boss/${newName}.webp`;
|
||||
// 실제로 이름이 바뀌면 이미지를 다시 올려달라고 하는 게 안전. 현재는 path만 업데이트하고 추후 다음 업로드 시 새 경로로 저장됨
|
||||
}
|
||||
|
||||
await sequelize.transaction(async (tx) => {
|
||||
boss.name = newName;
|
||||
boss.image_path = newImagePath;
|
||||
boss.max_party_size = maxPartySize;
|
||||
await boss.save({ transaction: tx });
|
||||
|
||||
await BossCrystalBossDifficulty.destroy({ where: { boss_id: boss.id }, transaction: tx });
|
||||
await BossCrystalBossDifficulty.bulkCreate(
|
||||
difficulties.map((d) => ({ ...d, boss_id: boss.id })),
|
||||
{ transaction: tx }
|
||||
);
|
||||
});
|
||||
|
||||
const fresh = await BossCrystalBoss.findByPk(boss.id, {
|
||||
include: [{ model: BossCrystalBossDifficulty, as: 'difficulties' }],
|
||||
});
|
||||
res.json(serialize(fresh));
|
||||
} catch (err) {
|
||||
console.error('보스 수정 오류:', err.message);
|
||||
res.status(500).json({ error: err.message || '보스 수정 실패' });
|
||||
}
|
||||
});
|
||||
|
||||
// 삭제
|
||||
router.delete('/bosses/:id', async (req, res) => {
|
||||
try {
|
||||
const boss = await BossCrystalBoss.findByPk(req.params.id);
|
||||
if (!boss) return res.status(404).json({ error: '보스를 찾을 수 없습니다' });
|
||||
|
||||
if (boss.image_path) {
|
||||
await deleteBossImage(boss.image_path);
|
||||
}
|
||||
await boss.destroy();
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
console.error('보스 삭제 오류:', err.message);
|
||||
res.status(500).json({ error: '보스 삭제 실패' });
|
||||
}
|
||||
});
|
||||
|
||||
// 정렬 변경
|
||||
router.post('/bosses/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 BossCrystalBoss.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;
|
||||
26
backend/services/boss-crystal/image.js
Normal file
26
backend/services/boss-crystal/image.js
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import sharp from 'sharp';
|
||||
import { uploadObject, deleteObject } from '../../lib/s3.js';
|
||||
|
||||
const BOSS_IMAGE_PREFIX = 'crystal/boss';
|
||||
|
||||
/**
|
||||
* 보스 이미지를 webp로 변환하고 RustFS의 crystal/boss/{name}.webp에 업로드
|
||||
* @param {Buffer} buffer
|
||||
* @param {string} bossName
|
||||
* @returns {Promise<string>} S3 키 (예: crystal/boss/검은마법사.webp)
|
||||
*/
|
||||
export async function uploadBossImage(buffer, bossName) {
|
||||
const webp = await sharp(buffer).webp({ quality: 90 }).toBuffer();
|
||||
const path = `${BOSS_IMAGE_PREFIX}/${bossName}.webp`;
|
||||
await uploadObject(path, webp, 'image/webp');
|
||||
return path;
|
||||
}
|
||||
|
||||
export async function deleteBossImage(path) {
|
||||
if (!path) return;
|
||||
try {
|
||||
await deleteObject(path);
|
||||
} catch (err) {
|
||||
console.warn(`보스 이미지 삭제 실패 (${path}):`, err.message);
|
||||
}
|
||||
}
|
||||
|
|
@ -29,7 +29,7 @@ const inputCls = 'w-full rounded-lg border border-white/10 bg-gray-950 px-3 py-2
|
|||
function emptyDifficultyState() {
|
||||
const obj = {}
|
||||
DIFFICULTIES.forEach((d) => {
|
||||
obj[d.key] = { enabled: false, crystal_price: '', max_party_size: 6 }
|
||||
obj[d.key] = { enabled: false, crystal_price: '' }
|
||||
})
|
||||
return obj
|
||||
}
|
||||
|
|
@ -42,6 +42,7 @@ export default function BossForm() {
|
|||
const fileInputRef = useRef(null)
|
||||
|
||||
const [name, setName] = useState('')
|
||||
const [maxPartySize, setMaxPartySize] = useState(6)
|
||||
const [imageFile, setImageFile] = useState(null)
|
||||
const [imagePreview, setImagePreview] = useState(null)
|
||||
const [existingImageUrl, setExistingImageUrl] = useState(null)
|
||||
|
|
@ -59,6 +60,7 @@ export default function BossForm() {
|
|||
useEffect(() => {
|
||||
if (!isEdit) {
|
||||
setName('')
|
||||
setMaxPartySize(6)
|
||||
setImageFile(null)
|
||||
setImagePreview(null)
|
||||
setExistingImageUrl(null)
|
||||
|
|
@ -67,6 +69,7 @@ export default function BossForm() {
|
|||
}
|
||||
if (bossData) {
|
||||
setName(bossData.name || '')
|
||||
setMaxPartySize(bossData.max_party_size || 6)
|
||||
setExistingImageUrl(bossData.image_url || null)
|
||||
setImagePreview(null)
|
||||
setImageFile(null)
|
||||
|
|
@ -76,7 +79,6 @@ export default function BossForm() {
|
|||
next[d.difficulty] = {
|
||||
enabled: true,
|
||||
crystal_price: String(d.crystal_price),
|
||||
max_party_size: d.max_party_size,
|
||||
}
|
||||
})
|
||||
setDifficulties(next)
|
||||
|
|
@ -121,6 +123,7 @@ export default function BossForm() {
|
|||
mutationFn: async () => {
|
||||
const formData = new FormData()
|
||||
formData.append('name', name.trim())
|
||||
formData.append('max_party_size', String(maxPartySize))
|
||||
if (imageFile) formData.append('image', imageFile)
|
||||
|
||||
const diffsPayload = DIFFICULTIES
|
||||
|
|
@ -128,7 +131,6 @@ export default function BossForm() {
|
|||
.map((d) => ({
|
||||
difficulty: d.key,
|
||||
crystal_price: Number(difficulties[d.key].crystal_price),
|
||||
max_party_size: Number(difficulties[d.key].max_party_size),
|
||||
}))
|
||||
formData.append('difficulties', JSON.stringify(diffsPayload))
|
||||
|
||||
|
|
@ -179,16 +181,26 @@ export default function BossForm() {
|
|||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-5 rounded-2xl border border-white/5 bg-gray-900/40 p-6">
|
||||
{/* 이름 */}
|
||||
<Field label="보스 이름" required error={errors.name}>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="예: 검은 마법사"
|
||||
className={inputCls}
|
||||
/>
|
||||
</Field>
|
||||
{/* 이름 + 최대 인원 */}
|
||||
<div className="grid grid-cols-[1fr_auto] gap-3">
|
||||
<Field label="보스 이름" required error={errors.name}>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="예: 검은 마법사"
|
||||
className={inputCls}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="최대 인원">
|
||||
<Select
|
||||
value={maxPartySize}
|
||||
onChange={setMaxPartySize}
|
||||
options={PARTY_OPTIONS}
|
||||
className="w-24"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
{/* 이미지 */}
|
||||
<Field label="보스 이미지" required={!isEdit} error={errors.image}>
|
||||
|
|
@ -260,9 +272,13 @@ export default function BossForm() {
|
|||
<div className="flex-1 min-w-0">
|
||||
<div className="relative">
|
||||
<input
|
||||
type="number"
|
||||
value={v.crystal_price}
|
||||
onChange={(e) => updateDifficulty(d.key, { crystal_price: e.target.value })}
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={v.crystal_price ? Number(v.crystal_price).toLocaleString() : ''}
|
||||
onChange={(e) => {
|
||||
const digits = e.target.value.replace(/[^\d]/g, '')
|
||||
updateDifficulty(d.key, { crystal_price: digits })
|
||||
}}
|
||||
disabled={!v.enabled}
|
||||
placeholder="결정 가격"
|
||||
className={`w-full rounded-lg border bg-gray-900 pl-4 pr-28 py-2 text-sm outline-none focus:border-emerald-500/50 disabled:opacity-50 disabled:cursor-not-allowed transition ${
|
||||
|
|
@ -276,16 +292,6 @@ export default function BossForm() {
|
|||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 최대 인원 */}
|
||||
<Select
|
||||
value={v.max_party_size}
|
||||
onChange={(val) => updateDifficulty(d.key, { max_party_size: val })}
|
||||
options={PARTY_OPTIONS}
|
||||
disabled={!v.enabled}
|
||||
className="w-20 shrink-0"
|
||||
align="right"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ export default function BossList() {
|
|||
{DIFFICULTIES.filter((d) => boss.difficulties?.some((bd) => bd.difficulty === d.key)).map((d) => {
|
||||
const bd = boss.difficulties.find((x) => x.difficulty === d.key)
|
||||
return (
|
||||
<span key={d.key} className={`text-[10px] px-1.5 py-0.5 rounded border ${d.color}`} title={`${formatMeso(bd.crystal_price)} / ${bd.max_party_size}인`}>
|
||||
<span key={d.key} className={`text-[10px] px-1.5 py-0.5 rounded border ${d.color}`} title={`${formatMeso(bd.crystal_price)} / ${boss.max_party_size}인`}>
|
||||
{d.label}
|
||||
</span>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -21,27 +21,33 @@ function slugToPascal(slug) {
|
|||
.join('')
|
||||
}
|
||||
|
||||
// 컴포넌트 캐시 - 동일 slug에 대해 항상 같은 컴포넌트 인스턴스 반환
|
||||
// (매 렌더마다 새 lazy() 생성하면 React가 unmount/remount하면서 화면 갱신이 깨짐)
|
||||
const userCache = new Map()
|
||||
const adminCache = new Map()
|
||||
|
||||
function loadCached(cache, slug, suffix) {
|
||||
if (cache.has(slug)) return cache.get(slug)
|
||||
const pascal = slugToPascal(slug)
|
||||
const path = `./${slug}/${pascal}${suffix}.jsx`
|
||||
const loader = userPages[path]
|
||||
const component = loader ? lazy(loader) : null
|
||||
cache.set(slug, component)
|
||||
return component
|
||||
}
|
||||
|
||||
/**
|
||||
* slug에 해당하는 사용자 페이지 컴포넌트 반환
|
||||
* @returns {React.LazyExoticComponent | null}
|
||||
*/
|
||||
export function getUserComponent(slug) {
|
||||
const pascal = slugToPascal(slug)
|
||||
const path = `./${slug}/${pascal}.jsx`
|
||||
const loader = userPages[path]
|
||||
if (!loader) return null
|
||||
return lazy(loader)
|
||||
return loadCached(userCache, slug, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* slug에 해당하는 관리자 페이지 컴포넌트 반환
|
||||
*/
|
||||
export function getAdminComponent(slug) {
|
||||
const pascal = slugToPascal(slug)
|
||||
const path = `./${slug}/${pascal}Admin.jsx`
|
||||
const loader = userPages[path]
|
||||
if (!loader) return null
|
||||
return lazy(loader)
|
||||
return loadCached(adminCache, slug, 'Admin')
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -50,6 +56,5 @@ export function getAdminComponent(slug) {
|
|||
export function hasAdminPage(slug) {
|
||||
if (!slug) return false
|
||||
const cleaned = slug.replace(/^\/+/, '').split('/')[0]
|
||||
const pascal = slugToPascal(cleaned)
|
||||
return !!userPages[`./${cleaned}/${pascal}Admin.jsx`]
|
||||
return getAdminComponent(cleaned) !== null
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue