Update VPN client and gateway behavior
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import { ConfirmationDialog } from '../../ui/ConfirmationDialog.js';
|
||||
import { CopyButton } from '../../ui/CopyButton.js';
|
||||
import { Tooltip } from '../../ui/Tooltip.js';
|
||||
import {
|
||||
connectionAction,
|
||||
connectionDurationParts,
|
||||
@@ -170,9 +172,9 @@ export function ConnectionPanel({
|
||||
>
|
||||
{brandSlot}
|
||||
{powerButton}
|
||||
{powerUnavailable && <span className="client-tooltip" id="gateway-power-unavailable" role="tooltip">
|
||||
{powerUnavailable && <Tooltip id="gateway-power-unavailable">
|
||||
{remoteOwned ? 'Подключением управляет Harbor Gateway' : 'Сначала добавьте подписку и выберите сервер'}
|
||||
</span>}
|
||||
</Tooltip>}
|
||||
</div>
|
||||
<div className="client-state-detail">
|
||||
{connected && !remoteOwned ? (
|
||||
@@ -216,9 +218,9 @@ export function ConnectionPanel({
|
||||
</span>
|
||||
</time>
|
||||
</span>
|
||||
<span className="client-tooltip" role="tooltip">
|
||||
<Tooltip>
|
||||
{durationMode === 'digital' ? 'Показать время словами' : 'Показать цифровой таймер'}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</button>
|
||||
) : (
|
||||
<p key="hint">
|
||||
@@ -254,16 +256,13 @@ export function ConnectionPanel({
|
||||
<div className="client-proxy-actions">
|
||||
{proxyKinds.map(([kind, label]) => {
|
||||
const feedback = copyFeedback?.[kind];
|
||||
return <button
|
||||
className={`client-copy-button${feedback ? feedback.failed ? ' is-copy-error' : ' is-copied' : ''}`}
|
||||
type="button"
|
||||
return <CopyButton
|
||||
label={label}
|
||||
feedback={feedback}
|
||||
key={kind}
|
||||
aria-label={`Скопировать ${label}: ${kind === 'gateway' ? gatewayAddress : proxyUrls[kind]}`}
|
||||
ariaLabel={`Скопировать ${label}: ${kind === 'gateway' ? gatewayAddress : proxyUrls[kind]}`}
|
||||
onClick={() => onCopyProxy(kind)}
|
||||
>
|
||||
<span className="client-copy-label">{label}</span>
|
||||
{feedback && <span className="client-copy-feedback" aria-hidden="true" key={feedback.cycle}>{feedback.failed ? 'Ошибка' : 'Скопировано'}</span>}
|
||||
</button>;
|
||||
/>;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { RailAction } from '../../ui/RailAction.js';
|
||||
import {
|
||||
formatByteString,
|
||||
formatLastSeen,
|
||||
@@ -191,13 +192,13 @@ export function useDevicesFeature({
|
||||
export type DevicesFeature = ReturnType<typeof useDevicesFeature>;
|
||||
|
||||
export function DevicesToggle({ feature, onToggle }: { feature: DevicesFeature; onToggle: () => void }) {
|
||||
return <button
|
||||
ref={feature.toggleRef}
|
||||
className={`client-instructions-toggle client-devices-toggle${feature.isOpen ? ' is-open' : ''}`}
|
||||
type="button"
|
||||
aria-expanded={feature.isOpen}
|
||||
aria-controls="client-devices"
|
||||
aria-label={feature.isOpen ? 'Закрыть устройства' : 'Устройства Gateway'}
|
||||
return <RailAction
|
||||
buttonRef={feature.toggleRef}
|
||||
className="client-instructions-toggle client-devices-toggle"
|
||||
open={feature.isOpen}
|
||||
controls="client-devices"
|
||||
ariaLabel={feature.isOpen ? 'Закрыть устройства' : 'Устройства Gateway'}
|
||||
label="Устройства"
|
||||
onClick={onToggle}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
@@ -205,8 +206,7 @@ export function DevicesToggle({ feature, onToggle }: { feature: DevicesFeature;
|
||||
<rect className="client-rail-device-secondary" x="13.5" y="8" width="7" height="7" rx="1.5" />
|
||||
<path className="client-rail-device-link" d="M6 19h12M7 15v4M17 15v4" />
|
||||
</svg>
|
||||
<span>Устройства</span>
|
||||
</button>;
|
||||
</RailAction>;
|
||||
}
|
||||
|
||||
export function GatewayTrafficSummary({ feature, now }: { feature: DevicesFeature; now: number }) {
|
||||
|
||||
@@ -5,8 +5,9 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
type CSSProperties,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
import { Drawer } from '../../ui/Drawer.js';
|
||||
import { Tooltip } from '../../ui/Tooltip.js';
|
||||
import { copyText } from '../../utils/clientControls.js';
|
||||
import {
|
||||
byteString,
|
||||
@@ -40,10 +41,6 @@ function requestMessage(value: unknown) {
|
||||
return typeof message === 'string' ? message : undefined;
|
||||
}
|
||||
|
||||
function Tooltip({ children }: { children: ReactNode }) {
|
||||
return <span className="client-tooltip" role="tooltip">{children}</span>;
|
||||
}
|
||||
|
||||
function TextMorph({ from, to }: { from: string; to: string }) {
|
||||
const anchor = from.length >= to.length ? from : to;
|
||||
return <span className="client-text-morph" aria-hidden="true">
|
||||
@@ -280,22 +277,17 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<aside
|
||||
ref={panelRef}
|
||||
<Drawer
|
||||
panelRef={panelRef}
|
||||
closeRef={closeRef}
|
||||
id="client-devices"
|
||||
className={`client-drawer client-instructions client-devices${open ? ' is-open' : ''}`}
|
||||
aria-labelledby="client-devices-title"
|
||||
aria-hidden={!open}
|
||||
inert={!open ? true : undefined}
|
||||
className="client-instructions client-devices"
|
||||
sheetClassName="client-instructions-sheet client-devices-sheet"
|
||||
open={open}
|
||||
labelledBy="client-devices-title"
|
||||
closeLabel="Закрыть устройства"
|
||||
onClose={onClose}
|
||||
>
|
||||
<div className="client-drawer-sheet client-instructions-sheet client-devices-sheet">
|
||||
<button
|
||||
ref={closeRef}
|
||||
className="client-drawer-close"
|
||||
type="button"
|
||||
aria-label="Закрыть устройства"
|
||||
onClick={onClose}
|
||||
>×</button>
|
||||
<header className="client-instructions-header client-devices-header">
|
||||
<div className="client-devices-kicker">
|
||||
<span>Gateway · {devices.length}</span>
|
||||
@@ -583,7 +575,6 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
|
||||
</article>;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
type FormEvent,
|
||||
} from 'react';
|
||||
import { flushSync } from 'react-dom';
|
||||
import { Drawer } from '../../ui/Drawer.js';
|
||||
import { Tooltip } from '../../ui/Tooltip.js';
|
||||
import {
|
||||
CONNECTIVITY_IP_SOURCES,
|
||||
CONNECTIVITY_SITES,
|
||||
@@ -324,23 +326,19 @@ export function ConnectivityDiagnosticsPanel({
|
||||
const { isOpen: open, panelRef, closeRef, close: onClose } = feature;
|
||||
|
||||
return (
|
||||
<aside
|
||||
ref={panelRef}
|
||||
<Drawer
|
||||
panelRef={panelRef}
|
||||
closeRef={closeRef}
|
||||
sheetRef={sheetRef}
|
||||
id="client-diagnostics"
|
||||
className={`client-drawer client-instructions client-diagnostics${open ? ' is-open' : ''}`}
|
||||
aria-labelledby="client-diagnostics-title"
|
||||
aria-hidden={!open}
|
||||
inert={!open ? true : undefined}
|
||||
className="client-instructions client-diagnostics"
|
||||
sheetClassName="client-instructions-sheet client-diagnostics-sheet"
|
||||
open={open}
|
||||
labelledBy="client-diagnostics-title"
|
||||
closeLabel="Закрыть диагностику"
|
||||
onClose={onClose}
|
||||
leading={<span ref={runnerRef} className="client-diagnostics-active-marker" aria-hidden="true" />}
|
||||
>
|
||||
<div ref={sheetRef} className="client-drawer-sheet client-instructions-sheet client-diagnostics-sheet">
|
||||
<span ref={runnerRef} className="client-diagnostics-active-marker" aria-hidden="true" />
|
||||
<button
|
||||
ref={closeRef}
|
||||
className="client-drawer-close"
|
||||
type="button"
|
||||
aria-label="Закрыть диагностику"
|
||||
onClick={onClose}
|
||||
>×</button>
|
||||
<header className="client-instructions-header client-diagnostics-header">
|
||||
<span>{isGateway ? 'Gateway' : 'Connect'} · Direct ↔ VPN</span>
|
||||
<div className="client-diagnostics-title-row">
|
||||
@@ -358,7 +356,7 @@ export function ConnectivityDiagnosticsPanel({
|
||||
<path d="M20 11a8 8 0 1 0-2.3 6.7M20 5v6h-6" />
|
||||
</svg>
|
||||
</button>
|
||||
<span className="client-tooltip" role="tooltip">Проверить маршруты</span>
|
||||
<Tooltip>Проверить маршруты</Tooltip>
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
@@ -420,7 +418,7 @@ export function ConnectivityDiagnosticsPanel({
|
||||
<span role="cell"><Status value={resultStatus(direct, running)} route={`Напрямую, ${site.label}`} /></span>
|
||||
<span role="cell"><Status value={resultStatus(vpn, running, result?.vpn?.available !== false)} route={`VPN, ${site.label}`} /></span>
|
||||
<button
|
||||
className="client-local-rule-delete"
|
||||
className="client-row-delete"
|
||||
type="button"
|
||||
aria-label={`Удалить сервис ${site.label}`}
|
||||
disabled={serviceEditorBlocked}
|
||||
@@ -466,7 +464,7 @@ export function ConnectivityDiagnosticsPanel({
|
||||
<button type="submit" disabled={serviceEditorBlocked}>Добавить</button>
|
||||
</span>
|
||||
<button
|
||||
className="client-local-rule-delete"
|
||||
className="client-row-delete"
|
||||
type="button"
|
||||
aria-label="Отменить добавление сервиса"
|
||||
onClick={() => removeService('draft')}
|
||||
@@ -480,9 +478,9 @@ export function ConnectivityDiagnosticsPanel({
|
||||
|
||||
{!sites.length && !adding && <p className="client-diagnostics-services-empty">Сервисов пока нет.</p>}
|
||||
|
||||
<div className="client-local-rule-add-slot client-diagnostics-add-slot">
|
||||
<div className="client-row-add-slot client-diagnostics-add-slot">
|
||||
<button
|
||||
className="client-local-rule-add"
|
||||
className="client-row-add"
|
||||
type="button"
|
||||
disabled={serviceEditorBlocked || adding || customServices.length >= MAX_CUSTOM_DIAGNOSTIC_SERVICES}
|
||||
onClick={() => setAdding(true)}
|
||||
@@ -493,7 +491,6 @@ export function ConnectivityDiagnosticsPanel({
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</aside>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { RailAction } from '../../ui/RailAction.js';
|
||||
|
||||
export function useDiagnosticsFeature() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
@@ -47,19 +48,18 @@ export function DiagnosticsToggle({
|
||||
feature: DiagnosticsFeature;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
return <button
|
||||
ref={feature.toggleRef}
|
||||
className={`client-instructions-toggle client-diagnostics-toggle${feature.isOpen ? ' is-open' : ''}`}
|
||||
type="button"
|
||||
aria-expanded={feature.isOpen}
|
||||
aria-controls="client-diagnostics"
|
||||
aria-label={feature.isOpen ? 'Закрыть диагностику' : 'Проверить маршруты'}
|
||||
return <RailAction
|
||||
buttonRef={feature.toggleRef}
|
||||
className="client-instructions-toggle client-diagnostics-toggle"
|
||||
open={feature.isOpen}
|
||||
controls="client-diagnostics"
|
||||
ariaLabel={feature.isOpen ? 'Закрыть диагностику' : 'Проверить маршруты'}
|
||||
label="Диагностика"
|
||||
onClick={onToggle}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path className="client-rail-diagnostics-base" d="M3 12h4l2.2-5 4.2 10 2.1-5H21" />
|
||||
<path className="client-rail-diagnostics-pulse" pathLength="1" d="M3 12h4l2.2-5 4.2 10 2.1-5H21" />
|
||||
</svg>
|
||||
<span>Диагностика</span>
|
||||
</button>;
|
||||
</RailAction>;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { flushSync } from 'react-dom';
|
||||
import { CopyButton } from '../../ui/CopyButton.js';
|
||||
import { Drawer } from '../../ui/Drawer.js';
|
||||
import { RailAction } from '../../ui/RailAction.js';
|
||||
import { copyText } from '../../utils/clientControls.js';
|
||||
import { instructionBlocks } from './instructionBlocks.js';
|
||||
|
||||
@@ -135,16 +138,12 @@ function InstructionBlock({
|
||||
const feedback = copyFeedback[action.id];
|
||||
return <div className="client-instruction-copy" key={action.id}>
|
||||
<span>{action.label}</span>
|
||||
<button
|
||||
className={`client-copy-button client-instruction-copy-button${feedback ? feedback.failed ? ' is-copy-error' : ' is-copied' : ''}`}
|
||||
type="button"
|
||||
<CopyButton
|
||||
className="client-instruction-copy-button"
|
||||
label="Скопировать"
|
||||
feedback={feedback}
|
||||
onClick={() => copyInstruction(action)}
|
||||
>
|
||||
<span className="client-copy-label">Скопировать</span>
|
||||
{feedback && <span className="client-copy-feedback" aria-hidden="true">
|
||||
{feedback.failed ? 'Ошибка' : 'Скопировано'}
|
||||
</span>}
|
||||
</button>
|
||||
/>
|
||||
</div>;
|
||||
})}
|
||||
<span className="client-live-region" role="status" aria-live="polite">
|
||||
@@ -238,21 +237,20 @@ export function InstructionsToggle({
|
||||
feature: InstructionsFeature;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
return <button
|
||||
ref={feature.toggleRef}
|
||||
className={`client-instructions-toggle${feature.isOpen ? ' is-open' : ''}`}
|
||||
type="button"
|
||||
aria-expanded={feature.isOpen}
|
||||
aria-controls="client-instructions"
|
||||
aria-label={feature.isOpen ? 'Закрыть инструкции' : 'Как использовать'}
|
||||
return <RailAction
|
||||
buttonRef={feature.toggleRef}
|
||||
className="client-instructions-toggle"
|
||||
open={feature.isOpen}
|
||||
controls="client-instructions"
|
||||
ariaLabel={feature.isOpen ? 'Закрыть инструкции' : 'Как использовать'}
|
||||
label="Как использовать"
|
||||
onClick={onToggle}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<circle className="client-rail-info-ring" cx="12" cy="12" r="8.5" pathLength="1" />
|
||||
<path d="M12 11v5M12 8h.01" />
|
||||
</svg>
|
||||
<span>Как использовать</span>
|
||||
</button>;
|
||||
</RailAction>;
|
||||
}
|
||||
|
||||
export function InstructionsPanel({
|
||||
@@ -262,40 +260,34 @@ export function InstructionsPanel({
|
||||
feature: InstructionsFeature;
|
||||
isGateway: boolean;
|
||||
}) {
|
||||
return <aside
|
||||
ref={feature.panelRef}
|
||||
return <Drawer
|
||||
panelRef={feature.panelRef}
|
||||
closeRef={feature.closeRef}
|
||||
id="client-instructions"
|
||||
className={`client-drawer client-instructions${feature.isOpen ? ' is-open' : ''}`}
|
||||
aria-labelledby="instructions-title"
|
||||
aria-hidden={!feature.isOpen}
|
||||
inert={!feature.isOpen ? true : undefined}
|
||||
className="client-instructions"
|
||||
sheetClassName="client-instructions-sheet"
|
||||
open={feature.isOpen}
|
||||
labelledBy="instructions-title"
|
||||
closeLabel="Закрыть инструкции"
|
||||
onClose={feature.close}
|
||||
>
|
||||
<div className="client-drawer-sheet client-instructions-sheet">
|
||||
<button
|
||||
ref={feature.closeRef}
|
||||
className="client-drawer-close"
|
||||
type="button"
|
||||
aria-label="Закрыть инструкции"
|
||||
onClick={feature.close}
|
||||
>×</button>
|
||||
<header className="client-instructions-header">
|
||||
<span>Подключение</span>
|
||||
<h2 id="instructions-title">Как использовать {isGateway ? 'Gateway' : 'прокси'}</h2>
|
||||
<div className="client-instructions-intro">
|
||||
{feature.intro.paragraphs?.map((paragraph) => <p key={paragraph}>{paragraph}</p>)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="client-instruction-list">
|
||||
{feature.guides.map((block) => (
|
||||
<InstructionBlock
|
||||
block={block}
|
||||
key={block.id}
|
||||
open={block.id === feature.openInstructionId}
|
||||
onToggle={() => feature.toggleInstruction(block.id)}
|
||||
/>
|
||||
))}
|
||||
<header className="client-instructions-header">
|
||||
<span>Подключение</span>
|
||||
<h2 id="instructions-title">Как использовать {isGateway ? 'Gateway' : 'прокси'}</h2>
|
||||
<div className="client-instructions-intro">
|
||||
{feature.intro.paragraphs?.map((paragraph) => <p key={paragraph}>{paragraph}</p>)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="client-instruction-list">
|
||||
{feature.guides.map((block) => (
|
||||
<InstructionBlock
|
||||
block={block}
|
||||
key={block.id}
|
||||
open={block.id === feature.openInstructionId}
|
||||
onToggle={() => feature.toggleInstruction(block.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</aside>;
|
||||
</Drawer>;
|
||||
}
|
||||
|
||||
@@ -4,13 +4,14 @@ import {
|
||||
useState,
|
||||
type FormEvent,
|
||||
type ReactNode,
|
||||
type RefObject,
|
||||
} from 'react';
|
||||
import { flushSync } from 'react-dom';
|
||||
import { canAppendRouteRule } from '../../../shared/routingRules.js';
|
||||
import type { RouteRule } from '../../../shared/contracts/state.js';
|
||||
import { operationBlocked } from '../../state/operations.js';
|
||||
import { ConfirmationDialog } from '../../ui/ConfirmationDialog.js';
|
||||
import { Drawer } from '../../ui/Drawer.js';
|
||||
import { RailAction } from '../../ui/RailAction.js';
|
||||
|
||||
const ROUTE_RULE_OPTIONS: Array<[RouteRule['type'], string]> = [
|
||||
['domain', 'Точный домен'],
|
||||
@@ -393,18 +394,22 @@ export function RoutingToggle({
|
||||
}) {
|
||||
const disabled = gatewayDirect || (isGateway && !hasSubscription);
|
||||
return (
|
||||
<button
|
||||
ref={feature.toggleRef}
|
||||
className={`client-local-rules-toggle${feature.isOpen ? ' is-open' : ''}${feature.pendingRestart ? ' has-pending' : ''}`}
|
||||
type="button"
|
||||
<RailAction
|
||||
buttonRef={feature.toggleRef}
|
||||
className={`client-local-rules-toggle${feature.pendingRestart ? ' has-pending' : ''}`}
|
||||
open={feature.isOpen}
|
||||
controls="client-local-rules"
|
||||
disabled={disabled}
|
||||
aria-expanded={feature.isOpen}
|
||||
aria-controls="client-local-rules"
|
||||
aria-label={disabled
|
||||
ariaLabel={disabled
|
||||
? hasSubscription
|
||||
? 'Локальные правила недоступны: сейчас работают правила Harbor Gateway'
|
||||
: 'Локальные правила недоступны: сначала добавьте подписку'
|
||||
: feature.isOpen ? 'Закрыть локальные правила' : 'Настроить локальные правила'}
|
||||
label={disabled
|
||||
? hasSubscription
|
||||
? 'Локальные правила недоступны: сейчас работают правила Gateway'
|
||||
: 'Сначала добавьте подписку'
|
||||
: feature.pendingRestart ? 'Правила ждут перезапуска' : 'Локальные правила'}
|
||||
onClick={() => feature.isOpen ? feature.requestClose() : onOpen()}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
@@ -412,12 +417,7 @@ export function RoutingToggle({
|
||||
<circle className="client-rail-rule-knob is-top" cx="15" cy="7" r="2" />
|
||||
<circle className="client-rail-rule-knob is-bottom" cx="9" cy="17" r="2" />
|
||||
</svg>
|
||||
<span>{disabled
|
||||
? hasSubscription
|
||||
? 'Локальные правила недоступны: сейчас работают правила Gateway'
|
||||
: 'Сначала добавьте подписку'
|
||||
: feature.pendingRestart ? 'Правила ждут перезапуска' : 'Локальные правила'}</span>
|
||||
</button>
|
||||
</RailAction>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -446,22 +446,17 @@ export function RoutingPanel({ feature, statusSlot }: { feature: RoutingFeature;
|
||||
const incomplete = draftRules.some((rule) => !String(rule.value || '').trim());
|
||||
|
||||
return (
|
||||
<aside
|
||||
ref={feature.panelRef as RefObject<HTMLElement>}
|
||||
<Drawer
|
||||
panelRef={feature.panelRef}
|
||||
closeRef={feature.closeRef}
|
||||
id="client-local-rules"
|
||||
className={`client-drawer client-local-rules${feature.isOpen ? ' is-open' : ''}`}
|
||||
aria-labelledby="local-rules-title"
|
||||
aria-hidden={!feature.isOpen}
|
||||
inert={!feature.isOpen ? true : undefined}
|
||||
className="client-local-rules"
|
||||
sheetClassName="client-local-rules-sheet"
|
||||
open={feature.isOpen}
|
||||
labelledBy="local-rules-title"
|
||||
closeLabel="Закрыть локальные правила"
|
||||
onClose={feature.requestClose}
|
||||
>
|
||||
<div className="client-drawer-sheet client-local-rules-sheet">
|
||||
<button
|
||||
ref={feature.closeRef}
|
||||
className="client-drawer-close"
|
||||
type="button"
|
||||
aria-label="Закрыть локальные правила"
|
||||
onClick={feature.requestClose}
|
||||
>×</button>
|
||||
<header className="client-local-rules-header">
|
||||
<span>Маршрутизация</span>
|
||||
<button
|
||||
@@ -532,7 +527,7 @@ export function RoutingPanel({ feature, statusSlot }: { feature: RoutingFeature;
|
||||
/>
|
||||
<span className="client-local-rule-status" role="status">{statusLabel}</span>
|
||||
<button
|
||||
className="client-local-rule-delete"
|
||||
className="client-row-delete"
|
||||
type="button"
|
||||
aria-label={`Удалить правило ${index + 1}`}
|
||||
onClick={() => feature.remove(rule._key)}
|
||||
@@ -549,8 +544,8 @@ export function RoutingPanel({ feature, statusSlot }: { feature: RoutingFeature;
|
||||
})}
|
||||
{!feature.rules.length && <p className="client-local-rules-empty">Правил пока нет. Весь трафик идёт через VPN.</p>}
|
||||
</div>
|
||||
<div className="client-local-rule-add-slot">
|
||||
<button className="client-local-rule-add" type="button" disabled={!canAdd} onClick={feature.add}>
|
||||
<div className="client-row-add-slot">
|
||||
<button className="client-row-add" type="button" disabled={!canAdd} onClick={feature.add}>
|
||||
+ Добавить правило
|
||||
</button>
|
||||
<span className={incomplete ? 'is-visible' : ''}>Сначала заполните текущее правило</span>
|
||||
@@ -565,8 +560,7 @@ export function RoutingPanel({ feature, statusSlot }: { feature: RoutingFeature;
|
||||
<button type="button" onClick={feature.requestClose}>Отмена</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</aside>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,8 @@ import {
|
||||
} from '../../utils/clientControls.js';
|
||||
import { formatBytes } from '../../utils/format.js';
|
||||
import { ConfirmationDialog } from '../../ui/ConfirmationDialog.js';
|
||||
import { Drawer } from '../../ui/Drawer.js';
|
||||
import { RailAction } from '../../ui/RailAction.js';
|
||||
import type { RequestError } from './requestError.js';
|
||||
|
||||
interface ProfileError extends RequestError {
|
||||
@@ -75,7 +77,7 @@ export function useSubscriptionFeature({
|
||||
const [deleteId, setDeleteId] = useState('');
|
||||
const [refreshingIds, setRefreshingIds] = useState<string[]>([]);
|
||||
const [revealVersions, setRevealVersions] = useState<Record<string, number>>({});
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const panelRef = useRef<HTMLElement>(null);
|
||||
const toggleRef = useRef<HTMLButtonElement>(null);
|
||||
const closeRef = useRef<HTMLButtonElement>(null);
|
||||
const labelRef = useRef<HTMLInputElement>(null);
|
||||
@@ -307,20 +309,19 @@ export function SubscriptionToggle({
|
||||
feature: SubscriptionFeatureController;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
return <button
|
||||
ref={feature.toggleRef}
|
||||
className={`client-instructions-toggle client-subscription-toggle${feature.open ? ' is-open' : ''}`}
|
||||
type="button"
|
||||
aria-expanded={feature.open}
|
||||
aria-controls="client-subscription-drawer"
|
||||
aria-label={feature.open ? 'Закрыть подписки' : 'Управление подписками'}
|
||||
return <RailAction
|
||||
buttonRef={feature.toggleRef}
|
||||
className="client-instructions-toggle client-subscription-toggle"
|
||||
open={feature.open}
|
||||
controls="client-subscription-drawer"
|
||||
ariaLabel={feature.open ? 'Закрыть подписки' : 'Управление подписками'}
|
||||
label="Подписки"
|
||||
onClick={onToggle}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M5 5h14v14H5zM8 9h8M8 13h5" />
|
||||
</svg>
|
||||
<span>Подписки</span>
|
||||
</button>;
|
||||
</RailAction>;
|
||||
}
|
||||
|
||||
function AddProfileForm({ feature }: { feature: SubscriptionFeatureController }) {
|
||||
@@ -514,44 +515,38 @@ export function SubscriptionPanel({
|
||||
<AddProfileForm feature={feature} />
|
||||
</div>}
|
||||
|
||||
<div
|
||||
ref={feature.panelRef}
|
||||
<Drawer
|
||||
panelRef={feature.panelRef}
|
||||
closeRef={feature.closeRef}
|
||||
id="client-subscription-drawer"
|
||||
className={`client-drawer client-subscription-drawer${drawerOpen ? ' is-open' : ''}`}
|
||||
aria-label="Управление подписками"
|
||||
aria-hidden={!drawerOpen}
|
||||
inert={!drawerOpen ? true : undefined}
|
||||
className="client-subscription-drawer"
|
||||
sheetClassName="client-subscription-sheet"
|
||||
open={drawerOpen}
|
||||
label="Управление подписками"
|
||||
closeLabel="Закрыть подписки"
|
||||
onClose={feature.close}
|
||||
>
|
||||
<div className="client-drawer-sheet client-subscription-sheet">
|
||||
<header className="client-profiles-header">
|
||||
<h2>ПОДПИСКИ</h2>
|
||||
<button
|
||||
ref={feature.closeRef}
|
||||
className="client-drawer-close"
|
||||
type="button"
|
||||
aria-label="Закрыть подписки"
|
||||
onClick={feature.close}
|
||||
>×</button>
|
||||
</header>
|
||||
{statusSlot}
|
||||
<div className="client-profile-list">
|
||||
{feature.profiles.map((profile) => <ProfileGroup
|
||||
key={profile.id}
|
||||
feature={feature}
|
||||
profile={profile}
|
||||
renderServerPicker={renderServerPicker}
|
||||
/>)}
|
||||
</div>
|
||||
{(feature.adding || feature.addClosing) && feature.profiles.length > 0
|
||||
? <AddProfileForm feature={feature} />
|
||||
: <button
|
||||
id="client-profile-add-trigger"
|
||||
className="client-profile-add-trigger"
|
||||
type="button"
|
||||
onClick={feature.showAdd}
|
||||
>+ Добавить подписку</button>}
|
||||
<header className="client-profiles-header">
|
||||
<h2>ПОДПИСКИ</h2>
|
||||
</header>
|
||||
{statusSlot}
|
||||
<div className="client-profile-list">
|
||||
{feature.profiles.map((profile) => <ProfileGroup
|
||||
key={profile.id}
|
||||
feature={feature}
|
||||
profile={profile}
|
||||
renderServerPicker={renderServerPicker}
|
||||
/>)}
|
||||
</div>
|
||||
</div>
|
||||
{(feature.adding || feature.addClosing) && feature.profiles.length > 0
|
||||
? <AddProfileForm feature={feature} />
|
||||
: <button
|
||||
id="client-profile-add-trigger"
|
||||
className="client-profile-add-trigger"
|
||||
type="button"
|
||||
onClick={feature.showAdd}
|
||||
>+ Добавить подписку</button>}
|
||||
</Drawer>
|
||||
</>;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user