Refactor proxy routing and installer flows

This commit is contained in:
2026-07-08 11:05:52 +03:00
parent e745633d91
commit 88d5b94133
14 changed files with 1899 additions and 487 deletions

View File

@@ -1,6 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { open } from '@tauri-apps/plugin-dialog';
import { Cpu, FileCode2, FolderOpen, Gauge, Info, Link2, Trash2, Wand2 } from 'lucide-react';
import { Cpu, FileCode2, FolderOpen, Gauge, Link2, Trash2, Wand2 } from 'lucide-react';
import {
applyProfiles,
fetchSingBoxSubscription,
@@ -34,7 +34,7 @@ import {
type SingBoxSetupStatus,
} from '../api/tauriCommands';
import type { ComponentStatus, Profile, ProfileItemInput, ProfileItemType, SubscriptionServer, Target } from '../domain/types';
import { Button, IconButton, LogDock, ServiceControlRow, Tabs } from '../ui';
import { Button, DetailsPopover, IconButton, LogDock, ServiceControlRow, Tabs } from '../ui';
import { getApplyReadiness } from './readiness';
import { serviceControlState } from './viewModel';
@@ -76,6 +76,48 @@ interface ConfigSnapshot {
items: ConfigSnapshotItem[];
}
interface ConnectionCheckView {
tone: StatusTone;
title: string;
text: string;
endpoint: string;
details: string[];
disabledReason?: string;
loading: boolean;
}
interface RouteChainSegment {
id: string;
label: string;
value: string;
tone: StatusTone;
details: string[];
}
interface ConnectionCheckInput {
routeMode: RouteMode;
proxyInput: string;
proxyPing: PingServerResponse | null;
singbox: ComponentStatus | undefined;
singBoxStatus: LocalSingBoxStatusResponse | null;
selectedServer: SubscriptionServer | null;
selectedServerPing: PingServerResponse | undefined;
isDetectingComponents: boolean;
isProxyChecking: boolean;
serverPingTag: string | null;
}
interface RouteChainInput {
routeMode: RouteMode;
proxyInput: string;
proxyfier: ComponentStatus | undefined;
singbox: ComponentStatus | undefined;
singBoxStatus: LocalSingBoxStatusResponse | null;
selectedServer: SubscriptionServer | null;
appCount: number;
isDetectingComponents: boolean;
}
const MAIN_TARGET_ID = 'main-proxy';
const MAIN_PROFILE_ID = 'main-profile';
const LOCAL_SINGBOX_TARGET_ID = 'local-singbox';
@@ -123,8 +165,6 @@ export function App() {
const [subscriptionInput, setSubscriptionInput] = useState('');
const [serverPings, setServerPings] = useState<Record<string, PingServerResponse>>({});
const [proxyPing, setProxyPing] = useState<PingServerResponse | null>(null);
const [isSingBoxSetupOpen, setIsSingBoxSetupOpen] = useState(false);
const [isSingBoxInfoOpen, setIsSingBoxInfoOpen] = useState(false);
const [generatedConfigPath, setGeneratedConfigPath] = useState('');
const [logEntries, setLogEntries] = useState<LogEntry[]>([]);
const [activeLogId, setActiveLogId] = useState<string | null>(null);
@@ -157,6 +197,11 @@ export function App() {
);
const isSingBoxInstalled = Boolean(singbox?.installed);
const selectedServerTag = singBoxStatus?.config.selectedServerTag;
const selectedServer = useMemo(
() => singBoxStatus?.cache?.servers.find((server) => server.tag === selectedServerTag) ?? null,
[selectedServerTag, singBoxStatus],
);
const selectedServerPing = selectedServerTag ? serverPings[selectedServerTag] : undefined;
const currentSnapshot = useMemo(
() => configSnapshotFromUi(routeMode, proxyInput, items, selectedServerTag),
[items, proxyInput, routeMode, selectedServerTag],
@@ -820,6 +865,19 @@ export function App() {
}
}
async function pingSelectedSingBoxServer() {
if (!selectedServer) {
showNotice({
kind: 'error',
title: 'Сервер не выбран',
text: 'Выбери сервер Local sing-box перед проверкой.',
});
return;
}
await pingSingleSingBoxServer(selectedServer);
}
async function generateSingBoxNow() {
setSingBoxAction('generate');
try {
@@ -1173,10 +1231,9 @@ export function App() {
externalProxyError,
busy: isApplying || Boolean(serviceAction) || Boolean(singBoxAction),
});
const showBlocker = !readiness.ready && !isLoading && !isDetectingComponents;
const disabledReason = !readiness.ready && readiness.title && readiness.text
? `${readiness.title}. ${readiness.text}`
: undefined;
const blockerAlreadyVisible = routeBlockerIsVisibleInProxyPanel(context, routeMode, readiness.title);
const showBlocker = !readiness.ready && !isLoading && !isDetectingComponents && !blockerAlreadyVisible;
const showPending = readiness.ready && hasUnappliedChanges;
return (
<>
@@ -1185,7 +1242,7 @@ export function App() {
<strong>{readiness.title}</strong>
<span>{readiness.text}</span>
</div>
) : showState && hasUnappliedChanges ? (
) : showState && showPending ? (
<div className="apply-state pending" role="status">
<strong>Изменения еще не применены в ProxiFyre</strong>
<span>{applyStateText(routeMode, isSingBoxInstalled, Boolean(singbox?.running))}</span>
@@ -1201,7 +1258,6 @@ export function App() {
disabled={!readiness.ready}
loading={isApplying}
loadingLabel={applyButtonLabel(context, true, hasUnappliedChanges, singBoxAction)}
title={disabledReason}
>
{applyButtonLabel(context, isApplying, hasUnappliedChanges, singBoxAction)}
</Button>
@@ -1250,30 +1306,60 @@ export function App() {
placeholder="socks5://127.0.0.1:1080"
spellCheck={false}
/>
{proxyValidation ? <span className="field-error">{proxyValidation}</span> : null}
</label>
<div className="proxy-check-line">
<div className={`proxy-check-state ${proxyPing ? pingTone(proxyPing) : proxyValidation ? 'warning' : 'muted'}`}>
<strong>{proxyPing ? pingResultTitle(proxyPing) : proxyValidation ? 'Проверь формат' : 'Проверка не запускалась'}</strong>
<span>{proxyPing ? pingResultText(proxyPing) : proxyValidation ?? 'TCP check покажет, доступен ли host:port.'}</span>
</div>
<Button
type="button"
variant="neutral"
size="lg"
onClick={() => void pingExternalProxy()}
disabled={Boolean(proxyValidation)}
loading={isProxyChecking}
loadingLabel="Проверяю"
>
Проверить
</Button>
</div>
);
}
function renderConnectionCheck(check: ConnectionCheckView) {
const buttonDisabled = Boolean(check.disabledReason);
return (
<div className="connection-check" aria-label="Проверка соединения">
<div className="connection-check-main">
<span>Проверка endpoint</span>
<strong>{check.title}</strong>
<p>{check.text}</p>
</div>
<DetailsPopover
className="connection-endpoint"
details={check.details}
popoverLabel="Проверка endpoint"
align="end"
aria-label={`Endpoint: ${check.endpoint}. ${check.details.join('. ')}`}
>
<span>Endpoint</span>
<strong>{check.endpoint}</strong>
</DetailsPopover>
<Button
type="button"
variant="neutral"
size="lg"
onClick={() => {
if (routeMode === 'external') {
void pingExternalProxy();
} else {
void pingSelectedSingBoxServer();
}
}}
disabled={buttonDisabled}
loading={check.loading}
loadingLabel="Проверяю"
>
Проверить
</Button>
</div>
);
}
function renderSingBoxCard() {
const state = serviceControlState(singbox, isDetectingComponents);
const setupSummary = singBoxSetupStatus
? singBoxSetupStatus.ready
? 'состав готов'
: `не хватает: ${singBoxSetupStatus.missingCount}`
: 'состав не проверен';
const primaryAction = singbox?.installed
? {
label: singbox.running ? 'Остановить' : 'Запустить',
@@ -1297,7 +1383,7 @@ export function App() {
state={state}
visualState={singBoxAction ? 'working' : null}
className="singbox-card"
title={singBoxTitle(singbox, isDetectingComponents)}
title="Local sing-box"
detail={singBoxDetails(singbox, singBoxStatus, isDetectingComponents)}
primaryAction={primaryAction}
menu={singbox?.installed ? {
@@ -1313,107 +1399,18 @@ export function App() {
}],
} : undefined}
inlineActions={(
<>
<Button
type="button"
variant="neutral"
size="sm"
className="setup-toggle"
onClick={() => setIsSingBoxSetupOpen((current) => !current)}
disabled={isDetectingComponents && !singBoxSetupStatus}
aria-expanded={isSingBoxSetupOpen}
>
{singbox?.installed ? 'Состав Local sing-box' : 'Что будет установлено'}
{singBoxSetupStatus ? (
<span>{singBoxSetupStatus.ready ? 'все есть' : `не хватает: ${singBoxSetupStatus.missingCount}`}</span>
) : null}
</Button>
<IconButton
type="button"
variant="neutral"
className="info-toggle"
onClick={() => setIsSingBoxInfoOpen((current) => !current)}
label="Подробности Local sing-box"
aria-expanded={isSingBoxInfoOpen}
title="Подробности"
icon={<Info size={16} strokeWidth={2} />}
/>
</>
<DetailsPopover
className="setup-summary"
details={singBoxDetailLines(singbox, singBoxStatus, singBoxSetupStatus, selectedServerTag)}
popoverLabel="Состав Local sing-box"
aria-label="Подробности Local sing-box"
>
<span>Детали</span>
<strong>{setupSummary}</strong>
</DetailsPopover>
)}
>
{isSingBoxInfoOpen ? (
<div className="singbox-info-popover">
<div className="singbox-info-grid">
<span>Локально</span>
<strong>{localSingBoxAddress(singBoxStatus)}</strong>
<span>LAN</span>
<strong>{lanSingBoxAddress(singBoxStatus) ?? 'недоступен'}</strong>
<span>Сервер</span>
<strong>
{selectedServerTag
? displayServerTag(selectedServerTag)
: 'не выбран'}
</strong>
<span>Файл</span>
<strong>{singbox?.path ?? 'не найден'}</strong>
<span>Конфиг</span>
<strong>{singBoxStatus?.generatedConfigPath ?? 'не создан'}</strong>
</div>
<div className="singbox-info-actions">
<Button
type="button"
onClick={() => void pingSingBoxServers()}
disabled={Boolean(singBoxAction) || !singBoxStatus?.cache?.servers.length}
variant="neutral"
size="sm"
>
<Gauge size={15} strokeWidth={1.9} />
Ping все
</Button>
<Button
type="button"
onClick={() => void generateSingBoxNow()}
disabled={Boolean(singBoxAction) || !selectedServerTag}
variant="neutral"
size="sm"
>
<Wand2 size={15} strokeWidth={1.9} />
Конфиг
</Button>
</div>
</div>
) : null}
{isSingBoxSetupOpen ? (
<div className="setup-details">
{singBoxSetupStatus ? (
singBoxSetupStatus.items.map((item) => (
<div className={`setup-item ${item.installed ? 'installed' : 'missing'}`} key={item.id}>
<span className="setup-state-dot" aria-hidden="true" />
<div>
<strong>{item.name}</strong>
<span>{setupItemDetails(item.installed, item.version, item.details)}</span>
</div>
</div>
))
) : (
<div className="setup-item">
<span className="setup-state-dot" aria-hidden="true" />
<div>
<strong>Проверяю состав</strong>
<span>Ищу sing-box, WinSW wrapper и службу.</span>
</div>
</div>
)}
</div>
) : null}
{isSingBoxInstalled ? renderSingBoxWorkspace() : (
<div className="singbox-install-note">
<strong>Local sing-box не установлен</strong>
<span>Установи компонент, чтобы подключить подписку, выбрать сервер и включить локальный маршрут.</span>
</div>
)}
{isSingBoxInstalled ? renderSingBoxWorkspace() : null}
</ServiceControlRow>
);
}
@@ -1446,11 +1443,37 @@ export function App() {
onClick={() => void forgetSingBoxSubscriptionData()}
disabled={Boolean(singBoxAction) || !singBoxStatus?.config.hasSubscription}
label="Очистить подписку Local sing-box"
title="Очистить"
tooltip="Очистить"
icon={<Trash2 size={18} strokeWidth={1.9} />}
/>
</div>
<div className="singbox-workspace-head">
<span>Серверы подписки</span>
<div className="singbox-workspace-actions">
<Button
type="button"
onClick={() => void pingSingBoxServers()}
disabled={Boolean(singBoxAction) || !singBoxStatus?.cache?.servers.length}
variant="neutral"
size="sm"
leftIcon={<Gauge size={15} strokeWidth={1.9} />}
>
Ping все
</Button>
<Button
type="button"
onClick={() => void generateSingBoxNow()}
disabled={Boolean(singBoxAction) || !selectedServerTag}
variant="neutral"
size="sm"
leftIcon={<Wand2 size={15} strokeWidth={1.9} />}
>
Конфиг
</Button>
</div>
</div>
{singBoxStatus?.cache?.servers.length ? (
<div className="server-list">
{singBoxStatus.cache.servers.map((server) => {
@@ -1462,7 +1485,8 @@ export function App() {
type="button"
className="server-select-button"
onClick={() => void chooseSingBoxServer(server)}
title={serverTooltip(server, ping)}
data-tooltip={serverTooltip(server, ping)}
aria-label={`Выбрать ${displayServerTag(server.tag)}. ${serverTooltip(server, ping)}`}
>
<span className="server-select-dot" aria-hidden="true" />
<strong>{displayServerTag(server.tag)}</strong>
@@ -1474,7 +1498,7 @@ export function App() {
disabled={Boolean(serverPingTag)}
loading={serverPingTag === server.tag}
label={`Проверить ${displayServerTag(server.tag)}`}
title="Ping"
tooltip="Ping"
icon={<Gauge size={14} strokeWidth={1.9} />}
/>
</div>
@@ -1488,6 +1512,34 @@ export function App() {
);
}
function renderProxyOverview() {
const check = connectionCheckView({
routeMode,
proxyInput,
proxyPing,
singbox,
singBoxStatus,
selectedServer,
selectedServerPing,
isDetectingComponents,
isProxyChecking,
serverPingTag,
});
return (
<section className={`proxy-overview ${check.tone}`} aria-labelledby="proxy-overview-title">
<div className="proxy-overview-head">
<div>
<span>Настройки VPN / Прокси</span>
<h2 id="proxy-overview-title">Маршрут и проверка соединения</h2>
</div>
<strong>{routeMode === 'local-singbox' ? 'Локальный прокси' : 'Внешний прокси'}</strong>
</div>
{renderConnectionCheck(check)}
</section>
);
}
function renderProxyPanel() {
return (
<section
@@ -1496,13 +1548,7 @@ export function App() {
id="panel-proxy"
aria-labelledby="tab-proxy"
>
<div className="panel-section-head">
<div>
<span>Настройки VPN / Прокси</span>
<h2>Маршрут и проверка соединения</h2>
</div>
<strong>{routeMode === 'local-singbox' ? 'Локальный прокси' : 'Внешний прокси'}</strong>
</div>
{renderProxyOverview()}
<section className="route-panel" aria-label="Маршрут приложений">
<div className="route-switch">
@@ -1527,16 +1573,46 @@ export function App() {
{routeMode === 'external' ? renderExternalProxyControls() : renderSingBoxCard()}
</section>
<div className="route-preview">
<span>Маршрут</span>
<strong>{routePreviewText(routeMode, proxyInput, singBoxStatus)}</strong>
</div>
{renderRouteChain()}
{renderApplyActions('proxy')}
</section>
);
}
function renderRouteChain() {
const segments = routeChainSegments({
routeMode,
proxyInput,
proxyfier,
singbox,
singBoxStatus,
selectedServer,
appCount: items.length,
isDetectingComponents,
});
return (
<section className="route-chain" aria-label="Текущий маршрут">
{segments.map((segment, index) => (
<DetailsPopover
className={`route-chain-segment ${segment.tone}`}
details={segment.details}
popoverLabel={segment.label}
align={index >= segments.length - 2 ? 'end' : 'start'}
aria-label={`${segment.label}: ${segment.value}. ${segment.details.join('. ')}`}
key={segment.id}
>
<span className="route-chain-dot" aria-hidden="true" />
<span>{segment.label}</span>
<strong>{segment.value}</strong>
{index < segments.length - 1 ? <span className="route-chain-arrow" aria-hidden="true">-&gt;</span> : null}
</DetailsPopover>
))}
</section>
);
}
function renderActivePanel() {
if (activePanel === 'proxifyre') return renderProxiFyrePanel();
if (activePanel === 'proxy') return renderProxyPanel();
@@ -1772,6 +1848,227 @@ function routeEndpointLabel(
}
}
function connectionCheckView(input: ConnectionCheckInput): ConnectionCheckView {
if (input.routeMode === 'external') {
const proxyValidation = input.proxyInput.trim() ? safeProxyError(input.proxyInput) : 'Введи адрес SOCKS5 прокси.';
const endpoint = routeEndpointLabel('external', input.proxyInput, input.singBoxStatus);
const details = [
'Режим: внешний SOCKS5',
`Endpoint: ${endpoint}`,
'Проверка: TCP connect до proxy host:port.',
'Local sing-box для внешнего маршрута не требуется.',
];
if (proxyValidation) {
return {
tone: 'warning',
title: 'Endpoint не готов',
text: proxyValidation,
endpoint,
details,
disabledReason: proxyValidation,
loading: input.isProxyChecking,
};
}
if (input.proxyPing) {
return {
tone: pingTone(input.proxyPing),
title: input.proxyPing.ok ? 'Endpoint отвечает' : 'Endpoint не отвечает',
text: pingResultText(input.proxyPing),
endpoint,
details,
loading: input.isProxyChecking,
};
}
return {
tone: 'muted',
title: 'Проверка не запускалась',
text: 'Проверит доступность SOCKS5 endpoint без изменения маршрута.',
endpoint,
details,
loading: input.isProxyChecking,
};
}
const localAddress = localSingBoxAddress(input.singBoxStatus);
const selectedEndpoint = input.selectedServer
? `${displayServerTag(input.selectedServer.tag)} · ${formatHostPort(input.selectedServer.server, input.selectedServer.serverPort)}`
: 'сервер не выбран';
const details = [
'Режим: локальный sing-box',
`Local listener: ${localAddress}`,
`LAN: ${lanSingBoxAddress(input.singBoxStatus) ?? 'недоступен'}`,
`Сервер: ${selectedEndpoint}`,
'Проверка: TCP connect до выбранного сервера подписки.',
];
if (input.isDetectingComponents) {
return {
tone: 'checking',
title: 'Проверяю компоненты',
text: 'Обновляю состояние Local sing-box перед проверкой.',
endpoint: localAddress,
details,
disabledReason: 'Дождись завершения проверки компонентов.',
loading: Boolean(input.serverPingTag),
};
}
if (!input.singbox?.installed) {
return {
tone: 'warning',
title: 'Локальный runtime не готов',
text: 'Установи Local sing-box, затем загрузи подписку и выбери сервер.',
endpoint: localAddress,
details,
disabledReason: 'Local sing-box не установлен.',
loading: false,
};
}
if (!input.singbox.running) {
return {
tone: 'warning',
title: 'Служба остановлена',
text: 'Запусти Local sing-box перед проверкой локального маршрута.',
endpoint: localAddress,
details,
disabledReason: 'Local sing-box остановлен.',
loading: false,
};
}
if (!input.selectedServer) {
return {
tone: 'warning',
title: 'Сервер не выбран',
text: 'Выбери сервер подписки для проверки.',
endpoint: selectedEndpoint,
details,
disabledReason: 'Сервер Local sing-box не выбран.',
loading: false,
};
}
if (input.selectedServerPing) {
return {
tone: pingTone(input.selectedServerPing),
title: input.selectedServerPing.ok ? 'Сервер отвечает' : 'Сервер не отвечает',
text: pingResultText(input.selectedServerPing),
endpoint: selectedEndpoint,
details,
loading: input.serverPingTag === input.selectedServer.tag,
};
}
return {
tone: 'muted',
title: 'Проверка не запускалась',
text: 'Проверит выбранный сервер подписки без применения маршрута.',
endpoint: selectedEndpoint,
details,
loading: input.serverPingTag === input.selectedServer.tag,
};
}
function routeChainSegments(input: RouteChainInput): RouteChainSegment[] {
const proxyValidation = input.routeMode === 'external' && input.proxyInput.trim()
? safeProxyError(input.proxyInput)
: null;
const localServer = input.singBoxStatus?.config.selectedServerTag
? displayServerTag(input.singBoxStatus.config.selectedServerTag)
: 'сервер не выбран';
const endpoint = input.routeMode === 'local-singbox'
? localSingBoxAddress(input.singBoxStatus)
: routeEndpointLabel('external', input.proxyInput, input.singBoxStatus);
const endpointTone: StatusTone = input.routeMode === 'external'
? proxyValidation || !input.proxyInput.trim() ? 'warning' : 'ok'
: input.singbox?.running ? 'ok' : input.singbox?.installed ? 'warning' : 'warning';
const exitTone: StatusTone = input.routeMode === 'external'
? proxyValidation || !input.proxyInput.trim() ? 'warning' : 'ok'
: input.singbox?.running && input.selectedServer ? 'ok' : 'warning';
return [
{
id: 'apps',
label: 'Приложения',
value: appCountCompact(input.appCount),
tone: input.appCount > 0 ? 'ok' : 'warning',
details: [
input.appCount > 0 ? appCountText(input.appCount) : 'Добавь хотя бы одно приложение на вкладке ProxiFyre.',
],
},
{
id: 'proxifyre',
label: 'ProxiFyre',
value: input.isDetectingComponents ? 'проверяю' : input.proxyfier?.running ? 'запущен' : input.proxyfier?.installed ? 'остановлен' : 'не найден',
tone: componentChainTone(input.proxyfier, input.isDetectingComponents),
details: [
proxyfierTitle(input.proxyfier, input.isDetectingComponents),
proxyfierDetails(input.proxyfier, input.isDetectingComponents),
],
},
{
id: 'endpoint',
label: input.routeMode === 'local-singbox' ? 'Local endpoint' : 'SOCKS5 endpoint',
value: endpoint,
tone: endpointTone,
details: input.routeMode === 'local-singbox'
? singBoxDetailLines(input.singbox, input.singBoxStatus, null, input.singBoxStatus?.config.selectedServerTag)
: [
`Endpoint: ${endpoint}`,
proxyValidation ?? 'Формат внешнего SOCKS5 корректен.',
'Local sing-box не участвует во внешнем маршруте.',
],
},
{
id: 'exit',
label: input.routeMode === 'local-singbox' ? 'VPN сервер' : 'Выход',
value: input.routeMode === 'local-singbox' ? localServer : 'внешний SOCKS5',
tone: exitTone,
details: input.routeMode === 'local-singbox'
? [
input.selectedServer ? serverLabel(input.selectedServer) : 'Сервер Local sing-box не выбран.',
'Применение создаст sing-box config и обновит ProxiFyre.',
]
: [
'Выбранные приложения идут через внешний SOCKS5.',
'Local sing-box не нужен для этого маршрута.',
],
},
];
}
function routeBlockerIsVisibleInProxyPanel(
context: 'proxifyre' | 'proxy',
routeMode: RouteMode,
title: string | undefined,
) {
if (context !== 'proxy' || !title) return false;
if (routeMode === 'external') {
return title === 'Прокси не указан' || title === 'Проверь формат прокси';
}
return title === 'Local sing-box не установлен' || title === 'Сервер не выбран';
}
function componentChainTone(component: ComponentStatus | undefined, checking: boolean): StatusTone {
if (checking) return 'checking';
if (!component?.installed) return 'warning';
if (!component.running) return 'warning';
return 'ok';
}
function appCountCompact(count: number) {
if (count === 1) return '1 приложение';
if (count > 1 && count < 5) return `${count} приложения`;
return `${count} приложений`;
}
function appCountText(count: number) {
if (count === 1) return '1 приложение маршрутизируется через профиль.';
if (count > 1 && count < 5) return `${count} приложения маршрутизируются через профиль.`;
@@ -1800,21 +2097,6 @@ function pingResultText(ping: PingServerResponse) {
return `${ping.server}:${ping.serverPort}: ${ping.error ?? 'нет ответа'}`;
}
function routePreviewText(
routeMode: RouteMode,
proxyInput: string,
status: LocalSingBoxStatusResponse | null,
) {
if (routeMode === 'local-singbox') {
const server = status?.config.selectedServerTag
? displayServerTag(status.config.selectedServerTag)
: 'сервер не выбран';
return `Выбранные приложения -> ProxiFyre -> Local sing-box ${localSingBoxAddress(status)} -> ${server}`;
}
return `Выбранные приложения -> ProxiFyre -> внешний прокси ${routeEndpointLabel('external', proxyInput, status)}`;
}
function applyButtonLabel(
context: 'proxifyre' | 'proxy',
isApplying: boolean,
@@ -1961,12 +2243,6 @@ function itemIcon(type: DraftItemType) {
return <FileCode2 size={18} strokeWidth={1.9} />;
}
function setupItemDetails(installed: boolean, version: string | undefined, details: string) {
if (!installed) return `Нужно установить. ${details}`;
if (version) return `${version}. ${details}`;
return details;
}
function proxifyreSetupPlaceholders(): ProxiFyreSetupStatus['items'] {
return [
{ id: 'vc-runtime', name: 'Среда запуска', installed: false, details: 'Проверяю' },
@@ -2019,14 +2295,6 @@ function proxyfierDetails(component: ComponentStatus | undefined, checking: bool
return component.problems[0] ?? 'Путь установки не найден.';
}
function singBoxTitle(component: ComponentStatus | undefined, checking: boolean) {
if (checking) return 'Проверяю Local sing-box';
if (!component) return 'Local sing-box не проверен';
if (component.running) return 'Local sing-box найден и запущен';
if (component.installed) return 'Local sing-box найден';
return 'Local sing-box не установлен';
}
function singBoxDetails(
component: ComponentStatus | undefined,
status: LocalSingBoxStatusResponse | null,
@@ -2043,7 +2311,30 @@ function singBoxDetails(
if (status?.config.hasSubscription) {
return status.config.subscriptionDisplayUrl ?? 'Подписка сохранена.';
}
return component?.problems[0] ?? 'Установи компонент, чтобы подключить подписку и выбрать сервер.';
return component?.problems[0] ?? 'Установи компонент для подписки и локального маршрута.';
}
function singBoxDetailLines(
component: ComponentStatus | undefined,
status: LocalSingBoxStatusResponse | null,
setupStatus: SingBoxSetupStatus | null,
selectedServerTag: string | undefined,
) {
const setupDetails = setupStatus
? setupStatus.items
.map((item) => `${item.name}: ${setupItemShortStatus(item)}`)
.join('; ')
: 'состав не проверен';
return [
`Локально: ${localSingBoxAddress(status)}`,
`LAN: ${lanSingBoxAddress(status) ?? 'недоступен'}`,
`Сервер: ${selectedServerTag ? displayServerTag(selectedServerTag) : 'не выбран'}`,
`Файл: ${component?.path ?? 'не найден'}`,
`Конфиг: ${status?.generatedConfigPath ?? 'не создан'}`,
`Подписка: ${status?.config.subscriptionDisplayUrl ?? (status?.config.hasSubscription ? 'сохранена' : 'не загружена')}`,
`Состав: ${setupDetails}`,
];
}
function componentDetails(component: ComponentStatus | undefined, checking: boolean) {

View File

@@ -122,7 +122,11 @@ button:disabled {
.ui-button:focus-visible,
.ui-icon-button:focus-visible,
.ui-details-popover-trigger:focus-visible,
.ui-details-popover-close:focus-visible,
.ui-hover-details:focus-visible,
.ui-tab:focus-visible,
.server-select-button:focus-visible,
.ui-action-menu-popover button:focus-visible {
outline: 0;
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.72);
@@ -196,6 +200,111 @@ button:disabled {
padding: 0;
}
.ui-hover-details {
position: relative;
outline: none;
cursor: help;
}
.ui-details-popover-trigger {
appearance: none;
font: inherit;
outline: none;
text-align: left;
}
.ui-details-popover {
position: fixed;
z-index: 90;
display: grid;
gap: 8px;
border: 1px solid #334155;
border-radius: 5px;
background: #0d1118;
box-shadow: 0 18px 42px rgba(0, 0, 0, 0.46);
color: #e5e7eb;
padding: 10px;
animation: ui-popover-in 130ms var(--ease-out);
}
.ui-details-popover::before {
position: absolute;
top: -5px;
left: var(--details-popover-arrow-left);
width: 8px;
height: 8px;
border-top: 1px solid #334155;
border-left: 1px solid #334155;
background: #0d1118;
content: "";
transform: translateX(-50%) rotate(45deg);
}
.ui-details-popover[data-placement="top"]::before {
top: auto;
bottom: -5px;
border: 0;
border-right: 1px solid #334155;
border-bottom: 1px solid #334155;
}
.ui-details-popover-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
min-width: 0;
}
.ui-details-popover-head strong {
min-width: 0;
color: #f8fafc;
font-size: 13px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ui-details-popover-close {
display: grid;
place-items: center;
width: 26px;
min-width: 26px;
height: 26px;
border: 1px solid #263040;
border-radius: 4px;
background: #131923;
color: #cbd5e1;
cursor: pointer;
font-size: 16px;
line-height: 1;
padding: 0;
}
.ui-details-popover-close:hover {
border-color: #3b82f6;
background: #182033;
color: #f8fafc;
}
.ui-details-popover-body {
display: grid;
gap: 5px;
color: #a9b4c5;
font-size: 12px;
font-weight: 650;
line-height: 1.35;
}
.ui-details-popover-body p {
margin: 0;
overflow-wrap: anywhere;
}
.ui-details-popover-body p:first-child {
color: #e5e7eb;
}
.ui-button-icon,
.ui-button-label {
display: inline-flex;
@@ -546,10 +655,7 @@ button:disabled {
gap: 6px;
}
.ui-service-row > .setup-details,
.ui-service-row > .singbox-info-popover,
.ui-service-row > .singbox-workspace,
.ui-service-row > .singbox-install-note {
.ui-service-row > .singbox-workspace {
grid-column: 1 / -1;
}
@@ -600,7 +706,7 @@ button:disabled {
.process-add-line,
.app-row,
.route-switch,
.singbox-info-actions,
.singbox-workspace-actions,
.panel-tabs,
.server-row {
display: flex;
@@ -766,6 +872,60 @@ button:disabled {
text-align: right;
}
.proxy-overview {
display: grid;
gap: 10px;
border: 1px solid #2b3342;
border-radius: 4px;
background: #111720;
padding: 10px;
}
.proxy-overview.ok {
border-color: rgba(34, 197, 94, 0.36);
}
.proxy-overview.warning {
border-color: rgba(245, 158, 11, 0.42);
}
.proxy-overview.error {
border-color: rgba(239, 68, 68, 0.42);
}
.proxy-overview.checking {
border-color: rgba(59, 130, 246, 0.42);
}
.proxy-overview-head {
display: flex;
align-items: end;
justify-content: space-between;
gap: 12px;
min-width: 0;
}
.proxy-overview-head span {
display: block;
color: #8d99ae;
font-size: 12px;
}
.proxy-overview-head h2 {
margin: 2px 0 0;
color: #eef2ff;
font-size: 17px;
letter-spacing: 0;
}
.proxy-overview-head > strong {
min-width: 0;
color: #bfdbfe;
font-size: 13px;
overflow-wrap: anywhere;
text-align: right;
}
.summary-panel {
gap: 12px;
}
@@ -1045,7 +1205,7 @@ button.summary-card:hover {
.service-menu-button,
.subscription-line button,
.route-switch button,
.singbox-info-actions button,
.singbox-workspace-actions button,
.server-row {
min-height: 36px;
border: 1px solid #343b49;
@@ -1065,7 +1225,7 @@ button.summary-card:hover {
.service-menu-button:hover,
.subscription-line button:hover,
.route-switch button:hover,
.singbox-info-actions button:hover,
.singbox-workspace-actions button:hover,
.server-row:hover {
background: #2d3543;
}
@@ -1221,35 +1381,6 @@ button.summary-card:hover {
animation: spin 0.75s linear infinite;
}
.setup-toggle {
display: inline-flex;
gap: 7px;
align-items: center;
width: fit-content;
min-height: 0;
border: 0;
background: transparent;
color: #93c5fd;
padding: 4px 0 0;
text-align: left;
cursor: pointer;
}
.setup-toggle:hover {
color: #bfdbfe;
}
.setup-toggle .ui-button-label > span {
display: inline-block;
border: 1px solid #343b49;
border-radius: 4px;
background: #1b202b;
color: #cbd5e1;
padding: 1px 6px;
font-size: 11px;
font-weight: 700;
}
.setup-strip {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
@@ -1313,52 +1444,6 @@ button.summary-card:hover {
white-space: nowrap;
}
.setup-details {
display: grid;
grid-column: 2 / -1;
gap: 6px;
border-top: 1px solid #2b3342;
margin-top: 2px;
padding-top: 10px;
}
.setup-item {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 9px;
align-items: start;
min-height: 34px;
border: 1px solid #263040;
border-radius: 4px;
background: #111720;
padding: 8px 9px;
}
.setup-state-dot {
width: 9px;
height: 9px;
border-radius: 999px;
background: #f59e0b;
box-shadow: 0 0 0 3px rgba(245, 158, 11, 0.12);
margin-top: 5px;
}
.setup-item.installed .setup-state-dot {
background: #22c55e;
box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.12);
}
.setup-item strong,
.setup-item span {
display: block;
overflow-wrap: anywhere;
}
.setup-item span {
color: #9aa8bd;
font-size: 13px;
}
.service-actions {
position: relative;
display: flex;
@@ -1434,62 +1519,71 @@ button.summary-card:hover {
.route-panel {
display: grid;
gap: 8px;
margin-top: 10px;
margin-top: 8px;
border: 1px solid #2b3342;
border-radius: 4px;
background: #151923;
padding: 10px;
}
.external-proxy-card {
.connection-check {
display: grid;
gap: 9px;
}
.proxy-check-line {
display: grid;
grid-template-columns: minmax(0, 1fr) 132px;
grid-template-columns: minmax(0, 1fr) minmax(180px, 260px) 132px;
gap: 8px;
align-items: stretch;
}
.proxy-check-state,
.route-preview {
.connection-check-main,
.connection-endpoint {
display: grid;
align-content: center;
gap: 3px;
min-width: 0;
min-height: 46px;
border: 1px solid #2b3342;
border: 1px solid #263040;
border-radius: 4px;
background: #111720;
padding: 9px 11px;
background: #0d1016;
padding: 8px 10px;
}
.proxy-check-state.ok {
border-color: rgba(34, 197, 94, 0.42);
.connection-endpoint {
cursor: pointer;
}
.proxy-check-state.warning {
border-color: rgba(245, 158, 11, 0.42);
.connection-endpoint:hover {
border-color: #3b82f6;
background: #101827;
}
.proxy-check-state.error {
border-color: rgba(239, 68, 68, 0.42);
}
.proxy-check-state strong,
.route-preview strong {
color: #eef2ff;
overflow-wrap: anywhere;
}
.proxy-check-state span,
.route-preview span {
.connection-check-main > span,
.connection-endpoint > span {
color: #8d99ae;
font-size: 12px;
font-weight: 700;
}
.connection-check-main strong,
.connection-endpoint strong {
min-width: 0;
color: #eef2ff;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.connection-check-main p {
color: #9aa8bd;
margin: 0;
overflow-wrap: anywhere;
}
.route-preview {
margin-top: 2px;
.connection-check .ui-button {
width: 100%;
}
.external-proxy-card {
display: grid;
gap: 9px;
}
.simple-field {
@@ -1502,6 +1596,12 @@ button.summary-card:hover {
margin: 0;
}
.field-error {
color: #fcd34d;
font-size: 12px;
overflow-wrap: anywhere;
}
.simple-field input,
.process-add-line input,
.subscription-line input {
@@ -1531,82 +1631,30 @@ button.summary-card:hover {
padding-top: 10px;
}
.singbox-inline-actions {
display: flex;
align-items: center;
flex-wrap: wrap;
.setup-summary {
display: inline-grid;
grid-template-columns: auto auto;
gap: 6px;
}
.info-toggle {
display: grid;
place-items: center;
width: 28px;
height: 24px;
align-items: center;
width: fit-content;
border: 1px solid #263040;
border-radius: 4px;
background: #111720;
color: #bfdbfe;
cursor: pointer;
}
.info-toggle:hover {
background: #1d2735;
}
.info-toggle svg {
display: block;
}
.singbox-info-popover {
position: relative;
z-index: 8;
display: grid;
grid-column: 1 / -1;
gap: 10px;
width: 100%;
border: 1px solid #343b49;
border-radius: 4px;
background: #111720;
box-shadow: none;
padding: 12px;
}
.singbox-info-grid {
display: grid;
grid-template-columns: 86px minmax(0, 1fr);
gap: 7px 10px;
align-items: start;
}
.singbox-info-grid span {
color: #8d99ae;
padding: 4px 7px;
font-size: 12px;
}
.singbox-info-grid strong {
min-width: 0;
color: #dbeafe;
font-size: 12px;
overflow-wrap: anywhere;
}
.singbox-info-actions {
justify-content: flex-start;
gap: 7px;
}
.singbox-info-actions button {
display: inline-flex;
align-items: center;
gap: 7px;
min-height: 32px;
color: #dbeafe;
font-weight: 700;
}
.singbox-info-actions button svg {
display: block;
.setup-summary:hover {
border-color: #3b82f6;
background: #162033;
}
.setup-summary strong {
color: #dbeafe;
font-size: 11px;
}
.route-switch {
@@ -1669,6 +1717,30 @@ button.summary-card:hover {
color: #fecaca;
}
.singbox-workspace-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-width: 0;
}
.singbox-workspace-head > span {
color: #8d99ae;
font-size: 12px;
font-weight: 800;
}
.singbox-workspace-actions {
justify-content: flex-end;
gap: 7px;
}
.singbox-workspace-actions .ui-button {
min-height: 32px;
color: #dbeafe;
}
.server-list {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
@@ -1711,6 +1783,7 @@ button.summary-card:hover {
}
.server-select-button {
position: relative;
display: flex;
align-items: center;
gap: 7px;
@@ -1765,18 +1838,90 @@ button.summary-card:hover {
font-weight: 800;
}
.singbox-install-note {
.route-chain {
display: grid;
grid-column: 1 / -1;
gap: 3px;
border-top: 1px solid #2b3342;
color: #9aa8bd;
margin-top: 2px;
padding-top: 10px;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 7px;
margin-top: 8px;
}
.singbox-install-note strong {
color: #e5e7eb;
.route-chain-segment {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
grid-template-areas:
"dot label arrow"
"dot value arrow";
gap: 2px 8px;
align-items: center;
min-width: 0;
min-height: 48px;
border: 1px solid #2b3342;
border-radius: 4px;
background: #111720;
color: #dbeafe;
cursor: pointer;
padding: 8px 9px;
width: 100%;
}
.route-chain-segment:hover {
border-color: #3b82f6;
background: #141d2b;
}
.route-chain-segment > span:not(.route-chain-dot):not(.route-chain-arrow) {
grid-area: label;
color: #8d99ae;
font-size: 11px;
font-weight: 800;
}
.route-chain-segment strong {
grid-area: value;
min-width: 0;
overflow: hidden;
color: #eef2ff;
font-size: 13px;
text-overflow: ellipsis;
white-space: nowrap;
}
.route-chain-dot {
grid-area: dot;
width: 9px;
height: 9px;
border-radius: 999px;
background: #64748b;
box-shadow: 0 0 0 3px rgba(100, 116, 139, 0.12);
}
.route-chain-arrow {
grid-area: arrow;
color: #64748b;
font-weight: 800;
}
.route-chain-segment.ok .route-chain-dot {
background: #22c55e;
box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.12);
}
.route-chain-segment.warning .route-chain-dot {
background: #f59e0b;
box-shadow: 0 0 0 3px rgba(245, 158, 11, 0.12);
}
.route-chain-segment.error .route-chain-dot {
background: #ef4444;
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.12);
}
.route-chain-segment.checking .route-chain-dot {
border: 2px solid #3b82f6;
border-top-color: transparent;
background: transparent;
box-shadow: none;
animation: spin 0.75s linear infinite;
}
.apply-state {
@@ -1893,6 +2038,10 @@ button.summary-card:hover {
.ui-icon-button[data-tooltip]::before,
.ui-icon-button[data-tooltip]::after,
.ui-hover-details[data-tooltip]::before,
.ui-hover-details[data-tooltip]::after,
.server-select-button[data-tooltip]::before,
.server-select-button[data-tooltip]::after,
.add-tile[data-tooltip]::before,
.add-tile[data-tooltip]::after {
position: absolute;
@@ -1907,6 +2056,8 @@ button.summary-card:hover {
}
.ui-icon-button[data-tooltip]::before,
.ui-hover-details[data-tooltip]::before,
.server-select-button[data-tooltip]::before,
.add-tile[data-tooltip]::before {
bottom: calc(100% + 3px);
width: 8px;
@@ -1919,8 +2070,12 @@ button.summary-card:hover {
}
.ui-icon-button[data-tooltip]::after,
.ui-hover-details[data-tooltip]::after,
.server-select-button[data-tooltip]::after,
.add-tile[data-tooltip]::after {
bottom: calc(100% + 8px);
width: max-content;
max-width: min(360px, calc(100vw - 32px));
border: 1px solid #334155;
border-radius: 4px;
background: #0d1118;
@@ -1929,16 +2084,25 @@ button.summary-card:hover {
content: attr(data-tooltip);
font-size: 12px;
font-weight: 750;
line-height: 1;
line-height: 1.25;
overflow-wrap: break-word;
padding: 7px 8px;
transform: translate(-50%, 4px);
white-space: nowrap;
white-space: pre-line;
}
.ui-icon-button[data-tooltip]:hover::before,
.ui-icon-button[data-tooltip]:hover::after,
.ui-icon-button[data-tooltip]:focus-visible::before,
.ui-icon-button[data-tooltip]:focus-visible::after,
.ui-hover-details[data-tooltip]:hover::before,
.ui-hover-details[data-tooltip]:hover::after,
.ui-hover-details[data-tooltip]:focus-visible::before,
.ui-hover-details[data-tooltip]:focus-visible::after,
.server-select-button[data-tooltip]:hover::before,
.server-select-button[data-tooltip]:hover::after,
.server-select-button[data-tooltip]:focus-visible::before,
.server-select-button[data-tooltip]:focus-visible::after,
.add-tile[data-tooltip]:hover::before,
.add-tile[data-tooltip]:hover::after,
.add-tile[data-tooltip]:focus-visible::before,
@@ -1948,6 +2112,10 @@ button.summary-card:hover {
.ui-icon-button[data-tooltip]:hover::before,
.ui-icon-button[data-tooltip]:focus-visible::before,
.ui-hover-details[data-tooltip]:hover::before,
.ui-hover-details[data-tooltip]:focus-visible::before,
.server-select-button[data-tooltip]:hover::before,
.server-select-button[data-tooltip]:focus-visible::before,
.add-tile[data-tooltip]:hover::before,
.add-tile[data-tooltip]:focus-visible::before {
transform: translate(-50%, 0) rotate(45deg);
@@ -1955,6 +2123,10 @@ button.summary-card:hover {
.ui-icon-button[data-tooltip]:hover::after,
.ui-icon-button[data-tooltip]:focus-visible::after,
.ui-hover-details[data-tooltip]:hover::after,
.ui-hover-details[data-tooltip]:focus-visible::after,
.server-select-button[data-tooltip]:hover::after,
.server-select-button[data-tooltip]:focus-visible::after,
.add-tile[data-tooltip]:hover::after,
.add-tile[data-tooltip]:focus-visible::after {
transform: translate(-50%, 0);
@@ -2115,7 +2287,6 @@ button.summary-card:hover {
}
.command-row .ui-button,
.proxy-check-line .ui-button,
.subscription-line .ui-button {
width: 100%;
}
@@ -2509,7 +2680,13 @@ button.summary-card:hover {
flex-direction: column;
}
.panel-section-head > strong {
.proxy-overview-head {
align-items: stretch;
flex-direction: column;
}
.panel-section-head > strong,
.proxy-overview-head > strong {
text-align: left;
}
@@ -2558,10 +2735,14 @@ button.summary-card:hover {
grid-template-columns: 1fr;
}
.proxy-check-line {
.connection-check {
grid-template-columns: 1fr;
}
.route-chain {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.add-toolbar {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
@@ -2625,20 +2806,21 @@ button.summary-card:hover {
overflow: visible;
}
.setup-details {
grid-column: 1 / -1;
}
.service-button {
flex: 1;
}
.singbox-info-popover {
width: auto;
.singbox-workspace-head {
align-items: stretch;
flex-direction: column;
}
.singbox-info-grid {
grid-template-columns: 70px minmax(0, 1fr);
.singbox-workspace-actions {
width: 100%;
}
.singbox-workspace-actions .ui-button {
flex: 1;
}
.server-list {

209
src/ui/DetailsPopover.tsx Normal file
View File

@@ -0,0 +1,209 @@
import {
useEffect,
useId,
useLayoutEffect,
useRef,
useState,
type ButtonHTMLAttributes,
type CSSProperties,
type MouseEvent,
type ReactNode,
} from 'react';
import { createPortal } from 'react-dom';
export type DetailsPopoverAlign = 'start' | 'center' | 'end';
export interface DetailsPopoverProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'title'> {
details: string | string[];
children: ReactNode;
popoverLabel?: string;
align?: DetailsPopoverAlign;
maxWidth?: number;
}
interface DetailsPopoverPosition {
top: number;
left: number;
width: number;
arrowLeft: number;
placement: 'top' | 'bottom';
}
const VIEWPORT_MARGIN = 12;
export function DetailsPopover({
details,
children,
className,
popoverLabel = 'Детали',
align = 'start',
maxWidth = 360,
disabled,
onClick,
...props
}: DetailsPopoverProps) {
const detailsId = useId();
const triggerRef = useRef<HTMLButtonElement>(null);
const popoverRef = useRef<HTMLDivElement>(null);
const [open, setOpen] = useState(false);
const [position, setPosition] = useState<DetailsPopoverPosition>({
top: 0,
left: 0,
width: Math.min(maxWidth, 360),
arrowLeft: 24,
placement: 'bottom',
});
const detailLines = Array.isArray(details)
? details.filter(Boolean)
: [details].filter(Boolean);
const classes = ['ui-details-popover-trigger', className ?? ''].filter(Boolean).join(' ');
useEffect(() => {
if (disabled && open) setOpen(false);
}, [disabled, open]);
useLayoutEffect(() => {
if (!open) return;
const updatePosition = () => {
const trigger = triggerRef.current;
if (!trigger) return;
const rect = trigger.getBoundingClientRect();
const width = Math.min(maxWidth, Math.max(220, window.innerWidth - VIEWPORT_MARGIN * 2));
let left = rect.left;
if (align === 'center') left = rect.left + rect.width / 2 - width / 2;
if (align === 'end') left = rect.right - width;
left = Math.max(VIEWPORT_MARGIN, Math.min(left, window.innerWidth - width - VIEWPORT_MARGIN));
const popoverHeight = popoverRef.current?.offsetHeight ?? 0;
let top = rect.bottom + 8;
let placement: DetailsPopoverPosition['placement'] = 'bottom';
if (
popoverHeight
&& top + popoverHeight > window.innerHeight - VIEWPORT_MARGIN
&& rect.top > popoverHeight + VIEWPORT_MARGIN + 8
) {
top = rect.top - popoverHeight - 8;
placement = 'top';
} else if (popoverHeight) {
top = Math.min(top, window.innerHeight - popoverHeight - VIEWPORT_MARGIN);
}
const arrowLeft = Math.max(
16,
Math.min(rect.left + rect.width / 2 - left, width - 16),
);
setPosition({
top: Math.max(VIEWPORT_MARGIN, top),
left,
width,
arrowLeft,
placement,
});
};
updatePosition();
const frame = window.requestAnimationFrame(updatePosition);
window.addEventListener('resize', updatePosition);
window.addEventListener('scroll', updatePosition, true);
return () => {
window.cancelAnimationFrame(frame);
window.removeEventListener('resize', updatePosition);
window.removeEventListener('scroll', updatePosition, true);
};
}, [align, maxWidth, open]);
useEffect(() => {
if (!open) return;
const closeOnOutsidePointer = (event: PointerEvent) => {
const target = event.target as Node;
if (triggerRef.current?.contains(target)) return;
if (popoverRef.current?.contains(target)) return;
setOpen(false);
};
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key !== 'Escape') return;
setOpen(false);
triggerRef.current?.focus();
};
document.addEventListener('pointerdown', closeOnOutsidePointer);
document.addEventListener('keydown', closeOnEscape);
return () => {
document.removeEventListener('pointerdown', closeOnOutsidePointer);
document.removeEventListener('keydown', closeOnEscape);
};
}, [open]);
const handleClick = (event: MouseEvent<HTMLButtonElement>) => {
onClick?.(event);
if (!event.defaultPrevented) setOpen((current) => !current);
};
const popoverStyle = {
top: position.top,
left: position.left,
width: position.width,
'--details-popover-arrow-left': `${position.arrowLeft}px`,
} as CSSProperties;
return (
<>
<button
{...props}
ref={triggerRef}
type={props.type ?? 'button'}
className={classes}
aria-controls={open ? detailsId : undefined}
aria-expanded={open}
aria-haspopup="dialog"
disabled={disabled}
onClick={handleClick}
>
{children}
</button>
{open && detailLines.length && typeof document !== 'undefined'
? createPortal(
<div
ref={popoverRef}
id={detailsId}
className="ui-details-popover"
data-placement={position.placement}
role="dialog"
aria-label={popoverLabel}
style={popoverStyle}
>
<div className="ui-details-popover-head">
<strong>{popoverLabel}</strong>
<button
type="button"
className="ui-details-popover-close"
aria-label="Закрыть"
onClick={() => {
setOpen(false);
triggerRef.current?.focus();
}}
>
×
</button>
</div>
<div className="ui-details-popover-body">
{detailLines.map((line, index) => (
<p key={`${line}-${index}`}>{line}</p>
))}
</div>
</div>,
document.body,
)
: null}
</>
);
}

29
src/ui/HoverDetails.tsx Normal file
View File

@@ -0,0 +1,29 @@
import type { HTMLAttributes, ReactNode } from 'react';
export interface HoverDetailsProps extends HTMLAttributes<HTMLSpanElement> {
details: string | string[];
children: ReactNode;
}
export function HoverDetails({
details,
children,
className,
...props
}: HoverDetailsProps) {
const detailText = Array.isArray(details)
? details.filter(Boolean).join('\n')
: details;
const classes = ['ui-hover-details', className ?? ''].filter(Boolean).join(' ');
return (
<span
{...props}
className={classes}
data-tooltip={detailText}
tabIndex={props.tabIndex ?? 0}
>
{children}
</span>
);
}

View File

@@ -2,11 +2,12 @@ import type { ButtonHTMLAttributes, ReactNode } from 'react';
export type IconButtonVariant = 'neutral' | 'add' | 'danger';
export interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
export interface IconButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'title'> {
label: string;
icon: ReactNode;
variant?: IconButtonVariant;
loading?: boolean;
tooltip?: string;
}
export function IconButton({
@@ -14,12 +15,12 @@ export function IconButton({
icon,
variant = 'neutral',
loading = false,
tooltip,
className,
disabled,
title,
...props
}: IconButtonProps) {
const tooltip = title === '' ? undefined : title ?? label;
const tooltipText = tooltip === '' ? undefined : tooltip ?? label;
const classes = [
'ui-icon-button',
`ui-icon-button--${variant}`,
@@ -33,7 +34,7 @@ export function IconButton({
type={props.type ?? 'button'}
className={classes}
aria-label={label}
data-tooltip={tooltip}
data-tooltip={tooltipText}
disabled={disabled || loading}
>
{loading ? <span className="ui-button-spinner" aria-hidden="true" /> : icon}

View File

@@ -2,8 +2,12 @@ export { ActionMenu } from './ActionMenu';
export type { ActionMenuItem } from './ActionMenu';
export { Button } from './Button';
export type { ButtonProps, ButtonSize, ButtonVariant } from './Button';
export { DetailsPopover } from './DetailsPopover';
export type { DetailsPopoverAlign, DetailsPopoverProps } from './DetailsPopover';
export { Field } from './Field';
export type { FieldProps } from './Field';
export { HoverDetails } from './HoverDetails';
export type { HoverDetailsProps } from './HoverDetails';
export { IconButton } from './IconButton';
export type { IconButtonProps, IconButtonVariant } from './IconButton';
export { LogDock } from './LogDock';