860 lines
35 KiB
JavaScript
860 lines
35 KiB
JavaScript
import React, { useEffect, useRef, useState } from 'react';
|
||
import { flushSync } from 'react-dom';
|
||
import { api } from '../api.js';
|
||
import {
|
||
connectionAction,
|
||
connectionDurationParts,
|
||
copyText,
|
||
localProxyUrls,
|
||
subscriptionDomain,
|
||
subscriptionDaysLeft,
|
||
subscriptionUsage,
|
||
} from '../utils/clientControls.js';
|
||
import { formatBytes } from '../utils/format.js';
|
||
import { instructionBlocks } from '../instructions.js';
|
||
|
||
const SUBSCRIPTION_REVEAL_DELAY_MS = 1350;
|
||
const DURATION_MODE_STORAGE_KEY = 'harbor-duration-mode';
|
||
|
||
function CloudTooltip({ children }) {
|
||
return <span className="client-tooltip" role="tooltip">{children}</span>;
|
||
}
|
||
|
||
function InstructionStep({ step }) {
|
||
if (typeof step === 'string') return step;
|
||
return (
|
||
<>
|
||
{step.before}
|
||
<a href={step.link[1]} target="_blank" rel="noreferrer">{step.link[0]}</a>
|
||
{step.after}
|
||
</>
|
||
);
|
||
}
|
||
|
||
function InstructionBlock({ block, open, onToggle }) {
|
||
return (
|
||
<section
|
||
className={`client-instruction-block${open ? ' is-open' : ''}`}
|
||
style={{ viewTransitionName: `instruction-${block.id}` }}
|
||
>
|
||
<button
|
||
className="client-instruction-summary"
|
||
type="button"
|
||
aria-expanded={open}
|
||
onClick={onToggle}
|
||
>
|
||
<span>{block.label}</span>
|
||
<strong>{block.title}</strong>
|
||
<small>{block.summary}</small>
|
||
<i aria-hidden="true" />
|
||
</button>
|
||
<div className="client-instruction-reveal" aria-hidden={!open} inert={!open ? true : undefined}>
|
||
<div className="client-instruction-body">
|
||
{block.paragraphs?.map((paragraph) => <p key={paragraph}>{paragraph}</p>)}
|
||
{block.steps && (
|
||
<ol>
|
||
{block.steps.map((step) => (
|
||
<li key={typeof step === 'string' ? step : step.link[1]}>
|
||
<InstructionStep step={step} />
|
||
</li>
|
||
))}
|
||
</ol>
|
||
)}
|
||
{block.code && <code>{block.code}</code>}
|
||
{block.note && <p className="client-instruction-note">{block.note}</p>}
|
||
</div>
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
function DurationPart({ name, children }) {
|
||
return (
|
||
<span
|
||
className={`client-duration-part client-duration-${name}${name.endsWith('-value') ? ' is-value' : ' is-label'}`}
|
||
>
|
||
{children}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
function HarborBrand({ isGateway, gatewayAvailable, gatewayDirect, busy, onSetGatewayAuto }) {
|
||
const [modeAnimating, setModeAnimating] = useState(false);
|
||
const stopModeAnimationRef = useRef(false);
|
||
const product = isGateway ? 'Gateway' : 'Connect';
|
||
const switchable = !isGateway && gatewayAvailable;
|
||
const label = gatewayDirect
|
||
? 'Игнорировать Harbor Gateway и использовать локальный VPN'
|
||
: 'Использовать обнаруженный Harbor Gateway';
|
||
|
||
function startModeAnimation() {
|
||
stopModeAnimationRef.current = false;
|
||
setModeAnimating(true);
|
||
}
|
||
|
||
function finishModeAnimation() {
|
||
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||
setModeAnimating(false);
|
||
return;
|
||
}
|
||
stopModeAnimationRef.current = true;
|
||
}
|
||
|
||
const content = <div className="harbor-brand-content">
|
||
<svg viewBox="0 0 32 32" aria-hidden="true">
|
||
<circle cx="16" cy="6" r="3" />
|
||
<path d="M16 9v15M10 14h12" />
|
||
<path className="harbor-anchor-left" d="M16 28C11 28 8.4 25.2 6.3 22v-3.3M3.8 21.4l2.5-2.7 2.5 2.7" />
|
||
<path className="harbor-anchor-right" d="M16 28C21 28 23.6 25.2 25.7 22v-3.3M23.2 21.4l2.5-2.7 2.5 2.7" />
|
||
</svg>
|
||
<span className="harbor-brand-name">
|
||
<strong>Harbor</strong>
|
||
{switchable ? <span className="harbor-mode-control">
|
||
<span className="harbor-mode-stack" aria-hidden="true">
|
||
<em className="harbor-mode-connect">Connect</em>
|
||
<em className="harbor-mode-gateway">Gateway</em>
|
||
</span>
|
||
<svg className="harbor-mode-swap" viewBox="0 0 18 18" aria-hidden="true">
|
||
<g className="is-connect"><path d="M3 6h10m-3-3 3 3-3 3" /></g>
|
||
<g className="is-gateway"><path d="M15 12H5m3 3-3-3 3-3" /></g>
|
||
</svg>
|
||
<span id="harbor-mode-tooltip" className="harbor-mode-tooltip" role="tooltip">
|
||
<strong>{gatewayDirect ? 'Harbor Gateway активен' : 'Harbor Gateway доступен'}</strong>
|
||
<span>{gatewayDirect
|
||
? 'Трафик идёт через Gateway в этой сети. Нажмите, чтобы использовать локальный VPN.'
|
||
: 'Сейчас используется локальный VPN. Нажмите, чтобы направить трафик через Gateway.'}</span>
|
||
</span>
|
||
</span> : <em>{product}</em>}
|
||
</span>
|
||
</div>;
|
||
|
||
return (
|
||
<div className={`harbor-brand is-${product.toLowerCase()}${switchable ? ` is-switchable${gatewayDirect ? ' is-gateway-active' : ''}` : ''}`}>
|
||
{switchable ? <button
|
||
className={`harbor-brand-control${modeAnimating ? ' is-mode-animating' : ''}`}
|
||
type="button"
|
||
aria-label={label}
|
||
aria-describedby="harbor-mode-tooltip"
|
||
aria-pressed={gatewayDirect}
|
||
disabled={busy}
|
||
onPointerEnter={startModeAnimation}
|
||
onPointerLeave={finishModeAnimation}
|
||
onFocus={startModeAnimation}
|
||
onBlur={finishModeAnimation}
|
||
onAnimationIteration={(event) => {
|
||
if (event.animationName === 'harbor-mode-float-front' && stopModeAnimationRef.current) {
|
||
stopModeAnimationRef.current = false;
|
||
setModeAnimating(false);
|
||
}
|
||
}}
|
||
onClick={() => onSetGatewayAuto(!gatewayDirect)}
|
||
>
|
||
{content}
|
||
</button> : <div aria-label={`Harbor ${product}`}>{content}</div>}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function ClientOverviewPage({
|
||
state,
|
||
busy,
|
||
error,
|
||
subscriptionUrl,
|
||
setSubscriptionUrl,
|
||
servers,
|
||
pendingTag,
|
||
setPendingTag,
|
||
onFetchSubscription,
|
||
onRefreshSubscription,
|
||
onForgetSubscription,
|
||
onApply,
|
||
onRestart,
|
||
onStop,
|
||
onSetGatewayAuto,
|
||
}) {
|
||
const isGateway = state?.mode === 'gateway';
|
||
const gatewayDirect = !isGateway && state?.gatewayAuto?.mode === 'gateway-direct';
|
||
const gatewayAvailable = !isGateway && Boolean(state?.gatewayAuto?.available);
|
||
const connected = Boolean(state?.singboxRunning);
|
||
const hasSubscription = Boolean(state?.hasSubscription);
|
||
const selectedTag = pendingTag || state?.selectedTag || '';
|
||
const showPower = hasSubscription && Boolean(selectedTag);
|
||
const canStart = Boolean(selectedTag || state?.configExists);
|
||
const [now, setNow] = useState(Date.now());
|
||
const [durationMode, setDurationMode] = useState(() => {
|
||
try {
|
||
return localStorage.getItem(DURATION_MODE_STORAGE_KEY) === 'words' ? 'words' : 'digital';
|
||
} catch {
|
||
return 'digital';
|
||
}
|
||
});
|
||
const [editingSubscription, setEditingSubscription] = useState(!state?.hasSubscription);
|
||
const [subscriptionValidation, setSubscriptionValidation] = useState({ url: '', status: 'idle' });
|
||
const [showIntro, setShowIntro] = useState(!hasSubscription);
|
||
const [subscriptionContentReady, setSubscriptionContentReady] = useState(hasSubscription);
|
||
const [pings, setPings] = useState({});
|
||
const [accessTab, setAccessTab] = useState('gateway');
|
||
const [copyFeedback, setCopyFeedback] = useState(null);
|
||
const [refreshingInfo, setRefreshingInfo] = useState(false);
|
||
const [usageUpdated, setUsageUpdated] = useState(false);
|
||
const [serverRevealVersion, setServerRevealVersion] = useState(0);
|
||
const [serversLeaving, setServersLeaving] = useState(false);
|
||
const [instructionsOpen, setInstructionsOpen] = useState(false);
|
||
const [confirmingDelete, setConfirmingDelete] = useState(false);
|
||
const [openInstructionId, setOpenInstructionId] = useState('');
|
||
const subscriptionInputRef = useRef(null);
|
||
const subscriptionRef = useRef(null);
|
||
const copyTimerRef = useRef(null);
|
||
const instructionsPanelRef = useRef(null);
|
||
const instructionsToggleRef = useRef(null);
|
||
const previousHasSubscriptionRef = useRef(hasSubscription);
|
||
const serverKey = servers.map((server) => `${server.tag}:${server.server}:${server.server_port}`).join('|');
|
||
const gatewayAddress = isGateway ? window.location.hostname : '127.0.0.1';
|
||
const proxyUrls = localProxyUrls(state?.proxyPort, gatewayAddress);
|
||
const usage = subscriptionUsage(state?.userInfo);
|
||
const duration = connectionDurationParts(state?.singboxStartedAt, now);
|
||
const [displayedUsed, setDisplayedUsed] = useState(usage.used);
|
||
const hasUsage = Boolean(
|
||
state?.userInfo && ['upload', 'download', 'total', 'expire'].some((key) => key in state.userInfo),
|
||
);
|
||
const instructions = instructionBlocks({
|
||
isGateway,
|
||
host: gatewayAddress,
|
||
port: state?.proxyPort || (isGateway ? 8080 : 8082),
|
||
});
|
||
const [instructionsIntro, ...instructionGuides] = instructions;
|
||
const openInstruction = instructionGuides.find((block) => block.id === openInstructionId);
|
||
const orderedInstructionGuides = openInstruction
|
||
? [openInstruction, ...instructionGuides.filter((block) => block.id !== openInstructionId)]
|
||
: instructionGuides;
|
||
const normalizedSubscriptionUrl = subscriptionUrl.trim();
|
||
const subscriptionValidationStatus = subscriptionValidation.url === normalizedSubscriptionUrl
|
||
? subscriptionValidation.status
|
||
: normalizedSubscriptionUrl ? 'checking' : 'idle';
|
||
const subscriptionWaiting = hasSubscription && !subscriptionContentReady;
|
||
|
||
useEffect(() => {
|
||
setNow(Date.now());
|
||
if (!connected || !state?.singboxStartedAt) return undefined;
|
||
|
||
const timer = setInterval(() => setNow(Date.now()), 1000);
|
||
return () => clearInterval(timer);
|
||
}, [connected, state?.singboxStartedAt]);
|
||
|
||
useEffect(() => {
|
||
if (!servers.length) {
|
||
setPings({});
|
||
return undefined;
|
||
}
|
||
|
||
let cancelled = false;
|
||
setPings(Object.fromEntries(servers.map((server) => [server.tag, { checking: true }])));
|
||
api.servers.pingAll()
|
||
.then((data) => {
|
||
if (cancelled) return;
|
||
setPings(Object.fromEntries((data.results || []).map((ping) => [
|
||
String(ping.tag || '').trim(),
|
||
ping,
|
||
])));
|
||
})
|
||
.catch(() => {
|
||
if (!cancelled) {
|
||
setPings(Object.fromEntries(servers.map((server) => [server.tag, { ok: false }])));
|
||
}
|
||
});
|
||
|
||
return () => { cancelled = true; };
|
||
}, [serverKey]);
|
||
|
||
useEffect(() => {
|
||
if (editingSubscription) subscriptionInputRef.current?.focus();
|
||
}, [editingSubscription]);
|
||
|
||
useEffect(() => {
|
||
if (!showIntro) return undefined;
|
||
const timer = setTimeout(() => setShowIntro(false), 1200);
|
||
return () => clearTimeout(timer);
|
||
}, [showIntro]);
|
||
|
||
useEffect(() => {
|
||
const previouslyHadSubscription = previousHasSubscriptionRef.current;
|
||
previousHasSubscriptionRef.current = hasSubscription;
|
||
|
||
if (!hasSubscription) {
|
||
setSubscriptionContentReady(false);
|
||
return undefined;
|
||
}
|
||
if (previouslyHadSubscription) {
|
||
setSubscriptionContentReady(true);
|
||
return undefined;
|
||
}
|
||
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||
setSubscriptionContentReady(true);
|
||
return undefined;
|
||
}
|
||
|
||
const timer = setTimeout(() => setSubscriptionContentReady(true), SUBSCRIPTION_REVEAL_DELAY_MS);
|
||
return () => clearTimeout(timer);
|
||
}, [hasSubscription]);
|
||
|
||
useEffect(() => {
|
||
if (!normalizedSubscriptionUrl) {
|
||
setSubscriptionValidation({ url: '', status: 'idle' });
|
||
return undefined;
|
||
}
|
||
|
||
const controller = new AbortController();
|
||
setSubscriptionValidation({ url: normalizedSubscriptionUrl, status: 'checking' });
|
||
const timer = setTimeout(() => {
|
||
api.subscription.validate(normalizedSubscriptionUrl, controller.signal)
|
||
.then(() => setSubscriptionValidation({ url: normalizedSubscriptionUrl, status: 'valid' }))
|
||
.catch(() => {
|
||
if (!controller.signal.aborted) {
|
||
setSubscriptionValidation({ url: normalizedSubscriptionUrl, status: 'invalid' });
|
||
}
|
||
});
|
||
}, 350);
|
||
|
||
return () => {
|
||
clearTimeout(timer);
|
||
controller.abort();
|
||
};
|
||
}, [normalizedSubscriptionUrl]);
|
||
|
||
useEffect(() => {
|
||
if (!hasSubscription) {
|
||
setEditingSubscription(true);
|
||
setInstructionsOpen(false);
|
||
}
|
||
}, [hasSubscription]);
|
||
|
||
useEffect(() => {
|
||
if (!editingSubscription || !state?.hasSubscription || subscriptionUrl) return undefined;
|
||
const timer = setTimeout(() => setEditingSubscription(false), 5000);
|
||
return () => clearTimeout(timer);
|
||
}, [editingSubscription, state?.hasSubscription, subscriptionUrl]);
|
||
|
||
useEffect(() => {
|
||
if (!editingSubscription || !hasSubscription) return undefined;
|
||
const closeOnOutsideClick = (event) => {
|
||
if (subscriptionRef.current?.contains(event.target)) return;
|
||
setSubscriptionUrl('');
|
||
setEditingSubscription(false);
|
||
};
|
||
document.addEventListener('pointerdown', closeOnOutsideClick);
|
||
return () => document.removeEventListener('pointerdown', closeOnOutsideClick);
|
||
}, [editingSubscription, hasSubscription, setSubscriptionUrl]);
|
||
|
||
useEffect(() => {
|
||
if (!state?.hasSubscription) return undefined;
|
||
onRefreshSubscription().catch(() => {});
|
||
return undefined;
|
||
}, [state?.hasSubscription]);
|
||
|
||
useEffect(() => {
|
||
const from = displayedUsed;
|
||
const to = usage.used;
|
||
if (from === to) return undefined;
|
||
const startedAt = performance.now();
|
||
let frame;
|
||
const tick = (now) => {
|
||
const progress = Math.min(1, (now - startedAt) / 900);
|
||
const eased = 1 - Math.pow(1 - progress, 4);
|
||
setDisplayedUsed(from + (to - from) * eased);
|
||
if (progress < 1) frame = requestAnimationFrame(tick);
|
||
};
|
||
frame = requestAnimationFrame(tick);
|
||
return () => cancelAnimationFrame(frame);
|
||
}, [usage.used]);
|
||
|
||
useEffect(() => () => clearTimeout(copyTimerRef.current), []);
|
||
|
||
useEffect(() => {
|
||
if (!instructionsOpen) return undefined;
|
||
const closeOnEscape = (event) => {
|
||
if (event.key === 'Escape') setInstructionsOpen(false);
|
||
};
|
||
document.addEventListener('keydown', closeOnEscape);
|
||
return () => document.removeEventListener('keydown', closeOnEscape);
|
||
}, [instructionsOpen]);
|
||
|
||
useEffect(() => {
|
||
if (!instructionsOpen) return undefined;
|
||
const closeInstructionsOutside = (event) => {
|
||
if (instructionsPanelRef.current?.contains(event.target)) return;
|
||
if (instructionsToggleRef.current?.contains(event.target)) return;
|
||
setInstructionsOpen(false);
|
||
};
|
||
document.addEventListener('pointerdown', closeInstructionsOutside);
|
||
return () => document.removeEventListener('pointerdown', closeInstructionsOutside);
|
||
}, [instructionsOpen]);
|
||
|
||
async function toggleConnection() {
|
||
const action = connectionAction({ connected, selectedTag, configExists: state?.configExists });
|
||
if (action?.type === 'stop') return onStop();
|
||
if (action?.type === 'apply') return onApply(action.selectedTag);
|
||
if (action?.type === 'restart') return onRestart();
|
||
}
|
||
|
||
function selectServer(tag) {
|
||
setPendingTag(tag);
|
||
if (connected && tag) onApply(tag);
|
||
}
|
||
|
||
async function submitSubscription(event) {
|
||
event.preventDefault();
|
||
if (subscriptionValidationStatus !== 'valid') return;
|
||
await onFetchSubscription();
|
||
setSubscriptionUrl('');
|
||
setEditingSubscription(false);
|
||
}
|
||
|
||
async function copyProxy(kind) {
|
||
const value = kind === 'gateway' ? gatewayAddress : proxyUrls[kind];
|
||
clearTimeout(copyTimerRef.current);
|
||
setCopyFeedback({ kind, failed: false });
|
||
copyTimerRef.current = setTimeout(() => setCopyFeedback(null), 800);
|
||
try {
|
||
await copyText(value);
|
||
} catch {
|
||
setCopyFeedback({ kind, failed: true });
|
||
}
|
||
}
|
||
|
||
async function refreshSubscription() {
|
||
const startedAt = performance.now();
|
||
setRefreshingInfo(true);
|
||
try {
|
||
await onRefreshSubscription();
|
||
setUsageUpdated(false);
|
||
requestAnimationFrame(() => setUsageUpdated(true));
|
||
setTimeout(() => setUsageUpdated(false), 900);
|
||
setServersLeaving(true);
|
||
await new Promise((resolve) => setTimeout(resolve, 420 + Math.max(0, servers.length - 1) * 90));
|
||
setServerRevealVersion((version) => version + 1);
|
||
setServersLeaving(false);
|
||
} finally {
|
||
const elapsed = performance.now() - startedAt;
|
||
const completeCyclesAt = Math.max(900, Math.ceil(elapsed / 900) * 900);
|
||
await new Promise((resolve) => setTimeout(resolve, completeCyclesAt - elapsed));
|
||
setRefreshingInfo(false);
|
||
}
|
||
}
|
||
|
||
async function forgetSubscription() {
|
||
await onForgetSubscription();
|
||
setConfirmingDelete(false);
|
||
}
|
||
|
||
function toggleInstruction(id) {
|
||
const update = () => flushSync(() => {
|
||
setOpenInstructionId((current) => current === id ? '' : id);
|
||
});
|
||
|
||
if (!document.startViewTransition || matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||
update();
|
||
return;
|
||
}
|
||
|
||
document.startViewTransition(update);
|
||
}
|
||
|
||
function toggleDurationMode() {
|
||
setDurationMode((mode) => {
|
||
const nextMode = mode === 'digital' ? 'words' : 'digital';
|
||
try {
|
||
localStorage.setItem(DURATION_MODE_STORAGE_KEY, nextMode);
|
||
} catch {
|
||
// The visual preference still works for this session.
|
||
}
|
||
return nextMode;
|
||
});
|
||
}
|
||
|
||
return (
|
||
<div className={`client-shell${hasSubscription ? '' : ' is-first-run'}${showIntro && !hasSubscription ? ' is-intro' : ''}`}>
|
||
<HarborBrand
|
||
isGateway={isGateway}
|
||
gatewayAvailable={gatewayAvailable}
|
||
gatewayDirect={gatewayDirect}
|
||
busy={busy}
|
||
onSetGatewayAuto={onSetGatewayAuto}
|
||
/>
|
||
{hasSubscription && subscriptionContentReady && <button
|
||
ref={instructionsToggleRef}
|
||
className={`client-instructions-toggle${instructionsOpen ? ' is-open' : ''}`}
|
||
type="button"
|
||
aria-expanded={instructionsOpen}
|
||
aria-controls="client-instructions"
|
||
aria-label={instructionsOpen ? 'Закрыть инструкции' : 'Как использовать'}
|
||
onClick={() => setInstructionsOpen((open) => !open)}
|
||
>
|
||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||
<path d="m3 5 7 7-7 7" />
|
||
<path d="m7 5 7 7-7 7" />
|
||
<path d="m11 5 7 7-7 7" />
|
||
</svg>
|
||
<span>Как использовать</span>
|
||
</button>}
|
||
<main className={`client-panel${showPower ? '' : ' is-setup'}`}>
|
||
{showPower && (
|
||
<section className="client-power-section" aria-labelledby="connection-title">
|
||
<button
|
||
className="client-power"
|
||
type="button"
|
||
role="switch"
|
||
aria-checked={connected}
|
||
aria-label={connected ? 'Остановить Harbor Connect' : 'Запустить Harbor Connect'}
|
||
disabled={busy || (!connected && !canStart)}
|
||
onClick={toggleConnection}
|
||
>
|
||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||
<path d="M12 2v10M5.6 5.6a9 9 0 1 0 12.8 0" />
|
||
</svg>
|
||
</button>
|
||
<div className="client-state-copy" aria-live="polite">
|
||
<h2 key={connected ? 'connected' : 'disconnected'} id="connection-title">
|
||
{connected
|
||
? gatewayDirect ? 'Gateway подключён' : 'VPN включён'
|
||
: 'Подключение выключено'}
|
||
</h2>
|
||
<div className="client-state-detail">
|
||
{connected ? (
|
||
<button
|
||
className={`client-duration-toggle client-tooltip-anchor${durationMode === 'words' ? ' is-words' : ''}`}
|
||
type="button"
|
||
key="duration"
|
||
aria-label={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" key={`digital-${duration.seconds.value}`}>{String(duration.seconds.value).padStart(2, '0')}</DurationPart>
|
||
</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`} key={`${name}-${name === 'seconds' ? part.value : 'value'}`}>{part.value}</DurationPart>{' '}
|
||
<DurationPart name={`${name}-label`}>{part.label}</DurationPart>
|
||
</span>
|
||
))}
|
||
</time>
|
||
</span>
|
||
<CloudTooltip>{durationMode === 'digital' ? 'Показать время словами' : 'Показать цифровой таймер'}</CloudTooltip>
|
||
</button>
|
||
) : (
|
||
<p key="hint">
|
||
{canStart ? 'Нажмите, чтобы включить' : 'Добавьте ссылку и выберите сервер'}
|
||
</p>
|
||
)}
|
||
</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="Способ подключения">
|
||
{[
|
||
['gateway', 'Gateway'],
|
||
['proxy', 'Gateway Proxy'],
|
||
].map(([tab, label]) => (
|
||
<button
|
||
className={`client-access-tab${accessTab === tab ? ' is-active' : ''}`}
|
||
type="button"
|
||
role="tab"
|
||
key={tab}
|
||
aria-selected={accessTab === tab}
|
||
onClick={() => setAccessTab(tab)}
|
||
>
|
||
{label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
{(isGateway ? accessTab === 'gateway' : false) ? (
|
||
<div className="client-access-point" role="tabpanel" key="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">{copyFeedback.failed ? 'Error' : 'Copied'}</span>}
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<div className="client-access-point" role={isGateway ? 'tabpanel' : undefined} key="proxy">
|
||
{!isGateway && (
|
||
<span className={`client-proxy-label${gatewayDirect ? ' is-gateway' : ''}`}>
|
||
{gatewayDirect
|
||
? `Через Harbor Gateway · ${state.gatewayAuto.address}`
|
||
: 'Локальный VPN'}
|
||
</span>
|
||
)}
|
||
<strong className="client-proxy-address">
|
||
{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">{copyFeedback.failed ? 'Error' : 'Copied'}</span>}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</section>
|
||
</section>
|
||
)}
|
||
|
||
{error && <p className="client-error" role="alert">{error}</p>}
|
||
|
||
<div
|
||
className={`client-form${subscriptionWaiting ? ' is-waiting' : ''}${confirmingDelete ? ' is-confirming-delete' : ''}`}
|
||
aria-hidden={subscriptionWaiting}
|
||
aria-disabled={gatewayDirect}
|
||
inert={subscriptionWaiting || gatewayDirect ? true : undefined}
|
||
>
|
||
{confirmingDelete && <section
|
||
className="client-delete-confirmation"
|
||
role="group"
|
||
aria-labelledby="delete-subscription-title"
|
||
aria-describedby="delete-subscription-description"
|
||
>
|
||
<span>Необратимое действие</span>
|
||
<h2 id="delete-subscription-title">Удалить подписку?</h2>
|
||
<p id="delete-subscription-description">
|
||
Harbor остановит VPN и удалит сохранённую подписку. Приложения, настроенные на локальный прокси, не смогут выходить в сеть до добавления новой подписки.
|
||
</p>
|
||
<div className="client-delete-actions">
|
||
<button type="button" disabled={busy} onClick={() => setConfirmingDelete(false)}>Отмена</button>
|
||
<button className="is-danger" type="button" disabled={busy} onClick={forgetSubscription}>Удалить</button>
|
||
</div>
|
||
</section>}
|
||
<div className="client-form-content" inert={confirmingDelete ? true : undefined}>
|
||
<div
|
||
ref={subscriptionRef}
|
||
className={`client-subscription ${editingSubscription ? 'is-editing' : ''}${editingSubscription && state?.hasSubscription && !subscriptionUrl ? ' is-timing-out' : ''}`}
|
||
>
|
||
<div
|
||
className="client-subscription-summary"
|
||
aria-hidden={editingSubscription}
|
||
inert={editingSubscription ? true : undefined}
|
||
>
|
||
<div className="client-subscription-heading">
|
||
<span>Ваша подписка</span>
|
||
<button
|
||
className="client-subscription-refresh client-tooltip-anchor"
|
||
type="button"
|
||
aria-label="Обновить подписку"
|
||
disabled={refreshingInfo}
|
||
onClick={refreshSubscription}
|
||
>
|
||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||
<path d="M21 12a9 9 0 0 0-15.2-6.5L3 8m0-5v5h5M3 12a9 9 0 0 0 15.2 6.5L21 16m0 5v-5h-5" />
|
||
</svg>
|
||
<CloudTooltip>Обновить подписку</CloudTooltip>
|
||
</button>
|
||
<button
|
||
className="client-subscription-delete client-tooltip-anchor"
|
||
type="button"
|
||
aria-label="Удалить подписку"
|
||
disabled={busy}
|
||
onClick={() => setConfirmingDelete(true)}
|
||
>
|
||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||
<path className="client-trash-lid" d="M4 7h16M9 7V4h6v3" />
|
||
<path d="m6 7 1 13h10l1-13M10 11v5M14 11v5" />
|
||
</svg>
|
||
<CloudTooltip>Удалить подписку</CloudTooltip>
|
||
</button>
|
||
</div>
|
||
<button
|
||
className="client-subscription-domain-button"
|
||
type="button"
|
||
tabIndex={editingSubscription ? -1 : 0}
|
||
onClick={() => setEditingSubscription(true)}
|
||
>
|
||
<strong>{subscriptionDomain(state?.subscriptionHost)}</strong>
|
||
</button>
|
||
</div>
|
||
|
||
<form
|
||
className={`client-subscription-edit is-${subscriptionValidationStatus}`}
|
||
autoComplete="off"
|
||
aria-hidden={!editingSubscription}
|
||
inert={!editingSubscription ? true : undefined}
|
||
onSubmit={submitSubscription}
|
||
>
|
||
<input
|
||
ref={subscriptionInputRef}
|
||
id="subscription-url"
|
||
type="url"
|
||
inputMode="url"
|
||
autoComplete="off"
|
||
tabIndex={editingSubscription ? 0 : -1}
|
||
aria-label="Ссылка подписки"
|
||
placeholder="Вставьте ссылку подписки"
|
||
className={subscriptionUrl ? 'has-value' : ''}
|
||
value={subscriptionUrl}
|
||
onChange={(event) => setSubscriptionUrl(event.target.value)}
|
||
onKeyDown={(event) => {
|
||
if (event.key === 'Escape' && state?.hasSubscription) {
|
||
setSubscriptionUrl('');
|
||
setEditingSubscription(false);
|
||
}
|
||
}}
|
||
/>
|
||
{subscriptionUrl && (
|
||
<span className="client-subscription-domain">
|
||
{subscriptionDomain(subscriptionUrl)}
|
||
</span>
|
||
)}
|
||
{normalizedSubscriptionUrl && (
|
||
<button
|
||
className="client-subscription-submit"
|
||
type="submit"
|
||
aria-label={subscriptionValidationStatus === 'valid'
|
||
? 'Сохранить подписку'
|
||
: subscriptionValidationStatus === 'invalid'
|
||
? 'Ссылка подписки не распознана'
|
||
: 'Проверяем ссылку подписки'}
|
||
disabled={busy || subscriptionValidationStatus !== 'valid'}
|
||
>
|
||
{subscriptionValidationStatus === 'valid'
|
||
? '✓'
|
||
: subscriptionValidationStatus === 'invalid' ? '×' : '…'}
|
||
</button>
|
||
)}
|
||
</form>
|
||
</div>
|
||
|
||
{hasSubscription && subscriptionContentReady && hasUsage && (
|
||
<section className={`client-usage${usageUpdated ? ' is-updated' : ''}`} aria-label="Статистика подписки">
|
||
<span>Использовано</span>
|
||
<strong>
|
||
{formatBytes(displayedUsed)}
|
||
<small> / {usage.total ? formatBytes(usage.total) : 'без лимита'}</small>
|
||
</strong>
|
||
{usage.percent !== null && (
|
||
<div
|
||
className="client-usage-bar"
|
||
role="progressbar"
|
||
aria-label="Использованный трафик"
|
||
aria-valuemin="0"
|
||
aria-valuemax="100"
|
||
aria-valuenow={Math.round(usage.percent)}
|
||
>
|
||
<i style={{ width: `${usage.percent}%` }} />
|
||
</div>
|
||
)}
|
||
<div className="client-usage-details">
|
||
{usage.expiresAt && !Number.isNaN(usage.expiresAt.getTime()) && (
|
||
<span>
|
||
до {usage.expiresAt.toLocaleDateString('ru-RU', { day: 'numeric', month: 'long' })}
|
||
{' · '}{subscriptionDaysLeft(usage.expiresAt)}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</section>
|
||
)}
|
||
|
||
{hasSubscription && subscriptionContentReady && (
|
||
<section className="client-servers" aria-label="Выберите сервер">
|
||
{!showPower && <span className="client-server-prompt">Выберите сервер</span>}
|
||
<div
|
||
className={`client-server-grid${serversLeaving ? ' is-leaving' : ''}`}
|
||
key={`${serverKey}:${serverRevealVersion}`}
|
||
>
|
||
{servers.map((server, index) => {
|
||
const ping = pings[server.tag];
|
||
const selected = server.tag === selectedTag;
|
||
const pingText = ping?.checking
|
||
? 'Проверка…'
|
||
: ping?.ok ? `${ping.latency} ms` : 'Недоступен';
|
||
const pingClass = ping?.ok
|
||
? ping.latency < 100 ? 'good' : ping.latency < 250 ? 'medium' : 'slow'
|
||
: '';
|
||
|
||
return (
|
||
<button
|
||
className={`client-server ${selected ? 'is-selected' : ''}`}
|
||
type="button"
|
||
key={server.tag}
|
||
disabled={busy}
|
||
aria-pressed={selected}
|
||
style={{ '--server-index': index }}
|
||
onClick={() => selectServer(server.tag)}
|
||
>
|
||
<strong>{server.tag}</strong>
|
||
<small className={pingClass}>{pingText}</small>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</section>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</main>
|
||
|
||
{hasSubscription && subscriptionContentReady && <aside
|
||
ref={instructionsPanelRef}
|
||
id="client-instructions"
|
||
className={`client-instructions${instructionsOpen ? ' is-open' : ''}`}
|
||
aria-labelledby="instructions-title"
|
||
aria-hidden={!instructionsOpen}
|
||
inert={!instructionsOpen ? true : undefined}
|
||
>
|
||
<div className="client-instructions-sheet">
|
||
<header className="client-instructions-header">
|
||
<span>Подключение</span>
|
||
<h2 id="instructions-title">Как использовать {isGateway ? 'Gateway' : 'прокси'}</h2>
|
||
<div className="client-instructions-intro">
|
||
{instructionsIntro.paragraphs.map((paragraph) => <p key={paragraph}>{paragraph}</p>)}
|
||
</div>
|
||
</header>
|
||
|
||
<div className="client-instruction-list">
|
||
{orderedInstructionGuides.map((block) => (
|
||
<InstructionBlock
|
||
block={block}
|
||
key={block.id}
|
||
open={block.id === openInstructionId}
|
||
onToggle={() => toggleInstruction(block.id)}
|
||
/>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</aside>}
|
||
|
||
</div>
|
||
);
|
||
}
|