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:
caadiq 2026-04-13 15:27:04 +09:00
parent a0fd5a2dbb
commit 1de5dcb7b9
7 changed files with 195 additions and 3 deletions

View file

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

View 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>
)
}

View 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>
)
}

View file

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

View 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>
)
}

View 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>
)
}

View 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`]
}