generated from gc/template-java-maven
- 대시보드 레이아웃 개선 (히어로 배너, 도메인 카드 이미지, 인기/최신 API) - 인기 API: 최근 1주일 기준 Top 3 (PopularApiResponse 백엔드 추가) - 도메인 상세 페이지 (ApiHubDomainPage) 구현 + 리스트 뷰/검색 - 사이드바 도메인 클릭 시 도메인 상세 페이지 이동 - 브레드크럼: 서비스 제거, 도메인 기반으로 변경 - NoResourceFoundException 404 처리 추가 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
336 lines
14 KiB
TypeScript
336 lines
14 KiB
TypeScript
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';
|
|
import { getCatalog } from '../services/apiHubService';
|
|
import type { ServiceCatalog } from '../types/apihub';
|
|
|
|
const ROLES = ['ADMIN', 'MANAGER', 'USER'] as const;
|
|
|
|
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;
|
|
// <path d="..."/> 형태에서 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];
|
|
};
|
|
|
|
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();
|
|
const { theme, toggleTheme } = useTheme();
|
|
const navigate = useNavigate();
|
|
const location = useLocation();
|
|
|
|
const [catalog, setCatalog] = useState<ServiceCatalog[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [openDomains, setOpenDomains] = useState<Record<string, boolean>>({});
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
|
|
useEffect(() => {
|
|
getCatalog()
|
|
.then((res) => {
|
|
const items = res.data ?? [];
|
|
setCatalog(items);
|
|
})
|
|
.finally(() => setLoading(false));
|
|
}, []);
|
|
|
|
const toggleDomain = (key: string) => {
|
|
setOpenDomains((prev) => ({ ...prev, [key]: !prev[key] }));
|
|
};
|
|
|
|
// 서비스 계층을 제거하고 도메인 기준으로 플랫하게 재그룹핑
|
|
const domainGroups = useMemo<FlatDomainGroup[]>(() => {
|
|
const map = new Map<string, { apis: FlatDomainGroup['apis']; iconPath: string | null; sortOrder: number }>();
|
|
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<typeof dg> => dg !== null);
|
|
}, [domainGroups, searchQuery]);
|
|
|
|
const isSearching = searchQuery.trim().length > 0;
|
|
|
|
return (
|
|
<div className="flex min-h-screen">
|
|
{/* Sidebar */}
|
|
<aside className="fixed left-0 top-0 h-screen w-72 bg-gray-900 text-white flex flex-col">
|
|
{/* Sidebar header */}
|
|
<div className="flex-shrink-0 border-b border-gray-700">
|
|
<button
|
|
onClick={() => navigate('/api-hub')}
|
|
className="flex items-center gap-2 px-5 h-16 w-full hover:bg-gray-800 transition-colors"
|
|
>
|
|
<svg
|
|
className="h-5 w-5 text-blue-400 flex-shrink-0"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
|
|
/>
|
|
</svg>
|
|
<span className="text-base font-bold tracking-wide text-white">S&P API HUB</span>
|
|
</button>
|
|
<div className="px-5 pb-3">
|
|
<button
|
|
onClick={() => navigate('/dashboard')}
|
|
className="flex items-center gap-1.5 text-xs text-gray-400 hover:text-white transition-colors"
|
|
>
|
|
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
|
</svg>
|
|
Back to Dashboard
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Search */}
|
|
<div className="flex-shrink-0 px-3 pt-3 pb-1">
|
|
<div className="relative">
|
|
<svg
|
|
className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-gray-400 pointer-events-none"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
>
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
|
</svg>
|
|
<input
|
|
type="text"
|
|
value={searchQuery}
|
|
onChange={(e) => 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 && (
|
|
<button
|
|
onClick={() => setSearchQuery('')}
|
|
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-300"
|
|
>
|
|
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
</svg>
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Navigation tree */}
|
|
<nav className="flex-1 overflow-y-auto px-3 py-2 space-y-1">
|
|
{loading ? (
|
|
<div className="flex items-center justify-center py-10">
|
|
<svg
|
|
className="h-6 w-6 animate-spin text-gray-400"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<circle
|
|
className="opacity-25"
|
|
cx="12"
|
|
cy="12"
|
|
r="10"
|
|
stroke="currentColor"
|
|
strokeWidth="4"
|
|
/>
|
|
<path
|
|
className="opacity-75"
|
|
fill="currentColor"
|
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
|
/>
|
|
</svg>
|
|
</div>
|
|
) : filteredDomainGroups.length === 0 ? (
|
|
<p className="text-xs text-gray-500 text-center py-6">검색 결과가 없습니다</p>
|
|
) : (
|
|
filteredDomainGroups.map((dg) => {
|
|
const domainOpen = isSearching || (openDomains[dg.domain] ?? true);
|
|
|
|
return (
|
|
<div key={dg.domain}>
|
|
{/* Domain header */}
|
|
<div className="flex w-full items-center gap-1 rounded-lg text-sm font-semibold text-gray-200 hover:bg-gray-800 hover:text-white transition-colors">
|
|
{/* 도메인명 클릭 → 도메인 상세 페이지 이동 */}
|
|
<button
|
|
onClick={() => navigate(`/api-hub/domains/${encodeURIComponent(dg.domain)}`)}
|
|
className="flex flex-1 items-center gap-2 px-3 py-2 min-w-0"
|
|
>
|
|
<svg
|
|
className="h-4 w-4 flex-shrink-0 text-gray-400"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
strokeWidth={2}
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
>
|
|
{parseIconPaths(dg.iconPath).map((d, i) => (
|
|
<path key={i} d={d} />
|
|
))}
|
|
</svg>
|
|
<span className="truncate tracking-wider">{dg.domain}</span>
|
|
<span className="ml-auto flex-shrink-0 rounded-full bg-gray-700 px-1.5 py-0.5 text-xs text-gray-300">
|
|
{dg.apis.length}
|
|
</span>
|
|
</button>
|
|
{/* 화살표 버튼 → 펼침/접힘 토글 */}
|
|
<button
|
|
onClick={() => toggleDomain(dg.domain)}
|
|
className="flex-shrink-0 rounded-md p-1.5 text-gray-500 hover:text-gray-300 transition-colors"
|
|
title={domainOpen ? '접기' : '펼치기'}
|
|
>
|
|
<svg
|
|
className={`h-3 w-3 transition-transform ${domainOpen ? 'rotate-90' : ''}`}
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
>
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
{/* API items */}
|
|
{domainOpen && (
|
|
<div className="ml-4 mt-0.5 space-y-0.5">
|
|
{dg.apis.map((api) => {
|
|
const apiPath = `/api-hub/services/${api.serviceId}/apis/${api.apiId}`;
|
|
const isActive = location.pathname === apiPath;
|
|
|
|
return (
|
|
<NavLink
|
|
key={`${api.serviceId}-${api.apiId}`}
|
|
to={apiPath}
|
|
className={`block rounded-lg px-2.5 py-1.5 text-xs truncate transition-colors ${
|
|
isActive
|
|
? 'bg-gray-700 text-white'
|
|
: 'text-gray-300 hover:bg-gray-800 hover:text-white'
|
|
}`}
|
|
>
|
|
{api.apiName}
|
|
</NavLink>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})
|
|
)}
|
|
</nav>
|
|
</aside>
|
|
|
|
{/* Main content */}
|
|
<div className="flex-1 ml-72">
|
|
{/* Header */}
|
|
<header className="h-16 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between px-6">
|
|
<div />
|
|
<div className="flex items-center gap-4">
|
|
<button
|
|
onClick={toggleTheme}
|
|
className="rounded-lg p-2 text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
|
title={theme === 'light' ? 'Dark mode' : 'Light mode'}
|
|
>
|
|
{theme === 'light' ? (
|
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"
|
|
/>
|
|
</svg>
|
|
) : (
|
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"
|
|
/>
|
|
</svg>
|
|
)}
|
|
</button>
|
|
<span className="text-sm text-gray-500 dark:text-gray-400">Role:</span>
|
|
<div className="flex rounded-lg overflow-hidden border border-gray-300 dark:border-gray-600">
|
|
{ROLES.map((role) => (
|
|
<button
|
|
key={role}
|
|
onClick={() => setRole(role)}
|
|
className={`px-3 py-1.5 text-xs font-medium transition-colors ${
|
|
user?.role === role
|
|
? 'bg-blue-600 text-white'
|
|
: 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600'
|
|
}`}
|
|
>
|
|
{role}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
{/* Content */}
|
|
<main className="p-6 bg-gray-100 dark:bg-gray-900 min-h-[calc(100vh-4rem)]">
|
|
<Outlet />
|
|
</main>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default ApiHubLayout;
|