diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index a19ebae..1fc3d14 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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 ( }> } /> + + {/* 관리자 */} }> } /> } /> } /> } /> + } /> + + {/* 동적 기능 페이지 */} + } /> ) diff --git a/frontend/src/features/FeaturePage.jsx b/frontend/src/features/FeaturePage.jsx new file mode 100644 index 0000000..f8c7a48 --- /dev/null +++ b/frontend/src/features/FeaturePage.jsx @@ -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 + } + + return ( + +
+
+ }> + +
+ ) +} diff --git a/frontend/src/features/admin/AdminFeaturePage.jsx b/frontend/src/features/admin/AdminFeaturePage.jsx new file mode 100644 index 0000000..75bda92 --- /dev/null +++ b/frontend/src/features/admin/AdminFeaturePage.jsx @@ -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 ( +
+ {menu && ( +
+
+

{menu.title}

+

{menu.description}

+
+ + ⚙ 메뉴 정보 편집 + +
+ )} +
+
🛠️
+

이 기능에는 관리 페이지가 없습니다

+

+ features/{slug}/{slug.split('-').map((s) => s[0].toUpperCase() + s.slice(1)).join('')}Admin.jsx +

+
+
+ ) + } + + return ( +
+ {menu && ( +
+ + ⚙ 메뉴 정보 편집 + +
+ )} + +
+
+ }> + +
+
+ ) +} diff --git a/frontend/src/features/admin/AdminHome.jsx b/frontend/src/features/admin/AdminHome.jsx index 812521f..306cf60 100644 --- a/frontend/src/features/admin/AdminHome.jsx +++ b/frontend/src/features/admin/AdminHome.jsx @@ -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 (
+ {/* 톱니바퀴 - 메뉴 정보 편집 */} + +
{menu.title}
-
+

{menu.title}

{menu.description}

{menu.url}

diff --git a/frontend/src/features/boss-crystal/BossCrystal.jsx b/frontend/src/features/boss-crystal/BossCrystal.jsx new file mode 100644 index 0000000..11f2f26 --- /dev/null +++ b/frontend/src/features/boss-crystal/BossCrystal.jsx @@ -0,0 +1,8 @@ +export default function BossCrystal() { + return ( +
+

주간 보스 결정석 계산기

+

준비 중입니다.

+
+ ) +} diff --git a/frontend/src/features/boss-crystal/BossCrystalAdmin.jsx b/frontend/src/features/boss-crystal/BossCrystalAdmin.jsx new file mode 100644 index 0000000..8bbb555 --- /dev/null +++ b/frontend/src/features/boss-crystal/BossCrystalAdmin.jsx @@ -0,0 +1,11 @@ +export default function BossCrystalAdmin() { + return ( +
+

보스 결정석 관리

+

보스 정보 및 결정석 가격을 관리합니다

+
+ 준비 중 +
+
+ ) +} diff --git a/frontend/src/features/registry.js b/frontend/src/features/registry.js new file mode 100644 index 0000000..6e93d07 --- /dev/null +++ b/frontend/src/features/registry.js @@ -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`] +}