URL slug 기반 동적 기능 페이지 라우팅 추가
- features/registry.js: import.meta.glob으로 자동 컴포넌트 등록
- /:slug → features/{slug}/{PascalCase}.jsx 매핑
- /admin/:slug → features/{slug}/{PascalCase}Admin.jsx 매핑
- AdminHome 카드 분리 액션 (본체→기능 관리, ⚙→메뉴 정보 편집)
- AdminFeaturePage에 메뉴 정보 편집 단축 링크 추가
- 예시: features/boss-crystal/ stub 컴포넌트
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
a0fd5a2dbb
commit
1de5dcb7b9
7 changed files with 195 additions and 3 deletions
|
|
@ -1,22 +1,30 @@
|
|||
import { Routes, Route } from 'react-router-dom'
|
||||
import Layout from './components/Layout'
|
||||
import Home from './pages/Home'
|
||||
import FeaturePage from './features/FeaturePage'
|
||||
import AdminLayout from './features/admin/AdminLayout'
|
||||
import AdminHome from './features/admin/AdminHome'
|
||||
import AdminImages from './features/admin/AdminImages'
|
||||
import AdminMenuForm from './features/admin/AdminMenuForm'
|
||||
import AdminFeaturePage from './features/admin/AdminFeaturePage'
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route element={<Layout />}>
|
||||
<Route index element={<Home />} />
|
||||
|
||||
{/* 관리자 */}
|
||||
<Route path="/admin" element={<AdminLayout />}>
|
||||
<Route index element={<AdminHome />} />
|
||||
<Route path="images" element={<AdminImages />} />
|
||||
<Route path="menus/new" element={<AdminMenuForm />} />
|
||||
<Route path="menus/:id" element={<AdminMenuForm />} />
|
||||
<Route path=":slug" element={<AdminFeaturePage />} />
|
||||
</Route>
|
||||
|
||||
{/* 동적 기능 페이지 */}
|
||||
<Route path="/:slug" element={<FeaturePage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
)
|
||||
|
|
|
|||
22
frontend/src/features/FeaturePage.jsx
Normal file
22
frontend/src/features/FeaturePage.jsx
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { Suspense } from 'react'
|
||||
import { useParams, Navigate } from 'react-router-dom'
|
||||
import { getUserComponent } from './registry'
|
||||
|
||||
export default function FeaturePage() {
|
||||
const { slug } = useParams()
|
||||
const Component = getUserComponent(slug)
|
||||
|
||||
if (!Component) {
|
||||
return <Navigate to="/" replace />
|
||||
}
|
||||
|
||||
return (
|
||||
<Suspense fallback={
|
||||
<div className="flex items-center justify-center pt-20">
|
||||
<div className="w-6 h-6 border-2 border-emerald-500 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
}>
|
||||
<Component />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
67
frontend/src/features/admin/AdminFeaturePage.jsx
Normal file
67
frontend/src/features/admin/AdminFeaturePage.jsx
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import { Suspense } from 'react'
|
||||
import { useParams, Navigate, Link } from 'react-router-dom'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { getAdminComponent } from '../registry'
|
||||
import { api } from '../../api/client'
|
||||
|
||||
export default function AdminFeaturePage() {
|
||||
const { slug } = useParams()
|
||||
const Component = getAdminComponent(slug)
|
||||
|
||||
// 메뉴 정보 조회 (편집 링크 표시용)
|
||||
const { data: menus = [] } = useQuery({
|
||||
queryKey: ['admin', 'menus'],
|
||||
queryFn: () => api('/api/admin/menus').catch(() => []),
|
||||
})
|
||||
const menu = menus.find((m) => (m.url || '').replace(/^\/+/, '').split('/')[0] === slug)
|
||||
|
||||
if (!Component) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{menu && (
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">{menu.title}</h2>
|
||||
<p className="text-sm text-gray-500 mt-0.5">{menu.description}</p>
|
||||
</div>
|
||||
<Link
|
||||
to={`/admin/menus/${menu.id}`}
|
||||
className="text-sm rounded-lg border border-white/10 hover:border-white/20 hover:bg-white/5 px-3 py-1.5 transition"
|
||||
>
|
||||
⚙ 메뉴 정보 편집
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
<div className="rounded-2xl border border-dashed border-white/10 bg-white/[0.02] p-12 text-center">
|
||||
<div className="text-4xl mb-3 opacity-30">🛠️</div>
|
||||
<p className="text-gray-400">이 기능에는 관리 페이지가 없습니다</p>
|
||||
<p className="text-xs text-gray-600 mt-2 font-mono">
|
||||
features/{slug}/{slug.split('-').map((s) => s[0].toUpperCase() + s.slice(1)).join('')}Admin.jsx
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{menu && (
|
||||
<div className="flex items-center justify-end">
|
||||
<Link
|
||||
to={`/admin/menus/${menu.id}`}
|
||||
className="text-sm rounded-lg border border-white/10 hover:border-white/20 hover:bg-white/5 px-3 py-1.5 transition"
|
||||
>
|
||||
⚙ 메뉴 정보 편집
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
<Suspense fallback={
|
||||
<div className="flex items-center justify-center pt-20">
|
||||
<div className="w-6 h-6 border-2 border-emerald-500 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
}>
|
||||
<Component />
|
||||
</Suspense>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,20 +1,41 @@
|
|||
import { Link } from 'react-router-dom'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { api } from '../../api/client'
|
||||
|
||||
function MenuCard({ menu }) {
|
||||
const navigate = useNavigate()
|
||||
// 메뉴 url에서 slug 추출 (/boss-crystal → boss-crystal)
|
||||
const slug = (menu.url || '').replace(/^\/+/, '').split('/')[0]
|
||||
const adminPath = slug ? `/admin/${slug}` : `/admin/menus/${menu.id}`
|
||||
|
||||
const handleEditClick = (e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
navigate(`/admin/menus/${menu.id}`)
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={`/admin/menus/${menu.id}`}
|
||||
to={adminPath}
|
||||
className="group relative overflow-hidden rounded-2xl border border-white/5 bg-gradient-to-br from-gray-900/80 to-gray-900/40 p-5 hover:border-emerald-500/30 hover:from-emerald-500/5 hover:to-cyan-500/5 transition-all duration-300"
|
||||
>
|
||||
<div className="absolute -top-12 -right-12 w-32 h-32 rounded-full bg-emerald-500/0 group-hover:bg-emerald-500/10 blur-2xl transition-all duration-500" />
|
||||
|
||||
{/* 톱니바퀴 - 메뉴 정보 편집 */}
|
||||
<button
|
||||
onClick={handleEditClick}
|
||||
className="absolute top-3 right-3 w-8 h-8 rounded-lg border border-white/5 hover:border-white/20 hover:bg-white/5 text-gray-500 hover:text-gray-300 flex items-center justify-center text-sm transition opacity-0 group-hover:opacity-100 z-10"
|
||||
title="메뉴 정보 편집"
|
||||
aria-label="메뉴 정보 편집"
|
||||
>
|
||||
⚙
|
||||
</button>
|
||||
|
||||
<div className="relative flex items-start gap-4">
|
||||
<div className="shrink-0 w-12 h-12 rounded-xl bg-gradient-to-br from-gray-800 to-gray-900 border border-white/5 flex items-center justify-center overflow-hidden group-hover:scale-110 group-hover:border-emerald-500/30 transition-all duration-300">
|
||||
<img src={menu.image?.url || '/default.png'} alt={menu.title} className="w-9 h-9 object-contain" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex-1 min-w-0 pr-8">
|
||||
<h3 className="font-semibold text-white group-hover:text-emerald-300 transition truncate">{menu.title}</h3>
|
||||
<p className="text-sm text-gray-400 mt-1 leading-relaxed truncate">{menu.description}</p>
|
||||
<p className="text-xs text-gray-600 mt-1 font-mono truncate">{menu.url}</p>
|
||||
|
|
|
|||
8
frontend/src/features/boss-crystal/BossCrystal.jsx
Normal file
8
frontend/src/features/boss-crystal/BossCrystal.jsx
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
export default function BossCrystal() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-2xl font-bold">주간 보스 결정석 계산기</h1>
|
||||
<p className="text-gray-400">준비 중입니다.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
11
frontend/src/features/boss-crystal/BossCrystalAdmin.jsx
Normal file
11
frontend/src/features/boss-crystal/BossCrystalAdmin.jsx
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
export default function BossCrystalAdmin() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold">보스 결정석 관리</h2>
|
||||
<p className="text-sm text-gray-500">보스 정보 및 결정석 가격을 관리합니다</p>
|
||||
<div className="rounded-2xl border border-white/5 bg-gray-900/40 p-12 text-center text-gray-500">
|
||||
준비 중
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
55
frontend/src/features/registry.js
Normal file
55
frontend/src/features/registry.js
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
/**
|
||||
* 기능 자동 등록 시스템
|
||||
*
|
||||
* - features/{kebab-case}/{PascalCase}.jsx : 사용자 페이지
|
||||
* - features/{kebab-case}/{PascalCase}Admin.jsx : 관리자 페이지
|
||||
*
|
||||
* 예시:
|
||||
* /boss-crystal → features/boss-crystal/BossCrystal.jsx
|
||||
* /admin/boss-crystal → features/boss-crystal/BossCrystalAdmin.jsx
|
||||
*/
|
||||
|
||||
import { lazy } from 'react'
|
||||
|
||||
// Vite의 import.meta.glob으로 features 폴더 전체를 스캔
|
||||
const userPages = import.meta.glob('./*/*.jsx')
|
||||
|
||||
function slugToPascal(slug) {
|
||||
return slug
|
||||
.split('-')
|
||||
.map((s) => s.charAt(0).toUpperCase() + s.slice(1))
|
||||
.join('')
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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`]
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue