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>
239 lines
9.6 KiB
TypeScript
239 lines
9.6 KiB
TypeScript
import { useState, useEffect, useMemo } from 'react';
|
|
import { useParams, useNavigate } from 'react-router-dom';
|
|
import type { ServiceCatalog } from '../../types/apihub';
|
|
import { getCatalog } from '../../services/apiHubService';
|
|
|
|
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',
|
|
];
|
|
|
|
const DOMAIN_COLOR_PALETTE = [
|
|
{ color: 'text-emerald-400', bg: 'bg-emerald-500/10', border: 'border-emerald-500/30', line: 'from-emerald-500' },
|
|
{ color: 'text-rose-400', bg: 'bg-rose-500/10', border: 'border-rose-500/30', line: 'from-rose-500' },
|
|
{ color: 'text-blue-400', bg: 'bg-blue-500/10', border: 'border-blue-500/30', line: 'from-blue-500' },
|
|
{ color: 'text-amber-400', bg: 'bg-amber-500/10', border: 'border-amber-500/30', line: 'from-amber-500' },
|
|
{ color: 'text-violet-400', bg: 'bg-violet-500/10', border: 'border-violet-500/30', line: 'from-violet-500' },
|
|
{ color: 'text-cyan-400', bg: 'bg-cyan-500/10', border: 'border-cyan-500/30', line: 'from-cyan-500' },
|
|
{ color: 'text-orange-400', bg: 'bg-orange-500/10', border: 'border-orange-500/30', line: 'from-orange-500' },
|
|
{ color: 'text-pink-400', bg: 'bg-pink-500/10', border: 'border-pink-500/30', line: 'from-pink-500' },
|
|
{ color: 'text-lime-400', bg: 'bg-lime-500/10', border: 'border-lime-500/30', line: 'from-lime-500' },
|
|
{ color: 'text-indigo-400', bg: 'bg-indigo-500/10', border: 'border-indigo-500/30', line: 'from-indigo-500' },
|
|
{ color: 'text-teal-400', bg: 'bg-teal-500/10', border: 'border-teal-500/30', line: 'from-teal-500' },
|
|
{ color: 'text-fuchsia-400', bg: 'bg-fuchsia-500/10', border: 'border-fuchsia-500/30', line: 'from-fuchsia-500' },
|
|
];
|
|
|
|
const domainColorCache = new Map<string, (typeof DOMAIN_COLOR_PALETTE)[0]>();
|
|
let nextColorIdx = 0;
|
|
|
|
const getDomainColorByHash = (domain: string) => {
|
|
const key = domain.toUpperCase();
|
|
const cached = domainColorCache.get(key);
|
|
if (cached) return cached;
|
|
const color = DOMAIN_COLOR_PALETTE[nextColorIdx % DOMAIN_COLOR_PALETTE.length];
|
|
nextColorIdx++;
|
|
domainColorCache.set(key, color);
|
|
return color;
|
|
};
|
|
|
|
/** iconPath 문자열에서 SVG path d 값 배열을 추출 */
|
|
const parseIconPaths = (iconPath: string | null): string[] => {
|
|
if (!iconPath) return DEFAULT_ICON_PATHS;
|
|
const pathRegex = /d="([^"]+)"/g;
|
|
const matches: string[] = [];
|
|
let m;
|
|
while ((m = pathRegex.exec(iconPath)) !== null) {
|
|
matches.push(m[1]);
|
|
}
|
|
return matches.length > 0 ? matches : [iconPath];
|
|
};
|
|
|
|
const formatDomain = (d: string) => (/^[a-zA-Z\s\-_]+$/.test(d) ? d.toUpperCase() : d);
|
|
|
|
interface FlatApi {
|
|
serviceId: number;
|
|
apiId: number;
|
|
apiName: string;
|
|
apiPath: string;
|
|
apiMethod: string;
|
|
description: string | null;
|
|
}
|
|
|
|
interface DomainInfo {
|
|
domain: string;
|
|
iconPath: string | null;
|
|
apis: FlatApi[];
|
|
}
|
|
|
|
const ApiHubDomainPage = () => {
|
|
const { domainName } = useParams<{ domainName: string }>();
|
|
const navigate = useNavigate();
|
|
const [catalog, setCatalog] = useState<ServiceCatalog[]>([]);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
|
|
useEffect(() => {
|
|
getCatalog()
|
|
.then((res) => {
|
|
setCatalog(res.data ?? []);
|
|
})
|
|
.finally(() => setIsLoading(false));
|
|
}, []);
|
|
|
|
const domainInfo = useMemo<DomainInfo | null>(() => {
|
|
if (!domainName) return null;
|
|
const targetKey = decodeURIComponent(domainName).toUpperCase();
|
|
const apis: FlatApi[] = [];
|
|
let iconPath: string | null = null;
|
|
let foundDomain = '';
|
|
|
|
for (const svc of catalog) {
|
|
for (const dg of svc.domains) {
|
|
if (dg.domain.toUpperCase() === targetKey) {
|
|
if (!foundDomain) foundDomain = dg.domain;
|
|
if (iconPath === null && dg.iconPath) iconPath = dg.iconPath;
|
|
for (const api of dg.apis) {
|
|
apis.push({
|
|
serviceId: svc.serviceId,
|
|
apiId: api.apiId,
|
|
apiName: api.apiName,
|
|
apiPath: api.apiPath,
|
|
apiMethod: api.apiMethod,
|
|
description: api.description ?? null,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!foundDomain) return null;
|
|
return { domain: foundDomain, iconPath, apis };
|
|
}, [catalog, domainName]);
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="flex items-center justify-center h-64">
|
|
<div className="text-gray-500 dark:text-gray-400">로딩 중...</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!domainInfo) {
|
|
return (
|
|
<div className="max-w-7xl mx-auto">
|
|
<div className="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-12 text-center">
|
|
<p className="text-gray-500 dark:text-gray-400">도메인을 찾을 수 없습니다.</p>
|
|
<button
|
|
onClick={() => navigate('/api-hub')}
|
|
className="mt-4 text-sm text-indigo-500 hover:underline"
|
|
>
|
|
API HUB 홈으로 돌아가기
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const palette = getDomainColorByHash(domainInfo.domain);
|
|
const iconPaths = parseIconPaths(domainInfo.iconPath);
|
|
|
|
return (
|
|
<div className="max-w-7xl mx-auto space-y-6">
|
|
{/* 헤더 카드 */}
|
|
<div className={`relative overflow-hidden rounded-2xl border bg-white dark:bg-gray-800 ${palette.border} p-6`}>
|
|
{/* 상단 컬러 라인 */}
|
|
<div className={`absolute inset-x-0 top-0 h-1 bg-gradient-to-r ${palette.line} to-transparent`} />
|
|
|
|
<div className="flex items-center gap-4">
|
|
{/* 도메인 아이콘 */}
|
|
<div className={`flex h-14 w-14 flex-shrink-0 items-center justify-center rounded-2xl ${palette.bg}`}>
|
|
<svg
|
|
className={`h-7 w-7 ${palette.color}`}
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
strokeWidth={1.5}
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
>
|
|
{iconPaths.map((d, i) => (
|
|
<path key={i} d={d} />
|
|
))}
|
|
</svg>
|
|
</div>
|
|
|
|
{/* 도메인명 + API 개수 */}
|
|
<div>
|
|
<h1 className="text-2xl font-bold tracking-tight text-gray-900 dark:text-gray-100">
|
|
{formatDomain(domainInfo.domain)}
|
|
</h1>
|
|
<p className={`mt-1 text-sm font-medium ${palette.color}`}>
|
|
{domainInfo.apis.length}개 API
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* API 목록 */}
|
|
<div className="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 overflow-hidden">
|
|
{/* 검색 헤더 */}
|
|
<div className="flex items-center justify-between gap-4 border-b border-gray-200 dark:border-gray-700 px-5 py-3">
|
|
<h2 className="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
|
API 목록
|
|
<span className="ml-2 text-xs font-normal text-gray-400 dark:text-gray-500">{domainInfo.apis.length}건</span>
|
|
</h2>
|
|
<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-52 bg-gray-50 dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-lg pl-8 pr-3 py-1.5 text-xs text-gray-900 dark:text-gray-100 placeholder-gray-400 focus:ring-1 focus:ring-indigo-500 focus:outline-none"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 리스트 */}
|
|
{(() => {
|
|
const filtered = domainInfo.apis.filter((api) => {
|
|
if (!searchQuery.trim()) return true;
|
|
const q = searchQuery.toLowerCase();
|
|
return api.apiName.toLowerCase().includes(q) || (api.description ?? '').toLowerCase().includes(q);
|
|
});
|
|
|
|
if (filtered.length === 0) {
|
|
return (
|
|
<div className="px-5 py-12 text-center text-sm text-gray-400 dark:text-gray-500">
|
|
{searchQuery.trim() ? '검색 결과가 없습니다.' : '등록된 API가 없습니다.'}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="divide-y divide-gray-100 dark:divide-gray-700/50">
|
|
{filtered.map((api) => (
|
|
<div
|
|
key={`${api.serviceId}-${api.apiId}`}
|
|
onClick={() => navigate(`/api-hub/services/${api.serviceId}/apis/${api.apiId}`)}
|
|
className="flex items-center gap-4 px-5 py-3.5 cursor-pointer transition-colors hover:bg-gray-50 dark:hover:bg-gray-700/50"
|
|
>
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-sm font-semibold text-gray-900 dark:text-gray-100 truncate">{api.apiName}</p>
|
|
</div>
|
|
<svg className="h-4 w-4 flex-shrink-0 text-gray-300 dark:text-gray-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
|
</svg>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
})()}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default ApiHubDomainPage;
|