안드로이드 앱 다운로드 기능 적용
This commit is contained in:
+91
-6
@@ -187,6 +187,7 @@ html.theme-blue {
|
|||||||
padding: 0 12px;
|
padding: 0 12px;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
z-index: 200;
|
z-index: 200;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 로고 */
|
/* 로고 */
|
||||||
@@ -227,6 +228,85 @@ html.theme-blue {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
.tmb-nav--groups {
|
||||||
|
flex: 0 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.tmb-nav-group {
|
||||||
|
position: relative;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.tmb-nav-group-btn {
|
||||||
|
padding-right: 8px;
|
||||||
|
}
|
||||||
|
.tmb-nav-group-btn--open {
|
||||||
|
background: var(--bg4);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.tmb-nav-chevron {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
opacity: 0.65;
|
||||||
|
margin-left: 1px;
|
||||||
|
transition: transform 0.15s ease;
|
||||||
|
}
|
||||||
|
.tmb-nav-chevron--open {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
.tmb-nav-dropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 6px);
|
||||||
|
left: 0;
|
||||||
|
min-width: 168px;
|
||||||
|
padding: 4px;
|
||||||
|
background: var(--bg2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.28);
|
||||||
|
z-index: 400;
|
||||||
|
}
|
||||||
|
.tmb-nav-dropdown-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 36px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: none;
|
||||||
|
color: var(--text2);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.12s, color 0.12s;
|
||||||
|
}
|
||||||
|
.tmb-nav-dropdown-item:hover {
|
||||||
|
background: var(--bg4);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.tmb-nav-dropdown-item--active {
|
||||||
|
background: rgba(var(--accent-rgb, 122, 162, 247), 0.12);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
.app.dark .tmb-nav-dropdown-item--active { background: rgba(122, 162, 247, 0.13); }
|
||||||
|
.app.blue .tmb-nav-dropdown-item--active { background: rgba(94, 181, 255, 0.13); }
|
||||||
|
.app.light .tmb-nav-dropdown-item--active { background: rgba(25, 118, 210, 0.10); }
|
||||||
|
.tmb-nav-dropdown-icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
.tmb-nav-dropdown-label {
|
||||||
|
flex: 1;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.tmb-notify-badge--dropdown {
|
||||||
|
position: static;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
.tmb-nav-btn {
|
.tmb-nav-btn {
|
||||||
position: relative; /* 알림 뱃지 absolute 기준점 */
|
position: relative; /* 알림 뱃지 absolute 기준점 */
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@@ -269,6 +349,7 @@ html.theme-blue {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0;
|
gap: 0;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 테마 버튼 */
|
/* 테마 버튼 */
|
||||||
@@ -12878,20 +12959,24 @@ html.theme-light .tam-disclaimer { color: #90a4ae; }
|
|||||||
}
|
}
|
||||||
.app--mobile .top-menubar::-webkit-scrollbar { display: none; }
|
.app--mobile .top-menubar::-webkit-scrollbar { display: none; }
|
||||||
.app--mobile .tmb-logo-text { display: none; }
|
.app--mobile .tmb-logo-text { display: none; }
|
||||||
.app--mobile .tmb-nav {
|
.app--mobile .tmb-nav--groups {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
}
|
}
|
||||||
.app--mobile .tmb-nav::-webkit-scrollbar { display: none; }
|
.app--mobile .tmb-nav-group-btn .tmb-nav-label {
|
||||||
.app--mobile .tmb-nav-btn {
|
display: inline;
|
||||||
flex-shrink: 0;
|
|
||||||
padding: 6px 10px;
|
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
.app--mobile .tmb-nav-label { display: none; }
|
.app--mobile .tmb-nav-dropdown {
|
||||||
|
min-width: 152px;
|
||||||
|
}
|
||||||
|
.app--mobile .tmb-nav-dropdown-item {
|
||||||
|
min-height: 40px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
.app--mobile .tmb-theme-label,
|
.app--mobile .tmb-theme-label,
|
||||||
.app--mobile .tmb-login-btn span { display: none; }
|
.app--mobile .tmb-login-btn span { display: none; }
|
||||||
.app--mobile .tmb-right { flex-shrink: 0; }
|
.app--mobile .tmb-right { flex-shrink: 0; }
|
||||||
|
|||||||
@@ -2,15 +2,16 @@
|
|||||||
* 최상단 글로벌 메뉴바
|
* 최상단 글로벌 메뉴바
|
||||||
*
|
*
|
||||||
* 레이아웃:
|
* 레이아웃:
|
||||||
* [로고] | [대시보드] [실시간차트] [전략편집기] … | [테마토글] [로그인]
|
* [로고] | [그룹메뉴 ▾] … | [테마토글] [로그인]
|
||||||
*/
|
*/
|
||||||
import React, { memo, useCallback, useEffect, useState } from 'react';
|
import React, { memo, useEffect, useState } from 'react';
|
||||||
import type { Theme } from '../types';
|
import type { Theme } from '../types';
|
||||||
import type { AuthSession } from '../utils/auth';
|
import type { AuthSession } from '../utils/auth';
|
||||||
import type { TradeAlertPopupLayout, TradeAlertPopupPosition } from '../utils/tradeAlertPopupLayout';
|
import type { TradeAlertPopupLayout, TradeAlertPopupPosition } from '../utils/tradeAlertPopupLayout';
|
||||||
import { canAccessMenu } from '../utils/permissions';
|
import { canAccessMenu } from '../utils/permissions';
|
||||||
import { isDesktop } from '../utils/platform';
|
import { isDesktop } from '../utils/platform';
|
||||||
import DesktopUpdateModal from './DesktopUpdateModal';
|
import DesktopUpdateModal from './DesktopUpdateModal';
|
||||||
|
import TopMenuBarNavGroups from './TopMenuBarNavGroups';
|
||||||
|
|
||||||
export type MenuPage = 'dashboard' | 'chart' | 'paper' | 'virtual' | 'trend-search' | 'verification-board' | 'strategy' | 'strategy-editor' | 'strategy-evaluation' | 'backtest' | 'analysis-report' | 'notifications' | 'settings' | 'widget-dashboard';
|
export type MenuPage = 'dashboard' | 'chart' | 'paper' | 'virtual' | 'trend-search' | 'verification-board' | 'strategy' | 'strategy-editor' | 'strategy-evaluation' | 'backtest' | 'analysis-report' | 'notifications' | 'settings' | 'widget-dashboard';
|
||||||
|
|
||||||
@@ -244,6 +245,34 @@ const MENU_ITEMS: { page: MenuPage; label: string; icon: React.ReactNode }[] = [
|
|||||||
{ page: 'verification-board', label: '검증게시판', icon: <IcVerificationBoard /> },
|
{ page: 'verification-board', label: '검증게시판', icon: <IcVerificationBoard /> },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** 기능별 그룹 — 클릭 시 하단 드롭다운 */
|
||||||
|
const MENU_GROUPS = [
|
||||||
|
{
|
||||||
|
id: 'home',
|
||||||
|
label: '홈·분석',
|
||||||
|
icon: <IcDashboard />,
|
||||||
|
pages: ['dashboard', 'widget-dashboard', 'analysis-report'] as MenuPage[],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'trading',
|
||||||
|
label: '차트·매매',
|
||||||
|
icon: <IcChart />,
|
||||||
|
pages: ['chart', 'paper', 'virtual', 'trend-search'] as MenuPage[],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'strategy',
|
||||||
|
label: '전략',
|
||||||
|
icon: <IcStrategyEditor />,
|
||||||
|
pages: ['strategy-editor', 'strategy-evaluation', 'backtest'] as MenuPage[],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'system',
|
||||||
|
label: '알림·설정',
|
||||||
|
icon: <IcSettings />,
|
||||||
|
pages: ['notifications', 'verification-board', 'settings'] as MenuPage[],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
export const TopMenuBar = memo(function TopMenuBar({
|
export const TopMenuBar = memo(function TopMenuBar({
|
||||||
activePage, theme, onPage, onTheme,
|
activePage, theme, onPage, onTheme,
|
||||||
tradeNotifyUnread = 0,
|
tradeNotifyUnread = 0,
|
||||||
@@ -293,28 +322,14 @@ export const TopMenuBar = memo(function TopMenuBar({
|
|||||||
|
|
||||||
<div className="tmb-divider" />
|
<div className="tmb-divider" />
|
||||||
|
|
||||||
{/* 내비게이션 메뉴 */}
|
{/* 기능별 그룹 메뉴 */}
|
||||||
<nav className="tmb-nav">
|
<TopMenuBarNavGroups
|
||||||
{visibleItems.map(({ page, label, icon }, index) => (
|
activePage={activePage}
|
||||||
<React.Fragment key={page}>
|
items={visibleItems}
|
||||||
{index > 0 && <span className="tmb-divider tmb-divider--nav" aria-hidden="true" />}
|
groups={MENU_GROUPS}
|
||||||
<button
|
onPage={onPage}
|
||||||
type="button"
|
tradeNotifyUnread={tradeNotifyUnread}
|
||||||
className={`tmb-nav-btn ${activePage === page ? 'tmb-nav-btn--active' : ''}`}
|
/>
|
||||||
onClick={() => onPage(page)}
|
|
||||||
onMouseEnter={page === 'chart' ? () => { void import('../chart/ChartWorkspaceRoot'); } : undefined}
|
|
||||||
>
|
|
||||||
<span className="tmb-nav-icon">{icon}</span>
|
|
||||||
<span className="tmb-nav-label">{label}</span>
|
|
||||||
{page === 'notifications' && tradeNotifyUnread > 0 && (
|
|
||||||
<span className="tmb-notify-badge tmb-notify-badge--nav">
|
|
||||||
{tradeNotifyUnread > 99 ? '99+' : tradeNotifyUnread}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</React.Fragment>
|
|
||||||
))}
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<span className="tmb-divider" aria-hidden="true" />
|
<span className="tmb-divider" aria-hidden="true" />
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
/**
|
||||||
|
* TopMenuBar — 기능별 그룹 메뉴 + 하단 드롭다운
|
||||||
|
*/
|
||||||
|
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import type { MenuPage } from './TopMenuBar';
|
||||||
|
|
||||||
|
export type MenuItemDef = { page: MenuPage; label: string; icon: React.ReactNode };
|
||||||
|
|
||||||
|
type MenuGroupDef = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
pages: MenuPage[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const IcChevron = () => (
|
||||||
|
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||||
|
<polyline points="2,3.5 5,6.5 8,3.5" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
activePage: MenuPage;
|
||||||
|
items: MenuItemDef[];
|
||||||
|
groups: MenuGroupDef[];
|
||||||
|
onPage: (page: MenuPage) => void;
|
||||||
|
tradeNotifyUnread?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TopMenuBarNavGroups = memo(function TopMenuBarNavGroups({
|
||||||
|
activePage,
|
||||||
|
items,
|
||||||
|
groups,
|
||||||
|
onPage,
|
||||||
|
tradeNotifyUnread = 0,
|
||||||
|
}: Props) {
|
||||||
|
const [openGroupId, setOpenGroupId] = useState<string | null>(null);
|
||||||
|
const navRef = useRef<HTMLElement>(null);
|
||||||
|
|
||||||
|
const itemByPage = useMemo(() => {
|
||||||
|
const map = new Map<MenuPage, MenuItemDef>();
|
||||||
|
for (const item of items) map.set(item.page, item);
|
||||||
|
return map;
|
||||||
|
}, [items]);
|
||||||
|
|
||||||
|
const visibleGroups = useMemo(() => groups
|
||||||
|
.map(group => ({
|
||||||
|
...group,
|
||||||
|
items: group.pages
|
||||||
|
.map(page => itemByPage.get(page))
|
||||||
|
.filter((item): item is MenuItemDef => Boolean(item)),
|
||||||
|
}))
|
||||||
|
.filter(group => group.items.length > 0), [groups, itemByPage]);
|
||||||
|
|
||||||
|
const closeDropdown = useCallback(() => setOpenGroupId(null), []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!openGroupId) return;
|
||||||
|
const onDoc = (e: MouseEvent) => {
|
||||||
|
if (navRef.current && !navRef.current.contains(e.target as Node)) {
|
||||||
|
closeDropdown();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') closeDropdown();
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', onDoc);
|
||||||
|
document.addEventListener('keydown', onKey);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', onDoc);
|
||||||
|
document.removeEventListener('keydown', onKey);
|
||||||
|
};
|
||||||
|
}, [openGroupId, closeDropdown]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
closeDropdown();
|
||||||
|
}, [activePage, closeDropdown]);
|
||||||
|
|
||||||
|
const handleSelect = (page: MenuPage) => {
|
||||||
|
if (page === 'chart') {
|
||||||
|
void import('../chart/ChartWorkspaceRoot');
|
||||||
|
}
|
||||||
|
onPage(page);
|
||||||
|
closeDropdown();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav className="tmb-nav tmb-nav--groups" ref={navRef} aria-label="주요 메뉴">
|
||||||
|
{visibleGroups.map((group, index) => {
|
||||||
|
const isOpen = openGroupId === group.id;
|
||||||
|
const isActive = group.items.some(item => item.page === activePage);
|
||||||
|
const groupBadge = group.items.some(item => item.page === 'notifications') && tradeNotifyUnread > 0
|
||||||
|
? tradeNotifyUnread
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<React.Fragment key={group.id}>
|
||||||
|
{index > 0 && <span className="tmb-divider tmb-divider--nav" aria-hidden="true" />}
|
||||||
|
<div className={`tmb-nav-group${isOpen ? ' tmb-nav-group--open' : ''}`}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`tmb-nav-btn tmb-nav-group-btn${isActive ? ' tmb-nav-btn--active' : ''}${isOpen ? ' tmb-nav-group-btn--open' : ''}`}
|
||||||
|
aria-expanded={isOpen}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
onClick={() => setOpenGroupId(prev => (prev === group.id ? null : group.id))}
|
||||||
|
>
|
||||||
|
<span className="tmb-nav-icon">{group.icon}</span>
|
||||||
|
<span className="tmb-nav-label">{group.label}</span>
|
||||||
|
<span className={`tmb-nav-chevron${isOpen ? ' tmb-nav-chevron--open' : ''}`}>
|
||||||
|
<IcChevron />
|
||||||
|
</span>
|
||||||
|
{groupBadge > 0 && (
|
||||||
|
<span className="tmb-notify-badge tmb-notify-badge--nav">
|
||||||
|
{groupBadge > 99 ? '99+' : groupBadge}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<div className="tmb-nav-dropdown" role="menu">
|
||||||
|
{group.items.map(item => (
|
||||||
|
<button
|
||||||
|
key={item.page}
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
className={`tmb-nav-dropdown-item${activePage === item.page ? ' tmb-nav-dropdown-item--active' : ''}`}
|
||||||
|
onClick={() => handleSelect(item.page)}
|
||||||
|
>
|
||||||
|
<span className="tmb-nav-dropdown-icon">{item.icon}</span>
|
||||||
|
<span className="tmb-nav-dropdown-label">{item.label}</span>
|
||||||
|
{item.page === 'notifications' && tradeNotifyUnread > 0 && (
|
||||||
|
<span className="tmb-notify-badge tmb-notify-badge--dropdown">
|
||||||
|
{tradeNotifyUnread > 99 ? '99+' : tradeNotifyUnread}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default TopMenuBarNavGroups;
|
||||||
Reference in New Issue
Block a user