feat(ui): KCG 브랜딩 적용 및 레이아웃 디자인 개선 (#48)
- S&P/SNP 텍스트를 KCG로 변경 (타이틀, 사이드바, 대시보드) - 사이드 메뉴 한글화 (모니터링, 통계, API 키, 관리자) - 테넌트 → 부서 텍스트 변경 - MainLayout 헤더/사이드바 레퍼런스 디자인 적용 (아이콘, 인디케이터, 알약 역할 스위처) - ApiHubLayout 헤더/사이드바 레퍼런스 디자인 적용 (도메인 색상 팔레트, 도트 인디케이터) - 서비스 상태 카드 서비스 코드 제거 - 대시보드 배너 설명 텍스트 변경 - 도메인 이미지 파일명 한글 변경 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@ -8,7 +8,7 @@
|
|||||||
<link rel="manifest" href="/snp-connection/site.webmanifest" />
|
<link rel="manifest" href="/snp-connection/site.webmanifest" />
|
||||||
<link rel="shortcut icon" href="/snp-connection/favicon.ico" />
|
<link rel="shortcut icon" href="/snp-connection/favicon.ico" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>SNP Connection Monitoring</title>
|
<title>KCG Connection Monitoring</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
|
Before Width: | Height: | 크기: 4.0 MiB After Width: | Height: | 크기: 4.0 MiB |
|
Before Width: | Height: | 크기: 658 KiB After Width: | Height: | 크기: 658 KiB |
|
Before Width: | Height: | 크기: 4.0 MiB After Width: | Height: | 크기: 4.0 MiB |
|
Before Width: | Height: | 크기: 2.7 MiB After Width: | Height: | 크기: 2.7 MiB |
|
Before Width: | Height: | 크기: 1.8 MiB After Width: | Height: | 크기: 1.8 MiB |
@ -26,6 +26,20 @@ const parseIconPaths = (iconPath: string | null): string[] => {
|
|||||||
return matches.length > 0 ? matches : [iconPath];
|
return matches.length > 0 ? matches : [iconPath];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** 도메인 인덱스를 기반으로 색상 팔레트 반환 */
|
||||||
|
const DOMAIN_COLORS = [
|
||||||
|
{ bg: 'bg-indigo-500', dot: 'bg-indigo-400', badge: 'bg-indigo-900/60 text-indigo-300' },
|
||||||
|
{ bg: 'bg-violet-500', dot: 'bg-violet-400', badge: 'bg-violet-900/60 text-violet-300' },
|
||||||
|
{ bg: 'bg-sky-500', dot: 'bg-sky-400', badge: 'bg-sky-900/60 text-sky-300' },
|
||||||
|
{ bg: 'bg-emerald-500', dot: 'bg-emerald-400', badge: 'bg-emerald-900/60 text-emerald-300' },
|
||||||
|
{ bg: 'bg-amber-500', dot: 'bg-amber-400', badge: 'bg-amber-900/60 text-amber-300' },
|
||||||
|
{ bg: 'bg-rose-500', dot: 'bg-rose-400', badge: 'bg-rose-900/60 text-rose-300' },
|
||||||
|
{ bg: 'bg-teal-500', dot: 'bg-teal-400', badge: 'bg-teal-900/60 text-teal-300' },
|
||||||
|
{ bg: 'bg-orange-500', dot: 'bg-orange-400', badge: 'bg-orange-900/60 text-orange-300' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const getDomainColor = (index: number) => DOMAIN_COLORS[index % DOMAIN_COLORS.length];
|
||||||
|
|
||||||
interface FlatDomainGroup {
|
interface FlatDomainGroup {
|
||||||
domain: string;
|
domain: string;
|
||||||
iconPath: string | null;
|
iconPath: string | null;
|
||||||
@ -103,49 +117,33 @@ const ApiHubLayoutInner = () => {
|
|||||||
|
|
||||||
const isSearching = searchQuery.trim().length > 0;
|
const isSearching = searchQuery.trim().length > 0;
|
||||||
|
|
||||||
|
const totalApiCount = useMemo(() => domainGroups.reduce((sum, dg) => sum + dg.apis.length, 0), [domainGroups]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen">
|
<div className="flex min-h-screen">
|
||||||
{/* Sidebar */}
|
{/* Sidebar */}
|
||||||
<aside className="fixed left-0 top-0 h-screen w-72 bg-gray-900 text-white flex flex-col">
|
<aside className="fixed left-0 top-0 h-screen w-[272px] bg-gray-900 flex flex-col">
|
||||||
{/* Sidebar header */}
|
{/* 로고 영역 */}
|
||||||
<div className="flex-shrink-0 border-b border-gray-700">
|
<div className="flex-shrink-0 border-b border-gray-700/70">
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate('/api-hub')}
|
onClick={() => navigate('/api-hub')}
|
||||||
className="flex items-center gap-2 px-5 h-16 w-full hover:bg-gray-800 transition-colors"
|
className="flex items-center gap-3 px-5 h-14 w-full hover:bg-gray-800/60 transition-colors"
|
||||||
>
|
>
|
||||||
<svg
|
{/* Indigo 그라디언트 아이콘 배경 */}
|
||||||
className="h-5 w-5 text-blue-400 flex-shrink-0"
|
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg bg-gradient-to-br from-indigo-500 to-violet-600 shadow-lg">
|
||||||
fill="none"
|
<svg className="h-4.5 w-4.5 text-white" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||||
viewBox="0 0 24 24"
|
<path 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" />
|
||||||
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>
|
</svg>
|
||||||
Back to Dashboard
|
</div>
|
||||||
</button>
|
<span className="text-sm font-bold tracking-widest text-white uppercase">KCG API HUB</span>
|
||||||
</div>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Search */}
|
{/* 검색 영역 */}
|
||||||
<div className="flex-shrink-0 px-3 pt-3 pb-1">
|
<div className="flex-shrink-0 px-4 pt-4 pb-2">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<svg
|
<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"
|
className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-gray-500 pointer-events-none"
|
||||||
fill="none"
|
fill="none"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
@ -157,12 +155,12 @@ const ApiHubLayoutInner = () => {
|
|||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
placeholder="API 검색..."
|
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"
|
className="w-full bg-gray-800 border border-gray-700 text-gray-200 placeholder-gray-600 rounded-lg pl-8 pr-7 py-2 text-xs focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 focus:outline-none transition-colors"
|
||||||
/>
|
/>
|
||||||
{searchQuery && (
|
{searchQuery && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setSearchQuery('')}
|
onClick={() => setSearchQuery('')}
|
||||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-300"
|
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-600 hover:text-gray-400 transition-colors"
|
||||||
>
|
>
|
||||||
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<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" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
@ -170,69 +168,67 @@ const ApiHubLayoutInner = () => {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{/* API 수 요약 */}
|
||||||
|
{!loading && (
|
||||||
|
<p className="mt-2 text-[11px] text-gray-600">
|
||||||
|
{filteredDomainGroups.length}개 도메인 · {filteredDomainGroups.reduce((s, dg) => s + dg.apis.length, 0)}개 API
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Navigation tree */}
|
{/* 도메인 트리 */}
|
||||||
<nav className="flex-1 overflow-y-auto px-3 py-2 space-y-1">
|
<nav className="flex-1 overflow-y-auto px-2 py-1 space-y-0.5">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex items-center justify-center py-10">
|
<div className="flex items-center justify-center py-10">
|
||||||
<svg
|
<svg className="h-5 w-5 animate-spin text-gray-600" fill="none" viewBox="0 0 24 24">
|
||||||
className="h-6 w-6 animate-spin text-gray-400"
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||||
fill="none"
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||||
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>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
) : filteredDomainGroups.length === 0 ? (
|
) : filteredDomainGroups.length === 0 ? (
|
||||||
<p className="text-xs text-gray-500 text-center py-6">검색 결과가 없습니다</p>
|
<p className="text-xs text-gray-600 text-center py-6">검색 결과가 없습니다</p>
|
||||||
) : (
|
) : (
|
||||||
filteredDomainGroups.map((dg) => {
|
filteredDomainGroups.map((dg, domainIndex) => {
|
||||||
const domainOpen = isSearching || (openDomains[dg.domain] ?? true);
|
const domainOpen = isSearching || (openDomains[dg.domain] ?? true);
|
||||||
|
const color = getDomainColor(domainIndex);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={dg.domain}>
|
<div key={dg.domain} className="mb-0.5">
|
||||||
{/* 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">
|
<div className="flex w-full items-center rounded-lg hover:bg-gray-800/50 transition-colors group">
|
||||||
{/* 도메인명 클릭 → 도메인 상세 페이지 이동 */}
|
{/* 도메인명 클릭 → 도메인 상세 페이지 이동 */}
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate(`/api-hub/domains/${encodeURIComponent(dg.domain)}`)}
|
onClick={() => navigate(`/api-hub/domains/${encodeURIComponent(dg.domain)}`)}
|
||||||
className="flex flex-1 items-center gap-2 px-3 py-2 min-w-0"
|
className="flex flex-1 items-center gap-2.5 px-2 py-2 min-w-0"
|
||||||
>
|
>
|
||||||
<svg
|
{/* 도메인 색상 아이콘 배경 */}
|
||||||
className="h-4 w-4 flex-shrink-0 text-gray-400"
|
<div className={`flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-md ${color.bg}`}>
|
||||||
fill="none"
|
<svg
|
||||||
viewBox="0 0 24 24"
|
className="h-3.5 w-3.5 text-white"
|
||||||
stroke="currentColor"
|
fill="none"
|
||||||
strokeWidth={2}
|
viewBox="0 0 24 24"
|
||||||
strokeLinecap="round"
|
stroke="currentColor"
|
||||||
strokeLinejoin="round"
|
strokeWidth={1.8}
|
||||||
>
|
strokeLinecap="round"
|
||||||
{parseIconPaths(dg.iconPath).map((d, i) => (
|
strokeLinejoin="round"
|
||||||
<path key={i} d={d} />
|
>
|
||||||
))}
|
{parseIconPaths(dg.iconPath).map((d, i) => (
|
||||||
</svg>
|
<path key={i} d={d} />
|
||||||
<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">
|
</svg>
|
||||||
|
</div>
|
||||||
|
<span className="flex-1 truncate text-xs font-semibold text-gray-300 group-hover:text-white transition-colors tracking-wide">
|
||||||
|
{dg.domain}
|
||||||
|
</span>
|
||||||
|
{/* API 카운트 뱃지 */}
|
||||||
|
<span className={`flex-shrink-0 rounded-full px-1.5 py-0.5 text-[10px] font-medium ${color.badge}`}>
|
||||||
{dg.apis.length}
|
{dg.apis.length}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
{/* 화살표 버튼 → 펼침/접힘 토글 */}
|
{/* 접힘/펼침 화살표 버튼 */}
|
||||||
<button
|
<button
|
||||||
onClick={() => toggleDomain(dg.domain)}
|
onClick={() => toggleDomain(dg.domain)}
|
||||||
className="flex-shrink-0 rounded-md p-1.5 text-gray-500 hover:text-gray-300 transition-colors"
|
className="flex-shrink-0 rounded-md p-1.5 mr-1 text-gray-600 hover:text-gray-400 transition-colors"
|
||||||
title={domainOpen ? '접기' : '펼치기'}
|
title={domainOpen ? '접기' : '펼치기'}
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
@ -246,9 +242,9 @@ const ApiHubLayoutInner = () => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* API items */}
|
{/* API 항목 목록 */}
|
||||||
{domainOpen && (
|
{domainOpen && (
|
||||||
<div className="ml-4 mt-0.5 space-y-0.5">
|
<div className="ml-3 mt-0.5 space-y-0.5 border-l border-gray-800 pl-3">
|
||||||
{dg.apis.map((api) => {
|
{dg.apis.map((api) => {
|
||||||
const apiPath = `/api-hub/services/${api.serviceId}/apis/${api.apiId}`;
|
const apiPath = `/api-hub/services/${api.serviceId}/apis/${api.apiId}`;
|
||||||
const isActive = location.pathname === apiPath;
|
const isActive = location.pathname === apiPath;
|
||||||
@ -257,13 +253,17 @@ const ApiHubLayoutInner = () => {
|
|||||||
<NavLink
|
<NavLink
|
||||||
key={`${api.serviceId}-${api.apiId}`}
|
key={`${api.serviceId}-${api.apiId}`}
|
||||||
to={apiPath}
|
to={apiPath}
|
||||||
className={`block rounded-lg px-2.5 py-1.5 text-xs truncate transition-colors ${
|
className={`flex items-center gap-2 rounded-md px-2 py-1.5 text-xs truncate transition-colors ${
|
||||||
isActive
|
isActive
|
||||||
? 'bg-gray-700 text-white'
|
? 'bg-gray-800 text-white font-medium'
|
||||||
: 'text-gray-300 hover:bg-gray-800 hover:text-white'
|
: 'text-gray-500 hover:bg-gray-800/60 hover:text-gray-200'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{api.apiName}
|
{/* 활성 시 좌측 도트 인디케이터 */}
|
||||||
|
<span className={`flex-shrink-0 h-1.5 w-1.5 rounded-full transition-colors ${
|
||||||
|
isActive ? `${color.dot}` : 'bg-gray-700'
|
||||||
|
}`} />
|
||||||
|
<span className="truncate">{api.apiName}</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@ -274,49 +274,50 @@ const ApiHubLayoutInner = () => {
|
|||||||
})
|
})
|
||||||
)}
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
{/* 하단 정보 */}
|
||||||
|
{!loading && (
|
||||||
|
<div className="flex-shrink-0 border-t border-gray-700/70 px-4 py-3">
|
||||||
|
<div className="flex items-center justify-between text-[11px] text-gray-600">
|
||||||
|
<span>전체 {totalApiCount}개 API</span>
|
||||||
|
<span>{domainGroups.length}개 도메인</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{/* Main content */}
|
{/* Main content */}
|
||||||
<div className="flex-1 ml-72">
|
<div className="flex-1 ml-[272px]">
|
||||||
{/* 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">
|
<header className="h-14 bg-gray-900 border-b border-gray-700/70 flex items-center justify-between px-6">
|
||||||
<div />
|
<div />
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-3">
|
||||||
|
{/* 테마 토글 */}
|
||||||
<button
|
<button
|
||||||
onClick={toggleTheme}
|
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"
|
className="rounded-lg p-2 text-gray-500 hover:bg-gray-800 hover:text-gray-300 transition-colors"
|
||||||
title={theme === 'light' ? 'Dark mode' : 'Light mode'}
|
title={theme === 'light' ? 'Dark mode' : 'Light mode'}
|
||||||
>
|
>
|
||||||
{theme === 'light' ? (
|
{theme === 'light' ? (
|
||||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path
|
<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" />
|
||||||
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>
|
||||||
) : (
|
) : (
|
||||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path
|
<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" />
|
||||||
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>
|
</svg>
|
||||||
)}
|
)}
|
||||||
</button>
|
</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">
|
<div className="flex rounded-full overflow-hidden border border-gray-700 bg-gray-800 p-0.5">
|
||||||
{ROLES.map((role) => (
|
{ROLES.map((role) => (
|
||||||
<button
|
<button
|
||||||
key={role}
|
key={role}
|
||||||
onClick={() => setRole(role)}
|
onClick={() => setRole(role)}
|
||||||
className={`px-3 py-1.5 text-xs font-medium transition-colors ${
|
className={`rounded-full px-3 py-1 text-xs font-medium transition-colors ${
|
||||||
user?.role === role
|
user?.role === role
|
||||||
? 'bg-blue-600 text-white'
|
? 'bg-indigo-600 text-white shadow-sm'
|
||||||
: 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600'
|
: 'text-gray-500 hover:text-gray-300'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{role}
|
{role}
|
||||||
@ -326,8 +327,8 @@ const ApiHubLayoutInner = () => {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Content */}
|
{/* 콘텐츠 */}
|
||||||
<main className="p-6 bg-gray-100 dark:bg-gray-900 min-h-[calc(100vh-4rem)]">
|
<main className="p-6 bg-gray-100 dark:bg-gray-900 min-h-[calc(100vh-3.5rem)]">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
<BasketFloatingPanel />
|
<BasketFloatingPanel />
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@ -1,50 +1,178 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Outlet, NavLink } from 'react-router-dom';
|
import { Outlet, NavLink, Link } from 'react-router-dom';
|
||||||
import { useAuth } from '../hooks/useAuth';
|
import { useAuth } from '../hooks/useAuth';
|
||||||
import { useTheme } from '../hooks/useTheme';
|
import { useTheme } from '../hooks/useTheme';
|
||||||
|
|
||||||
|
interface NavItem {
|
||||||
|
label: string;
|
||||||
|
path: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
adminManagerOnly?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
interface NavGroup {
|
interface NavGroup {
|
||||||
label: string;
|
label: string;
|
||||||
items: { label: string; path: string }[];
|
items: NavItem[];
|
||||||
adminOnly?: boolean;
|
adminOnly?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const iconProps = {
|
||||||
|
fill: 'none' as const,
|
||||||
|
viewBox: '0 0 24 24',
|
||||||
|
stroke: 'currentColor',
|
||||||
|
strokeWidth: 1.8,
|
||||||
|
strokeLinecap: 'round' as const,
|
||||||
|
strokeLinejoin: 'round' as const,
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
};
|
||||||
|
|
||||||
|
const IconDashboard = () => (
|
||||||
|
<svg {...iconProps}>
|
||||||
|
<rect x="3" y="3" width="7" height="7" rx="1.5" />
|
||||||
|
<rect x="14" y="3" width="7" height="7" rx="1.5" />
|
||||||
|
<rect x="3" y="14" width="7" height="7" rx="1.5" />
|
||||||
|
<rect x="14" y="14" width="7" height="7" rx="1.5" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const IconRequestLog = () => (
|
||||||
|
<svg {...iconProps}>
|
||||||
|
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" />
|
||||||
|
<polyline points="14,2 14,8 20,8" />
|
||||||
|
<line x1="16" y1="13" x2="8" y2="13" />
|
||||||
|
<line x1="16" y1="17" x2="8" y2="17" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const IconServiceStatus = () => (
|
||||||
|
<svg {...iconProps}>
|
||||||
|
<polyline points="22,12 18,12 15,21 9,3 6,12 2,12" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const IconServiceStats = () => (
|
||||||
|
<svg {...iconProps}>
|
||||||
|
<line x1="18" y1="20" x2="18" y2="10" />
|
||||||
|
<line x1="12" y1="20" x2="12" y2="4" />
|
||||||
|
<line x1="6" y1="20" x2="6" y2="14" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const IconUserStats = () => (
|
||||||
|
<svg {...iconProps}>
|
||||||
|
<path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2" />
|
||||||
|
<circle cx="9" cy="7" r="4" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const IconApiStats = () => (
|
||||||
|
<svg {...iconProps}>
|
||||||
|
<path d="M21 12a9 9 0 11-6.219-8.56" />
|
||||||
|
<path d="M21 3v6h-6" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const IconTenantStats = () => (
|
||||||
|
<svg {...iconProps}>
|
||||||
|
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||||
|
<path d="M3 9h18" />
|
||||||
|
<path d="M9 21V9" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const IconUsageTrend = () => (
|
||||||
|
<svg {...iconProps}>
|
||||||
|
<polyline points="23,6 13.5,15.5 8.5,10.5 1,18" />
|
||||||
|
<polyline points="17,6 23,6 23,12" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const IconMyKey = () => (
|
||||||
|
<svg {...iconProps}>
|
||||||
|
<circle cx="12" cy="12" r="3" />
|
||||||
|
<path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const IconKeyRequest = () => (
|
||||||
|
<svg {...iconProps}>
|
||||||
|
<path d="M9 11l3 3L22 4" />
|
||||||
|
<path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const IconKeyManage = () => (
|
||||||
|
<svg {...iconProps}>
|
||||||
|
<path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 11-7.778 7.778 5.5 5.5 0 017.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const IconService = () => (
|
||||||
|
<svg {...iconProps}>
|
||||||
|
<polygon points="12,2 2,7 12,12 22,7" />
|
||||||
|
<polyline points="2,17 12,22 22,17" />
|
||||||
|
<polyline points="2,12 12,17 22,12" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const IconDomain = () => (
|
||||||
|
<svg {...iconProps}>
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<path d="M12 2a14.5 14.5 0 000 20 14.5 14.5 0 000-20" />
|
||||||
|
<path d="M2 12h20" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const IconApiManage = () => (
|
||||||
|
<svg {...iconProps}>
|
||||||
|
<path d="M4 6h16M4 12h16M4 18h10" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const IconSampleCode = () => (
|
||||||
|
<svg {...iconProps}>
|
||||||
|
<polyline points="16,18 22,12 16,6" />
|
||||||
|
<polyline points="8,6 2,12 8,18" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
const navGroups: NavGroup[] = [
|
const navGroups: NavGroup[] = [
|
||||||
{
|
{
|
||||||
label: 'Monitoring',
|
label: '모니터링',
|
||||||
items: [
|
items: [
|
||||||
{ label: 'Request Logs', path: '/monitoring/request-logs' },
|
{ label: '요청 로그', path: '/monitoring/request-logs', icon: <IconRequestLog /> },
|
||||||
{ label: 'Service Status', path: '/monitoring/service-status' },
|
{ label: '서비스 상태', path: '/monitoring/service-status', icon: <IconServiceStatus /> },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Statistics',
|
label: '통계',
|
||||||
items: [
|
items: [
|
||||||
{ label: '서비스 통계', path: '/statistics/services' },
|
{ label: '서비스 통계', path: '/statistics/services', icon: <IconServiceStats /> },
|
||||||
{ label: '사용자 통계', path: '/statistics/users' },
|
{ label: '사용자 통계', path: '/statistics/users', icon: <IconUserStats /> },
|
||||||
{ label: 'API 통계', path: '/statistics/apis' },
|
{ label: 'API 통계', path: '/statistics/apis', icon: <IconApiStats /> },
|
||||||
{ label: '테넌트 통계', path: '/statistics/tenants' },
|
{ label: '부서 통계', path: '/statistics/tenants', icon: <IconTenantStats /> },
|
||||||
{ label: '사용량 추이', path: '/statistics/usage-trend' },
|
{ label: '사용량 추이', path: '/statistics/usage-trend', icon: <IconUsageTrend /> },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'API Keys',
|
label: 'API 키',
|
||||||
items: [
|
items: [
|
||||||
{ label: 'My Keys', path: '/apikeys/my-keys' },
|
{ label: '내 키', path: '/apikeys/my-keys', icon: <IconMyKey /> },
|
||||||
{ label: 'Request', path: '/apikeys/request' },
|
{ label: '키 신청', path: '/apikeys/request', icon: <IconKeyRequest /> },
|
||||||
{ label: 'Admin', path: '/apikeys/admin' },
|
{ label: '키 관리', path: '/apikeys/admin', icon: <IconKeyManage />, adminManagerOnly: true },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Admin',
|
label: '관리자',
|
||||||
adminOnly: true,
|
adminOnly: true,
|
||||||
items: [
|
items: [
|
||||||
{ label: 'Services', path: '/admin/services' },
|
{ label: '서비스', path: '/admin/services', icon: <IconService /> },
|
||||||
{ label: 'Domains', path: '/admin/domains' },
|
{ label: '도메인', path: '/admin/domains', icon: <IconDomain /> },
|
||||||
{ label: 'API 관리', path: '/admin/apis' },
|
{ label: 'API 관리', path: '/admin/apis', icon: <IconApiManage /> },
|
||||||
{ label: '공통 샘플 코드', path: '/admin/sample-code' },
|
{ label: '공통 샘플 코드', path: '/admin/sample-code', icon: <IconSampleCode /> },
|
||||||
{ label: 'Users', path: '/admin/users' },
|
{ label: '사용자', path: '/admin/users', icon: <IconUserStats /> },
|
||||||
{ label: 'Tenants', path: '/admin/tenants' },
|
{ label: '부서', path: '/admin/tenants', icon: <IconTenantStats /> },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@ -55,10 +183,10 @@ const MainLayout = () => {
|
|||||||
const { user, setRole } = useAuth();
|
const { user, setRole } = useAuth();
|
||||||
const { theme, toggleTheme } = useTheme();
|
const { theme, toggleTheme } = useTheme();
|
||||||
const [openGroups, setOpenGroups] = useState<Record<string, boolean>>({
|
const [openGroups, setOpenGroups] = useState<Record<string, boolean>>({
|
||||||
Monitoring: true,
|
'모니터링': true,
|
||||||
Statistics: true,
|
'통계': true,
|
||||||
'API Keys': true,
|
'API 키': true,
|
||||||
Admin: true,
|
'관리자': true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const toggleGroup = (label: string) => {
|
const toggleGroup = (label: string) => {
|
||||||
@ -70,43 +198,64 @@ const MainLayout = () => {
|
|||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen">
|
<div className="flex min-h-screen">
|
||||||
{/* Sidebar */}
|
{/* Sidebar */}
|
||||||
<aside className="fixed left-0 top-0 h-screen w-64 bg-gray-900 text-white flex flex-col">
|
<aside className="fixed left-0 top-0 h-screen w-60 bg-gray-900 text-white flex flex-col z-40">
|
||||||
<div className="flex items-center gap-2 px-6 h-16 border-b border-gray-700">
|
|
||||||
<svg className="h-6 w-6" style={{ color: '#FF2E63' }} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
{/* 로고 영역 */}
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
|
<div className="flex items-center gap-3 px-4 h-14 border-b border-gray-700/60 flex-shrink-0">
|
||||||
</svg>
|
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-gradient-to-br from-indigo-500 to-indigo-700 flex-shrink-0">
|
||||||
<span className="text-lg font-semibold">SNP Connection</span>
|
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" width={16} height={16} className="text-white">
|
||||||
|
<path d="M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71" />
|
||||||
|
<path d="M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-sm font-semibold text-white leading-tight truncate">KCG Connection</div>
|
||||||
|
<div className="text-[10px] text-gray-400 leading-tight truncate">Monitoring Service</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav className="flex-1 overflow-y-auto px-3 py-4">
|
<nav className="flex-1 overflow-y-auto py-3 px-2 space-y-1 scrollbar-thin scrollbar-track-gray-900 scrollbar-thumb-gray-700">
|
||||||
{/* Dashboard */}
|
|
||||||
|
{/* API Hub 바로가기 카드 */}
|
||||||
|
<Link
|
||||||
|
to="/api-hub"
|
||||||
|
className="flex items-center gap-3 mx-1 mb-3 px-3 py-2.5 rounded-lg border border-indigo-500/40 bg-indigo-500/10 hover:bg-indigo-500/20 transition-colors group"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-center w-7 h-7 rounded-md bg-indigo-600/30 flex-shrink-0">
|
||||||
|
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" width={15} height={15} className="text-indigo-300">
|
||||||
|
<rect x="4" y="4" width="16" height="16" rx="2" />
|
||||||
|
<path d="M9 9h6M9 13h4" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-xs font-semibold text-indigo-300 leading-tight">API Hub</div>
|
||||||
|
<div className="text-[10px] text-indigo-400/70 leading-tight truncate">API 탐색 및 명세 확인</div>
|
||||||
|
</div>
|
||||||
|
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" width={14} height={14} className="text-indigo-400/60 flex-shrink-0 group-hover:text-indigo-300 transition-colors">
|
||||||
|
<path d="M9 18l6-6-6-6" />
|
||||||
|
</svg>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* 대시보드 */}
|
||||||
<NavLink
|
<NavLink
|
||||||
to="/dashboard"
|
to="/dashboard"
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
`flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors ${
|
`relative flex items-center gap-2.5 px-3 py-2 rounded-lg text-sm transition-colors ${
|
||||||
isActive ? 'bg-gray-700 text-white' : 'text-gray-300 hover:bg-gray-800 hover:text-white'
|
isActive
|
||||||
|
? 'bg-gray-800 text-white'
|
||||||
|
: 'text-gray-400 hover:bg-gray-800/60 hover:text-gray-200'
|
||||||
}`
|
}`
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
{({ isActive }) => (
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zm10 0a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zm10 0a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
|
<>
|
||||||
</svg>
|
{isActive && (
|
||||||
Dashboard
|
<span className="absolute left-0 top-1/2 -translate-y-1/2 w-0.5 h-5 bg-indigo-500 rounded-r-full" />
|
||||||
</NavLink>
|
)}
|
||||||
|
<span className="flex-shrink-0"><IconDashboard /></span>
|
||||||
{/* API Hub */}
|
<span className="font-medium">대시보드</span>
|
||||||
<NavLink
|
</>
|
||||||
to="/api-hub"
|
)}
|
||||||
className={({ isActive }) =>
|
|
||||||
`flex items-center gap-3 rounded-lg px-3 py-2 mt-4 text-sm font-medium transition-colors ${
|
|
||||||
isActive ? 'bg-gray-700 text-white' : 'text-gray-300 hover:bg-gray-800 hover:text-white'
|
|
||||||
}`
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<svg className="h-5 w-5" 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>
|
|
||||||
API Hub
|
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
|
||||||
{/* Nav Groups */}
|
{/* Nav Groups */}
|
||||||
@ -116,44 +265,57 @@ const MainLayout = () => {
|
|||||||
const isOpen = openGroups[group.label] ?? false;
|
const isOpen = openGroups[group.label] ?? false;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={group.label} className="mt-4">
|
<div key={group.label}>
|
||||||
|
{/* 구분선 */}
|
||||||
|
<div className="my-2 mx-1 border-t border-gray-700/50" />
|
||||||
|
|
||||||
|
{/* 섹션 타이틀 */}
|
||||||
<button
|
<button
|
||||||
onClick={() => toggleGroup(group.label)}
|
onClick={() => toggleGroup(group.label)}
|
||||||
className="flex w-full items-center justify-between rounded-lg px-3 py-2 text-xs font-semibold uppercase tracking-wider text-gray-400 hover:text-white"
|
className="flex w-full items-center justify-between px-3 py-1.5 text-[10px] font-semibold uppercase tracking-widest text-gray-500 hover:text-gray-300 transition-colors"
|
||||||
>
|
>
|
||||||
{group.label}
|
<span>{group.label}</span>
|
||||||
<svg
|
<svg
|
||||||
className={`h-4 w-4 transition-transform ${isOpen ? 'rotate-90' : ''}`}
|
|
||||||
fill="none"
|
fill="none"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
width={12}
|
||||||
|
height={12}
|
||||||
|
className={`transition-transform duration-200 ${isOpen ? 'rotate-180' : ''}`}
|
||||||
>
|
>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
<path d="M19 9l-7 7-7-7" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<div className="ml-2 space-y-1">
|
<div className="mt-0.5 space-y-0.5">
|
||||||
{group.items.map((item) => {
|
{group.items.map((item) => {
|
||||||
if (
|
if (item.adminManagerOnly && !isAdminOrManager) return null;
|
||||||
group.label === 'API Keys' &&
|
|
||||||
item.label === 'Admin' &&
|
|
||||||
!isAdminOrManager
|
|
||||||
) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NavLink
|
<NavLink
|
||||||
key={item.path}
|
key={item.path}
|
||||||
to={item.path}
|
to={item.path}
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
`block rounded-lg px-3 py-2 text-sm transition-colors ${
|
`relative flex items-center gap-2.5 px-3 py-2 rounded-lg text-sm transition-colors ${
|
||||||
isActive ? 'bg-gray-700 text-white' : 'text-gray-300 hover:bg-gray-800 hover:text-white'
|
isActive
|
||||||
|
? 'bg-gray-800 text-white'
|
||||||
|
: 'text-gray-400 hover:bg-gray-800/60 hover:text-gray-200'
|
||||||
}`
|
}`
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{item.label}
|
{({ isActive }) => (
|
||||||
|
<>
|
||||||
|
{isActive && (
|
||||||
|
<span className="absolute left-0 top-1/2 -translate-y-1/2 w-0.5 h-5 bg-indigo-500 rounded-r-full" />
|
||||||
|
)}
|
||||||
|
<span className="flex-shrink-0">{item.icon}</span>
|
||||||
|
<span>{item.label}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@ -162,40 +324,45 @@ const MainLayout = () => {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
{/* 마지막 구분선 */}
|
||||||
|
<div className="my-2 mx-1 border-t border-gray-700/50" />
|
||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{/* Main Content */}
|
{/* Main Content */}
|
||||||
<div className="flex-1 ml-64">
|
<div className="flex-1 ml-60">
|
||||||
{/* Header */}
|
{/* 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">
|
<header className="h-14 bg-gray-900 border-b border-gray-700/60 flex items-center justify-between px-6 sticky top-0 z-30">
|
||||||
<div />
|
<div />
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-3">
|
||||||
|
{/* 테마 토글 */}
|
||||||
<button
|
<button
|
||||||
onClick={toggleTheme}
|
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"
|
className="flex items-center justify-center w-8 h-8 rounded-lg text-gray-400 hover:text-gray-200 hover:bg-gray-800 transition-colors"
|
||||||
title={theme === 'light' ? 'Dark mode' : 'Light mode'}
|
title={theme === 'light' ? 'Dark mode' : 'Light mode'}
|
||||||
>
|
>
|
||||||
{theme === 'light' ? (
|
{theme === 'light' ? (
|
||||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" width={18} height={18}>
|
||||||
<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" />
|
<path 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>
|
||||||
) : (
|
) : (
|
||||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" width={18} height={18}>
|
||||||
<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" />
|
<path 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>
|
</svg>
|
||||||
)}
|
)}
|
||||||
</button>
|
</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">
|
{/* 역할 스위처 */}
|
||||||
|
<div className="flex items-center bg-gray-800 rounded-full p-0.5 gap-0.5">
|
||||||
{ROLES.map((role) => (
|
{ROLES.map((role) => (
|
||||||
<button
|
<button
|
||||||
key={role}
|
key={role}
|
||||||
onClick={() => setRole(role)}
|
onClick={() => setRole(role)}
|
||||||
className={`px-3 py-1.5 text-xs font-medium transition-colors ${
|
className={`px-3 py-1 text-xs font-medium rounded-full transition-colors ${
|
||||||
user?.role === role
|
user?.role === role
|
||||||
? 'bg-blue-600 text-white'
|
? 'bg-indigo-600 text-white shadow-sm'
|
||||||
: 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600'
|
: 'text-gray-400 hover:text-gray-200'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{role}
|
{role}
|
||||||
@ -206,7 +373,7 @@ const MainLayout = () => {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<main className="p-6 bg-gray-100 dark:bg-gray-900 min-h-[calc(100vh-4rem)]">
|
<main className="p-6 bg-gray-100 dark:bg-gray-900 min-h-[calc(100vh-3.5rem)]">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -142,8 +142,8 @@ const ApiHubDashboardPage = () => {
|
|||||||
<div className="pointer-events-none absolute right-32 -top-8 h-32 w-32 rounded-full bg-purple-400 opacity-10 blur-2xl" />
|
<div className="pointer-events-none absolute right-32 -top-8 h-32 w-32 rounded-full bg-purple-400 opacity-10 blur-2xl" />
|
||||||
|
|
||||||
{/* 제목 */}
|
{/* 제목 */}
|
||||||
<h1 className="mb-2 text-4xl font-extrabold tracking-tight text-white">S&P API HUB</h1>
|
<h1 className="mb-2 text-4xl font-extrabold tracking-tight text-white">KCG API HUB</h1>
|
||||||
<p className="text-indigo-200">S&P 해양/선박 세계데이터를 직접 만나보세요.</p>
|
<p className="text-indigo-200">해양경찰청의 해양데이터를 직접 만나보세요.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 인기 API 섹션 */}
|
{/* 인기 API 섹션 */}
|
||||||
|
|||||||
@ -92,7 +92,6 @@ const ServiceStatusPage = () => {
|
|||||||
>
|
>
|
||||||
{svc.serviceName}
|
{svc.serviceName}
|
||||||
</h2>
|
</h2>
|
||||||
<span className="text-sm text-gray-500 dark:text-gray-400">{svc.serviceCode}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<span className={`text-sm font-medium ${svc.currentStatus === 'UP' ? 'text-green-600' : svc.currentStatus === 'DOWN' ? 'text-red-600' : 'text-gray-500'}`}>
|
<span className={`text-sm font-medium ${svc.currentStatus === 'UP' ? 'text-green-600' : svc.currentStatus === 'DOWN' ? 'text-red-600' : 'text-gray-500'}`}>
|
||||||
|
|||||||