301 lines
9.5 KiB
TypeScript
301 lines
9.5 KiB
TypeScript
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';
|
|
|
|
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<Record<string, { failed: boolean }>>({});
|
|
const [copyAnnouncement, setCopyAnnouncement] = useState<{ id: string; message: string } | null>(null);
|
|
const copyTimers = useRef(new Map<string, ReturnType<typeof setTimeout>>());
|
|
const copyAttempts = useRef(new Map<string, object>());
|
|
|
|
useEffect(() => () => {
|
|
for (const timer of copyTimers.current.values()) clearTimeout(timer);
|
|
copyTimers.current.clear();
|
|
copyAttempts.current.clear();
|
|
}, []);
|
|
|
|
async function copyInstruction(action: InstructionCopyAction) {
|
|
const activeTimer = copyTimers.current.get(action.id);
|
|
if (activeTimer) clearTimeout(activeTimer);
|
|
const attempt = {};
|
|
copyAttempts.current.set(action.id, attempt);
|
|
let feedback: { failed: boolean };
|
|
try {
|
|
await copyText(action.text);
|
|
feedback = { failed: false };
|
|
} catch {
|
|
feedback = { failed: true };
|
|
}
|
|
if (copyAttempts.current.get(action.id) !== attempt) return;
|
|
const announcement = {
|
|
id: action.id,
|
|
message: feedback.failed ? `Не удалось скопировать ${action.label}` : `${action.label} скопировано`,
|
|
};
|
|
const pendingTimer = copyTimers.current.get(action.id);
|
|
if (pendingTimer) clearTimeout(pendingTimer);
|
|
setCopyFeedback((current) => ({ ...current, [action.id]: feedback }));
|
|
setCopyAnnouncement(announcement);
|
|
copyTimers.current.set(action.id, setTimeout(() => {
|
|
setCopyFeedback((current) => {
|
|
const next = { ...current };
|
|
delete next[action.id];
|
|
return next;
|
|
});
|
|
setCopyAnnouncement((current) => current?.id === action.id ? null : current);
|
|
copyTimers.current.delete(action.id);
|
|
copyAttempts.current.delete(action.id);
|
|
}, 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[action.id];
|
|
return <div className="client-instruction-copy" key={action.id}>
|
|
<span>{action.label}</span>
|
|
<CopyButton
|
|
className="client-instruction-copy-button"
|
|
label="Скопировать"
|
|
feedback={feedback}
|
|
onClick={() => copyInstruction(action)}
|
|
/>
|
|
</div>;
|
|
})}
|
|
<span className="client-live-region" role="status" aria-live="polite">
|
|
{copyAnnouncement?.message || ''}
|
|
</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,
|
|
open,
|
|
onToggle,
|
|
}: {
|
|
feature: InstructionsFeature;
|
|
open: boolean;
|
|
onToggle: () => void;
|
|
}) {
|
|
return <RailAction
|
|
buttonRef={feature.toggleRef}
|
|
className="client-instructions-toggle"
|
|
open={open}
|
|
controls="client-instructions"
|
|
ariaLabel={open ? 'Закрыть инструкции' : 'Как использовать'}
|
|
label="Как использовать"
|
|
onClick={onToggle}
|
|
>
|
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
|
<g className="client-rail-book-page-position is-left">
|
|
<path className="client-rail-book-page is-left" d="M12 7.5C10.2 6.1 7.7 5.7 5 6v11c2.8-.3 5.2.3 7 1.7z" />
|
|
</g>
|
|
<g className="client-rail-book-page-position is-right">
|
|
<path className="client-rail-book-page is-right" d="M12 7.5c1.8-1.4 4.3-1.8 7-1.5v11c-2.8-.3-5.2.3-7 1.7z" />
|
|
</g>
|
|
<path className="client-rail-book-spine" d="M12 7.5v11.2" />
|
|
</svg>
|
|
</RailAction>;
|
|
}
|
|
|
|
export function InstructionsPanel({
|
|
feature,
|
|
isGateway,
|
|
}: {
|
|
feature: InstructionsFeature;
|
|
isGateway: boolean;
|
|
}) {
|
|
return <Drawer
|
|
panelRef={feature.panelRef}
|
|
closeRef={feature.closeRef}
|
|
id="client-instructions"
|
|
className="client-instructions"
|
|
sheetClassName="client-instructions-sheet"
|
|
open={feature.isOpen}
|
|
labelledBy="instructions-title"
|
|
closeLabel="Закрыть инструкции"
|
|
onClose={feature.close}
|
|
>
|
|
<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>
|
|
</Drawer>;
|
|
}
|