diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c3fc7cd..c9ebd69 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -11,6 +11,10 @@ import MyKeysPage from './pages/apikeys/MyKeysPage'; import KeyRequestPage from './pages/apikeys/KeyRequestPage'; import KeyAdminPage from './pages/apikeys/KeyAdminPage'; import ServicesPage from './pages/admin/ServicesPage'; +import DomainsPage from './pages/admin/DomainsPage'; +import ApisPage from './pages/admin/ApisPage'; +import ApiEditPage from './pages/admin/ApiEditPage'; +import SampleCodePage from './pages/admin/SampleCodePage'; import UsersPage from './pages/admin/UsersPage'; import TenantsPage from './pages/admin/TenantsPage'; import ServiceStatsPage from './pages/statistics/ServiceStatsPage'; @@ -49,6 +53,10 @@ const App = () => { } /> } /> } /> + } /> + } /> + } /> + } /> } /> } /> } /> diff --git a/frontend/src/layouts/ApiHubLayout.tsx b/frontend/src/layouts/ApiHubLayout.tsx index b47cd56..07f6bc8 100644 --- a/frontend/src/layouts/ApiHubLayout.tsx +++ b/frontend/src/layouts/ApiHubLayout.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useMemo } from 'react'; import { Outlet, NavLink, useNavigate, useLocation } from 'react-router-dom'; import { useAuth } from '../hooks/useAuth'; import { useTheme } from '../hooks/useTheme'; @@ -7,22 +7,28 @@ import type { ServiceCatalog } from '../types/apihub'; const ROLES = ['ADMIN', 'MANAGER', 'USER'] as const; -const METHOD_BADGE_CLASS: Record = { - GET: 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300', - POST: 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300', - PUT: 'bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300', - DELETE: 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300', +const DEFAULT_ICON_PATHS = ['M3.75 9.776c.112-.017.227-.026.344-.026h15.812c.117 0 .232.009.344.026m-16.5 0a2.25 2.25 0 00-1.883 2.542l.857 6a2.25 2.25 0 002.227 1.932H19.05a2.25 2.25 0 002.227-1.932l.857-6a2.25 2.25 0 00-1.883-2.542m-16.5 0V6A2.25 2.25 0 016 3.75h3.879a1.5 1.5 0 011.06.44l2.122 2.12a1.5 1.5 0 001.06.44H18A2.25 2.25 0 0120.25 9v.776']; + +/** iconPath 문자열에서 SVG path d 값 배열을 추출 */ +const parseIconPaths = (iconPath: string | null): string[] => { + if (!iconPath) return DEFAULT_ICON_PATHS; + // 형태에서 d 값 추출 + const pathRegex = /d="([^"]+)"/g; + const matches: string[] = []; + let m; + while ((m = pathRegex.exec(iconPath)) !== null) { + matches.push(m[1]); + } + // d 태그가 없으면 단일 path d 값으로 간주 + return matches.length > 0 ? matches : [iconPath]; }; -const getMethodBadgeClass = (method: string): string => - METHOD_BADGE_CLASS[method.toUpperCase()] ?? - 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300'; - -const HEALTH_DOT_CLASS: Record = { - UP: 'bg-green-500', - DOWN: 'bg-red-500', - UNKNOWN: 'bg-gray-400', -}; +interface FlatDomainGroup { + domain: string; + iconPath: string | null; + sortOrder: number; + apis: { serviceId: number; apiId: number; apiName: string; apiPath: string; apiMethod: string }[]; +} const ApiHubLayout = () => { const { user, setRole } = useAuth(); @@ -32,37 +38,68 @@ const ApiHubLayout = () => { const [catalog, setCatalog] = useState([]); const [loading, setLoading] = useState(true); - const [openServices, setOpenServices] = useState>({}); const [openDomains, setOpenDomains] = useState>({}); + const [searchQuery, setSearchQuery] = useState(''); useEffect(() => { getCatalog() .then((res) => { const items = res.data ?? []; setCatalog(items); - // Open all service groups and domain groups by default - const serviceState: Record = {}; - const domainState: Record = {}; - items.forEach((svc) => { - serviceState[svc.serviceId] = true; - svc.domains.forEach((dg) => { - domainState[`${svc.serviceId}:${dg.domain}`] = true; - }); - }); - setOpenServices(serviceState); - setOpenDomains(domainState); }) .finally(() => setLoading(false)); }, []); - const toggleService = (serviceId: number) => { - setOpenServices((prev) => ({ ...prev, [serviceId]: !prev[serviceId] })); - }; - const toggleDomain = (key: string) => { setOpenDomains((prev) => ({ ...prev, [key]: !prev[key] })); }; + // 서비스 계층을 제거하고 도메인 기준으로 플랫하게 재그룹핑 + const domainGroups = useMemo(() => { + const map = new Map(); + for (const svc of catalog) { + for (const dg of svc.domains) { + const key = dg.domain.toUpperCase(); + const existing = map.get(key); + const apis = existing?.apis ?? []; + apis.push( + ...dg.apis.map((api) => ({ + serviceId: svc.serviceId, + apiId: api.apiId, + apiName: api.apiName, + apiPath: api.apiPath, + apiMethod: api.apiMethod, + })), + ); + // 첫 번째로 발견된 iconPath/sortOrder 사용 + const iconPath = existing?.iconPath !== undefined ? existing.iconPath : (dg.iconPath ?? null); + const sortOrder = existing?.sortOrder !== undefined ? existing.sortOrder : (dg.sortOrder ?? Number.MAX_SAFE_INTEGER); + map.set(key, { apis, iconPath, sortOrder }); + } + } + return Array.from(map.entries()) + .map(([domain, { apis, iconPath, sortOrder }]) => ({ domain, iconPath, sortOrder, apis })) + .sort((a, b) => a.sortOrder - b.sortOrder || a.domain.localeCompare(b.domain)); + }, [catalog]); + + const filteredDomainGroups = useMemo(() => { + if (!searchQuery.trim()) return domainGroups; + const q = searchQuery.trim().toLowerCase(); + return domainGroups + .map((dg) => { + const domainMatch = dg.domain.toLowerCase().includes(q); + const filteredApis = dg.apis.filter( + (api) => api.apiName.toLowerCase().includes(q) || api.apiPath.toLowerCase().includes(q), + ); + if (domainMatch) return dg; + if (filteredApis.length > 0) return { ...dg, apis: filteredApis }; + return null; + }) + .filter((dg): dg is NonNullable => dg !== null); + }, [domainGroups, searchQuery]); + + const isSearching = searchQuery.trim().length > 0; + return (
{/* Sidebar */} @@ -98,8 +135,39 @@ const ApiHubLayout = () => {
+ {/* Search */} +
+
+ + + + setSearchQuery(e.target.value)} + placeholder="API 검색..." + className="w-full bg-gray-800 border border-gray-700 text-gray-200 placeholder-gray-500 rounded-lg pl-8 pr-8 py-1.5 text-xs focus:ring-1 focus:ring-blue-500 focus:border-blue-500 focus:outline-none" + /> + {searchQuery && ( + + )} +
+
+ {/* Navigation tree */} -