Files
harbor-net/src/web/features/instructions/InstructionsFeature.tsx
T
dokril 0a0a932057
Build and Deploy Gateway / build-and-push (push) Successful in 35s
Build and Deploy Gateway / deploy (push) Successful in 6s
Refine secondary rail icon animations
2026-08-17 17:07:25 +03:00

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>;
}