113616b039
Release / release (push) Successful in 3m19s
Stash the full claude.ai/design output (12 JSX variants — brutalist, classic, cli, compact, fluent-live, glass, hero-live, minimal, sketches, studio, wizard-live, workshop — plus shared hooks and a standalone HTML preview) for reference when we get to the Wails frontend in Phase 6/7. Source archive: C:\Users\root\Downloads\app(1).zip (~1MB). Not wired into any build target yet — current GUI is the temporary MessageBox stub. Pulling these in is the goal of the Wails phase. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
425 lines
20 KiB
React
425 lines
20 KiB
React
// drover-hero-live.jsx — Big toggle / VPN-style variant.
|
|
// Centerpiece: a giant connect button. Form lives in an expandable section below.
|
|
// Compact form (host:port) shown collapsed in active state; full form in idle.
|
|
|
|
const HERO_THEME = {
|
|
d: {
|
|
bg: 'radial-gradient(ellipse at top, #142340 0%, #0e1426 60%, #080d1c 100%)',
|
|
chrome: 'transparent',
|
|
panel: '#15192a',
|
|
panelAlt: '#1a1f33',
|
|
border: 'rgba(255,255,255,.08)',
|
|
borderHard:'rgba(255,255,255,.18)',
|
|
text: '#ffffff',
|
|
textDim: '#cbd1d9',
|
|
dim: '#7a8499',
|
|
dimmer: '#5a6178',
|
|
accent: '#5dd4b3',
|
|
accentDeep:'#2da085',
|
|
accentDarker:'#1a6e5b',
|
|
danger: '#ff6b6b',
|
|
warn: '#f3c764',
|
|
pass: '#5dd4b3',
|
|
skip: '#7a8499',
|
|
},
|
|
l: {
|
|
bg: 'linear-gradient(180deg, #f3f7fb 0%, #e8edf4 100%)',
|
|
chrome: 'transparent',
|
|
panel: '#ffffff',
|
|
panelAlt: '#f5f7fb',
|
|
border: 'rgba(0,0,0,.07)',
|
|
borderHard:'rgba(0,0,0,.15)',
|
|
text: '#0e1830',
|
|
textDim: '#3a4356',
|
|
dim: '#6a7383',
|
|
dimmer: '#9aa3b2',
|
|
accent: '#1aa787',
|
|
accentDeep:'#0f8c70',
|
|
accentDarker:'#0a6655',
|
|
danger: '#c0463f',
|
|
warn: '#a8731e',
|
|
pass: '#1aa787',
|
|
skip: '#7a8499',
|
|
},
|
|
};
|
|
|
|
function HeroWindow({ mode = 'dark', initial }) {
|
|
const themeKey = mode === 'dark' ? 'd' : 'l';
|
|
const t = HERO_THEME[themeKey];
|
|
const D = window.useDrover(initial);
|
|
const palette = { pending: t.dimmer, running: t.accent, passed: t.pass, failed: t.danger, skipped: t.skip };
|
|
const fontUI = "'Inter','Segoe UI',system-ui,sans-serif";
|
|
const fontMono = "'JetBrains Mono','SF Mono',ui-monospace,Consolas,monospace";
|
|
|
|
const [showForm, setShowForm] = React.useState(false);
|
|
const [showDetails, setShowDetails] = React.useState(false);
|
|
|
|
const phase = D.phase;
|
|
const summary = D.lastSummary;
|
|
const allFailed = summary && summary.failed === D.tests.length;
|
|
const failed = summary?.failed ?? 0;
|
|
const isActive = phase === 'active';
|
|
const warning = isActive && failed > 0;
|
|
|
|
// big-button click handler. Two-step: if idle/checked, go.
|
|
const handleBigClick = () => {
|
|
if (phase === 'active') return D.stopProxy();
|
|
if (phase === 'checking') return;
|
|
if (phase === 'idle') {
|
|
// run check, then auto-start if it passed
|
|
(async () => {
|
|
await D.runCheck();
|
|
})();
|
|
return;
|
|
}
|
|
if (phase === 'checked' && !allFailed) D.startProxy();
|
|
if (phase === 'checked' && allFailed) D.runCheck();
|
|
};
|
|
|
|
// After runCheck completes from idle, auto-start if all passed.
|
|
const prevPhase = React.useRef(phase);
|
|
React.useEffect(() => {
|
|
if (prevPhase.current === 'checking' && phase === 'checked') {
|
|
const sum = D.lastSummary;
|
|
if (sum && sum.failed === 0) {
|
|
// small delay so user perceives the "checked" state briefly
|
|
setTimeout(() => D.startProxy(), 600);
|
|
}
|
|
}
|
|
prevPhase.current = phase;
|
|
}, [phase]);
|
|
|
|
// Big-button label / colors
|
|
let buttonState;
|
|
if (phase === 'active') buttonState = warning ? 'active-warn' : 'active';
|
|
else if (phase === 'checking') buttonState = 'checking';
|
|
else if (phase === 'checked' && allFailed) buttonState = 'failed';
|
|
else buttonState = 'idle';
|
|
|
|
const ringColor = ({
|
|
idle: t.accent,
|
|
checking: t.accent,
|
|
active: t.accent,
|
|
'active-warn': t.warn,
|
|
failed: t.danger,
|
|
})[buttonState];
|
|
|
|
const headline = ({
|
|
idle: 'Tap to connect',
|
|
checking: 'Verifying…',
|
|
active: 'Discord is protected',
|
|
'active-warn': 'Connected · UDP fallback',
|
|
failed: 'Connection failed',
|
|
})[buttonState];
|
|
|
|
const subline = ({
|
|
idle: `via ${D.form.host}`,
|
|
checking: `running 7 checks…`,
|
|
active: `via ${D.form.host} · ${window.fmtUptime(D.stats.uptimeS)}`,
|
|
'active-warn': `voice/screen disabled · ${window.fmtUptime(D.stats.uptimeS)}`,
|
|
failed: `${failed} of ${D.tests.length} checks failed`,
|
|
})[buttonState];
|
|
|
|
return (
|
|
<div style={{
|
|
width: 480, height: 640, background: t.bg, color: t.text,
|
|
display: 'flex', flexDirection: 'column', overflow: 'hidden',
|
|
fontFamily: fontUI, fontSize: 13.5, lineHeight: 1.4,
|
|
}}>
|
|
{/* Title bar */}
|
|
<div style={{ height: 36, display: 'flex', alignItems: 'center', padding: '0 14px', fontSize: 12.5 }}>
|
|
<window.BrandMark size={14} color={t.accent} />
|
|
<span style={{ marginLeft: 10, fontWeight: 600, color: t.text }}>Drover-Go</span>
|
|
<span style={{ marginLeft: 8, fontSize: 11, color: t.dim }}>0.4.2</span>
|
|
<span style={{ marginLeft: 'auto', fontSize: 11, color: ringColor, display: 'flex', alignItems: 'center', gap: 6 }}>
|
|
{phase === 'active' && <span className="drv-pulsedot" style={{ width: 6, height: 6, borderRadius: 3, background: ringColor }} />}
|
|
{phase === 'idle' && 'idle'}
|
|
{phase === 'checking' && 'checking…'}
|
|
{phase === 'checked' && (allFailed ? 'failed' : 'ready')}
|
|
{phase === 'active' && (warning ? 'connected · warn' : 'connected')}
|
|
</span>
|
|
<div style={{ display: 'flex', marginLeft: 8 }}>
|
|
<HeroTitleBtn t={t}><window.IconGear color={t.dim} /></HeroTitleBtn>
|
|
<HeroTitleBtn t={t}><window.IconMin color={t.dim} /></HeroTitleBtn>
|
|
<HeroTitleBtn t={t} hoverBg="#c42b1c"><window.IconClose color={t.dim} /></HeroTitleBtn>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Big button + headlines */}
|
|
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', padding: '8px 18px 0', gap: 14, minHeight: 0 }}>
|
|
<BigToggle t={t} state={buttonState} onClick={handleBigClick} progress={
|
|
phase === 'checking' ? Object.keys(D.results).length / D.tests.length : 0
|
|
} />
|
|
|
|
<div style={{ textAlign: 'center' }}>
|
|
<div style={{ fontSize: 19, fontWeight: 600 }}>{headline}</div>
|
|
<div style={{ fontSize: 12.5, color: t.dim, marginTop: 4, fontFamily: fontMono }}>{subline}</div>
|
|
</div>
|
|
|
|
{/* Stats — only when active */}
|
|
{isActive && (
|
|
<div style={{
|
|
display: 'flex', width: '100%', background: t.panel,
|
|
border: `1px solid ${t.border}`, borderRadius: 12, padding: 12, justifyContent: 'space-around',
|
|
}}>
|
|
<HeroStat icon="↑" v={fmtCompact(D.stats.up)} u={fmtUnit(D.stats.up)} c={t.accent} mono={fontMono} t={t} />
|
|
<HeroStat icon="↓" v={fmtCompact(D.stats.down)} u={fmtUnit(D.stats.down)} c={t.accent} mono={fontMono} t={t} />
|
|
<HeroStat icon="◇" v={D.stats.tcp} u="tcp" c={t.text} mono={fontMono} t={t} />
|
|
<HeroStat icon="◈" v={D.stats.udp} u="udp" c={warning ? t.warn : t.text} mono={fontMono} t={t} />
|
|
</div>
|
|
)}
|
|
|
|
{/* Diagnostic mini-list during check or after */}
|
|
{(phase === 'checking' || (phase === 'checked' && !isActive)) && (
|
|
<div style={{ width: '100%', flex: 1, minHeight: 0, overflow: 'auto' }}>
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
|
{D.tests.map((test) => {
|
|
const r = D.results[test.id];
|
|
const state = r?.result || (D.running === test.id ? 'running' : 'pending');
|
|
return (
|
|
<div key={test.id}>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 9, padding: '4px 8px', minHeight: 24, fontSize: 12.5 }}>
|
|
<window.StatusDot state={state} palette={palette} size={11} />
|
|
<span style={{ color: state === 'pending' ? t.dimmer : t.textDim }} title={test.desc}>{test.label}</span>
|
|
<span style={{ marginLeft: 'auto', fontFamily: fontMono, fontSize: 11,
|
|
color: state === 'failed' ? t.danger : state === 'skipped' ? t.skip : t.dim }}>
|
|
{r?.metric || (state === 'running' ? '…' : '')}
|
|
</span>
|
|
{r?.result === 'failed' && (
|
|
<button onClick={() => D.toggleExpand(test.id)}
|
|
style={{ width: 18, height: 18, padding: 0, border: 'none', background: 'transparent', cursor: 'pointer' }}>
|
|
<window.IconChevron color={t.dim} dir={r.expanded ? 'up' : 'down'} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
{r?.result === 'failed' && r.expanded && (
|
|
<div className="drv-fadein" style={{
|
|
margin: '2px 8px 4px 28px', padding: '8px 10px', borderRadius: 6,
|
|
background: themeKey === 'd' ? 'rgba(255,107,107,.08)' : '#fdf2f2',
|
|
border: `1px solid ${t.danger}`, fontSize: 12,
|
|
}}>
|
|
<div style={{ color: t.danger, fontWeight: 600, marginBottom: 2 }}>{r.error}</div>
|
|
<div style={{ color: t.dim }}>{r.hint}</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Bottom: form (idle) or footer chips (active) */}
|
|
<div style={{ padding: '0 18px 14px' }}>
|
|
<div style={{
|
|
background: t.panel, border: `1px solid ${t.border}`, borderRadius: 12,
|
|
overflow: 'hidden',
|
|
}}>
|
|
<button onClick={() => setShowForm(s => !s)}
|
|
style={{
|
|
width: '100%', padding: '11px 14px', display: 'flex', alignItems: 'center', gap: 10,
|
|
background: 'transparent', border: 'none', cursor: 'pointer', color: t.text,
|
|
fontSize: 12.5, fontFamily: fontUI, textAlign: 'left',
|
|
}}>
|
|
<span style={{
|
|
width: 26, height: 26, borderRadius: 13, background: t.panelAlt,
|
|
border: `1px solid ${t.border}`, display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
|
fontFamily: fontMono, fontSize: 10, color: t.accent,
|
|
}}>S5</span>
|
|
<div style={{ flex: 1 }}>
|
|
<div style={{ fontSize: 12.5, fontWeight: 600 }}>Proxy</div>
|
|
<div style={{ fontSize: 11, color: t.dim, fontFamily: fontMono }}>{D.form.host}:{D.form.port}{D.form.auth ? ' · auth' : ''}</div>
|
|
</div>
|
|
<window.IconChevron color={t.dim} dir={showForm ? 'up' : 'down'} />
|
|
</button>
|
|
{showForm && (
|
|
<div className="drv-fadein" style={{ borderTop: `1px solid ${t.border}`, padding: 14, display: 'flex', flexDirection: 'column', gap: 10 }}>
|
|
<div style={{ display: 'flex', gap: 8 }}>
|
|
<HeroInput t={t} fontMono={fontMono} value={D.form.host}
|
|
onChange={v => D.update({ host: v })}
|
|
onEnter={D.runCheck} placeholder="95.165.72.59 или example.com" label="Host" style={{ flex: 1 }} />
|
|
<HeroInput t={t} fontMono={fontMono} value={D.form.port}
|
|
onChange={v => D.update({ port: v.replace(/\D/g,'') })}
|
|
onEnter={D.runCheck} placeholder="12334" label="Port" style={{ width: 90 }} />
|
|
</div>
|
|
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 8, cursor: 'pointer', userSelect: 'none', fontSize: 12.5 }}>
|
|
<span style={{
|
|
width: 16, height: 16, borderRadius: 4,
|
|
border: `1.5px solid ${D.form.auth ? t.accent : t.borderHard}`,
|
|
background: D.form.auth ? t.accent : 'transparent',
|
|
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
|
transition: 'background .12s',
|
|
}}>
|
|
{D.form.auth && <svg width="10" height="10" viewBox="0 0 11 11"><path d="M2 5.5l2.5 2.5 4.5-5" stroke={themeKey === 'd' ? '#0c1a18' : '#fff'} strokeWidth="1.8" fill="none" strokeLinecap="round" strokeLinejoin="round"/></svg>}
|
|
</span>
|
|
<input type="checkbox" checked={D.form.auth}
|
|
onChange={e => { D.update({ auth: e.target.checked }); if (e.target.checked) setTimeout(() => document.getElementById('hero-login')?.focus(), 30); }}
|
|
style={{ display: 'none' }} />
|
|
<span style={{ color: t.textDim }}>Authentication</span>
|
|
</label>
|
|
<div style={{ display: 'flex', gap: 8, opacity: D.form.auth ? 1 : 0.4, pointerEvents: D.form.auth ? 'auto' : 'none' }}>
|
|
<HeroInput t={t} fontMono={fontMono} id="hero-login" value={D.form.login}
|
|
onChange={v => D.update({ login: v })} onEnter={D.runCheck}
|
|
placeholder="user" label="Login" style={{ flex: 1 }} disabled={!D.form.auth} />
|
|
<HeroInput t={t} fontMono={fontMono} value={D.form.password} type="password"
|
|
onChange={v => D.update({ password: v })} onEnter={D.runCheck}
|
|
placeholder="••••••" label="Password" style={{ flex: 1 }} disabled={!D.form.auth} />
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Logs strip */}
|
|
<button onClick={() => setShowDetails(s => !s)}
|
|
style={{
|
|
width: '100%', marginTop: 8, padding: '6px 4px', display: 'flex', alignItems: 'center', gap: 6,
|
|
background: 'transparent', border: 'none', cursor: 'pointer', color: t.dim,
|
|
fontSize: 11, fontFamily: fontMono, textAlign: 'left',
|
|
}}>
|
|
<window.IconChevron color={t.dim} dir={showDetails ? 'down' : 'right'} />
|
|
<span>Logs</span>
|
|
<span style={{ marginLeft: 'auto', color: t.dimmer }}>{D.logs.length} lines</span>
|
|
</button>
|
|
{showDetails && (
|
|
<div className="drv-fadein" style={{
|
|
background: t.panelAlt, border: `1px solid ${t.border}`, borderRadius: 8,
|
|
maxHeight: 110, overflow: 'auto', padding: '8px 12px',
|
|
fontFamily: fontMono, fontSize: 10.5, color: t.textDim,
|
|
}} ref={el => el && (el.scrollTop = el.scrollHeight)}>
|
|
{D.logs.map((l, i) => (
|
|
<div key={i}>
|
|
<span style={{ color: t.dimmer }}>{window.fmtTime(l.t)}</span>{' '}
|
|
<span style={{ color: l.level === 'ERROR' ? t.danger : l.level === 'WARN' ? t.warn : t.pass, fontWeight: 600 }}>[{l.level}]</span>{' '}
|
|
<span>{l.msg}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Big toggle button ──────────────────────────────────────────────────
|
|
function BigToggle({ t, state, onClick, progress = 0 }) {
|
|
// size = 168 px circle; outer pulsing rings
|
|
const isActive = state === 'active' || state === 'active-warn';
|
|
const isChecking = state === 'checking';
|
|
const isFailed = state === 'failed';
|
|
const isWarn = state === 'active-warn';
|
|
|
|
const accent = isWarn ? t.warn : isFailed ? t.danger : t.accent;
|
|
const accentDeep = isWarn ? t.warn : isFailed ? t.danger : t.accentDeep;
|
|
const accentDarker = isWarn ? t.warn : isFailed ? t.danger : t.accentDarker;
|
|
|
|
const ring = 168;
|
|
const stroke = 5;
|
|
const r = (ring - stroke) / 2;
|
|
const c = 2 * Math.PI * r;
|
|
|
|
return (
|
|
<button onClick={onClick} disabled={isChecking}
|
|
style={{
|
|
position: 'relative', width: ring, height: ring, marginTop: 8,
|
|
background: isActive
|
|
? `radial-gradient(circle at 35% 30%, ${accent} 0%, ${accentDeep} 60%, ${accentDarker} 100%)`
|
|
: isFailed
|
|
? `radial-gradient(circle at 35% 30%, ${t.danger} 0%, #8c2c2c 100%)`
|
|
: `radial-gradient(circle at 35% 30%, ${t.panel} 0%, ${t.panelAlt} 100%)`,
|
|
boxShadow: isActive
|
|
? `0 0 60px ${accent}66, 0 0 0 6px ${accent}22, 0 0 0 12px ${accent}10`
|
|
: `0 0 0 2px ${accent}22, 0 6px 24px rgba(0,0,0,.15)`,
|
|
border: 'none', borderRadius: '50%', cursor: isChecking ? 'wait' : 'pointer',
|
|
color: isActive || isFailed ? '#fff' : accent,
|
|
transition: 'background .35s, box-shadow .35s, color .2s',
|
|
display: 'flex', alignItems: 'center', justifyContent: 'center', flexDirection: 'column',
|
|
}}>
|
|
{/* pulsing ring on active */}
|
|
{isActive && (
|
|
<span style={{
|
|
position: 'absolute', inset: -16, borderRadius: '50%',
|
|
border: `1px solid ${accent}66`, animation: 'drv-pulse-ring 2s ease-out infinite',
|
|
}} />
|
|
)}
|
|
{/* progress ring while checking */}
|
|
{isChecking && (
|
|
<svg width={ring} height={ring} style={{ position: 'absolute', inset: 0, transform: 'rotate(-90deg)' }}>
|
|
<circle cx={ring/2} cy={ring/2} r={r} fill="none" stroke={`${accent}30`} strokeWidth={stroke} />
|
|
<circle cx={ring/2} cy={ring/2} r={r} fill="none" stroke={accent} strokeWidth={stroke} strokeLinecap="round"
|
|
strokeDasharray={c} strokeDashoffset={c * (1 - progress)}
|
|
style={{ transition: 'stroke-dashoffset .35s' }} />
|
|
{/* spinner segment */}
|
|
<circle cx={ring/2} cy={ring/2} r={r-12} fill="none" stroke={accent} strokeWidth="2" strokeLinecap="round"
|
|
strokeDasharray={`${(c-24)*0.18} ${(c-24)*0.82}`}
|
|
style={{ animation: 'drv-spin 1.2s linear infinite', transformOrigin: '50% 50%' }} />
|
|
</svg>
|
|
)}
|
|
<span style={{ fontSize: 42, lineHeight: 1, fontWeight: 100 }}>⏻</span>
|
|
<span style={{ fontSize: 11, letterSpacing: '.25em', marginTop: 6, opacity: 0.95, fontWeight: 600 }}>
|
|
{isActive ? 'ACTIVE' : isChecking ? 'TESTING' : isFailed ? 'RETRY' : 'OFF'}
|
|
</span>
|
|
<style>{`
|
|
@keyframes drv-pulse-ring { 0%{ transform: scale(1); opacity:.5; } 70%{ transform: scale(1.18); opacity:0;} 100%{transform:scale(1.18); opacity:0;}}
|
|
`}</style>
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function HeroStat({ icon, v, u, c, mono, t }) {
|
|
return (
|
|
<div style={{ textAlign: 'center', minWidth: 56 }}>
|
|
<div style={{ fontSize: 11, color: t.accent }}>{icon}</div>
|
|
<div style={{ fontSize: 18, fontWeight: 600, fontFamily: mono, marginTop: 2, color: c }}>{v}</div>
|
|
<div style={{ fontSize: 9.5, color: t.dim, letterSpacing: '.06em', textTransform: 'uppercase' }}>{u}</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function HeroTitleBtn({ children, t, hoverBg }) {
|
|
const [hover, setHover] = React.useState(false);
|
|
return (
|
|
<div onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)}
|
|
style={{
|
|
width: 26, height: 26, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
cursor: 'pointer', borderRadius: 5,
|
|
background: hover ? (hoverBg || 'rgba(127,127,127,.16)') : 'transparent',
|
|
}}>{children}</div>
|
|
);
|
|
}
|
|
|
|
function HeroInput({ t, fontMono, value, onChange, onEnter, placeholder, label, style, type, disabled, id }) {
|
|
const [focus, setFocus] = React.useState(false);
|
|
return (
|
|
<label style={{ display: 'flex', flexDirection: 'column', gap: 4, ...style }}>
|
|
<span style={{ fontSize: 10.5, color: t.dim, fontWeight: 500, letterSpacing: 0.4, textTransform: 'uppercase' }}>{label}</span>
|
|
<input id={id} type={type} value={value} disabled={disabled}
|
|
onChange={e => onChange(e.target.value)}
|
|
onKeyDown={e => e.key === 'Enter' && onEnter?.()}
|
|
onFocus={() => setFocus(true)} onBlur={() => setFocus(false)}
|
|
placeholder={placeholder}
|
|
style={{
|
|
height: 32, background: t.panelAlt, color: disabled ? t.dimmer : t.text,
|
|
border: `1px solid ${focus ? t.accent : t.border}`,
|
|
borderRadius: 7, padding: '0 10px',
|
|
fontFamily: fontMono, fontSize: 12, outline: 'none', boxSizing: 'border-box',
|
|
transition: 'border-color .12s, box-shadow .12s',
|
|
boxShadow: focus ? `0 0 0 3px ${t.accent}22` : 'none',
|
|
}} />
|
|
</label>
|
|
);
|
|
}
|
|
|
|
function fmtCompact(n) {
|
|
if (n < 1024) return n.toFixed(0);
|
|
if (n < 1024 * 1024) return (n / 1024).toFixed(0);
|
|
return (n / 1024 / 1024).toFixed(1);
|
|
}
|
|
function fmtUnit(n) {
|
|
if (n < 1024) return 'B/s';
|
|
if (n < 1024 * 1024) return 'KB/s';
|
|
return 'MB/s';
|
|
}
|
|
|
|
window.HeroWindow = HeroWindow;
|