Rename product to Harbor and refine connection timer
This commit is contained in:
@@ -1,9 +1,9 @@
|
|||||||
# VPN Proxy
|
# Harbor
|
||||||
|
|
||||||
Один компактный VPN-клиент в двух режимах:
|
Один компактный VPN-продукт в двух режимах:
|
||||||
|
|
||||||
- `gateway` — отдельная Linux-машина принимает трафик устройств как системный Gateway или Gateway HTTP/SOCKS5 Proxy;
|
- **Harbor Gateway** — отдельная Linux-машина принимает трафик устройств как системный Gateway или Gateway HTTP/SOCKS5 Proxy;
|
||||||
- `client` — локальный proxy-клиент для macOS.
|
- **Harbor Connect** — локальный proxy-клиент для macOS.
|
||||||
|
|
||||||
В обоих режимах пользователь добавляет подписку, выбирает сервер и включает VPN на одном экране.
|
В обоих режимах пользователь добавляет подписку, выбирает сервер и включает VPN на одном экране.
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>VPN</title>
|
<link rel="icon" href="/harbor-mark.svg" type="image/svg+xml" />
|
||||||
|
<title>Harbor</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
9
public/harbor-mark.svg
Normal file
9
public/harbor-mark.svg
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||||
|
<rect width="32" height="32" rx="9" fill="#101812"/>
|
||||||
|
<circle cx="16" cy="16" r="11" fill="#65d889" opacity=".08"/>
|
||||||
|
<g fill="none" stroke="#78e29a" stroke-width="2.6" stroke-linecap="round">
|
||||||
|
<path d="M16 6.5v9"/>
|
||||||
|
<path d="M10.1 10.3a8 8 0 1 0 11.8 0"/>
|
||||||
|
</g>
|
||||||
|
<circle cx="16" cy="16" r="8.8" fill="none" stroke="#78e29a" opacity=".16"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 420 B |
@@ -2,6 +2,7 @@ import fs from 'node:fs';
|
|||||||
import http from 'node:http';
|
import http from 'node:http';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { spawn, spawnSync } from 'node:child_process';
|
import { spawn, spawnSync } from 'node:child_process';
|
||||||
|
import { isDeepStrictEqual } from 'node:util';
|
||||||
import { settings } from './config.js';
|
import { settings } from './config.js';
|
||||||
import { setGatewayInterception } from './gatewayRouting.js';
|
import { setGatewayInterception } from './gatewayRouting.js';
|
||||||
import { tcpPing } from './ping.js';
|
import { tcpPing } from './ping.js';
|
||||||
@@ -221,12 +222,18 @@ function refreshSavedSubscription() {
|
|||||||
const previousConfig = fs.existsSync(settings.configPath)
|
const previousConfig = fs.existsSync(settings.configPath)
|
||||||
? fs.readFileSync(settings.configPath, 'utf8')
|
? fs.readFileSync(settings.configPath, 'utf8')
|
||||||
: null;
|
: null;
|
||||||
|
const activeConfigChanged = Boolean(currentState.selectedTag && selectedTag) && (
|
||||||
|
!previousCache?.config || !isDeepStrictEqual(
|
||||||
|
buildGatewayConfig(previousCache.config, currentState.selectedTag),
|
||||||
|
buildGatewayConfig(parsed.config, selectedTag),
|
||||||
|
)
|
||||||
|
);
|
||||||
writeJson(settings.subscriptionCachePath, { url: subscriptionUrl, ...parsed });
|
writeJson(settings.subscriptionCachePath, { url: subscriptionUrl, ...parsed });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (singboxProcess && selectedTag) await applySelectedServer(selectedTag);
|
if (singboxProcess && activeConfigChanged) await applySelectedServer(selectedTag);
|
||||||
else if (selectedTag) {
|
else if (selectedTag) {
|
||||||
writeSingboxConfig(buildGatewayConfig(parsed.config, selectedTag));
|
if (!singboxProcess) writeSingboxConfig(buildGatewayConfig(parsed.config, selectedTag));
|
||||||
} else {
|
} else {
|
||||||
removeSingboxConfig();
|
removeSingboxConfig();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ function App() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (state?.mode) document.title = state.mode === 'gateway' ? 'Gateway' : 'Proxy';
|
if (state?.mode) document.title = state.mode === 'gateway' ? 'Harbor Gateway' : 'Harbor Connect';
|
||||||
}, [state?.mode]);
|
}, [state?.mode]);
|
||||||
|
|
||||||
async function run(action) {
|
async function run(action) {
|
||||||
@@ -79,7 +79,7 @@ function App() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!state) return <div className="app-loading">VPN</div>;
|
if (!state) return <div className="app-loading">Harbor</div>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app client-app">
|
<div className="app client-app">
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import { flushSync } from 'react-dom';
|
|||||||
import { api } from '../api.js';
|
import { api } from '../api.js';
|
||||||
import {
|
import {
|
||||||
connectionAction,
|
connectionAction,
|
||||||
|
connectionDurationParts,
|
||||||
copyText,
|
copyText,
|
||||||
formatConnectionDuration,
|
|
||||||
localProxyUrls,
|
localProxyUrls,
|
||||||
subscriptionDomain,
|
subscriptionDomain,
|
||||||
subscriptionDaysLeft,
|
subscriptionDaysLeft,
|
||||||
@@ -61,6 +61,30 @@ function InstructionBlock({ block, open, onToggle }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function DurationPart({ name, children }) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`client-duration-part${name.endsWith('-value') ? ' is-value' : ' is-label'}`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function HarborBrand({ isGateway }) {
|
||||||
|
const product = isGateway ? 'Gateway' : 'Connect';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`harbor-brand is-${product.toLowerCase()}`} aria-label={`Harbor ${product}`}>
|
||||||
|
<svg viewBox="0 0 32 32" aria-hidden="true">
|
||||||
|
<circle cx="16" cy="6" r="3" />
|
||||||
|
<path d="M16 9v15M10 14h12M6 20c1.5 5 5 8 10 8s8.5-3 10-8M6 20l5 1M26 20l-5 1" />
|
||||||
|
</svg>
|
||||||
|
<span><strong>Harbor</strong> <em>{product}</em></span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function ClientOverviewPage({
|
export function ClientOverviewPage({
|
||||||
state,
|
state,
|
||||||
busy,
|
busy,
|
||||||
@@ -84,6 +108,7 @@ export function ClientOverviewPage({
|
|||||||
const showPower = hasSubscription && Boolean(selectedTag);
|
const showPower = hasSubscription && Boolean(selectedTag);
|
||||||
const canStart = Boolean(selectedTag || state?.configExists);
|
const canStart = Boolean(selectedTag || state?.configExists);
|
||||||
const [now, setNow] = useState(Date.now());
|
const [now, setNow] = useState(Date.now());
|
||||||
|
const [durationMode, setDurationMode] = useState('digital');
|
||||||
const [editingSubscription, setEditingSubscription] = useState(!state?.hasSubscription);
|
const [editingSubscription, setEditingSubscription] = useState(!state?.hasSubscription);
|
||||||
const [pings, setPings] = useState({});
|
const [pings, setPings] = useState({});
|
||||||
const [accessTab, setAccessTab] = useState('gateway');
|
const [accessTab, setAccessTab] = useState('gateway');
|
||||||
@@ -103,6 +128,7 @@ export function ClientOverviewPage({
|
|||||||
const gatewayAddress = isGateway ? window.location.hostname : '127.0.0.1';
|
const gatewayAddress = isGateway ? window.location.hostname : '127.0.0.1';
|
||||||
const proxyUrls = localProxyUrls(state?.proxyPort, gatewayAddress);
|
const proxyUrls = localProxyUrls(state?.proxyPort, gatewayAddress);
|
||||||
const usage = subscriptionUsage(state?.userInfo);
|
const usage = subscriptionUsage(state?.userInfo);
|
||||||
|
const duration = connectionDurationParts(state?.singboxStartedAt, now);
|
||||||
const [displayedUsed, setDisplayedUsed] = useState(usage.used);
|
const [displayedUsed, setDisplayedUsed] = useState(usage.used);
|
||||||
const hasUsage = Boolean(
|
const hasUsage = Boolean(
|
||||||
state?.userInfo && ['upload', 'download', 'total', 'expire'].some((key) => key in state.userInfo),
|
state?.userInfo && ['upload', 'download', 'total', 'expire'].some((key) => key in state.userInfo),
|
||||||
@@ -288,8 +314,13 @@ export function ClientOverviewPage({
|
|||||||
document.startViewTransition(update);
|
document.startViewTransition(update);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleDurationMode() {
|
||||||
|
setDurationMode((mode) => mode === 'digital' ? 'words' : 'digital');
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="client-shell">
|
<div className="client-shell">
|
||||||
|
<HarborBrand isGateway={isGateway} />
|
||||||
{hasSubscription && <button
|
{hasSubscription && <button
|
||||||
ref={instructionsToggleRef}
|
ref={instructionsToggleRef}
|
||||||
className={`client-instructions-toggle${instructionsOpen ? ' is-open' : ''}`}
|
className={`client-instructions-toggle${instructionsOpen ? ' is-open' : ''}`}
|
||||||
@@ -328,9 +359,43 @@ export function ClientOverviewPage({
|
|||||||
</h2>
|
</h2>
|
||||||
<div className="client-state-detail">
|
<div className="client-state-detail">
|
||||||
{connected ? (
|
{connected ? (
|
||||||
<time key="duration" className="client-duration">
|
<button
|
||||||
{formatConnectionDuration(state?.singboxStartedAt, now)}
|
className={`client-duration-toggle${durationMode === 'words' ? ' is-words' : ''}`}
|
||||||
|
type="button"
|
||||||
|
key="duration"
|
||||||
|
aria-label={durationMode === 'digital' ? 'Показать время словами' : 'Показать цифровой таймер'}
|
||||||
|
title={durationMode === 'digital' ? 'Показать время словами' : 'Показать цифровой таймер'}
|
||||||
|
onClick={toggleDurationMode}
|
||||||
|
>
|
||||||
|
<span className="client-duration-stack">
|
||||||
|
<time
|
||||||
|
className={`client-duration${durationMode === 'digital' ? ' is-active' : ''}`}
|
||||||
|
aria-hidden={durationMode !== 'digital'}
|
||||||
|
>
|
||||||
|
<DurationPart name="hours-value">{String(duration.totalHours).padStart(2, '0')}</DurationPart>
|
||||||
|
:<DurationPart name="minutes-value">{String(duration.minutes.value).padStart(2, '0')}</DurationPart>
|
||||||
|
:<DurationPart name="seconds-value">{String(duration.seconds.value).padStart(2, '0')}</DurationPart>
|
||||||
</time>
|
</time>
|
||||||
|
<time
|
||||||
|
className={`client-duration${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`}>{part.value}</DurationPart>{' '}
|
||||||
|
<DurationPart name={`${name}-label`}>{part.label}</DurationPart>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</time>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<p key="hint">
|
<p key="hint">
|
||||||
{canStart ? 'Нажмите, чтобы включить' : 'Добавьте ссылку и выберите сервер'}
|
{canStart ? 'Нажмите, чтобы включить' : 'Добавьте ссылку и выберите сервер'}
|
||||||
|
|||||||
@@ -60,6 +60,9 @@ p {
|
|||||||
--client-muted: oklch(0.53 0.014 145);
|
--client-muted: oklch(0.53 0.014 145);
|
||||||
--client-accent: oklch(0.62 0.14 151);
|
--client-accent: oklch(0.62 0.14 151);
|
||||||
--client-accent-soft: oklch(0.92 0.045 151);
|
--client-accent-soft: oklch(0.92 0.045 151);
|
||||||
|
--harbor-word: oklch(0.39 0.075 232);
|
||||||
|
--harbor-connect: oklch(0.57 0.11 185);
|
||||||
|
--harbor-gateway: oklch(0.61 0.12 72);
|
||||||
grid-template-rows: 1fr;
|
grid-template-rows: 1fr;
|
||||||
color-scheme: light;
|
color-scheme: light;
|
||||||
background: var(--client-bg);
|
background: var(--client-bg);
|
||||||
@@ -87,6 +90,43 @@ p {
|
|||||||
color: var(--client-text);
|
color: var(--client-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.harbor-brand {
|
||||||
|
position: fixed;
|
||||||
|
top: 24px;
|
||||||
|
left: 28px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 9px;
|
||||||
|
color: var(--harbor-word);
|
||||||
|
font-size: 13px;
|
||||||
|
letter-spacing: -0.035em;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.harbor-brand svg {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
fill: none;
|
||||||
|
stroke: currentColor;
|
||||||
|
stroke-width: 1.7;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
}
|
||||||
|
|
||||||
|
.harbor-brand strong {
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.harbor-brand em {
|
||||||
|
color: var(--harbor-connect);
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.harbor-brand.is-gateway em {
|
||||||
|
color: var(--harbor-gateway);
|
||||||
|
}
|
||||||
|
|
||||||
.client-instructions-toggle {
|
.client-instructions-toggle {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
@@ -479,30 +519,50 @@ p {
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--client-muted);
|
color: var(--client-muted);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: color 800ms cubic-bezier(0.16, 1, 0.3, 1), opacity 600ms ease, transform 600ms cubic-bezier(0.16, 1, 0.3, 1);
|
transition: color 800ms cubic-bezier(0.16, 1, 0.3, 1), opacity 600ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-power::before {
|
.client-power::before,
|
||||||
|
.client-power::after {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
width: 58px;
|
|
||||||
height: 58px;
|
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: radial-gradient(circle, color-mix(in oklch, var(--client-accent) 42%, transparent), transparent 70%);
|
|
||||||
opacity: 0;
|
|
||||||
transform: scale(0.55);
|
|
||||||
transition: opacity 900ms cubic-bezier(0.16, 1, 0.3, 1), transform 900ms cubic-bezier(0.16, 1, 0.3, 1);
|
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.client-power::before {
|
||||||
|
width: 54px;
|
||||||
|
height: 54px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: radial-gradient(circle, color-mix(in oklch, currentColor 38%, transparent), transparent 70%);
|
||||||
|
opacity: 0;
|
||||||
|
filter: blur(2px);
|
||||||
|
transform: translate3d(0, 4px, 0) scale(0.62);
|
||||||
|
transition: opacity 1050ms cubic-bezier(0.16, 1, 0.3, 1), transform 1250ms cubic-bezier(0.16, 1, 0.3, 1), filter 1050ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-power::after {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
background: radial-gradient(circle, color-mix(in oklch, currentColor 48%, transparent), transparent 72%);
|
||||||
|
opacity: 0;
|
||||||
|
filter: blur(5px);
|
||||||
|
transform: translate3d(-8px, 7px, 0) scale(0.7);
|
||||||
|
transition: opacity 850ms cubic-bezier(0.16, 1, 0.3, 1), transform 950ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
}
|
||||||
|
|
||||||
.client-power[aria-checked='true']::before {
|
.client-power[aria-checked='true']::before {
|
||||||
opacity: 0.75;
|
opacity: 0.76;
|
||||||
transform: scale(1.75);
|
filter: blur(3px);
|
||||||
|
transform: translate3d(-3px, -2px, 0) scale(1.48);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-power[aria-checked='true']::after {
|
||||||
|
animation: client-power-light-flicker 4.7s 650ms ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-power:hover:not(:disabled) {
|
.client-power:hover:not(:disabled) {
|
||||||
transform: translateY(-3px) scale(1.08);
|
color: var(--client-muted);
|
||||||
color: var(--client-text);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-power[aria-checked='true'] {
|
.client-power[aria-checked='true'] {
|
||||||
@@ -510,8 +570,19 @@ p {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.client-power:active:not(:disabled) {
|
.client-power:active:not(:disabled) {
|
||||||
transform: translateY(0) scale(0.92);
|
opacity: 0.82;
|
||||||
transition-duration: 220ms;
|
}
|
||||||
|
|
||||||
|
.client-power:active:not(:disabled)::before {
|
||||||
|
animation: none;
|
||||||
|
opacity: 0.86;
|
||||||
|
transform: translate3d(3px, -2px, 0) scale(1.56);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-power:active:not(:disabled)::after {
|
||||||
|
animation: none;
|
||||||
|
opacity: 0.7;
|
||||||
|
transform: translate3d(-7px, 8px, 0) scale(1.18);
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-power:disabled {
|
.client-power:disabled {
|
||||||
@@ -528,11 +599,11 @@ p {
|
|||||||
stroke-width: 1.7;
|
stroke-width: 1.7;
|
||||||
stroke-linecap: round;
|
stroke-linecap: round;
|
||||||
filter: drop-shadow(0 0 0 transparent);
|
filter: drop-shadow(0 0 0 transparent);
|
||||||
transition: filter 700ms cubic-bezier(0.16, 1, 0.3, 1), transform 700ms cubic-bezier(0.16, 1, 0.3, 1);
|
transition: filter 700ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-power:hover:not(:disabled) svg {
|
.client-power:hover:not(:disabled) svg {
|
||||||
filter: drop-shadow(0 0 6px color-mix(in oklch, var(--client-muted) 55%, transparent)) drop-shadow(0 0 18px color-mix(in oklch, var(--client-muted) 22%, transparent));
|
filter: drop-shadow(0 0 4px color-mix(in oklch, var(--client-muted) 32%, transparent));
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-power[aria-checked='true']:hover:not(:disabled) {
|
.client-power[aria-checked='true']:hover:not(:disabled) {
|
||||||
@@ -540,21 +611,18 @@ p {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.client-power[aria-checked='true']:hover:not(:disabled) svg {
|
.client-power[aria-checked='true']:hover:not(:disabled) svg {
|
||||||
filter: drop-shadow(0 0 6px color-mix(in oklch, var(--client-accent) 75%, transparent)) drop-shadow(0 0 18px color-mix(in oklch, var(--client-accent) 35%, transparent));
|
filter: drop-shadow(0 0 5px color-mix(in oklch, var(--client-accent) 48%, transparent));
|
||||||
}
|
|
||||||
|
|
||||||
.client-power:active:not(:disabled) svg {
|
|
||||||
transform: scale(0.88);
|
|
||||||
filter: drop-shadow(0 0 10px var(--client-muted)) drop-shadow(0 0 26px color-mix(in oklch, var(--client-muted) 45%, transparent));
|
|
||||||
transition-duration: 220ms;
|
|
||||||
}
|
|
||||||
|
|
||||||
.client-power[aria-checked='true']:active:not(:disabled) svg {
|
|
||||||
filter: drop-shadow(0 0 10px var(--client-accent)) drop-shadow(0 0 26px color-mix(in oklch, var(--client-accent) 55%, transparent));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-power[aria-checked='true'] svg {
|
.client-power[aria-checked='true'] svg {
|
||||||
filter: drop-shadow(0 0 5px color-mix(in oklch, var(--client-accent) 65%, transparent)) drop-shadow(0 0 14px color-mix(in oklch, var(--client-accent) 28%, transparent));
|
filter: drop-shadow(0 0 4px color-mix(in oklch, var(--client-accent) 42%, transparent));
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes client-power-light-flicker {
|
||||||
|
0%, 100% { opacity: 0.18; transform: translate3d(-8px, 7px, 0) scale(0.82); }
|
||||||
|
19% { opacity: 0.5; transform: translate3d(7px, 5px, 0) scale(1.08); }
|
||||||
|
46% { opacity: 0.26; transform: translate3d(8px, -7px, 0) scale(0.78); }
|
||||||
|
71% { opacity: 0.58; transform: translate3d(-6px, -8px, 0) scale(1.12); }
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-power-section h2 {
|
.client-power-section h2 {
|
||||||
@@ -577,11 +645,67 @@ p {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.client-duration {
|
.client-duration {
|
||||||
|
grid-area: 1 / 1;
|
||||||
display: block;
|
display: block;
|
||||||
color: var(--client-text);
|
color: var(--client-text);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.04em;
|
||||||
|
opacity: 0;
|
||||||
|
filter: blur(5px);
|
||||||
|
transition: opacity 260ms ease, filter 420ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-duration.is-active {
|
||||||
|
opacity: 1;
|
||||||
|
filter: blur(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-duration-stack {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-duration-unit .client-duration-part.is-value {
|
||||||
|
display: inline-block;
|
||||||
|
min-width: 2ch;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-duration-unit .client-duration-part.is-label {
|
||||||
|
display: inline-block;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-duration-unit[data-unit='days'] .is-label { width: 4ch; }
|
||||||
|
.client-duration-unit[data-unit='hours'] .is-label { width: 5ch; }
|
||||||
|
.client-duration-unit[data-unit='minutes'] .is-label { width: 6ch; }
|
||||||
|
.client-duration-unit[data-unit='seconds'] .is-label { width: 7ch; }
|
||||||
|
|
||||||
|
.client-duration-unit + .client-duration-unit::before {
|
||||||
|
content: ' ';
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-duration-toggle {
|
||||||
|
width: 290px;
|
||||||
|
min-height: 24px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-duration-toggle:hover .client-duration,
|
||||||
|
.client-duration-toggle:focus-visible .client-duration {
|
||||||
|
text-shadow: 0 0 10px color-mix(in oklch, var(--client-text) 22%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-duration-toggle:focus-visible {
|
||||||
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-power-section p {
|
.client-power-section p {
|
||||||
@@ -1229,6 +1353,9 @@ p {
|
|||||||
--client-muted: oklch(0.68 0.012 145);
|
--client-muted: oklch(0.68 0.012 145);
|
||||||
--client-accent: oklch(0.76 0.13 151);
|
--client-accent: oklch(0.76 0.13 151);
|
||||||
--client-accent-soft: oklch(0.32 0.055 151);
|
--client-accent-soft: oklch(0.32 0.055 151);
|
||||||
|
--harbor-word: oklch(0.78 0.07 232);
|
||||||
|
--harbor-connect: oklch(0.75 0.1 185);
|
||||||
|
--harbor-gateway: oklch(0.79 0.11 72);
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1246,6 +1373,13 @@ p {
|
|||||||
padding: 20px 6px;
|
padding: 20px 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.harbor-brand {
|
||||||
|
position: absolute;
|
||||||
|
top: -4px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
.client-instructions-toggle {
|
.client-instructions-toggle {
|
||||||
left: 8px;
|
left: 8px;
|
||||||
}
|
}
|
||||||
@@ -1277,6 +1411,7 @@ p {
|
|||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.client-power,
|
.client-power,
|
||||||
.client-power::before,
|
.client-power::before,
|
||||||
|
.client-power::after,
|
||||||
.client-power svg,
|
.client-power svg,
|
||||||
.client-usage-bar i,
|
.client-usage-bar i,
|
||||||
.client-subscription-refresh,
|
.client-subscription-refresh,
|
||||||
@@ -1290,6 +1425,7 @@ p {
|
|||||||
.client-access-tab,
|
.client-access-tab,
|
||||||
.client-access-point,
|
.client-access-point,
|
||||||
.client-copy-feedback,
|
.client-copy-feedback,
|
||||||
|
.client-duration,
|
||||||
.client-instructions,
|
.client-instructions,
|
||||||
.client-instructions-toggle,
|
.client-instructions-toggle,
|
||||||
.client-instructions-toggle svg,
|
.client-instructions-toggle svg,
|
||||||
|
|||||||
@@ -6,17 +6,50 @@ export function connectionAction({ connected, selectedTag, configExists }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function formatConnectionDuration(startedAt, now = Date.now()) {
|
export function formatConnectionDuration(startedAt, now = Date.now()) {
|
||||||
|
const { totalHours, minutes, seconds } = connectionDurationParts(startedAt, now);
|
||||||
|
|
||||||
|
return [totalHours, minutes.value, seconds.value]
|
||||||
|
.map((part) => String(part).padStart(2, '0'))
|
||||||
|
.join(':');
|
||||||
|
}
|
||||||
|
|
||||||
|
function durationLabel(value, forms) {
|
||||||
|
const mod10 = value % 10;
|
||||||
|
const mod100 = value % 100;
|
||||||
|
return mod10 === 1 && mod100 !== 11
|
||||||
|
? forms[0]
|
||||||
|
: mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14) ? forms[1] : forms[2];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function connectionDurationParts(startedAt, now = Date.now()) {
|
||||||
const started = Date.parse(startedAt);
|
const started = Date.parse(startedAt);
|
||||||
const totalSeconds = Number.isFinite(started)
|
const totalSeconds = Number.isFinite(started)
|
||||||
? Math.max(0, Math.floor((now - started) / 1000))
|
? Math.max(0, Math.floor((now - started) / 1000))
|
||||||
: 0;
|
: 0;
|
||||||
const hours = Math.floor(totalSeconds / 3600);
|
const days = Math.floor(totalSeconds / 86_400);
|
||||||
|
const totalHours = Math.floor(totalSeconds / 3600);
|
||||||
|
const hours = Math.floor((totalSeconds % 86_400) / 3600);
|
||||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||||
const seconds = totalSeconds % 60;
|
const seconds = totalSeconds % 60;
|
||||||
|
|
||||||
return [hours, minutes, seconds]
|
return {
|
||||||
.map((part) => String(part).padStart(2, '0'))
|
totalHours,
|
||||||
.join(':');
|
days: { value: days, label: durationLabel(days, ['день', 'дня', 'дней']) },
|
||||||
|
hours: { value: hours, label: durationLabel(hours, ['час', 'часа', 'часов']) },
|
||||||
|
minutes: { value: minutes, label: durationLabel(minutes, ['минута', 'минуты', 'минут']) },
|
||||||
|
seconds: { value: seconds, label: durationLabel(seconds, ['секунда', 'секунды', 'секунд']) },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatConnectionDurationWords(startedAt, now = Date.now()) {
|
||||||
|
const { days, hours, minutes, seconds } = connectionDurationParts(startedAt, now);
|
||||||
|
|
||||||
|
return [
|
||||||
|
days.value && `${days.value} ${days.label}`,
|
||||||
|
hours.value && `${hours.value} ${hours.label}`,
|
||||||
|
minutes.value && `${minutes.value} ${minutes.label}`,
|
||||||
|
`${seconds.value} ${seconds.label}`,
|
||||||
|
].filter(Boolean).join(' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function subscriptionDomain(subscriptionHost) {
|
export function subscriptionDomain(subscriptionHost) {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
connectionAction,
|
connectionAction,
|
||||||
copyText,
|
copyText,
|
||||||
formatConnectionDuration,
|
formatConnectionDuration,
|
||||||
|
formatConnectionDurationWords,
|
||||||
localProxyUrls,
|
localProxyUrls,
|
||||||
subscriptionDomain,
|
subscriptionDomain,
|
||||||
subscriptionDaysLeft,
|
subscriptionDaysLeft,
|
||||||
@@ -30,6 +31,14 @@ test('connection duration is derived from the backend start time', () => {
|
|||||||
assert.equal(formatConnectionDuration(null, now), '00:00:00');
|
assert.equal(formatConnectionDuration(null, now), '00:00:00');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('connection duration can be written with Russian units', () => {
|
||||||
|
const startedAt = '2026-07-10T10:00:00.000Z';
|
||||||
|
const now = Date.parse('2026-07-11T12:03:04.900Z');
|
||||||
|
|
||||||
|
assert.equal(formatConnectionDurationWords(startedAt, now), '1 день 2 часа 3 минуты 4 секунды');
|
||||||
|
assert.equal(formatConnectionDurationWords(null, now), '0 секунд');
|
||||||
|
});
|
||||||
|
|
||||||
test('saved subscription is reduced to its public domain', () => {
|
test('saved subscription is reduced to its public domain', () => {
|
||||||
assert.equal(subscriptionDomain('sub.example.com/…'), 'sub.example.com');
|
assert.equal(subscriptionDomain('sub.example.com/…'), 'sub.example.com');
|
||||||
assert.equal(subscriptionDomain(''), '');
|
assert.equal(subscriptionDomain(''), '');
|
||||||
|
|||||||
Reference in New Issue
Block a user