init studio admin frontend
This commit is contained in:
@@ -0,0 +1,178 @@
|
||||
import { useMemo, useState, type ReactNode } from 'react';
|
||||
import { Bell, ChevronDown, ChevronRight, Menu, Search, UserRound } from 'lucide-react';
|
||||
import { Button } from '../components/ui';
|
||||
import { cn } from '../lib/utils';
|
||||
import { navGroups } from './navigation';
|
||||
|
||||
type AppLayoutProps = {
|
||||
children: ReactNode;
|
||||
currentPath: string;
|
||||
onNavigate: (path: string) => void;
|
||||
};
|
||||
|
||||
function isChildActive(item: { path?: string; children?: Array<{ path: string }> }, currentPath: string) {
|
||||
if (item.path === currentPath) return true;
|
||||
return item.children?.some((child) => child.path === currentPath) ?? false;
|
||||
}
|
||||
|
||||
export function AppLayout({ children, currentPath, onNavigate }: AppLayoutProps) {
|
||||
const [openGroups, setOpenGroups] = useState<Record<string, boolean>>({});
|
||||
const [menuSearch, setMenuSearch] = useState('');
|
||||
const normalizedSearch = menuSearch.trim().toLowerCase();
|
||||
|
||||
const visibleGroups = useMemo(() => {
|
||||
if (!normalizedSearch) return navGroups;
|
||||
|
||||
return navGroups
|
||||
.map((group) => ({
|
||||
...group,
|
||||
items: group.items.flatMap((item) => {
|
||||
const parentMatched = item.label.toLowerCase().includes(normalizedSearch);
|
||||
if ('children' in item && item.children) {
|
||||
const children = item.children.filter((child) => child.label.toLowerCase().includes(normalizedSearch));
|
||||
if (parentMatched) return [item];
|
||||
if (children.length) return [{ ...item, children }];
|
||||
return [];
|
||||
}
|
||||
return parentMatched ? [item] : [];
|
||||
})
|
||||
}))
|
||||
.filter((group) => group.items.length > 0);
|
||||
}, [normalizedSearch]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen overflow-hidden bg-background text-foreground">
|
||||
<div className="min-h-screen lg:pl-[17rem]">
|
||||
<aside className="fixed inset-y-0 left-0 hidden w-[17rem] flex-col border-r border-border bg-sidebar px-3 py-4 lg:flex">
|
||||
<div className="mb-8 flex shrink-0 justify-center px-4 py-3">
|
||||
<img className="h-5 w-auto object-contain" src="/assets/brand/header.webp" alt="Autonomy Studio" />
|
||||
</div>
|
||||
|
||||
<nav className="scrollbar-stable min-h-0 flex-1 space-y-5 overflow-y-auto pr-2">
|
||||
{visibleGroups.map((group) => (
|
||||
<div key={group.label}>
|
||||
<p className="mb-2 px-2 text-xs font-medium text-muted-foreground">{group.label}</p>
|
||||
<div className="space-y-1">
|
||||
{group.items.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const children = item.children;
|
||||
const hasChildren = Boolean(children?.length);
|
||||
const active = isChildActive(item, currentPath);
|
||||
const expanded = Boolean(normalizedSearch) || openGroups[item.label] || active;
|
||||
|
||||
return (
|
||||
<div key={item.label}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (hasChildren) {
|
||||
setOpenGroups((prev) => ({ ...prev, [item.label]: !prev[item.label] }));
|
||||
return;
|
||||
}
|
||||
if ('path' in item && item.path) onNavigate(item.path);
|
||||
}}
|
||||
className={cn(
|
||||
'flex h-9 w-full items-center gap-2 rounded-md px-2 text-sm text-muted-foreground transition-colors hover:bg-sidebar-accent hover:text-white',
|
||||
active && 'font-semibold text-white'
|
||||
)}
|
||||
>
|
||||
<Icon className={cn('size-4', active && 'text-white')} />
|
||||
<span className="min-w-0 flex-1 text-left">{item.label}</span>
|
||||
{hasChildren ? (
|
||||
expanded ? <ChevronDown className="size-3.5" /> : <ChevronRight className="size-3.5" />
|
||||
) : null}
|
||||
</button>
|
||||
|
||||
{hasChildren && expanded && children ? (
|
||||
<div className="mt-1 space-y-1 pl-6">
|
||||
{children.map((child) => {
|
||||
const childActive = child.path === currentPath;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={child.path}
|
||||
onClick={() => onNavigate(child.path)}
|
||||
className={cn(
|
||||
'flex h-8 w-full items-center gap-2 rounded-md px-2 text-left text-xs font-bold text-muted-foreground transition-colors hover:bg-sidebar-accent hover:text-white',
|
||||
childActive && 'text-white'
|
||||
)}
|
||||
>
|
||||
<span className={cn('h-3 w-1 rounded-full', childActive ? 'bg-white' : 'bg-transparent')} />
|
||||
<span>{child.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<div className="flex h-screen min-w-0 flex-col">
|
||||
<header className="sticky top-0 z-20 flex h-16 items-center justify-between border-b border-border bg-background/90 px-4 backdrop-blur-xl md:px-6">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<Button variant="outline" className="lg:hidden">
|
||||
<Menu className="size-4" />
|
||||
</Button>
|
||||
<div className="relative hidden w-80 md:block">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<input
|
||||
className="h-9 w-full rounded-md border border-input bg-background pl-9 pr-3 text-sm outline-none placeholder:text-muted-foreground focus:ring-2 focus:ring-ring"
|
||||
placeholder="메뉴 검색"
|
||||
value={menuSearch}
|
||||
onChange={(event) => setMenuSearch(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" className="size-9 px-0">
|
||||
<Bell className="size-4" />
|
||||
</Button>
|
||||
<Button variant="outline" className="gap-2">
|
||||
<span className="flex size-6 items-center justify-center rounded bg-muted text-xs font-semibold">
|
||||
<UserRound className="size-3.5" />
|
||||
</span>
|
||||
<span className="hidden md:inline">관리자</span>
|
||||
<ChevronDown className="size-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="scrollbar-stable min-h-0 flex-1 overflow-y-auto">
|
||||
<div className="mx-auto w-full max-w-[1520px] px-6 pb-16 pt-6 md:px-10 md:pb-20 xl:px-14">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PageTitle({
|
||||
eyebrow,
|
||||
title,
|
||||
description,
|
||||
actions
|
||||
}: {
|
||||
eyebrow: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
actions?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="mb-6 flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">{eyebrow}</p>
|
||||
<h1 className="mt-1 text-2xl font-semibold tracking-tight md:text-3xl">{title}</h1>
|
||||
{description ? <p className="mt-2 max-w-3xl text-sm leading-6 text-muted-foreground">{description}</p> : null}
|
||||
</div>
|
||||
{actions ? <div className="flex flex-wrap gap-2">{actions}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import {
|
||||
Boxes,
|
||||
ChartNoAxesColumnIncreasing,
|
||||
FileKey,
|
||||
LayoutDashboard,
|
||||
Megaphone,
|
||||
Network,
|
||||
Rocket,
|
||||
ServerCog,
|
||||
ShieldCheck,
|
||||
Users
|
||||
} from 'lucide-react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
|
||||
export type NavChild = {
|
||||
label: string;
|
||||
path: string;
|
||||
};
|
||||
|
||||
export type NavItem = {
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
path?: string;
|
||||
children?: NavChild[];
|
||||
};
|
||||
|
||||
export type NavGroup = {
|
||||
label: string;
|
||||
items: NavItem[];
|
||||
};
|
||||
|
||||
export const navGroups: NavGroup[] = [
|
||||
{
|
||||
label: '플랫폼',
|
||||
items: [
|
||||
{ label: '대시보드', path: '/dashboard', icon: LayoutDashboard },
|
||||
{
|
||||
label: '런처',
|
||||
icon: Rocket,
|
||||
children: [
|
||||
{ label: '소스 관리', path: '/launcher/sources' },
|
||||
{ label: '빌드 관리', path: '/launcher/builds' },
|
||||
{ label: '버전 관리', path: '/launcher/versions' },
|
||||
{ label: '공통 설정', path: '/launcher/common-settings' },
|
||||
{ label: '앱 연결', path: '/launcher/apps' }
|
||||
]
|
||||
},
|
||||
{
|
||||
label: '어플리케이션',
|
||||
icon: Boxes,
|
||||
children: [
|
||||
{ label: '앱 관리', path: '/applications/apps' },
|
||||
{ label: '소스 관리', path: '/applications/sources' },
|
||||
{ label: '빌드 관리', path: '/applications/builds' },
|
||||
{ label: '릴리즈 관리', path: '/applications/releases' },
|
||||
{ label: '실행 설정', path: '/applications/runtime-settings' },
|
||||
{ label: '배포 관리', path: '/applications/deployments' },
|
||||
{ label: '마이그레이션', path: '/applications/migrations' },
|
||||
{ label: '로그/이벤트', path: '/runtime/logs' }
|
||||
]
|
||||
},
|
||||
{
|
||||
label: '테일스케일',
|
||||
icon: Network,
|
||||
children: [
|
||||
{ label: '노드 현황', path: '/tailscale/status' },
|
||||
{ label: '라우트', path: '/tailscale/routes' },
|
||||
{ label: 'ACL', path: '/tailscale/acl' },
|
||||
{ label: '연결 진단', path: '/tailscale/diagnostics' }
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Agent',
|
||||
icon: ShieldCheck,
|
||||
children: [
|
||||
{ label: '장비 등록', path: '/agents/registration' },
|
||||
{ label: '장비 제어', path: '/runtime/devices' },
|
||||
{ label: '설치 상태', path: '/agents/install-status' },
|
||||
{ label: 'SSH 키 배포', path: '/agents/ssh-keys' },
|
||||
{ label: 'Workspace 설정', path: '/agents/workspace' },
|
||||
{ label: '원격 명령', path: '/agents/commands' }
|
||||
]
|
||||
},
|
||||
{
|
||||
label: '플랫폼 게이트웨이',
|
||||
icon: ServerCog,
|
||||
children: [
|
||||
{ label: '연결 상태', path: '/gateway/status' },
|
||||
{ label: 'Agent 관리', path: '/gateway/agents' },
|
||||
{ label: '장비 동기화', path: '/gateway/devices' }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
label: '운영',
|
||||
items: [
|
||||
{ label: '관리자 관리', path: '/operations/admins', icon: ShieldCheck },
|
||||
{ label: '사용자/세션', path: '/operations/users', icon: Users },
|
||||
{ label: '라이센스', path: '/operations/licenses', icon: FileKey },
|
||||
{ label: '공지사항', path: '/operations/notices', icon: Megaphone }
|
||||
]
|
||||
},
|
||||
{
|
||||
label: '시스템',
|
||||
items: [
|
||||
{ label: '모니터링', path: '/system/monitoring', icon: ChartNoAxesColumnIncreasing },
|
||||
{ label: '정책', path: '/system/policies', icon: ShieldCheck }
|
||||
]
|
||||
}
|
||||
];
|
||||
Reference in New Issue
Block a user