mobile download

This commit is contained in:
Macbook
2026-05-28 14:44:19 +09:00
parent e2816b037f
commit 3503ef33f5
152 changed files with 11021 additions and 687 deletions
+106
View File
@@ -0,0 +1,106 @@
import React, { useEffect } from 'react';
interface BottomSheetProps {
open: boolean;
title?: string;
onClose: () => void;
children: React.ReactNode;
height?: 'auto' | 'half' | 'full';
}
export default function BottomSheet({
open,
title,
onClose,
children,
height = 'auto',
}: BottomSheetProps) {
useEffect(() => {
if (!open) return;
const prev = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => { document.body.style.overflow = prev; };
}, [open]);
if (!open) return null;
return (
<div className="bs-root" role="dialog" aria-modal="true">
<button type="button" className="bs-backdrop" aria-label="닫기" onClick={onClose} />
<div className={`bs-panel bs-${height}`}>
<div className="bs-handle" />
{title && (
<div className="bs-header">
<h2>{title}</h2>
<button type="button" className="bs-close" onClick={onClose} aria-label="닫기"></button>
</div>
)}
<div className="bs-body">{children}</div>
</div>
<style>{`
.bs-root {
position: fixed;
inset: 0;
z-index: 150;
display: flex;
flex-direction: column;
justify-content: flex-end;
}
.bs-backdrop {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.55);
border: none;
min-height: auto;
}
.bs-panel {
position: relative;
background: var(--gc-bg-elevated);
border-radius: 20px 20px 0 0;
max-height: 90vh;
display: flex;
flex-direction: column;
animation: bsUp 0.28s ease;
}
.bs-auto { max-height: 70vh; }
.bs-half { height: 55vh; }
.bs-full { height: calc(100% - var(--gc-safe-top) - 24px); }
.bs-handle {
width: 36px;
height: 4px;
background: var(--gc-border);
border-radius: 2px;
margin: 8px auto 4px;
}
.bs-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 16px 12px;
border-bottom: 1px solid var(--gc-border);
}
.bs-header h2 {
font-size: 17px;
font-weight: 600;
}
.bs-close {
min-height: 36px;
min-width: 36px;
font-size: 18px;
color: var(--gc-text-muted);
}
.bs-body {
flex: 1;
overflow-y: auto;
padding: 16px;
padding-bottom: calc(16px + var(--gc-safe-bottom));
-webkit-overflow-scrolling: touch;
}
@keyframes bsUp {
from { transform: translateY(100%); }
to { transform: translateY(0); }
}
`}</style>
</div>
);
}
+37
View File
@@ -0,0 +1,37 @@
import { useEffect } from 'react';
import { loadVirtualTargets, loadVirtualSession } from '@frontend/utils/virtualTradingStorage';
import { useLiveStrategyMarkers } from '@frontend/hooks/useLiveStrategyMarkers';
import { useTradeNotification } from '../contexts/TradeNotificationContext';
/** 가상매매 대상 종목 STOMP 시그널 → 알림 Context */
export default function LiveSignalBridge() {
const { addNotification, refreshHistory } = useTradeNotification();
const targets = loadVirtualTargets();
const session = loadVirtualSession();
const markets = targets.map(t => t.market);
useLiveStrategyMarkers({
markets,
activeMarket: markets[0] ?? 'KRW-BTC',
enabled: session.running && markets.length > 0,
onMarkersChange: () => {},
onSignal: marker => {
addNotification({
market: marker.market,
signalType: marker.signal,
price: marker.price,
candleTime: marker.time,
candleType: marker.candleType ?? '1m',
});
window.setTimeout(() => { void refreshHistory(); }, 800);
},
});
useEffect(() => {
if (!session.running) return;
const id = window.setInterval(() => { void refreshHistory(); }, 20000);
return () => window.clearInterval(id);
}, [session.running, refreshHistory]);
return null;
}
+56
View File
@@ -0,0 +1,56 @@
import React from 'react';
interface SegmentedControlProps<T extends string> {
options: { value: T; label: string }[];
value: T;
onChange: (v: T) => void;
size?: 'sm' | 'md';
}
export default function SegmentedControl<T extends string>({
options,
value,
onChange,
size = 'md',
}: SegmentedControlProps<T>) {
return (
<div className={`seg seg-${size}`} role="tablist">
{options.map(opt => (
<button
key={opt.value}
type="button"
role="tab"
aria-selected={value === opt.value}
className={`seg-item${value === opt.value ? ' active' : ''}`}
onClick={() => onChange(opt.value)}
>
{opt.label}
</button>
))}
<style>{`
.seg {
display: flex;
background: rgba(255,255,255,0.06);
border-radius: 10px;
padding: 3px;
gap: 2px;
}
.seg-sm .seg-item { font-size: 11px; padding: 6px 8px; }
.seg-md .seg-item { font-size: 12px; padding: 8px 10px; }
.seg-item {
flex: 1;
border-radius: 8px;
color: var(--gc-text-muted);
font-weight: 500;
min-height: 32px;
transition: background 0.15s, color 0.15s;
}
.seg-item.active {
background: var(--gc-accent-soft);
color: var(--gc-accent);
font-weight: 600;
}
`}</style>
</div>
);
}
+101
View File
@@ -0,0 +1,101 @@
import React from 'react';
interface SettingRowProps {
label: string;
description?: string;
children: React.ReactNode;
}
export function SettingGroup({ title, children }: { title?: string; children: React.ReactNode }) {
return (
<section className="setting-group">
{title && <h3 className="setting-group-title">{title}</h3>}
<div className="setting-group-body">{children}</div>
<style>{`
.setting-group { margin-bottom: 24px; }
.setting-group-title {
font-size: 13px;
font-weight: 600;
color: var(--gc-text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 0 16px 8px;
}
.setting-group-body {
background: var(--gc-bg-elevated);
border-radius: var(--gc-radius-md);
margin: 0 16px;
overflow: hidden;
border: 1px solid var(--gc-border);
}
`}</style>
</section>
);
}
export function SettingRow({ label, description, children }: SettingRowProps) {
return (
<div className="setting-row">
<div className="setting-row-text">
<span className="setting-row-label">{label}</span>
{description && <span className="setting-row-desc">{description}</span>}
</div>
<div className="setting-row-control">{children}</div>
<style>{`
.setting-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 16px;
border-bottom: 1px solid var(--gc-border);
min-height: var(--gc-touch);
}
.setting-row:last-child { border-bottom: none; }
.setting-row-text { flex: 1; min-width: 0; }
.setting-row-label { display: block; font-size: 15px; font-weight: 500; }
.setting-row-desc { display: block; font-size: 12px; color: var(--gc-text-muted); margin-top: 2px; }
.setting-row-control { flex-shrink: 0; }
`}</style>
</div>
);
}
export function Toggle({ checked, onChange, label }: { checked: boolean; onChange: (v: boolean) => void; label?: string }) {
return (
<button
type="button"
role="switch"
aria-checked={checked}
aria-label={label}
className={`toggle${checked ? ' on' : ''}`}
onClick={() => onChange(!checked)}
>
<span className="toggle-thumb" />
<style>{`
.toggle {
width: 50px;
height: 30px;
border-radius: 15px;
background: rgba(255,255,255,0.15);
position: relative;
min-height: 30px;
transition: background 0.2s;
}
.toggle.on { background: var(--gc-accent); }
.toggle-thumb {
position: absolute;
top: 3px;
left: 3px;
width: 24px;
height: 24px;
border-radius: 50%;
background: #fff;
transition: transform 0.2s;
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
}
.toggle.on .toggle-thumb { transform: translateX(20px); }
`}</style>
</button>
);
}
+91
View File
@@ -0,0 +1,91 @@
import React from 'react';
export type TabId = 'virtual' | 'strategy' | 'notifications' | 'settings';
interface TabBarProps {
active: TabId;
onChange: (tab: TabId) => void;
unreadCount: number;
}
const TABS: { id: TabId; label: string; icon: string }[] = [
{ id: 'virtual', label: '가상매매', icon: '📊' },
{ id: 'strategy', label: '전략', icon: '🔀' },
{ id: 'notifications', label: '알림', icon: '🔔' },
{ id: 'settings', label: '설정', icon: '⚙️' },
];
export default function TabBar({ active, onChange, unreadCount }: TabBarProps) {
return (
<nav className="tab-bar" role="tablist">
{TABS.map(tab => (
<button
key={tab.id}
type="button"
role="tab"
aria-selected={active === tab.id}
className={`tab-bar-item${active === tab.id ? ' active' : ''}`}
onClick={() => onChange(tab.id)}
>
<span className="tab-bar-icon" aria-hidden>
{tab.icon}
{tab.id === 'notifications' && unreadCount > 0 && (
<span className="tab-bar-badge">{unreadCount > 99 ? '99+' : unreadCount}</span>
)}
</span>
<span className="tab-bar-label">{tab.label}</span>
</button>
))}
<style>{`
.tab-bar {
display: flex;
height: calc(var(--gc-tab-height) + var(--gc-safe-bottom));
padding-bottom: var(--gc-safe-bottom);
background: var(--gc-bg-elevated);
border-top: 1px solid var(--gc-border);
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 100;
}
.tab-bar-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
color: var(--gc-text-dim);
min-height: var(--gc-tab-height);
}
.tab-bar-item.active { color: var(--gc-accent); }
.tab-bar-icon {
position: relative;
font-size: 20px;
line-height: 1;
}
.tab-bar-badge {
position: absolute;
top: -6px;
right: -10px;
min-width: 16px;
height: 16px;
padding: 0 4px;
border-radius: 8px;
background: var(--gc-red);
color: #fff;
font-size: 10px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
}
.tab-bar-label {
font-size: 10px;
font-weight: 600;
}
`}</style>
</nav>
);
}