Simplify client overview access controls and layout
Build and Deploy Gateway / build-and-push (push) Successful in 14s
Build and Deploy Gateway / deploy (push) Successful in 7s

This commit is contained in:
2026-07-14 22:06:38 +03:00
parent e5a69dcb73
commit 162ef861d7
7 changed files with 104 additions and 165 deletions
+45 -93
View File
@@ -2,7 +2,6 @@ import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { flushSync } from 'react-dom';
import { api } from '../api.js';
import {
accessTabForKey,
connectionAction,
connectionDurationParts,
copyText,
@@ -27,10 +26,6 @@ import {
const SUBSCRIPTION_REVEAL_DELAY_MS = 1350;
const DURATION_MODE_STORAGE_KEY = 'harbor-duration-mode';
const ACCESS_TABS = [
['gateway', 'Gateway'],
['proxy', 'Gateway Proxy'],
];
function CloudTooltip({ children }) {
return <span className="client-tooltip" role="tooltip">{children}</span>;
@@ -632,7 +627,6 @@ export function ClientOverviewPage({
const [editingSubscription, setEditingSubscription] = useState(!state?.hasSubscription);
const [showIntro, setShowIntro] = useState(!hasSubscription);
const [subscriptionContentReady, setSubscriptionContentReady] = useState(hasSubscription);
const [accessTab, setAccessTab] = useState('gateway');
const [copyFeedback, setCopyFeedback] = useState(null);
const [subscriptionValidation, setSubscriptionValidation] = useState({
url: '',
@@ -666,6 +660,11 @@ export function ClientOverviewPage({
const proxyUrls = localProxyUrls(state?.proxyPort, gatewayAddress);
const usage = subscriptionUsage(state?.userInfo);
const duration = connectionDurationParts(state?.singboxStartedAt, now);
const wordClockDuration = [
['hours', duration.hours],
['minutes', duration.minutes],
['seconds', duration.seconds],
].filter(([name, part]) => duration.days.value || part.value || name === 'seconds');
const connectionTitle = connected
? gatewayDirect ? 'Gateway подключён' : 'VPN включён'
: 'Подключение выключено';
@@ -932,14 +931,6 @@ export function ClientOverviewPage({
copyTimerRef.current = setTimeout(() => setCopyFeedback(null), 800);
}
function navigateAccessTabs(event, current) {
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
event.preventDefault();
const next = accessTabForKey(ACCESS_TABS.map(([tab]) => tab), current, event.key);
setAccessTab(next);
requestAnimationFrame(() => document.getElementById(`client-access-tab-${next}`)?.focus());
}
async function refreshSubscription() {
const startedAt = performance.now();
setRefreshingInfo(true);
@@ -1158,24 +1149,27 @@ export function ClientOverviewPage({
:<DurationPart name="seconds-value"><AnimatedSeconds value={duration.seconds.value} /></DurationPart>
</time>
<time
className={`client-duration${durationMode === 'words' ? ' is-active' : ''}`}
className={`client-duration client-duration-words${durationMode === 'words' ? ' is-active' : ''}`}
aria-hidden={durationMode !== 'words'}
>
{[
['days', duration.days],
['hours', duration.hours],
['minutes', duration.minutes],
['seconds', duration.seconds],
]
.filter(([name, part]) => part.value || name === 'seconds')
.map(([name, part]) => (
<span className="client-duration-unit" data-unit={name} key={name}>
<DurationPart name={`${name}-value`}>{name === 'seconds'
? <AnimatedSeconds value={part.value} padded={false} />
: part.value}</DurationPart>{' '}
<DurationPart name={`${name}-label`}>{part.label}</DurationPart>
{duration.days.value > 0 && (
<span className="client-duration-word-row is-calendar">
<span className="client-duration-unit" data-unit="days">
<DurationPart name="days-value">{duration.days.value}</DurationPart>{' '}
<DurationPart name="days-label">{duration.days.label}</DurationPart>
</span>
</span>
))}
)}
<span className="client-duration-word-row is-clock">
{wordClockDuration.map(([name, part]) => (
<span className="client-duration-unit" data-unit={name} key={name}>
<DurationPart name={`${name}-value`}>{name === 'seconds'
? <AnimatedSeconds value={part.value} padded={false} />
: part.value}</DurationPart>{' '}
<DurationPart name={`${name}-label`}>{part.label}</DurationPart>
</span>
))}
</span>
</time>
</span>
<CloudTooltip>{durationMode === 'digital' ? 'Показать время словами' : 'Показать цифровой таймер'}</CloudTooltip>
@@ -1188,54 +1182,8 @@ export function ClientOverviewPage({
</div>
</div>
<section className={`client-proxies${isGateway ? ' has-tabs' : ''}`} aria-label={isGateway ? 'Gateway и Gateway Proxy' : 'Локальный прокси'}>
{isGateway && (
<div className="client-access-tabs" role="tablist" aria-label="Способ подключения">
{ACCESS_TABS.map(([tab, label]) => (
<button
id={`client-access-tab-${tab}`}
className={`client-access-tab${accessTab === tab ? ' is-active' : ''}`}
type="button"
role="tab"
key={tab}
aria-selected={accessTab === tab}
aria-controls={`client-access-panel-${tab}`}
tabIndex={accessTab === tab ? 0 : -1}
onClick={() => setAccessTab(tab)}
onKeyDown={(event) => navigateAccessTabs(event, tab)}
>
{label}
</button>
))}
</div>
)}
{isGateway && (
<div
id="client-access-panel-gateway"
className="client-access-point"
role="tabpanel"
aria-labelledby="client-access-tab-gateway"
hidden={accessTab !== 'gateway'}
>
<strong className="client-proxy-address">{gatewayAddress}</strong>
<button
className={`client-copy-button${copyFeedback?.kind === 'gateway' ? copyFeedback.failed ? ' is-copy-error' : ' is-copied' : ''}`}
type="button"
aria-label={`Скопировать Gateway: ${gatewayAddress}`}
onClick={() => copyProxy('gateway')}
>
<span className="client-copy-label">КОПИРОВАТЬ</span>
{copyFeedback?.kind === 'gateway' && <span className="client-copy-feedback" aria-hidden="true">{copyFeedback.failed ? 'ОШИБКА' : 'ГОТОВО'}</span>}
</button>
</div>
)}
<div
id={isGateway ? 'client-access-panel-proxy' : undefined}
className="client-access-point"
role={isGateway ? 'tabpanel' : undefined}
aria-labelledby={isGateway ? 'client-access-tab-proxy' : undefined}
hidden={isGateway && accessTab !== 'proxy'}
>
<section className={`client-proxies${isGateway ? ' is-gateway' : ''}`} aria-label={isGateway ? 'Gateway и Gateway Proxy' : 'Локальный прокси'}>
<div className="client-access-point">
{!isGateway && (
<span className={`client-proxy-label${gatewayDirect ? ' is-gateway' : ''}`}>
<span className={!gatewayDirect ? 'is-active' : ''}>Локальный VPN</span>
@@ -1245,24 +1193,28 @@ export function ClientOverviewPage({
</span>
)}
<strong className="client-proxy-address">
{proxyUrls.http.replace(/^https?:\/\//, '')}
{isGateway ? gatewayAddress : proxyUrls.http.replace(/^https?:\/\//, '')}
</strong>
<div className="client-proxy-actions">
{[
['socks5', 'SOCKS5'],
['http', 'HTTP'],
].map(([kind, label]) => (
<button
className={`client-copy-button${copyFeedback?.kind === kind ? copyFeedback.failed ? ' is-copy-error' : ' is-copied' : ''}`}
type="button"
key={kind}
aria-label={`Скопировать ${label}: ${proxyUrls[kind]}`}
onClick={() => copyProxy(kind)}
>
<span className="client-copy-label">{label}</span>
{copyFeedback?.kind === kind && <span className="client-copy-feedback" aria-hidden="true">{copyFeedback.failed ? 'ОШИБКА' : 'ГОТОВО'}</span>}
</button>
))}
{(isGateway ? [
['gateway', 'GATEWAY'],
['socks5', 'SOCKS5'],
['http', 'HTTP'],
] : [
['socks5', 'SOCKS5'],
['http', 'HTTP'],
]).map(([kind, label]) => (
<button
className={`client-copy-button${copyFeedback?.kind === kind ? copyFeedback.failed ? ' is-copy-error' : ' is-copied' : ''}`}
type="button"
key={kind}
aria-label={`Скопировать ${label}: ${kind === 'gateway' ? gatewayAddress : proxyUrls[kind]}`}
onClick={() => copyProxy(kind)}
>
<span className="client-copy-label">{label}</span>
{copyFeedback?.kind === kind && <span className="client-copy-feedback" aria-hidden="true">{copyFeedback.failed ? 'ОШИБКА' : 'ГОТОВО'}</span>}
</button>
))}
</div>
</div>
</section>
+41 -43
View File
@@ -1682,7 +1682,7 @@ p {
}
.client-state-copy {
min-height: 54px;
min-height: 76px;
}
.client-power {
@@ -1829,7 +1829,7 @@ p {
}
.client-state-detail {
min-height: 20px;
min-height: 44px;
display: grid;
place-items: center;
margin-top: 8px;
@@ -1863,6 +1863,20 @@ p {
place-items: center;
}
.client-duration-words {
display: grid;
place-items: center;
gap: 3px;
}
.client-duration-word-row {
min-height: 19px;
display: flex;
align-items: baseline;
justify-content: center;
white-space: nowrap;
}
.client-duration-unit {
display: inline-flex;
align-items: baseline;
@@ -1908,7 +1922,7 @@ p {
.client-duration-toggle {
width: min(290px, 100%);
min-height: 24px;
min-height: 44px;
display: grid;
place-items: center;
padding: 0;
@@ -1966,6 +1980,19 @@ p {
transition-delay: 20ms, 20ms, 20ms, 0s;
}
.client-duration-toggle > .client-tooltip {
top: 50%;
right: calc(100% + 12px);
bottom: auto;
left: auto;
transform: translate(2px, -50%);
}
.client-duration-toggle:hover > .client-tooltip,
.client-duration-toggle:focus-visible > .client-tooltip {
transform: translate(0, -50%);
}
.client-icon-tooltip {
width: 16px;
height: 16px;
@@ -3071,8 +3098,12 @@ p {
margin-top: 6px;
}
.client-proxies.has-tabs {
min-height: 91px;
.client-proxies.is-gateway {
width: 270px;
}
.client-proxies.is-gateway .client-copy-button {
width: 82px;
}
.client-proxy-label {
@@ -3136,47 +3167,11 @@ p {
animation: client-access-reveal 450ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
.client-access-point[hidden] {
display: none;
}
@keyframes client-access-reveal {
0% { opacity: 0; filter: blur(4px); }
100% { opacity: 1; filter: blur(0); }
}
.client-access-tabs {
display: grid;
grid-template-columns: 1fr 1fr;
width: 220px;
margin-bottom: 8px;
border-bottom: 1px solid var(--client-border);
}
.client-access-tabs .client-access-tab {
width: auto;
padding: 6px 4px 7px;
border: 0;
border-bottom: 1px solid transparent;
background: transparent;
color: var(--client-muted);
font: 700 9px/1.2 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace;
letter-spacing: 0.06em;
cursor: pointer;
transition: color 220ms ease, border-color 300ms ease, filter 300ms ease;
}
.client-access-tabs .client-access-tab.is-active {
border-bottom-color: var(--client-accent);
color: var(--client-text);
filter: drop-shadow(0 0 5px color-mix(in oklch, var(--client-accent) 45%, transparent));
}
.client-access-tab:focus-visible {
outline: 2px solid var(--client-accent);
outline-offset: 2px;
}
.client-proxy-actions {
display: flex;
gap: 8px;
@@ -3457,7 +3452,11 @@ p {
right: 8px;
}
.client-access-tabs .client-access-tab,
.client-duration-toggle > .client-tooltip {
right: calc(50% + 36px);
max-width: 110px;
}
.client-copy-button {
min-height: 44px;
font-size: 10px;
@@ -3574,7 +3573,6 @@ p {
.client-server-group-toggle,
.client-server-more,
.client-copy-button,
.client-access-tab,
.client-access-point,
.client-copy-feedback,
.client-duration,
-9
View File
@@ -98,15 +98,6 @@ export async function copyText(text, options = {}) {
await clipboard.writeText(text);
}
export function accessTabForKey(tabs, current, key) {
const index = tabs.indexOf(current);
if (key === 'Home') return tabs[0];
if (key === 'End') return tabs.at(-1);
if (key === 'ArrowLeft') return tabs[(index - 1 + tabs.length) % tabs.length];
if (key === 'ArrowRight') return tabs[(index + 1) % tabs.length];
return current;
}
export function subscriptionUsage(userInfo = {}) {
const upload = Math.max(0, Number(userInfo.upload) || 0);
const download = Math.max(0, Number(userInfo.download) || 0);