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="shortcut icon" href="/snp-connection/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>SNP Connection Monitoring</title>
|
||||
<title>KCG Connection Monitoring</title>
|
||||
</head>
|
||||
<body>
|
||||
<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];
|
||||
};
|
||||
|
||||
/** 도메인 인덱스를 기반으로 색상 팔레트 반환 */
|
||||
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 {
|
||||
domain: string;
|
||||
iconPath: string | null;
|
||||
@ -103,49 +117,33 @@ const ApiHubLayoutInner = () => {
|
||||
|
||||
const isSearching = searchQuery.trim().length > 0;
|
||||
|
||||
const totalApiCount = useMemo(() => domainGroups.reduce((sum, dg) => sum + dg.apis.length, 0), [domainGroups]);
|
||||
|
||||
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">
|
||||
<aside className="fixed left-0 top-0 h-screen w-[272px] bg-gray-900 flex flex-col">
|
||||
{/* 로고 영역 */}
|
||||
<div className="flex-shrink-0 border-b border-gray-700/70">
|
||||
<button
|
||||
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
|
||||
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"
|
||||
/>
|
||||
{/* Indigo 그라디언트 아이콘 배경 */}
|
||||
<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">
|
||||
<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">
|
||||
<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" />
|
||||
</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>
|
||||
<span className="text-sm font-bold tracking-widest text-white uppercase">KCG API HUB</span>
|
||||
</button>
|
||||
</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">
|
||||
<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"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
@ -157,12 +155,12 @@ const ApiHubLayoutInner = () => {
|
||||
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"
|
||||
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 && (
|
||||
<button
|
||||
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">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
@ -170,53 +168,47 @@ const ApiHubLayoutInner = () => {
|
||||
</button>
|
||||
)}
|
||||
</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>
|
||||
|
||||
{/* 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 ? (
|
||||
<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 className="h-5 w-5 animate-spin text-gray-600" 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>
|
||||
<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 color = getDomainColor(domainIndex);
|
||||
|
||||
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">
|
||||
<div key={dg.domain} className="mb-0.5">
|
||||
{/* 도메인 헤더 */}
|
||||
<div className="flex w-full items-center rounded-lg hover:bg-gray-800/50 transition-colors group">
|
||||
{/* 도메인명 클릭 → 도메인 상세 페이지 이동 */}
|
||||
<button
|
||||
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"
|
||||
>
|
||||
{/* 도메인 색상 아이콘 배경 */}
|
||||
<div className={`flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-md ${color.bg}`}>
|
||||
<svg
|
||||
className="h-4 w-4 flex-shrink-0 text-gray-400"
|
||||
className="h-3.5 w-3.5 text-white"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
strokeWidth={1.8}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
@ -224,15 +216,19 @@ const ApiHubLayoutInner = () => {
|
||||
<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">
|
||||
</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}
|
||||
</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"
|
||||
className="flex-shrink-0 rounded-md p-1.5 mr-1 text-gray-600 hover:text-gray-400 transition-colors"
|
||||
title={domainOpen ? '접기' : '펼치기'}
|
||||
>
|
||||
<svg
|
||||
@ -246,9 +242,9 @@ const ApiHubLayoutInner = () => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* API items */}
|
||||
{/* API 항목 목록 */}
|
||||
{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) => {
|
||||
const apiPath = `/api-hub/services/${api.serviceId}/apis/${api.apiId}`;
|
||||
const isActive = location.pathname === apiPath;
|
||||
@ -257,13 +253,17 @@ const ApiHubLayoutInner = () => {
|
||||
<NavLink
|
||||
key={`${api.serviceId}-${api.apiId}`}
|
||||
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
|
||||
? 'bg-gray-700 text-white'
|
||||
: 'text-gray-300 hover:bg-gray-800 hover:text-white'
|
||||
? 'bg-gray-800 text-white font-medium'
|
||||
: '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>
|
||||
);
|
||||
})}
|
||||
@ -274,49 +274,50 @@ const ApiHubLayoutInner = () => {
|
||||
})
|
||||
)}
|
||||
</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>
|
||||
|
||||
{/* 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 className="flex-1 ml-[272px]">
|
||||
{/* 헤더 */}
|
||||
<header className="h-14 bg-gray-900 border-b border-gray-700/70 flex items-center justify-between px-6">
|
||||
<div />
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
{/* 테마 토글 */}
|
||||
<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"
|
||||
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'}
|
||||
>
|
||||
{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 className="h-4 w-4" 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 className="h-4 w-4" 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">
|
||||
{/* 역할 스위처 (알약 형태) */}
|
||||
<div className="flex rounded-full overflow-hidden border border-gray-700 bg-gray-800 p-0.5">
|
||||
{ROLES.map((role) => (
|
||||
<button
|
||||
key={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
|
||||
? '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'
|
||||
? 'bg-indigo-600 text-white shadow-sm'
|
||||
: 'text-gray-500 hover:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
{role}
|
||||
@ -326,8 +327,8 @@ const ApiHubLayoutInner = () => {
|
||||
</div>
|
||||
</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 />
|
||||
<BasketFloatingPanel />
|
||||
</main>
|
||||
|
||||
@ -1,50 +1,178 @@
|
||||
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 { useTheme } from '../hooks/useTheme';
|
||||
|
||||
interface NavItem {
|
||||
label: string;
|
||||
path: string;
|
||||
icon: React.ReactNode;
|
||||
adminManagerOnly?: boolean;
|
||||
}
|
||||
|
||||
interface NavGroup {
|
||||
label: string;
|
||||
items: { label: string; path: string }[];
|
||||
items: NavItem[];
|
||||
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[] = [
|
||||
{
|
||||
label: 'Monitoring',
|
||||
label: '모니터링',
|
||||
items: [
|
||||
{ label: 'Request Logs', path: '/monitoring/request-logs' },
|
||||
{ label: 'Service Status', path: '/monitoring/service-status' },
|
||||
{ label: '요청 로그', path: '/monitoring/request-logs', icon: <IconRequestLog /> },
|
||||
{ label: '서비스 상태', path: '/monitoring/service-status', icon: <IconServiceStatus /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Statistics',
|
||||
label: '통계',
|
||||
items: [
|
||||
{ label: '서비스 통계', path: '/statistics/services' },
|
||||
{ label: '사용자 통계', path: '/statistics/users' },
|
||||
{ label: 'API 통계', path: '/statistics/apis' },
|
||||
{ label: '테넌트 통계', path: '/statistics/tenants' },
|
||||
{ label: '사용량 추이', path: '/statistics/usage-trend' },
|
||||
{ label: '서비스 통계', path: '/statistics/services', icon: <IconServiceStats /> },
|
||||
{ label: '사용자 통계', path: '/statistics/users', icon: <IconUserStats /> },
|
||||
{ label: 'API 통계', path: '/statistics/apis', icon: <IconApiStats /> },
|
||||
{ label: '부서 통계', path: '/statistics/tenants', icon: <IconTenantStats /> },
|
||||
{ label: '사용량 추이', path: '/statistics/usage-trend', icon: <IconUsageTrend /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'API Keys',
|
||||
label: 'API 키',
|
||||
items: [
|
||||
{ label: 'My Keys', path: '/apikeys/my-keys' },
|
||||
{ label: 'Request', path: '/apikeys/request' },
|
||||
{ label: 'Admin', path: '/apikeys/admin' },
|
||||
{ label: '내 키', path: '/apikeys/my-keys', icon: <IconMyKey /> },
|
||||
{ label: '키 신청', path: '/apikeys/request', icon: <IconKeyRequest /> },
|
||||
{ label: '키 관리', path: '/apikeys/admin', icon: <IconKeyManage />, adminManagerOnly: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Admin',
|
||||
label: '관리자',
|
||||
adminOnly: true,
|
||||
items: [
|
||||
{ label: 'Services', path: '/admin/services' },
|
||||
{ label: 'Domains', path: '/admin/domains' },
|
||||
{ label: 'API 관리', path: '/admin/apis' },
|
||||
{ label: '공통 샘플 코드', path: '/admin/sample-code' },
|
||||
{ label: 'Users', path: '/admin/users' },
|
||||
{ label: 'Tenants', path: '/admin/tenants' },
|
||||
{ label: '서비스', path: '/admin/services', icon: <IconService /> },
|
||||
{ label: '도메인', path: '/admin/domains', icon: <IconDomain /> },
|
||||
{ label: 'API 관리', path: '/admin/apis', icon: <IconApiManage /> },
|
||||
{ label: '공통 샘플 코드', path: '/admin/sample-code', icon: <IconSampleCode /> },
|
||||
{ label: '사용자', path: '/admin/users', icon: <IconUserStats /> },
|
||||
{ label: '부서', path: '/admin/tenants', icon: <IconTenantStats /> },
|
||||
],
|
||||
},
|
||||
];
|
||||
@ -55,10 +183,10 @@ const MainLayout = () => {
|
||||
const { user, setRole } = useAuth();
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
const [openGroups, setOpenGroups] = useState<Record<string, boolean>>({
|
||||
Monitoring: true,
|
||||
Statistics: true,
|
||||
'API Keys': true,
|
||||
Admin: true,
|
||||
'모니터링': true,
|
||||
'통계': true,
|
||||
'API 키': true,
|
||||
'관리자': true,
|
||||
});
|
||||
|
||||
const toggleGroup = (label: string) => {
|
||||
@ -70,43 +198,64 @@ const MainLayout = () => {
|
||||
return (
|
||||
<div className="flex min-h-screen">
|
||||
{/* Sidebar */}
|
||||
<aside className="fixed left-0 top-0 h-screen w-64 bg-gray-900 text-white flex flex-col">
|
||||
<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" />
|
||||
<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-3 px-4 h-14 border-b border-gray-700/60 flex-shrink-0">
|
||||
<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">
|
||||
<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>
|
||||
<span className="text-lg font-semibold">SNP Connection</span>
|
||||
</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>
|
||||
|
||||
<nav className="flex-1 overflow-y-auto px-3 py-4">
|
||||
{/* Dashboard */}
|
||||
<nav className="flex-1 overflow-y-auto py-3 px-2 space-y-1 scrollbar-thin scrollbar-track-gray-900 scrollbar-thumb-gray-700">
|
||||
|
||||
{/* 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
|
||||
to="/dashboard"
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors ${
|
||||
isActive ? 'bg-gray-700 text-white' : 'text-gray-300 hover:bg-gray-800 hover:text-white'
|
||||
`relative flex items-center gap-2.5 px-3 py-2 rounded-lg text-sm transition-colors ${
|
||||
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">
|
||||
<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>
|
||||
Dashboard
|
||||
</NavLink>
|
||||
|
||||
{/* API Hub */}
|
||||
<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
|
||||
{({ 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"><IconDashboard /></span>
|
||||
<span className="font-medium">대시보드</span>
|
||||
</>
|
||||
)}
|
||||
</NavLink>
|
||||
|
||||
{/* Nav Groups */}
|
||||
@ -116,44 +265,57 @@ const MainLayout = () => {
|
||||
const isOpen = openGroups[group.label] ?? false;
|
||||
|
||||
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
|
||||
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
|
||||
className={`h-4 w-4 transition-transform ${isOpen ? 'rotate-90' : ''}`}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
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>
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="ml-2 space-y-1">
|
||||
<div className="mt-0.5 space-y-0.5">
|
||||
{group.items.map((item) => {
|
||||
if (
|
||||
group.label === 'API Keys' &&
|
||||
item.label === 'Admin' &&
|
||||
!isAdminOrManager
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (item.adminManagerOnly && !isAdminOrManager) return null;
|
||||
|
||||
return (
|
||||
<NavLink
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
className={({ isActive }) =>
|
||||
`block rounded-lg px-3 py-2 text-sm transition-colors ${
|
||||
isActive ? 'bg-gray-700 text-white' : 'text-gray-300 hover:bg-gray-800 hover:text-white'
|
||||
`relative flex items-center gap-2.5 px-3 py-2 rounded-lg text-sm transition-colors ${
|
||||
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>
|
||||
);
|
||||
})}
|
||||
@ -162,40 +324,45 @@ const MainLayout = () => {
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 마지막 구분선 */}
|
||||
<div className="my-2 mx-1 border-t border-gray-700/50" />
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 ml-64">
|
||||
<div className="flex-1 ml-60">
|
||||
{/* 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 className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
{/* 테마 토글 */}
|
||||
<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"
|
||||
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'}
|
||||
>
|
||||
{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 fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" width={18} height={18}>
|
||||
<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 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 fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" width={18} height={18}>
|
||||
<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>
|
||||
)}
|
||||
</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) => (
|
||||
<button
|
||||
key={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
|
||||
? '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'
|
||||
? 'bg-indigo-600 text-white shadow-sm'
|
||||
: 'text-gray-400 hover:text-gray-200'
|
||||
}`}
|
||||
>
|
||||
{role}
|
||||
@ -206,7 +373,7 @@ const MainLayout = () => {
|
||||
</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 />
|
||||
</main>
|
||||
</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" />
|
||||
|
||||
{/* 제목 */}
|
||||
<h1 className="mb-2 text-4xl font-extrabold tracking-tight text-white">S&P API HUB</h1>
|
||||
<p className="text-indigo-200">S&P 해양/선박 세계데이터를 직접 만나보세요.</p>
|
||||
<h1 className="mb-2 text-4xl font-extrabold tracking-tight text-white">KCG API HUB</h1>
|
||||
<p className="text-indigo-200">해양경찰청의 해양데이터를 직접 만나보세요.</p>
|
||||
</div>
|
||||
|
||||
{/* 인기 API 섹션 */}
|
||||
|
||||
@ -92,7 +92,6 @@ const ServiceStatusPage = () => {
|
||||
>
|
||||
{svc.serviceName}
|
||||
</h2>
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">{svc.serviceCode}</span>
|
||||
</div>
|
||||
<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'}`}>
|
||||
|
||||