Refactor VPN proxy client implementation
Build and Deploy Gateway / build-and-push (push) Failing after 2s
Build and Deploy Gateway / deploy (push) Has been skipped

This commit is contained in:
2026-08-09 00:41:52 +03:00
parent 32be4380a3
commit 34d8b681ad
190 changed files with 20064 additions and 9761 deletions
@@ -0,0 +1,275 @@
import { useEffect, useRef, useState } from 'react';
import { flushSync } from 'react-dom';
import { copyText } from '../../utils/clientControls.js';
import { instructionBlocks } from './instructionBlocks.js';
interface InstructionLinkStep {
before?: string;
link: [string, string];
after?: string;
}
interface InstructionCopyAction {
id: string;
label: string;
text: string;
}
interface InstructionBlockData {
id: string;
label: string;
title: string;
summary: string;
paragraphs?: string[];
steps?: Array<string | InstructionLinkStep>;
code?: string;
multilineCode?: boolean;
copies?: InstructionCopyAction[];
note?: string;
}
interface InstructionsFeatureOptions {
isGateway: boolean;
host: string;
port: number;
controlHost: string;
}
function InstructionStep({ step }: { step: string | InstructionLinkStep }) {
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,
}: {
block: InstructionBlockData;
open: boolean;
onToggle: () => void;
}) {
const [copyFeedback, setCopyFeedback] = useState<{ id: string; failed: boolean } | null>(null);
const copyTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => () => {
if (copyTimer.current) clearTimeout(copyTimer.current);
}, []);
async function copyInstruction(action: InstructionCopyAction) {
if (copyTimer.current) clearTimeout(copyTimer.current);
try {
await copyText(action.text);
setCopyFeedback({ id: action.id, failed: false });
} catch {
setCopyFeedback({ id: action.id, failed: true });
}
copyTimer.current = setTimeout(() => setCopyFeedback(null), 800);
}
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 && (block.multilineCode
? <pre className="client-instruction-code"><code>{block.code}</code></pre>
: <code>{block.code}</code>)}
{block.copies && <div className="client-instruction-copies">
{block.copies.map((action) => {
const feedback = copyFeedback?.id === action.id ? copyFeedback : null;
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"
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">
{copyFeedback ? copyFeedback.failed ? 'Не удалось скопировать' : 'Скопировано' : ''}
</span>
</div>}
{block.note && <p className="client-instruction-note">{block.note}</p>}
</div>
</div>
</section>
);
}
export function useInstructionsFeature({
isGateway,
host,
port,
controlHost,
}: InstructionsFeatureOptions) {
const [isOpen, setIsOpen] = useState(false);
const [openInstructionId, setOpenInstructionId] = useState('');
const panelRef = useRef<HTMLElement>(null);
const toggleRef = useRef<HTMLButtonElement>(null);
const closeRef = useRef<HTMLButtonElement>(null);
const [intro, ...guides] = instructionBlocks({ isGateway, host, port, controlHost }) as InstructionBlockData[];
const openInstruction = guides.find((block) => block.id === openInstructionId);
const orderedGuides = openInstruction
? [openInstruction, ...guides.filter((block) => block.id !== openInstructionId)]
: guides;
useEffect(() => {
if (!isOpen) return undefined;
const frame = requestAnimationFrame(() => closeRef.current?.focus());
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') setIsOpen(false);
};
document.addEventListener('keydown', closeOnEscape);
return () => {
cancelAnimationFrame(frame);
document.removeEventListener('keydown', closeOnEscape);
requestAnimationFrame(() => {
if (panelRef.current?.contains(document.activeElement)) toggleRef.current?.focus();
});
};
}, [isOpen]);
useEffect(() => {
if (!isOpen) return undefined;
const closeOutside = (event: PointerEvent) => {
if (panelRef.current?.contains(event.target as Node)) return;
if (toggleRef.current?.contains(event.target as Node)) return;
setIsOpen(false);
};
document.addEventListener('pointerdown', closeOutside);
return () => document.removeEventListener('pointerdown', closeOutside);
}, [isOpen]);
function toggleInstruction(id: string) {
const update = () => flushSync(() => {
setOpenInstructionId((current) => current === id ? '' : id);
});
if (!document.startViewTransition || matchMedia('(prefers-reduced-motion: reduce)').matches) {
update();
return;
}
document.startViewTransition(update);
}
return {
isOpen,
openInstructionId,
intro,
guides: orderedGuides,
panelRef,
toggleRef,
closeRef,
close: () => setIsOpen(false),
toggle: () => setIsOpen((open) => !open),
toggleInstruction,
};
}
export type InstructionsFeature = ReturnType<typeof useInstructionsFeature>;
export function InstructionsToggle({
feature,
onToggle,
}: {
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 ? 'Закрыть инструкции' : 'Как использовать'}
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>;
}
export function InstructionsPanel({
feature,
isGateway,
}: {
feature: InstructionsFeature;
isGateway: boolean;
}) {
return <aside
ref={feature.panelRef}
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}
>
<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)}
/>
))}
</div>
</div>
</aside>;
}