위젯 기능 추가

This commit is contained in:
Macbook
2026-06-14 01:03:51 +09:00
parent 95595f7e9e
commit 6936792ad4
57 changed files with 6728 additions and 540 deletions
+7
View File
@@ -60,6 +60,7 @@ import {
type ChartPaneSeparatorOptions,
} from '../types/chartPaneSeparator';
import { migrateLocalStorageToUiPreferences } from '../utils/uiPreferencesMigrate';
import { reconcilePendingUiPreferencesOnLoad } from '../utils/uiPreferencesDb';
import { loadLocalTheme } from '../utils/localThemeStorage';
// 전역 캐시 — 여러 컴포넌트에서 공유하여 중복 요청 방지
@@ -79,6 +80,11 @@ export function getAppSettingsCache(): AppSettingsDto {
return _cache ?? {};
}
/** DB에서 앱 설정 로드가 완료되었는지 (null 캐시 = 미로드) */
export function isAppSettingsCacheLoaded(): boolean {
return _cache !== null;
}
/** 낙관적 캐시 패치 — _cache 가 null 일 때도 다음 로드 전까지 임시 보관 */
export function patchAppSettingsCache(patch: Partial<AppSettingsDto>): void {
if (_cache !== null) {
@@ -102,6 +108,7 @@ function ensureLoaded(): Promise<AppSettingsDto> {
return _cache ?? {};
}
_cache = data ?? {};
reconcilePendingUiPreferencesOnLoad();
const migrated = migrateLocalStorageToUiPreferences(_cache);
if (migrated?.changed) {
try {
+1
View File
@@ -116,6 +116,7 @@ export function useDraggablePanel(options: UseDraggablePanelOptions = {}) {
return {
panelRef: internalRef,
pos,
setPos,
dragging,
/** @deprecated onHeaderPointerDown 사용 */
onHeaderMouseDown: onHeaderPointerDown,
@@ -0,0 +1,124 @@
import { useCallback, useEffect, useRef, type RefObject } from 'react';
import {
bindWindowDrag,
isPrimaryPointerButton,
} from '../utils/pointerDrag';
export interface Size2d {
width: number;
height: number;
}
export interface Position2d {
x: number;
y: number;
}
export interface FloatingWidgetResizeResult {
width: number;
height: number;
x?: number;
}
export type FloatingWidgetResizeCorner = 'se' | 'sw';
function clampSize(
width: number,
height: number,
minSize: Size2d,
maxW: number,
maxH: number,
): Size2d {
return {
width: Math.round(Math.max(minSize.width, Math.min(maxW, width))),
height: Math.round(Math.max(minSize.height, Math.min(maxH, height))),
};
}
export function useFloatingWidgetResize(
panelRef: RefObject<HTMLElement | null>,
size: Size2d,
minSize: Size2d,
onResize: (result: FloatingWidgetResizeResult) => void,
) {
const unbindDragRef = useRef<(() => void) | null>(null);
const endResize = useCallback(() => {
unbindDragRef.current?.();
unbindDragRef.current = null;
}, []);
useEffect(() => () => {
unbindDragRef.current?.();
}, []);
const startResize = useCallback((
e: React.PointerEvent,
corner: FloatingWidgetResizeCorner,
) => {
if (!isPrimaryPointerButton(e)) return;
e.preventDefault();
e.stopPropagation();
const handle = e.currentTarget as HTMLElement;
try {
handle.setPointerCapture(e.pointerId);
} catch {
/* ignore */
}
const el = panelRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
const startX = e.clientX;
const startY = e.clientY;
const startW = size.width;
const startH = size.height;
const startRight = rect.right;
const maxH = window.innerHeight - rect.top - 8;
unbindDragRef.current?.();
unbindDragRef.current = bindWindowDrag(
(xy) => {
if (corner === 'se') {
const maxW = window.innerWidth - rect.left - 8;
const next = clampSize(
startW + xy.x - startX,
startH + xy.y - startY,
minSize,
maxW,
maxH,
);
onResize(next);
return;
}
/* sw — 우측 가장자리 고정, 좌측·하단 조절 */
let width = startRight - xy.x;
let x = xy.x;
if (x < 8) {
x = 8;
width = startRight - 8;
}
const maxW = startRight - 8;
const next = clampSize(width, startH + xy.y - startY, minSize, maxW, maxH);
onResize({ ...next, x: Math.round(startRight - next.width) });
},
endResize,
{ preventTouchScroll: true },
);
}, [panelRef, size.width, size.height, minSize, onResize, endResize]);
const onResizeSePointerDown = useCallback(
(e: React.PointerEvent) => startResize(e, 'se'),
[startResize],
);
const onResizeSwPointerDown = useCallback(
(e: React.PointerEvent) => startResize(e, 'sw'),
[startResize],
);
return { onResizeSePointerDown, onResizeSwPointerDown };
}
+7 -4
View File
@@ -11,7 +11,7 @@ export interface RecentTrade {
time: number;
}
const MAX_TRADES = 12;
const MAX_TRADES_DEFAULT = 12;
interface UpbitTradeRaw {
market: string;
@@ -30,9 +30,11 @@ interface UpbitTradeWs {
timestamp: number;
}
export function useUpbitRecentTrades(market: string, enabled = true) {
export function useUpbitRecentTrades(market: string, enabled = true, maxTrades = MAX_TRADES_DEFAULT) {
const [trades, setTrades] = useState<RecentTrade[]>([]);
const [strength, setStrength] = useState<number | null>(null);
const maxRef = useRef(maxTrades);
maxRef.current = maxTrades;
const mountedRef = useRef(true);
const marketRef = useRef(market);
@@ -40,7 +42,8 @@ export function useUpbitRecentTrades(market: string, enabled = true) {
const pushTrade = useCallback((t: RecentTrade) => {
setTrades(prev => {
const next = [t, ...prev].slice(0, MAX_TRADES);
const cap = maxRef.current;
const next = [t, ...prev].slice(0, cap);
const buyVol = next.filter(x => x.side === 'bid').reduce((s, x) => s + x.volume, 0);
const sellVol = next.filter(x => x.side === 'ask').reduce((s, x) => s + x.volume, 0);
if (sellVol > 0) setStrength((buyVol / sellVol) * 100);
@@ -51,7 +54,7 @@ export function useUpbitRecentTrades(market: string, enabled = true) {
const fetchSnapshot = useCallback(async () => {
try {
const res = await fetch(`/upbit-api/v1/trades/ticks?market=${marketRef.current}&count=${MAX_TRADES}`);
const res = await fetch(`/upbit-api/v1/trades/ticks?market=${marketRef.current}&count=${maxRef.current}`);
if (!res.ok) return;
const data: UpbitTradeRaw[] = await res.json();
const list: RecentTrade[] = data.map(d => ({