위젯 기능 추가
This commit is contained in:
@@ -0,0 +1,542 @@
|
||||
/**
|
||||
* 실시간 차트 툴바와 동일한 보조지표 추가·제거 패널 (ind-panel)
|
||||
*/
|
||||
import React, { useRef, useEffect, useState, useCallback } from 'react';
|
||||
import { useDraggablePanel } from '../hooks/useDraggablePanel';
|
||||
import { INDICATOR_REGISTRY, type IndicatorDef } from '../utils/indicatorRegistry';
|
||||
import {
|
||||
MAIN_INDICATOR_TYPES,
|
||||
MAIN_INDICATOR_LABELS,
|
||||
customTabKey,
|
||||
isCustomTabKey,
|
||||
parseCustomTabKey,
|
||||
type IndicatorTabKey,
|
||||
} from '../utils/indicatorMainTab';
|
||||
import { confirmAndApplyIndicatorTab } from '../utils/indicatorTabApply';
|
||||
import {
|
||||
getOrderedMainIndicatorTypes,
|
||||
saveMainTabOrder,
|
||||
} from '../utils/indicatorMainTabOrder';
|
||||
import {
|
||||
mergeIndicatorListOrder,
|
||||
reorderIndicatorListType,
|
||||
saveIndicatorListOrder,
|
||||
} from '../utils/indicatorListOrder';
|
||||
import { getSettingsIndicatorTypes, getIndicatorListLabels } from '../utils/indicatorSettingsList';
|
||||
import {
|
||||
loadCustomTabs,
|
||||
createCustomTab,
|
||||
updateCustomTab,
|
||||
deleteCustomTab,
|
||||
type IndicatorCustomTab,
|
||||
} from '../utils/indicatorCustomTabsStorage';
|
||||
import IndicatorCustomTabEditor from './IndicatorCustomTabEditor';
|
||||
import type { IndicatorConfig } from '../types';
|
||||
|
||||
const IcSearch = () => (
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<circle cx="6" cy="6" r="4" /><line x1="9.5" y1="9.5" x2="13" y2="13" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IcPlus = () => (
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.8">
|
||||
<line x1="7" y1="2" x2="7" y2="12" /><line x1="2" y1="7" x2="12" y2="7" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IcGear = () => (
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.4">
|
||||
<circle cx="7" cy="7" r="2.3" />
|
||||
<path d="M7 1 L7.7 2.8 L9.8 2 L9.8 4.2 L11.8 5 L11 7 L11.8 9 L9.8 9.8 L9.8 12 L7.7 11.2 L7 13 L6.3 11.2 L4.2 12 L4.2 9.8 L2.2 9 L3 7 L2.2 5 L4.2 4.2 L4.2 2 L6.3 2.8 Z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const BUILTIN_TABS: Array<{ key: IndicatorTabKey; label: string }> = [
|
||||
{ key: 'All', label: '전체' },
|
||||
{ key: 'Main', label: '주요지표' },
|
||||
];
|
||||
|
||||
export interface IndicatorDropdownPanelProps {
|
||||
activeIndicators: IndicatorConfig[];
|
||||
onAdd: (type: string) => void;
|
||||
onAddMany?: (types: string[]) => void;
|
||||
onIndicatorOrderChange?: (orderedTypes: string[]) => void;
|
||||
onRemove: (type: string) => void;
|
||||
onOpenSettings?: (type: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const IndicatorDropdownPanel: React.FC<IndicatorDropdownPanelProps> = ({
|
||||
activeIndicators,
|
||||
onAdd,
|
||||
onAddMany,
|
||||
onIndicatorOrderChange,
|
||||
onRemove,
|
||||
onOpenSettings,
|
||||
onClose,
|
||||
}) => {
|
||||
const [search, setSearch] = useState('');
|
||||
const [category, setCategory] = useState<IndicatorTabKey>('All');
|
||||
const [customTabs, setCustomTabs] = useState<IndicatorCustomTab[]>(() => loadCustomTabs());
|
||||
const [mainOrderTick, setMainOrderTick] = useState(0);
|
||||
const [customTabsRevision, setCustomTabsRevision] = useState(0);
|
||||
const [draggingType, setDraggingType] = useState<string | null>(null);
|
||||
const [dragOverType, setDragOverType] = useState<string | null>(null);
|
||||
const [editorOpen, setEditorOpen] = useState(false);
|
||||
const [editorMode, setEditorMode] = useState<'create' | 'edit'>('create');
|
||||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
const searchFocusedRef = useRef(false);
|
||||
|
||||
const refreshCustomTabs = useCallback(() => {
|
||||
setCustomTabs(loadCustomTabs());
|
||||
setCustomTabsRevision(t => t + 1);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const onTabsChanged = () => refreshCustomTabs();
|
||||
window.addEventListener('gc-indicator-custom-tabs-changed', onTabsChanged);
|
||||
return () => window.removeEventListener('gc-indicator-custom-tabs-changed', onTabsChanged);
|
||||
}, [refreshCustomTabs]);
|
||||
|
||||
const selectedCustomTab = React.useMemo(() => {
|
||||
const id = parseCustomTabKey(category);
|
||||
return id ? customTabs.find(t => t.id === id) ?? null : null;
|
||||
}, [category, customTabs]);
|
||||
|
||||
const openCreateEditor = () => {
|
||||
setEditorMode('create');
|
||||
setEditorOpen(true);
|
||||
};
|
||||
|
||||
const openEditEditor = () => {
|
||||
if (!selectedCustomTab) return;
|
||||
setEditorMode('edit');
|
||||
setEditorOpen(true);
|
||||
};
|
||||
|
||||
const handleDeleteTab = () => {
|
||||
if (!selectedCustomTab) return;
|
||||
if (!window.confirm(`"${selectedCustomTab.name}" 탭을 삭제할까요?`)) return;
|
||||
deleteCustomTab(selectedCustomTab.id);
|
||||
refreshCustomTabs();
|
||||
setCategory('All');
|
||||
};
|
||||
|
||||
const handleEditorSave = (name: string, indicatorTypes: string[]) => {
|
||||
if (editorMode === 'create') {
|
||||
const tab = createCustomTab(name, indicatorTypes);
|
||||
refreshCustomTabs();
|
||||
setCategory(customTabKey(tab.id));
|
||||
} else if (selectedCustomTab) {
|
||||
updateCustomTab(selectedCustomTab.id, { name, indicatorTypes });
|
||||
refreshCustomTabs();
|
||||
}
|
||||
setEditorOpen(false);
|
||||
setSearch('');
|
||||
};
|
||||
|
||||
const tabTypeOrder = React.useMemo((): string[] | null => {
|
||||
void mainOrderTick;
|
||||
void customTabsRevision;
|
||||
if (search.trim()) return null;
|
||||
if (category === 'Main') return getOrderedMainIndicatorTypes();
|
||||
if (isCustomTabKey(category)) {
|
||||
const id = parseCustomTabKey(category);
|
||||
if (!id) return null;
|
||||
const tab = customTabs.find(t => t.id === id);
|
||||
return tab ? [...tab.indicatorTypes] : null;
|
||||
}
|
||||
return null;
|
||||
}, [category, customTabs, search, mainOrderTick, customTabsRevision]);
|
||||
|
||||
const canReorderList = tabTypeOrder != null && tabTypeOrder.length > 1;
|
||||
|
||||
const syncChartToTabOrder = useCallback(() => {
|
||||
if (tabTypeOrder?.length && onIndicatorOrderChange) {
|
||||
onIndicatorOrderChange(tabTypeOrder);
|
||||
}
|
||||
}, [tabTypeOrder, onIndicatorOrderChange]);
|
||||
|
||||
const moveTypeInTabList = useCallback((fromType: string, toType: string) => {
|
||||
if (!tabTypeOrder) return;
|
||||
const next = reorderIndicatorListType(tabTypeOrder, fromType, toType, 'before');
|
||||
if (category === 'Main') {
|
||||
saveMainTabOrder(next);
|
||||
setMainOrderTick(t => t + 1);
|
||||
saveIndicatorListOrder(mergeIndicatorListOrder(next, getSettingsIndicatorTypes()));
|
||||
onIndicatorOrderChange?.(next);
|
||||
} else if (isCustomTabKey(category)) {
|
||||
const id = parseCustomTabKey(category);
|
||||
if (!id) return;
|
||||
const updated = updateCustomTab(id, { indicatorTypes: next });
|
||||
if (updated) {
|
||||
setCustomTabs(prev => prev.map(t => (t.id === id ? updated : t)));
|
||||
setCustomTabsRevision(t => t + 1);
|
||||
} else {
|
||||
refreshCustomTabs();
|
||||
}
|
||||
onIndicatorOrderChange?.(next);
|
||||
}
|
||||
}, [tabTypeOrder, category, refreshCustomTabs, onIndicatorOrderChange]);
|
||||
|
||||
useEffect(() => {
|
||||
const clearDrag = () => {
|
||||
setDraggingType(null);
|
||||
setDragOverType(null);
|
||||
};
|
||||
document.addEventListener('dragend', clearDrag);
|
||||
return () => document.removeEventListener('dragend', clearDrag);
|
||||
}, []);
|
||||
|
||||
const addAllContext = React.useMemo(() => {
|
||||
if (category === 'All') return null;
|
||||
if (category === 'Main') {
|
||||
return { label: '주요지표', types: getOrderedMainIndicatorTypes() };
|
||||
}
|
||||
if (isCustomTabKey(category) && selectedCustomTab) {
|
||||
const types = selectedCustomTab.indicatorTypes.filter(
|
||||
t => INDICATOR_REGISTRY.some(d => d.type === t),
|
||||
);
|
||||
return { label: selectedCustomTab.name, types };
|
||||
}
|
||||
return null;
|
||||
}, [category, selectedCustomTab]);
|
||||
|
||||
const addAllDisabled = category === 'All' || !addAllContext || addAllContext.types.length === 0;
|
||||
|
||||
const handleAddAllInTab = () => {
|
||||
if (!addAllContext || addAllContext.types.length === 0) return;
|
||||
if (category === 'Main') {
|
||||
confirmAndApplyIndicatorTab({ kind: 'main' }, onAddMany, onAdd);
|
||||
return;
|
||||
}
|
||||
if (isCustomTabKey(category) && selectedCustomTab) {
|
||||
confirmAndApplyIndicatorTab({ kind: 'custom', tab: selectedCustomTab }, onAddMany, onAdd);
|
||||
}
|
||||
};
|
||||
|
||||
const stopHeaderBtn = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (editorOpen || searchFocusedRef.current) return;
|
||||
searchRef.current?.focus();
|
||||
searchFocusedRef.current = true;
|
||||
}, [editorOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Escape') return;
|
||||
if (editorOpen) {
|
||||
setEditorOpen(false);
|
||||
return;
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [onClose, editorOpen]);
|
||||
|
||||
const isActive = (type: string) => activeIndicators.some(a => a.type === type);
|
||||
|
||||
const filtered = React.useMemo(() => {
|
||||
const q = search.toLowerCase();
|
||||
const matchesQuery = (d: IndicatorDef) => {
|
||||
if (!q) return true;
|
||||
const main = MAIN_INDICATOR_LABELS[d.type];
|
||||
return (
|
||||
d.name.toLowerCase().includes(q)
|
||||
|| d.shortName.toLowerCase().includes(q)
|
||||
|| d.description.includes(q)
|
||||
|| (main?.ko.includes(q) ?? false)
|
||||
|| (main?.en.toLowerCase().includes(q) ?? false)
|
||||
);
|
||||
};
|
||||
|
||||
if (category === 'Main' && tabTypeOrder) {
|
||||
return tabTypeOrder
|
||||
.map(type => INDICATOR_REGISTRY.find(d => d.type === type))
|
||||
.filter((d): d is IndicatorDef => d != null)
|
||||
.filter(matchesQuery);
|
||||
}
|
||||
|
||||
if (isCustomTabKey(category) && tabTypeOrder) {
|
||||
return tabTypeOrder
|
||||
.map(type => INDICATOR_REGISTRY.find(d => d.type === type))
|
||||
.filter((d): d is IndicatorDef => d != null)
|
||||
.filter(matchesQuery);
|
||||
}
|
||||
|
||||
return INDICATOR_REGISTRY.filter(matchesQuery);
|
||||
}, [search, category, customTabs, tabTypeOrder]);
|
||||
|
||||
const {
|
||||
panelRef,
|
||||
dragging,
|
||||
onHeaderPointerDown,
|
||||
headerTouchStyle,
|
||||
panelStyle,
|
||||
headerCursor,
|
||||
} = useDraggablePanel({ centerOnMount: true });
|
||||
|
||||
return (
|
||||
<div className="ind-overlay" onMouseDown={e => { if (e.target === e.currentTarget) onClose(); }}>
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="ind-panel"
|
||||
style={{
|
||||
...panelStyle,
|
||||
zIndex: 2001,
|
||||
cursor: dragging ? 'grabbing' : undefined,
|
||||
}}
|
||||
onMouseDown={e => e.stopPropagation()}
|
||||
>
|
||||
<div
|
||||
className="gc-popup-header"
|
||||
onPointerDown={onHeaderPointerDown}
|
||||
style={{ cursor: headerCursor, ...headerTouchStyle }}
|
||||
>
|
||||
<span className="gc-popup-title">지표 추가</span>
|
||||
<div className="ind-panel-header-actions">
|
||||
<button type="button" className="gc-popup-close" onClick={onClose} aria-label="닫기">✕</button>
|
||||
<button
|
||||
type="button"
|
||||
className="ind-panel-header-btn"
|
||||
onPointerDown={stopHeaderBtn}
|
||||
onClick={openCreateEditor}
|
||||
>
|
||||
탭 추가
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="ind-panel-header-btn"
|
||||
disabled={!selectedCustomTab}
|
||||
onPointerDown={stopHeaderBtn}
|
||||
onClick={openEditEditor}
|
||||
>
|
||||
수정
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="ind-panel-header-btn danger"
|
||||
disabled={!selectedCustomTab}
|
||||
onPointerDown={stopHeaderBtn}
|
||||
onClick={handleDeleteTab}
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ind-panel-search-row">
|
||||
<div className="ind-panel-search-wrap">
|
||||
<IcSearch />
|
||||
<input
|
||||
ref={searchRef}
|
||||
className="ind-panel-search"
|
||||
placeholder={`지표 검색... (전체 ${INDICATOR_REGISTRY.length}개)`}
|
||||
value={search}
|
||||
onChange={e => { setSearch(e.target.value); setCategory('All'); }}
|
||||
/>
|
||||
{search && (
|
||||
<button type="button" className="ind-panel-clear" onClick={() => setSearch('')}>✕</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ind-panel-cats-row">
|
||||
<div className="ind-panel-cats" role="tablist" aria-label="지표 탭">
|
||||
{BUILTIN_TABS.map(c => {
|
||||
const cnt = c.key === 'All'
|
||||
? INDICATOR_REGISTRY.length
|
||||
: MAIN_INDICATOR_TYPES.length;
|
||||
return (
|
||||
<button
|
||||
key={c.key}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={category === c.key}
|
||||
className={`ind-panel-cat${category === c.key ? ' active' : ''}`}
|
||||
onClick={() => { setCategory(c.key); setSearch(''); }}
|
||||
>
|
||||
{c.label}
|
||||
<span className="ind-panel-cat-cnt"> {cnt}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{customTabs.map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={category === customTabKey(tab.id)}
|
||||
className={`ind-panel-cat${category === customTabKey(tab.id) ? ' active' : ''}`}
|
||||
onClick={() => { setCategory(customTabKey(tab.id)); setSearch(''); }}
|
||||
>
|
||||
{tab.name}
|
||||
<span className="ind-panel-cat-cnt"> {tab.indicatorTypes.length}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="ind-panel-add-all"
|
||||
disabled={addAllDisabled}
|
||||
title={addAllDisabled ? '전체 탭에서는 사용할 수 없습니다' : '기존 보조지표를 제거하고 현재 탭 지표로 교체'}
|
||||
aria-label="현재 탭 지표로 보조지표 교체"
|
||||
onClick={handleAddAllInTab}
|
||||
>
|
||||
<IcPlus />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="ind-panel-list">
|
||||
{activeIndicators.length > 0 && !search && category === 'All' && (
|
||||
<div className="ind-panel-section">
|
||||
<div className="ind-panel-section-title">활성 지표 ({activeIndicators.length})</div>
|
||||
{activeIndicators.map(a => {
|
||||
const lbl = getIndicatorListLabels(a.type);
|
||||
return (
|
||||
<div key={a.id} className="ind-panel-item active">
|
||||
<div className="ind-panel-item-info">
|
||||
<span className="ind-panel-name">{lbl.ko}</span>
|
||||
<span className="ind-panel-desc">{lbl.en}</span>
|
||||
</div>
|
||||
<div className="ind-panel-item-right">
|
||||
<button type="button" className="ind-panel-rm" onClick={() => onRemove(a.type)}>제거</button>
|
||||
{onOpenSettings && (
|
||||
<button
|
||||
type="button"
|
||||
className="ind-panel-settings"
|
||||
title="설정"
|
||||
aria-label={`${lbl.ko} 설정`}
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
onOpenSettings(a.type);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<IcGear />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="ind-panel-divider" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<div className="ind-panel-empty">
|
||||
{search
|
||||
? `"${search}" 검색 결과 없음`
|
||||
: isCustomTabKey(category)
|
||||
? '이 탭에 표시할 지표가 없습니다. 수정 버튼으로 지표를 추가해 주세요.'
|
||||
: '표시할 지표가 없습니다'}
|
||||
</div>
|
||||
) : (
|
||||
filtered.map(def => {
|
||||
const active = isActive(def.type);
|
||||
const mainLbl = (category === 'Main' || isCustomTabKey(category))
|
||||
? MAIN_INDICATOR_LABELS[def.type]
|
||||
: undefined;
|
||||
const isDragOver = canReorderList && dragOverType === def.type && draggingType !== def.type;
|
||||
return (
|
||||
<div
|
||||
key={def.type}
|
||||
className={`ind-panel-item${active ? ' active' : ''}${isDragOver ? ' ind-panel-item--drag-over' : ''}`}
|
||||
onDragOver={canReorderList ? e => {
|
||||
if (!draggingType || draggingType === def.type) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
setDragOverType(def.type);
|
||||
} : undefined}
|
||||
onDragLeave={canReorderList ? () => {
|
||||
if (dragOverType === def.type) setDragOverType(null);
|
||||
} : undefined}
|
||||
onDrop={canReorderList ? e => {
|
||||
e.preventDefault();
|
||||
const from = e.dataTransfer.getData('text/indicator-type') || draggingType;
|
||||
if (from && from !== def.type) moveTypeInTabList(from, def.type);
|
||||
setDraggingType(null);
|
||||
setDragOverType(null);
|
||||
} : undefined}
|
||||
>
|
||||
{canReorderList && (
|
||||
<button
|
||||
type="button"
|
||||
className="ind-settings-drag-handle ind-panel-drag-handle"
|
||||
draggable
|
||||
title="드래그하여 순서 변경"
|
||||
aria-label="순서 변경"
|
||||
onDragStart={e => {
|
||||
setDraggingType(def.type);
|
||||
e.dataTransfer.setData('text/indicator-type', def.type);
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
}}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
⠿
|
||||
</button>
|
||||
)}
|
||||
<div className="ind-panel-item-info">
|
||||
<span className="ind-panel-name">{mainLbl?.ko ?? def.description}</span>
|
||||
<span className="ind-panel-desc">{mainLbl?.en ?? def.name}</span>
|
||||
</div>
|
||||
<div className="ind-panel-item-right">
|
||||
{def.overlay && <span className="ind-panel-badge">오버레이</span>}
|
||||
{active ? (
|
||||
<button type="button" className="ind-panel-rm" onClick={() => onRemove(def.type)}>제거</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="ind-panel-add"
|
||||
title="차트에 추가"
|
||||
onClick={() => {
|
||||
onAdd(def.type);
|
||||
syncChartToTabOrder();
|
||||
}}
|
||||
>
|
||||
<IcPlus />
|
||||
</button>
|
||||
)}
|
||||
{onOpenSettings && (
|
||||
<button
|
||||
type="button"
|
||||
className="ind-panel-settings"
|
||||
title="설정"
|
||||
aria-label={`${mainLbl?.ko ?? def.koreanName} 설정`}
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
onOpenSettings(def.type);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<IcGear />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{editorOpen && (
|
||||
<IndicatorCustomTabEditor
|
||||
mode={editorMode}
|
||||
initialName={editorMode === 'edit' && selectedCustomTab ? selectedCustomTab.name : ''}
|
||||
initialTypes={editorMode === 'edit' && selectedCustomTab ? selectedCustomTab.indicatorTypes : []}
|
||||
onSave={handleEditorSave}
|
||||
onClose={() => setEditorOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default IndicatorDropdownPanel;
|
||||
@@ -26,6 +26,8 @@ interface NotifyTopMenuBarProps {
|
||||
onTradeAlertPopupLayout?: (v: TradeAlertPopupLayout) => void;
|
||||
onTradeAlertPopupGridCols?: (v: number) => void;
|
||||
onFullscreen?: () => void;
|
||||
onOpenFloatingWidgets?: () => void;
|
||||
floatingWidgetCount?: number;
|
||||
}
|
||||
|
||||
export const NotifyTopMenuBar: React.FC<NotifyTopMenuBarProps> = props => {
|
||||
|
||||
@@ -1,38 +1,6 @@
|
||||
import React, { useRef, useEffect, useState, useCallback } from 'react';
|
||||
import { useDraggablePanel } from '../hooks/useDraggablePanel';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { INDICATOR_REGISTRY, type IndicatorDef } from '../utils/indicatorRegistry';
|
||||
import {
|
||||
MAIN_INDICATOR_TYPES,
|
||||
MAIN_INDICATOR_LABELS,
|
||||
customTabKey,
|
||||
isCustomTabKey,
|
||||
parseCustomTabKey,
|
||||
type IndicatorTabKey,
|
||||
} from '../utils/indicatorMainTab';
|
||||
import {
|
||||
confirmAndApplyIndicatorTab,
|
||||
} from '../utils/indicatorTabApply';
|
||||
import {
|
||||
getOrderedMainIndicatorTypes,
|
||||
saveMainTabOrder,
|
||||
} from '../utils/indicatorMainTabOrder';
|
||||
import {
|
||||
mergeIndicatorListOrder,
|
||||
reorderIndicatorListType,
|
||||
saveIndicatorListOrder,
|
||||
} from '../utils/indicatorListOrder';
|
||||
import { getSettingsIndicatorTypes } from '../utils/indicatorSettingsList';
|
||||
import {
|
||||
loadCustomTabs,
|
||||
createCustomTab,
|
||||
updateCustomTab,
|
||||
deleteCustomTab,
|
||||
type IndicatorCustomTab,
|
||||
} from '../utils/indicatorCustomTabsStorage';
|
||||
import IndicatorCustomTabEditor from './IndicatorCustomTabEditor';
|
||||
import { getIndicatorListLabels } from '../utils/indicatorSettingsList';
|
||||
// IndicatorCategory는 IndDropdown 상태 타입에 사용됨
|
||||
import IndicatorDropdownPanel from './IndicatorDropdownPanel';
|
||||
import type { ChartType, Theme, Timeframe, ChartMode, IndicatorConfig } from '../types';
|
||||
import { MarketSearchPanel } from './MarketSearchPanel';
|
||||
import { getKoreanName } from '../utils/marketNameCache';
|
||||
@@ -165,12 +133,6 @@ const IcPlus = () => (
|
||||
<line x1="7" y1="2" x2="7" y2="12"/><line x1="2" y1="7" x2="12" y2="7"/>
|
||||
</svg>
|
||||
);
|
||||
const IcGear = () => (
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.4">
|
||||
<circle cx="7" cy="7" r="2.3"/>
|
||||
<path d="M7 1 L7.7 2.8 L9.8 2 L9.8 4.2 L11.8 5 L11 7 L11.8 9 L9.8 9.8 L9.8 12 L7.7 11.2 L7 13 L6.3 11.2 L4.2 12 L4.2 9.8 L2.2 9 L3 7 L2.2 5 L4.2 4.2 L4.2 2 L6.3 2.8 Z"/>
|
||||
</svg>
|
||||
);
|
||||
const IcCandlestick = () => (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<line x1="4" y1="2" x2="4" y2="5"/><rect x="2" y="5" width="4" height="5" rx="0.5" fill="currentColor" opacity="0.3"/><line x1="4" y1="10" x2="4" y2="14"/>
|
||||
@@ -298,12 +260,6 @@ const IcReplay = () => (
|
||||
</svg>
|
||||
);
|
||||
|
||||
// ─── 기본 탭 (사용자 정의 탭은 localStorage) ───────────────────────────────
|
||||
const BUILTIN_TABS: Array<{ key: IndicatorTabKey; label: string }> = [
|
||||
{ key: 'All', label: '전체' },
|
||||
{ key: 'Main', label: '주요지표' },
|
||||
];
|
||||
|
||||
// ─── 차트 타입 아이콘 매핑 ─────────────────────────────────────────────────
|
||||
const ChartIcon: Record<ChartType, React.ReactNode> = {
|
||||
candlestick: <IcCandlestick />,
|
||||
@@ -322,487 +278,6 @@ const CHART_TYPES: { value: ChartType; label: string }[] = [
|
||||
{ value: 'baseline', label: '기준선' },
|
||||
];
|
||||
|
||||
// ─── 지표 선택 오버레이 패널 ──────────────────────────────────────────────
|
||||
interface IndDropdownProps {
|
||||
activeIndicators: IndicatorConfig[];
|
||||
onAdd: (type: string) => void;
|
||||
onAddMany?: (types: string[]) => void;
|
||||
onIndicatorOrderChange?: (orderedTypes: string[]) => void;
|
||||
onRemove: (type: string) => void;
|
||||
onOpenSettings?: (type: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const IndDropdown: React.FC<IndDropdownProps> = ({
|
||||
activeIndicators, onAdd, onAddMany, onIndicatorOrderChange, onRemove, onOpenSettings, onClose,
|
||||
}) => {
|
||||
const [search, setSearch] = React.useState('');
|
||||
const [category, setCategory] = React.useState<IndicatorTabKey>('All');
|
||||
const [customTabs, setCustomTabs] = React.useState<IndicatorCustomTab[]>(() => loadCustomTabs());
|
||||
const [mainOrderTick, setMainOrderTick] = React.useState(0);
|
||||
const [customTabsRevision, setCustomTabsRevision] = React.useState(0);
|
||||
const [draggingType, setDraggingType] = React.useState<string | null>(null);
|
||||
const [dragOverType, setDragOverType] = React.useState<string | null>(null);
|
||||
const [editorOpen, setEditorOpen] = React.useState(false);
|
||||
const [editorMode, setEditorMode] = React.useState<'create' | 'edit'>('create');
|
||||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
const searchFocusedRef = useRef(false);
|
||||
|
||||
const refreshCustomTabs = useCallback(() => {
|
||||
setCustomTabs(loadCustomTabs());
|
||||
setCustomTabsRevision(t => t + 1);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const onTabsChanged = () => refreshCustomTabs();
|
||||
window.addEventListener('gc-indicator-custom-tabs-changed', onTabsChanged);
|
||||
return () => window.removeEventListener('gc-indicator-custom-tabs-changed', onTabsChanged);
|
||||
}, [refreshCustomTabs]);
|
||||
|
||||
const selectedCustomTab = React.useMemo(() => {
|
||||
const id = parseCustomTabKey(category);
|
||||
return id ? customTabs.find(t => t.id === id) ?? null : null;
|
||||
}, [category, customTabs]);
|
||||
|
||||
const openCreateEditor = () => {
|
||||
setEditorMode('create');
|
||||
setEditorOpen(true);
|
||||
};
|
||||
|
||||
const openEditEditor = () => {
|
||||
if (!selectedCustomTab) return;
|
||||
setEditorMode('edit');
|
||||
setEditorOpen(true);
|
||||
};
|
||||
|
||||
const handleDeleteTab = () => {
|
||||
if (!selectedCustomTab) return;
|
||||
if (!window.confirm(`"${selectedCustomTab.name}" 탭을 삭제할까요?`)) return;
|
||||
deleteCustomTab(selectedCustomTab.id);
|
||||
refreshCustomTabs();
|
||||
setCategory('All');
|
||||
};
|
||||
|
||||
const handleEditorSave = (name: string, indicatorTypes: string[]) => {
|
||||
if (editorMode === 'create') {
|
||||
const tab = createCustomTab(name, indicatorTypes);
|
||||
refreshCustomTabs();
|
||||
setCategory(customTabKey(tab.id));
|
||||
} else if (selectedCustomTab) {
|
||||
updateCustomTab(selectedCustomTab.id, { name, indicatorTypes });
|
||||
refreshCustomTabs();
|
||||
}
|
||||
setEditorOpen(false);
|
||||
setSearch('');
|
||||
};
|
||||
|
||||
const tabTypeOrder = React.useMemo((): string[] | null => {
|
||||
void mainOrderTick;
|
||||
void customTabsRevision;
|
||||
if (search.trim()) return null;
|
||||
if (category === 'Main') return getOrderedMainIndicatorTypes();
|
||||
if (isCustomTabKey(category)) {
|
||||
const id = parseCustomTabKey(category);
|
||||
if (!id) return null;
|
||||
const tab = customTabs.find(t => t.id === id);
|
||||
return tab ? [...tab.indicatorTypes] : null;
|
||||
}
|
||||
return null;
|
||||
}, [category, customTabs, search, mainOrderTick, customTabsRevision]);
|
||||
|
||||
const canReorderList = tabTypeOrder != null && tabTypeOrder.length > 1;
|
||||
|
||||
const syncChartToTabOrder = useCallback(() => {
|
||||
if (tabTypeOrder?.length && onIndicatorOrderChange) {
|
||||
onIndicatorOrderChange(tabTypeOrder);
|
||||
}
|
||||
}, [tabTypeOrder, onIndicatorOrderChange]);
|
||||
|
||||
const moveTypeInTabList = useCallback((fromType: string, toType: string) => {
|
||||
if (!tabTypeOrder) return;
|
||||
const next = reorderIndicatorListType(tabTypeOrder, fromType, toType, 'before');
|
||||
if (category === 'Main') {
|
||||
saveMainTabOrder(next);
|
||||
setMainOrderTick(t => t + 1);
|
||||
saveIndicatorListOrder(mergeIndicatorListOrder(next, getSettingsIndicatorTypes()));
|
||||
onIndicatorOrderChange?.(next);
|
||||
} else if (isCustomTabKey(category)) {
|
||||
const id = parseCustomTabKey(category);
|
||||
if (!id) return;
|
||||
const updated = updateCustomTab(id, { indicatorTypes: next });
|
||||
if (updated) {
|
||||
setCustomTabs(prev => prev.map(t => (t.id === id ? updated : t)));
|
||||
setCustomTabsRevision(t => t + 1);
|
||||
} else {
|
||||
refreshCustomTabs();
|
||||
}
|
||||
onIndicatorOrderChange?.(next);
|
||||
}
|
||||
}, [tabTypeOrder, category, refreshCustomTabs, onIndicatorOrderChange]);
|
||||
|
||||
useEffect(() => {
|
||||
const clearDrag = () => {
|
||||
setDraggingType(null);
|
||||
setDragOverType(null);
|
||||
};
|
||||
document.addEventListener('dragend', clearDrag);
|
||||
return () => document.removeEventListener('dragend', clearDrag);
|
||||
}, []);
|
||||
|
||||
const addAllContext = React.useMemo(() => {
|
||||
if (category === 'All') return null;
|
||||
if (category === 'Main') {
|
||||
return { label: '주요지표', types: getOrderedMainIndicatorTypes() };
|
||||
}
|
||||
if (isCustomTabKey(category) && selectedCustomTab) {
|
||||
const types = selectedCustomTab.indicatorTypes.filter(
|
||||
t => INDICATOR_REGISTRY.some(d => d.type === t),
|
||||
);
|
||||
return { label: selectedCustomTab.name, types };
|
||||
}
|
||||
return null;
|
||||
}, [category, selectedCustomTab]);
|
||||
|
||||
const addAllDisabled = category === 'All' || !addAllContext || addAllContext.types.length === 0;
|
||||
|
||||
const handleAddAllInTab = () => {
|
||||
if (!addAllContext || addAllContext.types.length === 0) return;
|
||||
if (category === 'Main') {
|
||||
confirmAndApplyIndicatorTab({ kind: 'main' }, onAddMany, onAdd);
|
||||
return;
|
||||
}
|
||||
if (isCustomTabKey(category) && selectedCustomTab) {
|
||||
confirmAndApplyIndicatorTab({ kind: 'custom', tab: selectedCustomTab }, onAddMany, onAdd);
|
||||
}
|
||||
};
|
||||
|
||||
const stopHeaderBtn = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (editorOpen || searchFocusedRef.current) return;
|
||||
searchRef.current?.focus();
|
||||
searchFocusedRef.current = true;
|
||||
}, [editorOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Escape') return;
|
||||
if (editorOpen) {
|
||||
setEditorOpen(false);
|
||||
return;
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [onClose, editorOpen]);
|
||||
|
||||
const isActive = (type: string) => activeIndicators.some(a => a.type === type);
|
||||
|
||||
const filtered = React.useMemo(() => {
|
||||
const q = search.toLowerCase();
|
||||
const matchesQuery = (d: IndicatorDef) => {
|
||||
if (!q) return true;
|
||||
const main = MAIN_INDICATOR_LABELS[d.type];
|
||||
return (
|
||||
d.name.toLowerCase().includes(q)
|
||||
|| d.shortName.toLowerCase().includes(q)
|
||||
|| d.description.includes(q)
|
||||
|| (main?.ko.includes(q) ?? false)
|
||||
|| (main?.en.toLowerCase().includes(q) ?? false)
|
||||
);
|
||||
};
|
||||
|
||||
if (category === 'Main' && tabTypeOrder) {
|
||||
return tabTypeOrder
|
||||
.map(type => INDICATOR_REGISTRY.find(d => d.type === type))
|
||||
.filter((d): d is IndicatorDef => d != null)
|
||||
.filter(matchesQuery);
|
||||
}
|
||||
|
||||
if (isCustomTabKey(category) && tabTypeOrder) {
|
||||
return tabTypeOrder
|
||||
.map(type => INDICATOR_REGISTRY.find(d => d.type === type))
|
||||
.filter((d): d is IndicatorDef => d != null)
|
||||
.filter(matchesQuery);
|
||||
}
|
||||
|
||||
return INDICATOR_REGISTRY.filter(matchesQuery);
|
||||
}, [search, category, customTabs, tabTypeOrder]);
|
||||
|
||||
const {
|
||||
panelRef,
|
||||
dragging,
|
||||
onHeaderPointerDown, headerTouchStyle,
|
||||
panelStyle,
|
||||
headerCursor,
|
||||
} = useDraggablePanel({ centerOnMount: true });
|
||||
|
||||
return (
|
||||
// 배경 클릭 시 닫힘
|
||||
<div className="ind-overlay" onMouseDown={e => { if (e.target === e.currentTarget) onClose(); }}>
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="ind-panel"
|
||||
style={{
|
||||
...panelStyle,
|
||||
zIndex: 2001,
|
||||
cursor: dragging ? 'grabbing' : undefined,
|
||||
}}
|
||||
onMouseDown={e => e.stopPropagation()}
|
||||
>
|
||||
<div
|
||||
className="gc-popup-header"
|
||||
onPointerDown={onHeaderPointerDown}
|
||||
style={{ cursor: headerCursor, ...headerTouchStyle }}
|
||||
>
|
||||
<span className="gc-popup-title">지표 추가</span>
|
||||
<div className="ind-panel-header-actions">
|
||||
<button type="button" className="gc-popup-close" onClick={onClose} aria-label="닫기">✕</button>
|
||||
<button
|
||||
type="button"
|
||||
className="ind-panel-header-btn"
|
||||
onPointerDown={stopHeaderBtn}
|
||||
onClick={openCreateEditor}
|
||||
>
|
||||
탭 추가
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="ind-panel-header-btn"
|
||||
disabled={!selectedCustomTab}
|
||||
onPointerDown={stopHeaderBtn}
|
||||
onClick={openEditEditor}
|
||||
>
|
||||
수정
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="ind-panel-header-btn danger"
|
||||
disabled={!selectedCustomTab}
|
||||
onPointerDown={stopHeaderBtn}
|
||||
onClick={handleDeleteTab}
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 검색창 */}
|
||||
<div className="ind-panel-search-row">
|
||||
<div className="ind-panel-search-wrap">
|
||||
<IcSearch />
|
||||
<input
|
||||
ref={searchRef}
|
||||
className="ind-panel-search"
|
||||
placeholder={`지표 검색... (전체 ${INDICATOR_REGISTRY.length}개)`}
|
||||
value={search}
|
||||
onChange={e => { setSearch(e.target.value); setCategory('All'); }}
|
||||
/>
|
||||
{search && (
|
||||
<button className="ind-panel-clear" onClick={() => setSearch('')}>✕</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 카테고리 탭 */}
|
||||
<div className="ind-panel-cats-row">
|
||||
<div className="ind-panel-cats" role="tablist" aria-label="지표 탭">
|
||||
{BUILTIN_TABS.map(c => {
|
||||
const cnt = c.key === 'All'
|
||||
? INDICATOR_REGISTRY.length
|
||||
: MAIN_INDICATOR_TYPES.length;
|
||||
return (
|
||||
<button
|
||||
key={c.key}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={category === c.key}
|
||||
className={`ind-panel-cat${category === c.key ? ' active' : ''}`}
|
||||
onClick={() => { setCategory(c.key); setSearch(''); }}
|
||||
>
|
||||
{c.label}
|
||||
<span className="ind-panel-cat-cnt"> {cnt}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{customTabs.map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={category === customTabKey(tab.id)}
|
||||
className={`ind-panel-cat${category === customTabKey(tab.id) ? ' active' : ''}`}
|
||||
onClick={() => { setCategory(customTabKey(tab.id)); setSearch(''); }}
|
||||
>
|
||||
{tab.name}
|
||||
<span className="ind-panel-cat-cnt"> {tab.indicatorTypes.length}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="ind-panel-add-all"
|
||||
disabled={addAllDisabled}
|
||||
title={addAllDisabled ? '전체 탭에서는 사용할 수 없습니다' : '기존 보조지표를 제거하고 현재 탭 지표로 교체'}
|
||||
aria-label="현재 탭 지표로 보조지표 교체"
|
||||
onClick={handleAddAllInTab}
|
||||
>
|
||||
<IcPlus />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 지표 목록 */}
|
||||
<div className="ind-panel-list">
|
||||
{/* 활성 지표가 있을 때 상단에 표시 */}
|
||||
{activeIndicators.length > 0 && !search && category === 'All' && (
|
||||
<div className="ind-panel-section">
|
||||
<div className="ind-panel-section-title">활성 지표 ({activeIndicators.length})</div>
|
||||
{activeIndicators.map(a => {
|
||||
const lbl = getIndicatorListLabels(a.type);
|
||||
return (
|
||||
<div key={a.id} className="ind-panel-item active">
|
||||
<div className="ind-panel-item-info">
|
||||
<span className="ind-panel-name">{lbl.ko}</span>
|
||||
<span className="ind-panel-desc">{lbl.en}</span>
|
||||
</div>
|
||||
<div className="ind-panel-item-right">
|
||||
<button className="ind-panel-rm" onClick={() => onRemove(a.type)}>제거</button>
|
||||
{onOpenSettings && (
|
||||
<button
|
||||
type="button"
|
||||
className="ind-panel-settings"
|
||||
title="설정"
|
||||
aria-label={`${lbl.ko} 설정`}
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
onOpenSettings(a.type);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<IcGear />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="ind-panel-divider" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<div className="ind-panel-empty">
|
||||
{search
|
||||
? `"${search}" 검색 결과 없음`
|
||||
: isCustomTabKey(category)
|
||||
? '이 탭에 표시할 지표가 없습니다. 수정 버튼으로 지표를 추가해 주세요.'
|
||||
: '표시할 지표가 없습니다'}
|
||||
</div>
|
||||
) : (
|
||||
filtered.map(def => {
|
||||
const active = isActive(def.type);
|
||||
const mainLbl = (category === 'Main' || isCustomTabKey(category))
|
||||
? MAIN_INDICATOR_LABELS[def.type]
|
||||
: undefined;
|
||||
const isDragOver = canReorderList && dragOverType === def.type && draggingType !== def.type;
|
||||
return (
|
||||
<div
|
||||
key={def.type}
|
||||
className={`ind-panel-item${active ? ' active' : ''}${isDragOver ? ' ind-panel-item--drag-over' : ''}`}
|
||||
onDragOver={canReorderList ? e => {
|
||||
if (!draggingType || draggingType === def.type) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
setDragOverType(def.type);
|
||||
} : undefined}
|
||||
onDragLeave={canReorderList ? () => {
|
||||
if (dragOverType === def.type) setDragOverType(null);
|
||||
} : undefined}
|
||||
onDrop={canReorderList ? e => {
|
||||
e.preventDefault();
|
||||
const from = e.dataTransfer.getData('text/indicator-type') || draggingType;
|
||||
if (from && from !== def.type) moveTypeInTabList(from, def.type);
|
||||
setDraggingType(null);
|
||||
setDragOverType(null);
|
||||
} : undefined}
|
||||
>
|
||||
{canReorderList && (
|
||||
<button
|
||||
type="button"
|
||||
className="ind-settings-drag-handle ind-panel-drag-handle"
|
||||
draggable
|
||||
title="드래그하여 순서 변경"
|
||||
aria-label="순서 변경"
|
||||
onDragStart={e => {
|
||||
setDraggingType(def.type);
|
||||
e.dataTransfer.setData('text/indicator-type', def.type);
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
}}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
⠿
|
||||
</button>
|
||||
)}
|
||||
<div className="ind-panel-item-info">
|
||||
<span className="ind-panel-name">{mainLbl?.ko ?? def.description}</span>
|
||||
<span className="ind-panel-desc">{mainLbl?.en ?? def.name}</span>
|
||||
</div>
|
||||
<div className="ind-panel-item-right">
|
||||
{def.overlay && <span className="ind-panel-badge">오버레이</span>}
|
||||
{active ? (
|
||||
<button className="ind-panel-rm" onClick={() => onRemove(def.type)}>제거</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="ind-panel-add"
|
||||
title="차트에 추가"
|
||||
onClick={() => {
|
||||
onAdd(def.type);
|
||||
syncChartToTabOrder();
|
||||
}}
|
||||
>
|
||||
<IcPlus />
|
||||
</button>
|
||||
)}
|
||||
{onOpenSettings && (
|
||||
<button
|
||||
type="button"
|
||||
className="ind-panel-settings"
|
||||
title="설정"
|
||||
aria-label={`${mainLbl?.ko ?? def.koreanName} 설정`}
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
onOpenSettings(def.type);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<IcGear />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{editorOpen && (
|
||||
<IndicatorCustomTabEditor
|
||||
mode={editorMode}
|
||||
initialName={editorMode === 'edit' && selectedCustomTab ? selectedCustomTab.name : ''}
|
||||
initialTypes={editorMode === 'edit' && selectedCustomTab ? selectedCustomTab.indicatorTypes : []}
|
||||
onSave={handleEditorSave}
|
||||
onClose={() => setEditorOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── Toolbar ──────────────────────────────────────────────────────────────
|
||||
const Toolbar: React.FC<ToolbarProps> = ({
|
||||
@@ -1038,7 +513,7 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
||||
</button>
|
||||
{/* 지표 패널은 fixed overlay로 렌더링 — 부모 overflow 영향 없음 */}
|
||||
{showIndMenu && (
|
||||
<IndDropdown
|
||||
<IndicatorDropdownPanel
|
||||
activeIndicators={activeIndicators}
|
||||
onAdd={type => onAddIndicator(type)}
|
||||
onAddMany={onAddIndicators}
|
||||
|
||||
@@ -10,7 +10,7 @@ import type { AuthSession } from '../utils/auth';
|
||||
import type { TradeAlertPopupLayout, TradeAlertPopupPosition } from '../utils/tradeAlertPopupLayout';
|
||||
import { canAccessMenu } from '../utils/permissions';
|
||||
|
||||
export type MenuPage = 'dashboard' | 'chart' | 'paper' | 'virtual' | 'trend-search' | 'verification-board' | 'strategy' | 'strategy-editor' | 'strategy-evaluation' | 'backtest' | 'analysis-report' | 'notifications' | 'settings';
|
||||
export type MenuPage = 'dashboard' | 'chart' | 'paper' | 'virtual' | 'trend-search' | 'verification-board' | 'strategy' | 'strategy-editor' | 'strategy-evaluation' | 'backtest' | 'analysis-report' | 'notifications' | 'settings' | 'widget-dashboard';
|
||||
|
||||
interface TopMenuBarProps {
|
||||
activePage: MenuPage;
|
||||
@@ -42,6 +42,10 @@ interface TopMenuBarProps {
|
||||
onLogout?: () => void;
|
||||
/** 브라우저 전체화면 (모든 화면에서 사용) */
|
||||
onFullscreen?: () => void;
|
||||
/** 플로팅 위젯 레이아웃 선택 열기 */
|
||||
onOpenFloatingWidgets?: () => void;
|
||||
/** 실행 중인 플로팅 위젯 창 수 */
|
||||
floatingWidgetCount?: number;
|
||||
/** 메뉴별 접근 허용 (없으면 전체 표시) */
|
||||
menuPermissions?: Record<string, boolean>;
|
||||
}
|
||||
@@ -196,8 +200,28 @@ const IcVerificationBoard = () => (
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IcWidget = () => (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="1" y="1" width="6" height="6" rx="1"/>
|
||||
<rect x="9" y="1" width="6" height="4" rx="1"/>
|
||||
<rect x="1" y="9" width="6" height="6" rx="1"/>
|
||||
<rect x="9" y="7" width="6" height="8" rx="1"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IcFloatingWidgetAdd = () => (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="1" y="1" width="6" height="6" rx="1"/>
|
||||
<rect x="9" y="1" width="6" height="6" rx="1"/>
|
||||
<rect x="1" y="9" width="6" height="6" rx="1"/>
|
||||
<line x1="12" y1="12" x2="12" y2="15"/>
|
||||
<line x1="10.5" y1="13.5" x2="13.5" y2="13.5"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const MENU_ITEMS: { page: MenuPage; label: string; icon: React.ReactNode }[] = [
|
||||
{ page: 'dashboard', label: '대시보드', icon: <IcDashboard /> },
|
||||
{ page: 'widget-dashboard', label: '위젯', icon: <IcWidget /> },
|
||||
{ page: 'chart', label: '실시간차트', icon: <IcChart /> },
|
||||
{ page: 'paper', label: '투자관리', icon: <IcPaper /> },
|
||||
{ page: 'virtual', label: '가상매매', icon: <IcVirtual /> },
|
||||
@@ -228,6 +252,8 @@ export const TopMenuBar = memo(function TopMenuBar({
|
||||
onLoginClick,
|
||||
onLogout,
|
||||
onFullscreen,
|
||||
onOpenFloatingWidgets,
|
||||
floatingWidgetCount = 0,
|
||||
menuPermissions,
|
||||
}: TopMenuBarProps) {
|
||||
const [isFullscreen, setIsFullscreen] = useState(() => !!document.fullscreenElement);
|
||||
@@ -281,6 +307,25 @@ export const TopMenuBar = memo(function TopMenuBar({
|
||||
|
||||
{/* 우측 영역 */}
|
||||
<div className="tmb-right">
|
||||
{onOpenFloatingWidgets && (
|
||||
<div className="tmb-floating-widget-wrap">
|
||||
<button
|
||||
type="button"
|
||||
className="tmb-floating-widget-btn"
|
||||
onClick={onOpenFloatingWidgets}
|
||||
title="플로팅 위젯 추가"
|
||||
aria-label="플로팅 위젯 추가"
|
||||
>
|
||||
<IcFloatingWidgetAdd />
|
||||
</button>
|
||||
{floatingWidgetCount > 0 && (
|
||||
<span className="tmb-floating-widget-count" aria-hidden>
|
||||
{floatingWidgetCount > 9 ? '9+' : floatingWidgetCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{onOpenAppDownload && (
|
||||
<>
|
||||
<button
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { Theme } from '../types';
|
||||
import {
|
||||
loadPaperSummary,
|
||||
loadPaperTrades,
|
||||
loadStrategies,
|
||||
type PaperSummaryDto,
|
||||
type PaperTradeDto,
|
||||
type StrategyDto,
|
||||
} from '../utils/backendApi';
|
||||
import {
|
||||
flushPendingUiPreferences,
|
||||
getUiPreferences,
|
||||
patchUiPreferences,
|
||||
subscribeUiPreferences,
|
||||
} from '../utils/uiPreferencesDb';
|
||||
import { defaultWidgetLayout, normalizeWidgetLayout, type WidgetLayoutSchema } from '../types/widgetDashboard';
|
||||
import { WidgetDashboardProvider } from '../widgets/WidgetDashboardContext';
|
||||
import WidgetDashboardShell from './widgetDashboard/WidgetDashboardShell';
|
||||
import { PAPER_TRADES_CHANGED_EVENT } from '../utils/paperTradeEvents';
|
||||
import { useMarketTicker } from '../hooks/useMarketTicker';
|
||||
import type { ChartRealtimeSource } from '../hooks/useChartRealtimeData';
|
||||
import { useChartWorkspaceContext } from '../chart/ChartWorkspaceContext';
|
||||
import { useAppSettings } from '../hooks/useAppSettings';
|
||||
import '../styles/widgetDashboard.css';
|
||||
|
||||
interface Props {
|
||||
theme: Theme;
|
||||
paperTradingEnabled: boolean;
|
||||
paperAutoTradeEnabled: boolean;
|
||||
onPaperOrderFilled?: () => void;
|
||||
chartRealtimeSource?: ChartRealtimeSource;
|
||||
}
|
||||
|
||||
function readWidgetLayoutFromPrefs(): WidgetLayoutSchema {
|
||||
return normalizeWidgetLayout(getUiPreferences().widgetDashboard ?? defaultWidgetLayout());
|
||||
}
|
||||
|
||||
const WidgetDashboardPage: React.FC<Props> = ({
|
||||
theme,
|
||||
paperTradingEnabled,
|
||||
paperAutoTradeEnabled,
|
||||
onPaperOrderFilled,
|
||||
chartRealtimeSource = 'BACKEND_STOMP',
|
||||
}) => {
|
||||
const { isLoaded: appSettingsLoaded } = useAppSettings();
|
||||
const { symbol: defaultMarket } = useChartWorkspaceContext();
|
||||
const { tickers, marketInfos, loading: marketLoading, usdRate } = useMarketTicker(
|
||||
useMemo(() => ({ enabled: true, loadFull: true }), []),
|
||||
);
|
||||
const [layout, setLayout] = useState<WidgetLayoutSchema>(defaultWidgetLayout);
|
||||
const [strategies, setStrategies] = useState<StrategyDto[]>([]);
|
||||
const [summary, setSummary] = useState<PaperSummaryDto | null>(null);
|
||||
const [trades, setTrades] = useState<PaperTradeDto[]>([]);
|
||||
const layoutJsonRef = useRef('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!appSettingsLoaded) return;
|
||||
const next = readWidgetLayoutFromPrefs();
|
||||
const json = JSON.stringify(next);
|
||||
if (json !== layoutJsonRef.current) {
|
||||
layoutJsonRef.current = json;
|
||||
setLayout(next);
|
||||
}
|
||||
}, [appSettingsLoaded]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!appSettingsLoaded) return;
|
||||
return subscribeUiPreferences(() => {
|
||||
const next = readWidgetLayoutFromPrefs();
|
||||
const json = JSON.stringify(next);
|
||||
if (json === layoutJsonRef.current) return;
|
||||
layoutJsonRef.current = json;
|
||||
setLayout(next);
|
||||
});
|
||||
}, [appSettingsLoaded]);
|
||||
|
||||
useEffect(() => {
|
||||
const flush = () => { flushPendingUiPreferences(); };
|
||||
window.addEventListener('beforeunload', flush);
|
||||
window.addEventListener('pagehide', flush);
|
||||
return () => {
|
||||
window.removeEventListener('beforeunload', flush);
|
||||
window.removeEventListener('pagehide', flush);
|
||||
flush();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const persistLayout = useCallback((
|
||||
next: WidgetLayoutSchema,
|
||||
opts?: { immediate?: boolean },
|
||||
) => {
|
||||
const normalized = normalizeWidgetLayout(next);
|
||||
layoutJsonRef.current = JSON.stringify(normalized);
|
||||
setLayout(normalized);
|
||||
if (!appSettingsLoaded) return;
|
||||
patchUiPreferences({ widgetDashboard: normalized }, opts?.immediate ?? false);
|
||||
}, [appSettingsLoaded]);
|
||||
|
||||
const refreshPaperData = useCallback(async () => {
|
||||
try {
|
||||
const [sum, tr] = await Promise.all([loadPaperSummary(), loadPaperTrades()]);
|
||||
setSummary(sum);
|
||||
setTrades(tr ?? []);
|
||||
} catch { /* ignore */ }
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadStrategies().then(list => setStrategies(list ?? []));
|
||||
void refreshPaperData();
|
||||
}, [refreshPaperData]);
|
||||
|
||||
useEffect(() => {
|
||||
const onChanged = () => { void refreshPaperData(); };
|
||||
window.addEventListener(PAPER_TRADES_CHANGED_EVENT, onChanged);
|
||||
return () => window.removeEventListener(PAPER_TRADES_CHANGED_EVENT, onChanged);
|
||||
}, [refreshPaperData]);
|
||||
|
||||
if (!appSettingsLoaded) {
|
||||
return <div className="wd-loading">위젯 대시보드 로딩…</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<WidgetDashboardProvider
|
||||
theme={theme}
|
||||
tickers={tickers}
|
||||
marketInfos={marketInfos}
|
||||
marketLoading={marketLoading}
|
||||
usdRate={usdRate}
|
||||
defaultMarket={defaultMarket}
|
||||
strategies={strategies}
|
||||
summary={summary}
|
||||
trades={trades}
|
||||
refreshPaperData={() => { void refreshPaperData(); }}
|
||||
paperTradingEnabled={paperTradingEnabled}
|
||||
paperAutoTradeEnabled={paperAutoTradeEnabled}
|
||||
onPaperOrderFilled={onPaperOrderFilled}
|
||||
chartRealtimeSource={chartRealtimeSource}
|
||||
>
|
||||
<WidgetDashboardShell
|
||||
theme={theme}
|
||||
layout={layout}
|
||||
onLayoutChange={persistLayout}
|
||||
/>
|
||||
</WidgetDashboardProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default WidgetDashboardPage;
|
||||
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* 플로팅 위젯 레이어 — 메뉴바에서 다중 팝업 위젯 실행
|
||||
*/
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import type { Theme } from '../../types';
|
||||
import type { ChartRealtimeSource } from '../../hooks/useChartRealtimeData';
|
||||
import {
|
||||
loadPaperSummary,
|
||||
loadPaperTrades,
|
||||
loadStrategies,
|
||||
type PaperSummaryDto,
|
||||
type PaperTradeDto,
|
||||
type StrategyDto,
|
||||
} from '../../utils/backendApi';
|
||||
import { PAPER_TRADES_CHANGED_EVENT } from '../../utils/paperTradeEvents';
|
||||
import { useMarketTicker } from '../../hooks/useMarketTicker';
|
||||
import { WidgetDashboardProvider } from '../../widgets/WidgetDashboardContext';
|
||||
import {
|
||||
createFloatingWidgetInstance,
|
||||
type FloatingWidgetInstance,
|
||||
type FloatingWidgetLayoutPreset,
|
||||
} from '../../types/floatingWidget';
|
||||
import type { WidgetSlot } from '../../types/widgetDashboard';
|
||||
import FloatingWidgetLayoutPicker from './FloatingWidgetLayoutPicker';
|
||||
import FloatingWidgetWindow from './FloatingWidgetWindow';
|
||||
import '../../styles/floatingWidget.css';
|
||||
|
||||
const BASE_Z = 13000;
|
||||
|
||||
interface Props {
|
||||
theme: Theme;
|
||||
defaultMarket: string;
|
||||
layoutPickerOpen: boolean;
|
||||
onLayoutPickerOpenChange: (open: boolean) => void;
|
||||
paperTradingEnabled: boolean;
|
||||
paperAutoTradeEnabled: boolean;
|
||||
onPaperOrderFilled?: () => void;
|
||||
chartRealtimeSource?: ChartRealtimeSource;
|
||||
onInstancesChange?: (count: number) => void;
|
||||
}
|
||||
|
||||
const FloatingWidgetLayer: React.FC<Props> = ({
|
||||
theme,
|
||||
defaultMarket,
|
||||
layoutPickerOpen,
|
||||
onLayoutPickerOpenChange,
|
||||
paperTradingEnabled,
|
||||
paperAutoTradeEnabled,
|
||||
onPaperOrderFilled,
|
||||
chartRealtimeSource = 'BACKEND_STOMP',
|
||||
onInstancesChange,
|
||||
}) => {
|
||||
const [instances, setInstances] = useState<FloatingWidgetInstance[]>([]);
|
||||
const [focusedId, setFocusedId] = useState<string | null>(null);
|
||||
const zCounterRef = useRef(BASE_Z + 100);
|
||||
|
||||
const { tickers, marketInfos, loading: marketLoading, usdRate } = useMarketTicker(
|
||||
useMemo(() => ({ enabled: true, loadFull: true }), []),
|
||||
);
|
||||
const [strategies, setStrategies] = useState<StrategyDto[]>([]);
|
||||
const [summary, setSummary] = useState<PaperSummaryDto | null>(null);
|
||||
const [trades, setTrades] = useState<PaperTradeDto[]>([]);
|
||||
|
||||
const refreshPaperData = useCallback(async () => {
|
||||
try {
|
||||
const [sum, tr] = await Promise.all([loadPaperSummary(), loadPaperTrades()]);
|
||||
setSummary(sum);
|
||||
setTrades(tr ?? []);
|
||||
} catch { /* ignore */ }
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadStrategies().then(list => setStrategies(list ?? []));
|
||||
void refreshPaperData();
|
||||
}, [refreshPaperData]);
|
||||
|
||||
useEffect(() => {
|
||||
const onChanged = () => { void refreshPaperData(); };
|
||||
window.addEventListener(PAPER_TRADES_CHANGED_EVENT, onChanged);
|
||||
return () => window.removeEventListener(PAPER_TRADES_CHANGED_EVENT, onChanged);
|
||||
}, [refreshPaperData]);
|
||||
|
||||
const bringToFront = useCallback((id: string) => {
|
||||
zCounterRef.current += 1;
|
||||
const nextZ = zCounterRef.current;
|
||||
setFocusedId(id);
|
||||
setInstances(prev => prev.map(inst =>
|
||||
inst.id === id ? { ...inst, zIndex: nextZ } : inst,
|
||||
));
|
||||
}, []);
|
||||
|
||||
const handleLayoutSelect = useCallback((preset: FloatingWidgetLayoutPreset) => {
|
||||
zCounterRef.current += 1;
|
||||
const index = instances.length;
|
||||
const inst = createFloatingWidgetInstance(
|
||||
preset.rows,
|
||||
preset.cols,
|
||||
index,
|
||||
zCounterRef.current,
|
||||
);
|
||||
inst.zIndex = zCounterRef.current;
|
||||
setInstances(prev => [...prev, inst]);
|
||||
setFocusedId(inst.id);
|
||||
}, [instances.length]);
|
||||
|
||||
const handleClose = useCallback((id: string) => {
|
||||
setInstances(prev => prev.filter(i => i.id !== id));
|
||||
setFocusedId(prev => (prev === id ? null : prev));
|
||||
}, []);
|
||||
|
||||
const handleUpdateSlots = useCallback((id: string, slots: WidgetSlot[]) => {
|
||||
setInstances(prev => prev.map(inst => (inst.id === id ? { ...inst, slots } : inst)));
|
||||
}, []);
|
||||
|
||||
const handleUpdateSize = useCallback((id: string, width: number, height: number) => {
|
||||
setInstances(prev => prev.map(inst => (
|
||||
inst.id === id ? { ...inst, width, height } : inst
|
||||
)));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
onInstancesChange?.(instances.length);
|
||||
}, [instances.length, onInstancesChange]);
|
||||
|
||||
if (instances.length === 0 && !layoutPickerOpen) return null;
|
||||
|
||||
return (
|
||||
<WidgetDashboardProvider
|
||||
theme={theme}
|
||||
tickers={tickers}
|
||||
marketInfos={marketInfos}
|
||||
marketLoading={marketLoading}
|
||||
usdRate={usdRate}
|
||||
defaultMarket={defaultMarket}
|
||||
strategies={strategies}
|
||||
summary={summary}
|
||||
trades={trades}
|
||||
refreshPaperData={() => { void refreshPaperData(); }}
|
||||
paperTradingEnabled={paperTradingEnabled}
|
||||
paperAutoTradeEnabled={paperAutoTradeEnabled}
|
||||
onPaperOrderFilled={onPaperOrderFilled}
|
||||
chartRealtimeSource={chartRealtimeSource}
|
||||
>
|
||||
{createPortal(
|
||||
<div className="fw-layer" aria-label="플로팅 위젯">
|
||||
{instances.map(inst => (
|
||||
<FloatingWidgetWindow
|
||||
key={inst.id}
|
||||
instance={inst}
|
||||
focused={focusedId === inst.id}
|
||||
onClose={() => handleClose(inst.id)}
|
||||
onFocus={() => bringToFront(inst.id)}
|
||||
onUpdateSlots={slots => handleUpdateSlots(inst.id, slots)}
|
||||
onUpdateSize={(w, h) => handleUpdateSize(inst.id, w, h)}
|
||||
/>
|
||||
))}
|
||||
|
||||
<FloatingWidgetLayoutPicker
|
||||
open={layoutPickerOpen}
|
||||
onClose={() => onLayoutPickerOpenChange(false)}
|
||||
onSelect={handleLayoutSelect}
|
||||
/>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</WidgetDashboardProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default FloatingWidgetLayer;
|
||||
@@ -0,0 +1,64 @@
|
||||
import React from 'react';
|
||||
import AppPopup from '../AppPopup';
|
||||
import {
|
||||
FLOATING_WIDGET_LAYOUTS,
|
||||
type FloatingWidgetLayoutPreset,
|
||||
} from '../../types/floatingWidget';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSelect: (preset: FloatingWidgetLayoutPreset) => void;
|
||||
}
|
||||
|
||||
const FloatingWidgetLayoutPicker: React.FC<Props> = ({ open, onClose, onSelect }) => {
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<AppPopup
|
||||
onClose={onClose}
|
||||
title="위젯 레이아웃"
|
||||
titleKo="Floating Widget"
|
||||
width={520}
|
||||
maxWidth="96vw"
|
||||
backdrop
|
||||
closeOnBackdrop
|
||||
zIndex={13100}
|
||||
bodyClassName="wd-picker-popup-body"
|
||||
>
|
||||
<p className="wd-layout-intro">
|
||||
원하는 그리드 형태를 선택하세요. 각 영역에서 + 버튼으로 위젯을 추가할 수 있으며,
|
||||
여러 위젯 창을 동시에 띄울 수 있습니다.
|
||||
</p>
|
||||
<div className="fw-layout-picker-grid">
|
||||
{FLOATING_WIDGET_LAYOUTS.map(preset => (
|
||||
<button
|
||||
key={preset.id}
|
||||
type="button"
|
||||
className="fw-layout-option"
|
||||
onClick={() => {
|
||||
onSelect(preset);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="fw-layout-preview"
|
||||
style={{
|
||||
gridTemplateRows: `repeat(${preset.rows}, 1fr)`,
|
||||
gridTemplateColumns: `repeat(${preset.cols}, 1fr)`,
|
||||
}}
|
||||
>
|
||||
{Array.from({ length: preset.rows * preset.cols }).map((_, i) => (
|
||||
<span key={i} className="fw-layout-preview-cell" />
|
||||
))}
|
||||
</div>
|
||||
<span className="fw-layout-label">{preset.label}</span>
|
||||
<span className="fw-layout-desc">{preset.rows * preset.cols}칸</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</AppPopup>
|
||||
);
|
||||
};
|
||||
|
||||
export default FloatingWidgetLayoutPicker;
|
||||
@@ -0,0 +1,175 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useDraggablePanel } from '../../hooks/useDraggablePanel';
|
||||
import { useFloatingWidgetResize } from '../../hooks/useFloatingWidgetResize';
|
||||
import { minSizeForGrid, type FloatingWidgetInstance } from '../../types/floatingWidget';
|
||||
import type { WidgetSlot } from '../../types/widgetDashboard';
|
||||
import WidgetSlotView from '../widgetDashboard/WidgetSlotView';
|
||||
import WidgetPickerModal from '../widgetDashboard/WidgetPickerModal';
|
||||
|
||||
interface Props {
|
||||
instance: FloatingWidgetInstance;
|
||||
focused: boolean;
|
||||
onClose: () => void;
|
||||
onFocus: () => void;
|
||||
onUpdateSlots: (slots: WidgetSlot[]) => void;
|
||||
onUpdateSize: (width: number, height: number) => void;
|
||||
}
|
||||
|
||||
const FloatingWidgetWindow: React.FC<Props> = ({
|
||||
instance,
|
||||
focused,
|
||||
onClose,
|
||||
onFocus,
|
||||
onUpdateSlots,
|
||||
onUpdateSize,
|
||||
}) => {
|
||||
const [pickSlotId, setPickSlotId] = useState<string | null>(null);
|
||||
const bodyRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const {
|
||||
panelRef,
|
||||
setPos,
|
||||
dragging,
|
||||
onHeaderPointerDown,
|
||||
headerTouchStyle,
|
||||
panelStyle,
|
||||
headerCursor,
|
||||
} = useDraggablePanel({
|
||||
centerOnMount: false,
|
||||
initialPosition: instance.position,
|
||||
});
|
||||
|
||||
const minSize = useMemo(
|
||||
() => minSizeForGrid(instance.rows, instance.cols),
|
||||
[instance.rows, instance.cols],
|
||||
);
|
||||
|
||||
const handleResize = useCallback((result: { width: number; height: number; x?: number }) => {
|
||||
if (result.x != null) {
|
||||
setPos(prev => ({ ...prev, x: result.x! }));
|
||||
}
|
||||
onUpdateSize(result.width, result.height);
|
||||
}, [onUpdateSize, setPos]);
|
||||
|
||||
const { onResizeSePointerDown, onResizeSwPointerDown } = useFloatingWidgetResize(
|
||||
panelRef,
|
||||
{ width: instance.width, height: instance.height },
|
||||
minSize,
|
||||
handleResize,
|
||||
);
|
||||
|
||||
const updateSlot = useCallback((slotId: string, updater: (s: WidgetSlot) => WidgetSlot) => {
|
||||
onUpdateSlots(instance.slots.map(s => (s.id === slotId ? updater(s) : s)));
|
||||
}, [instance.slots, onUpdateSlots]);
|
||||
|
||||
const applyWidgetType = useCallback((widgetType: string) => {
|
||||
if (!pickSlotId) return;
|
||||
updateSlot(pickSlotId, s => ({ ...s, widgetType, config: {} }));
|
||||
setPickSlotId(null);
|
||||
}, [pickSlotId, updateSlot]);
|
||||
|
||||
const clearWidget = useCallback((slotId: string) => {
|
||||
updateSlot(slotId, s => ({ ...s, widgetType: null, config: {} }));
|
||||
}, [updateSlot]);
|
||||
|
||||
/* 창 크기 변경 시 내부 차트·위젯 레이아웃 재동기화 */
|
||||
useEffect(() => {
|
||||
const el = bodyRef.current;
|
||||
if (!el) return undefined;
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
const ro = new ResizeObserver(() => {
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = setTimeout(() => {
|
||||
window.dispatchEvent(new CustomEvent('gc-widget-layout-sync'));
|
||||
}, 80);
|
||||
});
|
||||
ro.observe(el);
|
||||
return () => {
|
||||
if (timer) clearTimeout(timer);
|
||||
ro.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const title = `위젯 ${instance.rows}×${instance.cols}`;
|
||||
|
||||
return createPortal(
|
||||
<>
|
||||
<div
|
||||
ref={panelRef}
|
||||
className={`fw-window${focused ? ' fw-window--focused' : ''}`}
|
||||
style={{
|
||||
...panelStyle,
|
||||
width: instance.width,
|
||||
height: instance.height,
|
||||
zIndex: instance.zIndex,
|
||||
cursor: dragging ? 'grabbing' : undefined,
|
||||
}}
|
||||
onMouseDown={onFocus}
|
||||
>
|
||||
<div
|
||||
className="fw-window-head"
|
||||
onPointerDown={onHeaderPointerDown}
|
||||
style={{ cursor: headerCursor, ...headerTouchStyle }}
|
||||
>
|
||||
<span className="fw-window-title">{title}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="fw-window-close"
|
||||
title="위젯 창 닫기"
|
||||
aria-label="위젯 창 닫기"
|
||||
onPointerDown={e => e.stopPropagation()}
|
||||
onClick={onClose}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={bodyRef}
|
||||
className="fw-window-body"
|
||||
style={{
|
||||
gridTemplateRows: `repeat(${instance.rows}, minmax(0, 1fr))`,
|
||||
gridTemplateColumns: `repeat(${instance.cols}, minmax(0, 1fr))`,
|
||||
}}
|
||||
>
|
||||
{instance.slots.map(slot => (
|
||||
<div key={slot.id} className="fw-window-cell">
|
||||
<WidgetSlotView
|
||||
slot={slot}
|
||||
zoneId="center"
|
||||
onPickWidget={id => setPickSlotId(id)}
|
||||
onClearWidget={id => clearWidget(id)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="fw-window-resize-layer">
|
||||
<div
|
||||
className="fw-window-resize-handle fw-window-resize-handle--sw"
|
||||
role="separator"
|
||||
aria-label="위젯 창 크기 조절 (좌하단)"
|
||||
onPointerDown={onResizeSwPointerDown}
|
||||
/>
|
||||
<div
|
||||
className="fw-window-resize-handle fw-window-resize-handle--se"
|
||||
role="separator"
|
||||
aria-label="위젯 창 크기 조절 (우하단)"
|
||||
onPointerDown={onResizeSePointerDown}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<WidgetPickerModal
|
||||
open={pickSlotId != null}
|
||||
category="all"
|
||||
onClose={() => setPickSlotId(null)}
|
||||
onSelect={applyWidgetType}
|
||||
/>
|
||||
</>,
|
||||
document.body,
|
||||
);
|
||||
};
|
||||
|
||||
export default FloatingWidgetWindow;
|
||||
@@ -53,6 +53,14 @@ export interface BuilderPageShellProps {
|
||||
collapsiblePanels?: boolean;
|
||||
leftCollapsedStorageKey?: string;
|
||||
rightCollapsedStorageKey?: string;
|
||||
/** 패널 크기·접기 변경 시 (위젯 대시보드 레이아웃 영속화 등) */
|
||||
onPanelLayoutChange?: (patch: {
|
||||
leftWidth?: number;
|
||||
rightWidth?: number;
|
||||
footerHeight?: number;
|
||||
leftOpen?: boolean;
|
||||
rightOpen?: boolean;
|
||||
}) => void;
|
||||
loading?: boolean;
|
||||
loadingText?: string;
|
||||
}
|
||||
@@ -129,6 +137,7 @@ export default function BuilderPageShell({
|
||||
collapsiblePanels = false,
|
||||
leftCollapsedStorageKey,
|
||||
rightCollapsedStorageKey,
|
||||
onPanelLayoutChange,
|
||||
loading = false,
|
||||
loadingText = '로딩 중…',
|
||||
}: BuilderPageShellProps) {
|
||||
@@ -158,7 +167,10 @@ export default function BuilderPageShell({
|
||||
() => leftWidthRef.current,
|
||||
LEFT_MIN,
|
||||
LEFT_MAX,
|
||||
v => storeSize(leftStorageKey, v),
|
||||
v => {
|
||||
storeSize(leftStorageKey, v);
|
||||
onPanelLayoutChange?.({ leftWidth: v });
|
||||
},
|
||||
);
|
||||
|
||||
const handleRightSplitter = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
|
||||
@@ -186,11 +198,12 @@ export default function BuilderPageShell({
|
||||
window.removeEventListener('pointermove', onMove);
|
||||
window.removeEventListener('pointerup', onUp);
|
||||
storeSize(rightStorageKey, rightWidthRef.current);
|
||||
onPanelLayoutChange?.({ rightWidth: rightWidthRef.current });
|
||||
};
|
||||
|
||||
window.addEventListener('pointermove', onMove);
|
||||
window.addEventListener('pointerup', onUp);
|
||||
}, [rightStorageKey]);
|
||||
}, [rightStorageKey, onPanelLayoutChange]);
|
||||
|
||||
const onFooterSplitter = usePanelResize(
|
||||
'horizontal',
|
||||
@@ -198,24 +211,29 @@ export default function BuilderPageShell({
|
||||
() => footerHeightRef.current,
|
||||
FOOTER_MIN,
|
||||
FOOTER_MAX,
|
||||
v => storeSize(footerStorageKey, v),
|
||||
v => {
|
||||
storeSize(footerStorageKey, v);
|
||||
onPanelLayoutChange?.({ footerHeight: v });
|
||||
},
|
||||
);
|
||||
|
||||
const toggleLeft = useCallback(() => {
|
||||
setLeftOpen(prev => {
|
||||
const next = !prev;
|
||||
if (leftCollapsedStorageKey) storeBool(leftCollapsedStorageKey, next);
|
||||
onPanelLayoutChange?.({ leftOpen: next });
|
||||
return next;
|
||||
});
|
||||
}, [leftCollapsedStorageKey]);
|
||||
}, [leftCollapsedStorageKey, onPanelLayoutChange]);
|
||||
|
||||
const toggleRight = useCallback(() => {
|
||||
setRightOpen(prev => {
|
||||
const next = !prev;
|
||||
if (rightCollapsedStorageKey) storeBool(rightCollapsedStorageKey, next);
|
||||
onPanelLayoutChange?.({ rightOpen: next });
|
||||
return next;
|
||||
});
|
||||
}, [rightCollapsedStorageKey]);
|
||||
}, [rightCollapsedStorageKey, onPanelLayoutChange]);
|
||||
|
||||
const pageStyle = useMemo(() => ({
|
||||
'--bps-left-width': leftOpen ? `${leftWidth}px` : '0px',
|
||||
|
||||
@@ -0,0 +1,361 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import AppPopup from '../AppPopup';
|
||||
import type {
|
||||
WidgetLayoutSchema,
|
||||
WidgetZoneConfig,
|
||||
WidgetZoneId,
|
||||
WidgetZoneMode,
|
||||
} from '../../types/widgetDashboard';
|
||||
import {
|
||||
defaultWidgetLayout,
|
||||
newWidgetSlot,
|
||||
normalizeZoneSlots,
|
||||
slotsForSplitCount,
|
||||
} from '../../types/widgetDashboard';
|
||||
import { addTabToZone, removeTabFromZone } from './WidgetZoneView';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
layout: WidgetLayoutSchema;
|
||||
onClose: () => void;
|
||||
onApply: (layout: WidgetLayoutSchema) => void;
|
||||
}
|
||||
|
||||
type ZoneKey = 'left' | 'center' | 'right' | 'bottom';
|
||||
|
||||
const ZONE_META: Record<ZoneKey, { title: string; desc: string }> = {
|
||||
left: { title: '좌측 패널', desc: '종목 목록·전략 목록 등 보조 위젯' },
|
||||
center: { title: '중앙 (메인)', desc: '차트·분석 등 메인 위젯 영역' },
|
||||
right: { title: '우측 패널', desc: '매수·매도·호가 등 거래 위젯' },
|
||||
bottom: { title: '하단 패널', desc: '거래 내역·알림 등 하단 위젯' },
|
||||
};
|
||||
|
||||
const MODE_OPTIONS: { value: WidgetZoneMode; label: string; desc: string }[] = [
|
||||
{ value: 'single', label: '단일', desc: '위젯 1개' },
|
||||
{ value: 'split', label: '상하 분할', desc: '2~3개 세로 배치' },
|
||||
{ value: 'tabs', label: '탭', desc: '탭별 위젯 구성' },
|
||||
];
|
||||
|
||||
function setZoneEnabled(
|
||||
layout: WidgetLayoutSchema,
|
||||
key: ZoneKey,
|
||||
enabled: boolean,
|
||||
): WidgetLayoutSchema {
|
||||
if (key === 'center') return layout;
|
||||
const defaults = defaultWidgetLayout();
|
||||
return {
|
||||
...layout,
|
||||
zones: {
|
||||
...layout.zones,
|
||||
[key]: enabled ? (layout.zones[key] ?? defaults.zones[key]) : null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function updateZoneConfig(
|
||||
layout: WidgetLayoutSchema,
|
||||
key: ZoneKey,
|
||||
patch: Partial<WidgetZoneConfig>,
|
||||
): WidgetLayoutSchema {
|
||||
const current = layout.zones[key];
|
||||
if (!current && key !== 'center') return layout;
|
||||
const base = current ?? defaultWidgetLayout().zones.center;
|
||||
let next: WidgetZoneConfig = normalizeZoneSlots({ ...base, ...patch });
|
||||
|
||||
if (patch.mode === 'split' && patch.splitCount) {
|
||||
const defaults = slotsForSplitCount(patch.splitCount, (base.slots ?? []).map(s => s.widgetType));
|
||||
next = normalizeZoneSlots({
|
||||
...next,
|
||||
mode: 'split',
|
||||
splitCount: patch.splitCount,
|
||||
slots: defaults.map((s, i) => ({
|
||||
...s,
|
||||
widgetType: base.slots?.[i]?.widgetType ?? s.widgetType,
|
||||
config: base.slots?.[i]?.config ?? {},
|
||||
})),
|
||||
tabs: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
if (patch.mode === 'tabs' && !next.tabs?.length) {
|
||||
next = addTabToZone({ ...next, mode: 'tabs' }, '탭 1');
|
||||
}
|
||||
|
||||
if (patch.mode === 'single') {
|
||||
next = normalizeZoneSlots({
|
||||
mode: 'single',
|
||||
splitCount: 1,
|
||||
slots: [base.slots?.[0] ?? newWidgetSlot()],
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
...layout,
|
||||
zones: {
|
||||
...layout.zones,
|
||||
[key]: next,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const SettingRow: React.FC<{
|
||||
label: string;
|
||||
desc?: string;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}> = ({ label, desc, className, children }) => (
|
||||
<div className={`stg-row${className ? ` ${className}` : ''}`}>
|
||||
<div className="stg-row-label">
|
||||
<span className="stg-row-title">{label}</span>
|
||||
{desc && <span className="stg-row-desc">{desc}</span>}
|
||||
</div>
|
||||
<div className="stg-row-ctrl">{children}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const LayoutEditorModal: React.FC<Props> = ({
|
||||
open,
|
||||
layout,
|
||||
onClose,
|
||||
onApply,
|
||||
}) => {
|
||||
const [draft, setDraft] = useState(layout);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (open) setDraft(layout);
|
||||
}, [open, layout]);
|
||||
|
||||
const apply = useCallback(() => {
|
||||
onApply(draft);
|
||||
onClose();
|
||||
}, [draft, onApply, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const renderZoneEditor = (key: ZoneKey) => {
|
||||
const enabled = key === 'center' || draft.zones[key] != null;
|
||||
const zone = key === 'center'
|
||||
? draft.zones.center
|
||||
: (draft.zones[key] ?? defaultWidgetLayout().zones[key]);
|
||||
|
||||
if (!zone) return null;
|
||||
|
||||
const mode = zone.mode;
|
||||
const splitCount = zone.splitCount ?? 1;
|
||||
const meta = ZONE_META[key];
|
||||
|
||||
return (
|
||||
<div key={key} className="stg-section wd-layout-section">
|
||||
<div className="stg-section-title">{meta.title}</div>
|
||||
<div className="stg-section-body">
|
||||
{key !== 'center' && (
|
||||
<SettingRow
|
||||
label="패널 표시"
|
||||
desc={meta.desc}
|
||||
>
|
||||
<label className="stg-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabled}
|
||||
onChange={e => setDraft(setZoneEnabled(draft, key, e.target.checked))}
|
||||
/>
|
||||
<span className="stg-toggle-slider" />
|
||||
</label>
|
||||
</SettingRow>
|
||||
)}
|
||||
|
||||
{enabled && (
|
||||
<>
|
||||
<SettingRow
|
||||
label="패널 구성"
|
||||
desc="단일·상하 분할·탭 방식 중 선택"
|
||||
className="stg-row--block"
|
||||
>
|
||||
<div className="stg-radio-row wd-layout-mode-row">
|
||||
{MODE_OPTIONS.map(opt => (
|
||||
<label
|
||||
key={opt.value}
|
||||
className="stg-radio-option stg-radio-option--blue"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name={`wd-zone-mode-${key}`}
|
||||
checked={mode === opt.value}
|
||||
onChange={() => setDraft(updateZoneConfig(
|
||||
draft,
|
||||
key,
|
||||
{ mode: opt.value },
|
||||
))}
|
||||
/>
|
||||
<span className="stg-radio-option-text">
|
||||
<strong>{opt.label}</strong>
|
||||
<span>{opt.desc}</span>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</SettingRow>
|
||||
|
||||
{mode === 'split' && (
|
||||
<SettingRow
|
||||
label="분할 수"
|
||||
desc="상하로 배치할 위젯 칸 수"
|
||||
>
|
||||
<select
|
||||
className="stg-select"
|
||||
value={splitCount}
|
||||
onChange={e => setDraft(updateZoneConfig(
|
||||
draft,
|
||||
key,
|
||||
{ mode: 'split', splitCount: Number(e.target.value) as 1 | 2 | 3 },
|
||||
))}
|
||||
>
|
||||
<option value={1}>1칸</option>
|
||||
<option value={2}>2칸</option>
|
||||
<option value={3}>3칸</option>
|
||||
</select>
|
||||
</SettingRow>
|
||||
)}
|
||||
|
||||
{mode === 'tabs' && (
|
||||
<>
|
||||
<div className="stg-subsection-label stg-subsection-label--spaced">
|
||||
탭 목록
|
||||
<span className="stg-subsection-desc">탭 이름과 탭 내부 분할 수를 설정합니다</span>
|
||||
</div>
|
||||
{(zone.tabs ?? []).map(tab => (
|
||||
<div key={tab.id} className="stg-row stg-row--block wd-layout-tab-row">
|
||||
<div className="wd-layout-tab-fields">
|
||||
<label className="wd-layout-tab-field">
|
||||
<span className="wd-layout-tab-field-label">탭 이름</span>
|
||||
<input
|
||||
type="text"
|
||||
className="stg-input stg-input--wide"
|
||||
value={tab.label}
|
||||
onChange={e => {
|
||||
const z = draft.zones[key];
|
||||
if (!z?.tabs) return;
|
||||
setDraft({
|
||||
...draft,
|
||||
zones: {
|
||||
...draft.zones,
|
||||
[key]: {
|
||||
...z,
|
||||
tabs: z.tabs.map(t =>
|
||||
t.id === tab.id ? { ...t, label: e.target.value } : t,
|
||||
),
|
||||
},
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label className="wd-layout-tab-field">
|
||||
<span className="wd-layout-tab-field-label">분할</span>
|
||||
<select
|
||||
className="stg-select"
|
||||
value={tab.splitCount}
|
||||
onChange={e => {
|
||||
const count = Number(e.target.value) as 1 | 2 | 3;
|
||||
const z = draft.zones[key];
|
||||
if (!z?.tabs) return;
|
||||
setDraft({
|
||||
...draft,
|
||||
zones: {
|
||||
...draft.zones,
|
||||
[key]: {
|
||||
...z,
|
||||
tabs: z.tabs.map(t =>
|
||||
t.id === tab.id
|
||||
? {
|
||||
...t,
|
||||
splitCount: count,
|
||||
slots: slotsForSplitCount(count, t.slots.map(s => s.widgetType)),
|
||||
}
|
||||
: t,
|
||||
),
|
||||
},
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
<option value={1}>1칸</option>
|
||||
<option value={2}>2칸</option>
|
||||
<option value={3}>3칸</option>
|
||||
</select>
|
||||
</label>
|
||||
{(zone.tabs?.length ?? 0) > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
className="stg-btn stg-btn--danger wd-layout-tab-delete"
|
||||
onClick={() => {
|
||||
const z = draft.zones[key];
|
||||
if (!z) return;
|
||||
setDraft(updateZoneConfig(draft, key, removeTabFromZone(z, tab.id)));
|
||||
}}
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="wd-layout-tab-add">
|
||||
<button
|
||||
type="button"
|
||||
className="stg-btn-secondary wd-layout-tab-add-btn"
|
||||
onClick={() => {
|
||||
const z = draft.zones[key];
|
||||
if (!z) return;
|
||||
const n = (z.tabs?.length ?? 0) + 1;
|
||||
setDraft(updateZoneConfig(draft, key, addTabToZone(z, `탭 ${n}`)));
|
||||
}}
|
||||
>
|
||||
+ 탭 추가
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<AppPopup
|
||||
onClose={onClose}
|
||||
title="화면 구성"
|
||||
titleKo="Layout"
|
||||
width={680}
|
||||
maxWidth="96vw"
|
||||
backdrop
|
||||
closeOnBackdrop
|
||||
zIndex={12000}
|
||||
bodyClassName="wd-layout-popup-body"
|
||||
footer={(
|
||||
<div className="stg-footer">
|
||||
<button
|
||||
type="button"
|
||||
className="stg-btn-reset"
|
||||
onClick={() => setDraft(defaultWidgetLayout())}
|
||||
>
|
||||
기본값으로 초기화
|
||||
</button>
|
||||
<button type="button" className="stg-btn-save" onClick={apply}>
|
||||
적용
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<p className="wd-layout-intro">
|
||||
좌·중·우·하단 패널의 표시 여부와 내부 구성을 설정합니다.
|
||||
변경 내용은 저장 후 새로고침·다른 기기에서도 동일하게 복원됩니다.
|
||||
</p>
|
||||
{(['left', 'center', 'right', 'bottom'] as ZoneKey[]).map(renderZoneEditor)}
|
||||
</AppPopup>
|
||||
);
|
||||
};
|
||||
|
||||
export default LayoutEditorModal;
|
||||
@@ -0,0 +1,316 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import BuilderPageShell from '../layout/BuilderPageShell';
|
||||
import type { Theme } from '../../types';
|
||||
import type {
|
||||
WidgetLayoutSchema,
|
||||
WidgetZoneConfig,
|
||||
WidgetZoneId,
|
||||
} from '../../types/widgetDashboard';
|
||||
import { defaultWidgetLayout } from '../../types/widgetDashboard';
|
||||
import { storeBool, storeSize } from '../strategyEditor/usePanelResize';
|
||||
import WidgetZoneView, { resolveActiveTabId, WidgetZoneTabBar } from './WidgetZoneView';
|
||||
import WidgetSidePanel from './WidgetSidePanel';
|
||||
import WidgetPickerModal from './WidgetPickerModal';
|
||||
import LayoutEditorModal from './LayoutEditorModal';
|
||||
|
||||
interface Props {
|
||||
theme: Theme;
|
||||
layout: WidgetLayoutSchema;
|
||||
onLayoutChange: (layout: WidgetLayoutSchema, opts?: { immediate?: boolean }) => void;
|
||||
}
|
||||
|
||||
interface PickTarget {
|
||||
slotId: string;
|
||||
zoneId: WidgetZoneId;
|
||||
tabId?: string;
|
||||
}
|
||||
|
||||
type SideZoneId = 'left' | 'right';
|
||||
|
||||
function zoneUsesTabs(zone: WidgetZoneConfig | null | undefined): boolean {
|
||||
return zone?.mode === 'tabs' && (zone.tabs?.length ?? 0) > 0;
|
||||
}
|
||||
|
||||
const WidgetDashboardShell: React.FC<Props> = ({
|
||||
theme,
|
||||
layout,
|
||||
onLayoutChange,
|
||||
}) => {
|
||||
const [layoutEditorOpen, setLayoutEditorOpen] = useState(false);
|
||||
const [pickTarget, setPickTarget] = useState<PickTarget | null>(null);
|
||||
const [activeTabByZone, setActiveTabByZone] = useState<Partial<Record<WidgetZoneId, string>>>({});
|
||||
|
||||
const pickCategory = pickTarget?.zoneId === 'center' ? 'center' : 'side';
|
||||
|
||||
const getActiveTab = useCallback((zoneId: WidgetZoneId, zone: WidgetZoneConfig | null | undefined) =>
|
||||
resolveActiveTabId(zone, activeTabByZone[zoneId]),
|
||||
[activeTabByZone]);
|
||||
|
||||
const setActiveTab = useCallback((zoneId: WidgetZoneId, tabId: string) => {
|
||||
setActiveTabByZone(prev => ({ ...prev, [zoneId]: tabId }));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setActiveTabByZone(prev => {
|
||||
const next = { ...prev };
|
||||
let changed = false;
|
||||
(['left', 'center', 'right', 'bottom'] as WidgetZoneId[]).forEach(zoneId => {
|
||||
const zone = zoneId === 'center' ? layout.zones.center : layout.zones[zoneId];
|
||||
if (!zoneUsesTabs(zone)) return;
|
||||
const resolved = resolveActiveTabId(zone, prev[zoneId]);
|
||||
if (resolved && resolved !== prev[zoneId]) {
|
||||
next[zoneId] = resolved;
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
return changed ? next : prev;
|
||||
});
|
||||
}, [layout]);
|
||||
|
||||
const updateZone = useCallback((zoneId: WidgetZoneId, zone: WidgetZoneConfig) => {
|
||||
onLayoutChange({
|
||||
...layout,
|
||||
zones: {
|
||||
...layout.zones,
|
||||
[zoneId]: zoneId === 'center' ? zone : zone,
|
||||
},
|
||||
});
|
||||
}, [layout, onLayoutChange]);
|
||||
|
||||
const pageClass = [
|
||||
'bps-page--wd',
|
||||
layout.zones.left ? '' : 'bps-page--wd-no-left',
|
||||
layout.zones.right ? '' : 'bps-page--wd-no-right',
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
const applyWidgetType = useCallback((widgetType: string) => {
|
||||
if (!pickTarget) return;
|
||||
const { slotId, zoneId, tabId } = pickTarget;
|
||||
const zone = layout.zones[zoneId];
|
||||
if (!zone) return;
|
||||
|
||||
const patchSlot = (s: { id: string; widgetType: string | null; config?: Record<string, unknown> }) =>
|
||||
s.id === slotId ? { ...s, widgetType, config: {} } : s;
|
||||
|
||||
let nextLayout: WidgetLayoutSchema;
|
||||
if (zone.mode === 'tabs' && tabId) {
|
||||
nextLayout = {
|
||||
...layout,
|
||||
zones: {
|
||||
...layout.zones,
|
||||
[zoneId]: {
|
||||
...zone,
|
||||
tabs: (zone.tabs ?? []).map(tab =>
|
||||
tab.id === tabId
|
||||
? { ...tab, slots: tab.slots.map(patchSlot) }
|
||||
: tab,
|
||||
),
|
||||
},
|
||||
},
|
||||
};
|
||||
} else {
|
||||
nextLayout = {
|
||||
...layout,
|
||||
zones: {
|
||||
...layout.zones,
|
||||
[zoneId]: {
|
||||
...zone,
|
||||
slots: (zone.slots ?? []).map(patchSlot),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
onLayoutChange(nextLayout, { immediate: true });
|
||||
setPickTarget(null);
|
||||
}, [pickTarget, layout, onLayoutChange]);
|
||||
|
||||
const renderZoneView = useCallback((
|
||||
zoneId: WidgetZoneId,
|
||||
zone: WidgetZoneConfig,
|
||||
options?: { hideTabBar?: boolean },
|
||||
) => (
|
||||
<WidgetZoneView
|
||||
zoneId={zoneId}
|
||||
zone={zone}
|
||||
onChange={z => updateZone(zoneId, z)}
|
||||
onPickWidget={(slotId, zid, tabId) => setPickTarget({ slotId, zoneId: zid, tabId })}
|
||||
hideTabBar={options?.hideTabBar}
|
||||
activeTabId={getActiveTab(zoneId, zone)}
|
||||
onActiveTabChange={tabId => setActiveTab(zoneId, tabId)}
|
||||
/>
|
||||
), [getActiveTab, setActiveTab, updateZone]);
|
||||
|
||||
const renderSideTabBar = useCallback((
|
||||
zoneId: SideZoneId,
|
||||
zone: WidgetZoneConfig,
|
||||
) => (
|
||||
<WidgetZoneTabBar
|
||||
tabs={zone.tabs ?? []}
|
||||
activeTabId={getActiveTab(zoneId, zone)}
|
||||
onSelect={tabId => setActiveTab(zoneId, tabId)}
|
||||
variant="side"
|
||||
/>
|
||||
), [getActiveTab, setActiveTab]);
|
||||
|
||||
const leftZone = layout.zones.left;
|
||||
const rightZone = layout.zones.right;
|
||||
const bottomZone = layout.zones.bottom;
|
||||
const centerZone = layout.zones.center;
|
||||
|
||||
const leftUsesTabs = zoneUsesTabs(leftZone);
|
||||
const rightUsesTabs = zoneUsesTabs(rightZone);
|
||||
const bottomUsesTabs = zoneUsesTabs(bottomZone);
|
||||
const centerUsesTabs = zoneUsesTabs(centerZone);
|
||||
|
||||
const leftPanelContent = leftZone
|
||||
? renderZoneView('left', leftZone, { hideTabBar: leftUsesTabs })
|
||||
: null;
|
||||
|
||||
const rightPanelContent = rightZone
|
||||
? renderZoneView('right', rightZone, { hideTabBar: rightUsesTabs })
|
||||
: null;
|
||||
|
||||
const leftPanel = leftPanelContent && leftUsesTabs && leftZone
|
||||
? (
|
||||
<WidgetSidePanel tabs={renderSideTabBar('left', leftZone)}>
|
||||
{leftPanelContent}
|
||||
</WidgetSidePanel>
|
||||
)
|
||||
: leftPanelContent;
|
||||
|
||||
const rightPanel = rightPanelContent && rightUsesTabs && rightZone
|
||||
? (
|
||||
<WidgetSidePanel tabs={renderSideTabBar('right', rightZone)}>
|
||||
{rightPanelContent}
|
||||
</WidgetSidePanel>
|
||||
)
|
||||
: rightPanelContent;
|
||||
|
||||
const centerPanel = renderZoneView('center', centerZone, { hideTabBar: centerUsesTabs });
|
||||
|
||||
const footerPanel = bottomZone
|
||||
? (
|
||||
<div className={`wd-footer-zone${bottomUsesTabs ? ' wd-footer-zone--tabs' : ''}`}>
|
||||
{bottomUsesTabs && bottomZone && (
|
||||
<WidgetZoneTabBar
|
||||
tabs={bottomZone.tabs ?? []}
|
||||
activeTabId={getActiveTab('bottom', bottomZone)}
|
||||
onSelect={tabId => setActiveTab('bottom', tabId)}
|
||||
variant="panel"
|
||||
/>
|
||||
)}
|
||||
{renderZoneView('bottom', bottomZone, { hideTabBar: bottomUsesTabs })}
|
||||
</div>
|
||||
)
|
||||
: null;
|
||||
|
||||
const centerHead = centerUsesTabs && centerZone
|
||||
? (
|
||||
<div className="wd-center-head-tabs">
|
||||
<WidgetZoneTabBar
|
||||
tabs={centerZone.tabs ?? []}
|
||||
activeTabId={getActiveTab('center', centerZone)}
|
||||
onSelect={tabId => setActiveTab('center', tabId)}
|
||||
variant="panel"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
: undefined;
|
||||
|
||||
const headerActions = (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="wd-toolbar-btn"
|
||||
onClick={() => setLayoutEditorOpen(true)}
|
||||
>
|
||||
화면 구성
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
|
||||
const storageKeys = useMemo(() => ({
|
||||
left: 'wd-left-width',
|
||||
right: 'wd-right-width',
|
||||
footer: 'wd-footer-height',
|
||||
leftCollapsed: 'wd-left-open',
|
||||
rightCollapsed: 'wd-right-open',
|
||||
}), []);
|
||||
|
||||
useEffect(() => {
|
||||
const { panelSizes, collapsed } = layout;
|
||||
if (panelSizes.leftWidth != null) storeSize(storageKeys.left, panelSizes.leftWidth);
|
||||
if (panelSizes.rightWidth != null) storeSize(storageKeys.right, panelSizes.rightWidth);
|
||||
if (panelSizes.footerHeight != null) storeSize(storageKeys.footer, panelSizes.footerHeight);
|
||||
if (collapsed.left != null) storeBool(storageKeys.leftCollapsed, !collapsed.left);
|
||||
if (collapsed.right != null) storeBool(storageKeys.rightCollapsed, !collapsed.right);
|
||||
}, [layout, storageKeys]);
|
||||
|
||||
const handlePanelLayoutChange = useCallback((patch: {
|
||||
leftWidth?: number;
|
||||
rightWidth?: number;
|
||||
footerHeight?: number;
|
||||
leftOpen?: boolean;
|
||||
rightOpen?: boolean;
|
||||
}) => {
|
||||
onLayoutChange({
|
||||
...layout,
|
||||
panelSizes: {
|
||||
...layout.panelSizes,
|
||||
...(patch.leftWidth != null ? { leftWidth: patch.leftWidth } : {}),
|
||||
...(patch.rightWidth != null ? { rightWidth: patch.rightWidth } : {}),
|
||||
...(patch.footerHeight != null ? { footerHeight: patch.footerHeight } : {}),
|
||||
},
|
||||
collapsed: {
|
||||
...layout.collapsed,
|
||||
...(patch.leftOpen != null ? { left: !patch.leftOpen } : {}),
|
||||
...(patch.rightOpen != null ? { right: !patch.rightOpen } : {}),
|
||||
},
|
||||
});
|
||||
}, [layout, onLayoutChange]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<BuilderPageShell
|
||||
theme={theme}
|
||||
title="위젯"
|
||||
subtitle="Widget Dashboard"
|
||||
pageClassName={pageClass}
|
||||
headerActions={headerActions}
|
||||
collapsiblePanels
|
||||
leftStorageKey={storageKeys.left}
|
||||
leftDefaultWidth={layout.panelSizes.leftWidth ?? 280}
|
||||
leftCollapsedStorageKey={storageKeys.leftCollapsed}
|
||||
rightStorageKey={storageKeys.right}
|
||||
rightDefaultWidth={layout.panelSizes.rightWidth ?? 320}
|
||||
rightCollapsedStorageKey={storageKeys.rightCollapsed}
|
||||
footerStorageKey={storageKeys.footer}
|
||||
onPanelLayoutChange={handlePanelLayoutChange}
|
||||
left={leftPanel ?? <span />}
|
||||
centerHead={centerHead}
|
||||
center={centerPanel}
|
||||
right={rightPanel ?? undefined}
|
||||
footer={footerPanel ?? undefined}
|
||||
footerLabel={bottomUsesTabs ? undefined : '하단'}
|
||||
leftTitle={layout.zones.left && !leftUsesTabs ? '좌측' : undefined}
|
||||
rightTitle={layout.zones.right && !rightUsesTabs ? '우측' : undefined}
|
||||
/>
|
||||
<WidgetPickerModal
|
||||
open={pickTarget != null}
|
||||
category={pickCategory}
|
||||
onClose={() => setPickTarget(null)}
|
||||
onSelect={applyWidgetType}
|
||||
/>
|
||||
<LayoutEditorModal
|
||||
open={layoutEditorOpen}
|
||||
layout={layout}
|
||||
onClose={() => setLayoutEditorOpen(false)}
|
||||
onApply={draft => onLayoutChange(draft, { immediate: true })}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default WidgetDashboardShell;
|
||||
|
||||
export { defaultWidgetLayout };
|
||||
@@ -0,0 +1,73 @@
|
||||
import React from 'react';
|
||||
import AppPopup from '../AppPopup';
|
||||
import type { WidgetCategory } from '../../types/widgetDashboard';
|
||||
import { widgetsByGroup, widgetsByGroupAll } from '../../widgets/widgetRegistry';
|
||||
|
||||
export type WidgetPickerCategory = WidgetCategory | 'all';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
category: WidgetPickerCategory;
|
||||
onClose: () => void;
|
||||
onSelect: (widgetType: string) => void;
|
||||
}
|
||||
|
||||
const WidgetPickerModal: React.FC<Props> = ({
|
||||
open,
|
||||
category,
|
||||
onClose,
|
||||
onSelect,
|
||||
}) => {
|
||||
if (!open) return null;
|
||||
|
||||
const groups = category === 'all' ? widgetsByGroupAll() : widgetsByGroup(category);
|
||||
const title = category === 'center'
|
||||
? '메인 위젯'
|
||||
: category === 'side'
|
||||
? '패널 위젯'
|
||||
: '위젯 선택';
|
||||
const intro = category === 'center'
|
||||
? '중앙 영역에 표시할 메인 위젯을 선택하세요.'
|
||||
: category === 'side'
|
||||
? '좌·우·하단 패널에 추가할 위젯을 선택하세요. 주식 HTS/위젯 프로그램에서 흔히 쓰는 구성을 지원합니다.'
|
||||
: '추가할 위젯 유형을 선택하세요. 차트·시세·매매·포트폴리오 등 모든 위젯을 사용할 수 있습니다.';
|
||||
|
||||
return (
|
||||
<AppPopup
|
||||
onClose={onClose}
|
||||
title={title}
|
||||
titleKo="Widget"
|
||||
width={620}
|
||||
maxWidth="96vw"
|
||||
backdrop
|
||||
closeOnBackdrop
|
||||
zIndex={13200}
|
||||
bodyClassName="wd-picker-popup-body"
|
||||
>
|
||||
<p className="wd-layout-intro">{intro}</p>
|
||||
{groups.map(g => (
|
||||
<section key={g.group} className="wd-picker-section">
|
||||
<h3 className="wd-picker-section-title">{g.label}</h3>
|
||||
<div className="wd-picker-grid">
|
||||
{g.items.map(w => (
|
||||
<button
|
||||
key={w.type}
|
||||
type="button"
|
||||
className="wd-picker-item"
|
||||
onClick={() => {
|
||||
onSelect(w.type);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<span className="wd-picker-item-label">{w.label}</span>
|
||||
<span className="wd-picker-item-desc">{w.description}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</AppPopup>
|
||||
);
|
||||
};
|
||||
|
||||
export default WidgetPickerModal;
|
||||
@@ -0,0 +1,18 @@
|
||||
import React from 'react';
|
||||
|
||||
interface SidePanelProps {
|
||||
tabs?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/** 좌·우 side zone — se-palette-panel 과 동일 카드 + 상단 탭 */
|
||||
export function WidgetSidePanel({ tabs, children }: SidePanelProps) {
|
||||
return (
|
||||
<div className="wd-side-panel">
|
||||
{tabs}
|
||||
<div className="wd-side-panel-body">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default WidgetSidePanel;
|
||||
@@ -0,0 +1,98 @@
|
||||
import React from 'react';
|
||||
import type { WidgetSlot as WidgetSlotModel } from '../../types/widgetDashboard';
|
||||
import type { WidgetZoneId } from '../../types/widgetDashboard';
|
||||
import { getWidgetDefinition, widgetLabel } from '../../widgets/widgetRegistry';
|
||||
|
||||
interface Props {
|
||||
slot: WidgetSlotModel;
|
||||
zoneId: WidgetZoneId;
|
||||
onPickWidget: (slotId: string) => void;
|
||||
onClearWidget?: (slotId: string) => void;
|
||||
}
|
||||
|
||||
const WidgetSlotView: React.FC<Props> = ({
|
||||
slot,
|
||||
zoneId,
|
||||
onPickWidget,
|
||||
onClearWidget,
|
||||
}) => {
|
||||
const def = getWidgetDefinition(slot.widgetType);
|
||||
const category = zoneId === 'center' ? 'center' : 'side';
|
||||
const Host = def?.Host;
|
||||
const filledEmbed = Boolean(Host);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={[
|
||||
'wd-slot',
|
||||
filledEmbed ? 'wd-slot--filled' : '',
|
||||
filledEmbed && zoneId === 'center' ? 'wd-slot--filled-center' : '',
|
||||
].filter(Boolean).join(' ')}
|
||||
>
|
||||
{!filledEmbed && (
|
||||
<header className="wd-slot-head">
|
||||
<span className="wd-slot-title">{widgetLabel(slot.widgetType)}</span>
|
||||
<div className="wd-slot-actions">
|
||||
{slot.widgetType && onClearWidget && (
|
||||
<button
|
||||
type="button"
|
||||
className="wd-slot-action"
|
||||
title="위젯 제거"
|
||||
aria-label="위젯 제거"
|
||||
onClick={() => onClearWidget(slot.id)}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
)}
|
||||
<div className="wd-slot-body">
|
||||
{Host ? (
|
||||
<Host config={slot.config} />
|
||||
) : (
|
||||
<div className="wd-slot-empty">
|
||||
<button
|
||||
type="button"
|
||||
className="wd-slot-add"
|
||||
title="위젯 추가"
|
||||
aria-label="위젯 추가"
|
||||
onClick={() => onPickWidget(slot.id)}
|
||||
>
|
||||
<span className="wd-slot-add-icon">+</span>
|
||||
</button>
|
||||
<p className="wd-slot-empty-hint">
|
||||
{category === 'center' ? '메인 위젯을 추가하세요' : '패널 위젯을 추가하세요'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{Host && (
|
||||
<>
|
||||
{filledEmbed && onClearWidget && (
|
||||
<button
|
||||
type="button"
|
||||
className="wd-slot-overlay-clear"
|
||||
title="위젯 제거"
|
||||
aria-label="위젯 제거"
|
||||
onClick={() => onClearWidget(slot.id)}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="wd-slot-change"
|
||||
title="위젯 변경"
|
||||
aria-label="위젯 변경"
|
||||
onClick={() => onPickWidget(slot.id)}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WidgetSlotView;
|
||||
@@ -0,0 +1,172 @@
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import type {
|
||||
WidgetSlot,
|
||||
WidgetTabConfig,
|
||||
WidgetZoneConfig,
|
||||
WidgetZoneId,
|
||||
} from '../../types/widgetDashboard';
|
||||
import { newWidgetTab } from '../../types/widgetDashboard';
|
||||
import WidgetSlotView from './WidgetSlotView';
|
||||
|
||||
interface Props {
|
||||
zoneId: WidgetZoneId;
|
||||
zone: WidgetZoneConfig;
|
||||
onChange: (zone: WidgetZoneConfig) => void;
|
||||
onPickWidget: (slotId: string, zoneId: WidgetZoneId, tabId?: string) => void;
|
||||
/** 패널 헤더 탭 — Shell에서 TabBar를 렌더할 때 true */
|
||||
hideTabBar?: boolean;
|
||||
activeTabId?: string | null;
|
||||
onActiveTabChange?: (tabId: string) => void;
|
||||
}
|
||||
|
||||
type TabBarVariant = 'left' | 'right' | 'panel' | 'side';
|
||||
|
||||
interface TabBarProps {
|
||||
tabs: WidgetTabConfig[];
|
||||
activeTabId: string | null;
|
||||
onSelect: (tabId: string) => void;
|
||||
variant?: TabBarVariant;
|
||||
}
|
||||
|
||||
/** 전략 편집기 se-right-tabs 스타일 (지표 | 템플릿) */
|
||||
export const WidgetZoneTabBar: React.FC<TabBarProps> = ({
|
||||
tabs,
|
||||
activeTabId,
|
||||
onSelect,
|
||||
variant = 'left',
|
||||
}) => {
|
||||
const isSide = variant === 'left' || variant === 'right' || variant === 'side';
|
||||
const wrapClass = isSide ? 'se-right-tabs wd-side-tabs' : 'se-right-tabs wd-panel-tabs';
|
||||
|
||||
return (
|
||||
<div className={wrapClass} role="tablist">
|
||||
{tabs.map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeTabId === tab.id}
|
||||
className={`se-right-tab${activeTabId === tab.id ? ' se-right-tab--on' : ''}`}
|
||||
onClick={() => onSelect(tab.id)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export function resolveActiveTabId(
|
||||
zone: WidgetZoneConfig | null | undefined,
|
||||
preferredId: string | null | undefined,
|
||||
): string | null {
|
||||
if (!zone || zone.mode !== 'tabs') return null;
|
||||
const tabs = zone.tabs ?? [];
|
||||
if (tabs.length === 0) return null;
|
||||
if (preferredId && tabs.some(t => t.id === preferredId)) return preferredId;
|
||||
return tabs[0].id;
|
||||
}
|
||||
|
||||
const WidgetZoneView: React.FC<Props> = ({
|
||||
zoneId,
|
||||
zone,
|
||||
onChange,
|
||||
onPickWidget,
|
||||
hideTabBar = false,
|
||||
activeTabId: activeTabIdProp,
|
||||
onActiveTabChange,
|
||||
}) => {
|
||||
const activeTabId = useMemo(
|
||||
() => resolveActiveTabId(zone, activeTabIdProp),
|
||||
[zone, activeTabIdProp],
|
||||
);
|
||||
|
||||
const updateSlot = useCallback((
|
||||
slotId: string,
|
||||
updater: (s: WidgetSlot) => WidgetSlot,
|
||||
tabId?: string,
|
||||
) => {
|
||||
if (zone.mode === 'tabs' && tabId) {
|
||||
onChange({
|
||||
...zone,
|
||||
tabs: (zone.tabs ?? []).map(tab =>
|
||||
tab.id === tabId
|
||||
? { ...tab, slots: tab.slots.map(s => (s.id === slotId ? updater(s) : s)) }
|
||||
: tab,
|
||||
),
|
||||
});
|
||||
return;
|
||||
}
|
||||
onChange({
|
||||
...zone,
|
||||
slots: (zone.slots ?? []).map(s => (s.id === slotId ? updater(s) : s)),
|
||||
});
|
||||
}, [zone, onChange]);
|
||||
|
||||
const clearWidget = useCallback((slotId: string, tabId?: string) => {
|
||||
updateSlot(slotId, s => ({ ...s, widgetType: null, config: {} }), tabId);
|
||||
}, [updateSlot]);
|
||||
|
||||
const renderSlots = (slots: WidgetSlot[], tabId?: string) => (
|
||||
<div
|
||||
className={`wd-zone-slots wd-zone-slots--${slots.length}`}
|
||||
data-split={slots.length}
|
||||
>
|
||||
{slots.map(slot => (
|
||||
<WidgetSlotView
|
||||
key={slot.id}
|
||||
slot={slot}
|
||||
zoneId={zoneId}
|
||||
onPickWidget={id => onPickWidget(id, zoneId, tabId)}
|
||||
onClearWidget={id => clearWidget(id, tabId)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (zone.mode === 'tabs') {
|
||||
const tabs = zone.tabs ?? [];
|
||||
const activeTab = tabs.find(t => t.id === activeTabId) ?? tabs[0];
|
||||
|
||||
return (
|
||||
<div className="wd-zone wd-zone--tabs">
|
||||
{!hideTabBar && (
|
||||
<WidgetZoneTabBar
|
||||
tabs={tabs}
|
||||
activeTabId={activeTabId}
|
||||
onSelect={id => onActiveTabChange?.(id)}
|
||||
variant="side"
|
||||
/>
|
||||
)}
|
||||
{activeTab && renderSlots(activeTab.slots, activeTab.id)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const slots = zone.slots ?? [];
|
||||
return (
|
||||
<div className={`wd-zone wd-zone--${zone.mode}`}>
|
||||
{renderSlots(slots)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WidgetZoneView;
|
||||
|
||||
export function addTabToZone(zone: WidgetZoneConfig, label = '탭'): WidgetZoneConfig {
|
||||
const tab = newWidgetTab(label, 1);
|
||||
return {
|
||||
...zone,
|
||||
mode: 'tabs',
|
||||
tabs: [...(zone.tabs ?? []), tab],
|
||||
slots: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function removeTabFromZone(zone: WidgetZoneConfig, tabId: string): WidgetZoneConfig {
|
||||
const tabs = (zone.tabs ?? []).filter(t => t.id !== tabId);
|
||||
if (tabs.length === 0) {
|
||||
return { mode: 'single', splitCount: 1, slots: zone.slots ?? [] };
|
||||
}
|
||||
return { ...zone, tabs };
|
||||
}
|
||||
Reference in New Issue
Block a user