Compare commits
2 Commits
68dccc10aa
...
2d0834472d
| Author | SHA1 | Date | |
|---|---|---|---|
| 2d0834472d | |||
| 83ad885641 |
@@ -1,9 +1,62 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
@import "pretendard/dist/web/variable/pretendardvariable-dynamic-subset.css";
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "Pretendard";
|
||||||
|
font-weight: 400;
|
||||||
|
font-style: normal;
|
||||||
|
src: url("~/shared/assets/fonts/Pretendard-Regular.subset.woff") format("woff");
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "Pretendard";
|
||||||
|
font-weight: 500;
|
||||||
|
font-style: normal;
|
||||||
|
src: url("~/shared/assets/fonts/Pretendard-Medium.subset.woff") format("woff");
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "Pretendard";
|
||||||
|
font-weight: 600;
|
||||||
|
font-style: normal;
|
||||||
|
src: url("~/shared/assets/fonts/Pretendard-SemiBold.subset.woff") format("woff");
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "Pretendard";
|
||||||
|
font-weight: 700;
|
||||||
|
font-style: normal;
|
||||||
|
src: url("~/shared/assets/fonts/Pretendard-Bold.subset.woff") format("woff");
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
@theme {
|
@theme {
|
||||||
--font-sans: "Pretendard Variable", ui-sans-serif, system-ui, sans-serif,
|
--font-sans: "Pretendard", ui-sans-serif, system-ui, sans-serif,
|
||||||
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
||||||
|
|
||||||
|
/* Primary (Navy) */
|
||||||
|
--color-primary: var(--color-dabeeo-navy-main);
|
||||||
|
--color-primary-secondary: var(--color-dabeeo-navy-secondary);
|
||||||
|
--color-primary-tertiary: var(--color-dabeeo-navy-tertiary);
|
||||||
|
--color-primary-tertiary01: var(--color-dabeeo-navy-tertiary01);
|
||||||
|
--color-primary-tertiary02: var(--color-dabeeo-navy-tertiary02);
|
||||||
|
|
||||||
|
/* Navy */
|
||||||
|
--color-dabeeo-navy-main: #00387d;
|
||||||
|
--color-dabeeo-navy-secondary: #032651;
|
||||||
|
--color-dabeeo-navy-tertiary: #5c84b4;
|
||||||
|
--color-dabeeo-navy-tertiary01: #d4dde9;
|
||||||
|
--color-dabeeo-navy-tertiary02: #f0f3f7;
|
||||||
|
|
||||||
|
/* Gray */
|
||||||
|
--color-dabeeo-gray-44: #444444;
|
||||||
|
--color-dabeeo-gray-99: #999999;
|
||||||
|
--color-dabeeo-gray-be: #bebebe;
|
||||||
|
--color-dabeeo-gray-da: #dadada;
|
||||||
|
--color-dabeeo-gray-eb: #ebebeb;
|
||||||
|
--color-dabeeo-gray-f9: #f9f9f9;
|
||||||
}
|
}
|
||||||
|
|
||||||
html,
|
html,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export default [
|
|||||||
route('login', './routes/login/page.tsx'),
|
route('login', './routes/login/page.tsx'),
|
||||||
]),
|
]),
|
||||||
layout('./routes/layout.tsx', [
|
layout('./routes/layout.tsx', [
|
||||||
|
index('./routes/page.tsx'),
|
||||||
...prefix('imagery', [
|
...prefix('imagery', [
|
||||||
...prefix('aerial', [
|
...prefix('aerial', [
|
||||||
index('./routes/imagery/aerial/page.tsx'),
|
index('./routes/imagery/aerial/page.tsx'),
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export default function Page() {
|
||||||
|
return <div>공통코드 관리</div>;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export default function Page() {
|
||||||
|
return <div>하이퍼파라미터 설정</div>;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import type { Route } from './+types/page';
|
||||||
|
|
||||||
|
export default function Page({ params }: Route.ComponentProps) {
|
||||||
|
return <div>추론 상세 id: {params.inferenceId}</div>;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export default function Page() {
|
||||||
|
return <div>라벨링 작업</div>;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export default function Page() {
|
||||||
|
return <div>라벨링 검수</div>;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,10 +1,31 @@
|
|||||||
import { Outlet } from 'react-router';
|
import { Outlet } from 'react-router';
|
||||||
|
|
||||||
|
import { LayoutMenu } from '~/shared/components/menu/LayoutMenu';
|
||||||
|
import { MENU_ITEMS } from '~/shared/constants/menu';
|
||||||
|
|
||||||
export default function Layout() {
|
export default function Layout() {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="flex h-screen w-screen">
|
||||||
기본 레이아웃
|
<aside className="bg-primary-tertiary01 z-10 h-full w-[260px] flex-none shadow-[4px_0_5px_0_rgba(0,0,0,0.1)]">
|
||||||
<Outlet />
|
<div className="flex flex-col items-center bg-white pt-6 pb-4">
|
||||||
|
<div className="flex items-center justify-center pb-7">
|
||||||
|
<span className="text-xl font-bold text-primary">DABEEO</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-600">사용자 정보</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-primary flex h-12 items-center px-5 font-bold text-white">
|
||||||
|
변화탐지관리
|
||||||
|
</div>
|
||||||
|
<LayoutMenu items={MENU_ITEMS} />
|
||||||
|
</aside>
|
||||||
|
<main className="bg-primary-tertiary02 flex flex-1 min-w-0 flex-col">
|
||||||
|
<div className="bg-primary w-full h-4" />
|
||||||
|
<div className="flex min-h-0 flex-1 flex-col gap-6 px-8 pt-6 pb-8">
|
||||||
|
<div className="flex min-h-0 flex-1">
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export default function Page() {
|
||||||
|
return <div>감사 로그</div>;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export default function Page() {
|
||||||
|
return <div>시스템 로그</div>;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import type { Route } from './+types/page';
|
||||||
|
|
||||||
|
export default function Page({ params }: Route.ComponentProps) {
|
||||||
|
return <div>모델 상세 id: {params.modelId}</div>;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export default function Page() {
|
||||||
|
return <div>모델 목록</div>;
|
||||||
|
}
|
||||||
|
|||||||
5
web-app/app/routes/page.tsx
Normal file
5
web-app/app/routes/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { Navigate } from 'react-router';
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return <Navigate to="/imagery/aerial" replace />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export default function Page() {
|
||||||
|
return <div>스케줄 목록</div>;
|
||||||
|
}
|
||||||
|
|||||||
BIN
web-app/app/shared/assets/fonts/Pretendard-Bold.subset.woff
Normal file
BIN
web-app/app/shared/assets/fonts/Pretendard-Bold.subset.woff
Normal file
Binary file not shown.
BIN
web-app/app/shared/assets/fonts/Pretendard-Medium.subset.woff
Normal file
BIN
web-app/app/shared/assets/fonts/Pretendard-Medium.subset.woff
Normal file
Binary file not shown.
BIN
web-app/app/shared/assets/fonts/Pretendard-Regular.subset.woff
Normal file
BIN
web-app/app/shared/assets/fonts/Pretendard-Regular.subset.woff
Normal file
Binary file not shown.
BIN
web-app/app/shared/assets/fonts/Pretendard-SemiBold.subset.woff
Normal file
BIN
web-app/app/shared/assets/fonts/Pretendard-SemiBold.subset.woff
Normal file
Binary file not shown.
22
web-app/app/shared/components/menu/LayoutMenu.tsx
Normal file
22
web-app/app/shared/components/menu/LayoutMenu.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { useCallback } from 'react';
|
||||||
|
import { useLocation, useNavigate } from 'react-router';
|
||||||
|
|
||||||
|
import { Menu, type MenuItemChildrenType, type MenuItemType } from './Menu';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
items: MenuItemType[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LayoutMenu = ({ items }: Props) => {
|
||||||
|
const { pathname } = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const onSelectionChange = useCallback(
|
||||||
|
(menu: MenuItemChildrenType) => {
|
||||||
|
navigate(menu.menuUrl);
|
||||||
|
},
|
||||||
|
[navigate],
|
||||||
|
);
|
||||||
|
|
||||||
|
return <Menu currentPath={pathname} items={items} onSelectionChange={onSelectionChange} />;
|
||||||
|
};
|
||||||
124
web-app/app/shared/components/menu/Menu.tsx
Normal file
124
web-app/app/shared/components/menu/Menu.tsx
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
import { useCallback, useEffect, useId, useState } from 'react';
|
||||||
|
|
||||||
|
export type MenuItemChildrenType = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
menuUrl: string;
|
||||||
|
};
|
||||||
|
export type MenuItemType = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
menuUrl?: null;
|
||||||
|
children: MenuItemChildrenType[];
|
||||||
|
};
|
||||||
|
export interface MenuProps {
|
||||||
|
items: MenuItemType[];
|
||||||
|
onSelectionChange: (menu: MenuItemChildrenType) => void;
|
||||||
|
currentPath: string;
|
||||||
|
}
|
||||||
|
export const Menu = (props: MenuProps) => {
|
||||||
|
const { items, currentPath, onSelectionChange } = props;
|
||||||
|
const id = useId();
|
||||||
|
|
||||||
|
const getCurrentPathItem = useCallback(() => {
|
||||||
|
return items.find((i) => i.children.some((c) => currentPath.includes(c.menuUrl)));
|
||||||
|
}, [items, currentPath]);
|
||||||
|
|
||||||
|
const [expandedItemKeys, setExpandedItemKeys] = useState<MenuItemType['id'][]>(() => {
|
||||||
|
const item = getCurrentPathItem();
|
||||||
|
return item ? [item.id] : [];
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const item = getCurrentPathItem();
|
||||||
|
setExpandedItemKeys((prev) => {
|
||||||
|
if (item && prev.length === 1 && prev[0] === item.id) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
return item ? [item.id] : [];
|
||||||
|
});
|
||||||
|
}, [getCurrentPathItem]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav aria-label="메인 메뉴">
|
||||||
|
{Array.isArray(items) && items.length > 0 && (
|
||||||
|
<ul>
|
||||||
|
{items.map((item) => (
|
||||||
|
<li key={item.id} className="border-b font-medium border-dabeeo-gray-be">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-expanded={
|
||||||
|
expandedItemKeys.some((expandedItemKey) => expandedItemKey === item.id) ? 'true' : 'false'
|
||||||
|
}
|
||||||
|
aria-controls={`${id}-panel-${item.id}`}
|
||||||
|
className="appearance-none peer w-full h-10 px-5 text-sm flex items-center justify-between cursor-pointer aria-expanded:bg-white transition-colors focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-inset"
|
||||||
|
onClick={() => {
|
||||||
|
setExpandedItemKeys((prev) => {
|
||||||
|
const isExpanded = prev.some((key) => key === item.id);
|
||||||
|
if (isExpanded) {
|
||||||
|
return prev.filter((key) => key !== item.id);
|
||||||
|
} else {
|
||||||
|
return [...prev, item.id];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.name}
|
||||||
|
<div
|
||||||
|
className="data-[expanded=true]:rotate-180 transition"
|
||||||
|
data-expanded={
|
||||||
|
expandedItemKeys.some((expandedItemKey) => expandedItemKey === item.id) ? 'true' : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{expandedItemKeys.some((expandedItemKey) => expandedItemKey === item.id) ? (
|
||||||
|
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect x="1" y="7" width="2" height="10" transform="rotate(-90 1 7)" fill="#222222" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M7 5H11V7H7V11H5V7H1V5H5V1H7V5Z" fill="#222222" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
{Array.isArray(item.children) && item.children.length > 0 && (
|
||||||
|
<div
|
||||||
|
id={`${id}-panel-${item.id}`}
|
||||||
|
aria-hidden={!expandedItemKeys.some((key) => key === item.id)}
|
||||||
|
data-expanded={expandedItemKeys.some((key) => key === item.id) || undefined}
|
||||||
|
className="group/submenu grid grid-rows-[0fr] data-[expanded]:grid-rows-[1fr] transition-[grid-template-rows]"
|
||||||
|
>
|
||||||
|
<ul className="overflow-hidden opacity-0 group-data-[expanded]/submenu:opacity-100 transition-opacity duration-500">
|
||||||
|
{item.children.map((child) => (
|
||||||
|
<li key={`menu-${item.id}-child-${child.id}`}>
|
||||||
|
<div
|
||||||
|
role="link"
|
||||||
|
tabIndex={expandedItemKeys.some((key) => key === item.id) ? 0 : -1}
|
||||||
|
aria-current={currentPath.includes(child.menuUrl) ? 'page' : undefined}
|
||||||
|
className="flex h-10 cursor-pointer items-center gap-2.5 pl-8 text-sm font-medium bg-primary-tertiary02 aria-[current=page]:bg-dabeeo-gray-da focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-blue-500"
|
||||||
|
onClick={() => {
|
||||||
|
setExpandedItemKeys([item.id]);
|
||||||
|
onSelectionChange(child);
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
setExpandedItemKeys([item.id]);
|
||||||
|
onSelectionChange(child);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{child.name}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
};
|
||||||
2
web-app/app/shared/components/menu/index.ts
Normal file
2
web-app/app/shared/components/menu/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { Menu } from './Menu';
|
||||||
|
export type { MenuProps, MenuItemType, MenuItemChildrenType } from './Menu';
|
||||||
53
web-app/app/shared/constants/menu.ts
Normal file
53
web-app/app/shared/constants/menu.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import type { MenuItemType } from '~/shared/components/menu';
|
||||||
|
|
||||||
|
export const MENU_ITEMS: MenuItemType[] = [
|
||||||
|
{
|
||||||
|
id: 'imagery',
|
||||||
|
name: '영상데이터관리',
|
||||||
|
children: [
|
||||||
|
{ id: 'aerial', name: '항공영상관리', menuUrl: '/imagery/aerial' },
|
||||||
|
{ id: 'satellite', name: '위성영상관리', menuUrl: '/imagery/satellite' },
|
||||||
|
{ id: 'drone', name: '드론영상관리', menuUrl: '/imagery/drone' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'inference',
|
||||||
|
name: '추론관리',
|
||||||
|
children: [{ id: 'inference-list', name: '추론 목록', menuUrl: '/inference' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'model',
|
||||||
|
name: '모델관리',
|
||||||
|
children: [{ id: 'model-list', name: '모델 목록', menuUrl: '/model' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'labeling',
|
||||||
|
name: '라벨링',
|
||||||
|
children: [
|
||||||
|
{ id: 'label', name: '라벨링 작업', menuUrl: '/labeling/label' },
|
||||||
|
{ id: 'review', name: '라벨링 검수', menuUrl: '/labeling/review' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'log',
|
||||||
|
name: '로그',
|
||||||
|
children: [
|
||||||
|
{ id: 'audit', name: '감사 로그', menuUrl: '/log/audit' },
|
||||||
|
{ id: 'system', name: '시스템 로그', menuUrl: '/log/system' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'schedule',
|
||||||
|
name: '스케줄관리',
|
||||||
|
children: [{ id: 'schedule-list', name: '스케줄 목록', menuUrl: '/schedule' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'system',
|
||||||
|
name: '시스템관리',
|
||||||
|
children: [
|
||||||
|
{ id: 'code', name: '공통코드 관리', menuUrl: '/code' },
|
||||||
|
{ id: 'hyper-parameter', name: '하이퍼파라미터 설정', menuUrl: '/hyper-parameter' },
|
||||||
|
{ id: 'user', name: '사용자 관리', menuUrl: '/user' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -21,6 +21,8 @@ export default tseslint.config(
|
|||||||
{
|
{
|
||||||
rules: {
|
rules: {
|
||||||
'@stylistic/jsx-one-expression-per-line': 'off',
|
'@stylistic/jsx-one-expression-per-line': 'off',
|
||||||
|
'@stylistic/multiline-ternary': 'off',
|
||||||
|
'react-hooks/set-state-in-effect': 'off',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user