Refactor VPN proxy routing logic

This commit is contained in:
2026-07-08 09:39:24 +03:00
parent 288acbf0c8
commit 149bb999dc
28 changed files with 2260 additions and 278 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, MoreHorizontal, Trash2, Wand2 } from 'lucide-react';
import { Cpu, FileCode2, FolderOpen, Gauge, Info, Link2, Trash2, Wand2 } from 'lucide-react';
import {
applyProfiles,
fetchSingBoxSubscription,
@@ -34,6 +34,9 @@ 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 { getApplyReadiness } from './readiness';
import { serviceControlState } from './viewModel';
type DraftItemType = Extract<ProfileItemType, 'process' | 'folder' | 'exe'>;
type ProxiFyreAction = 'start' | 'stop' | 'install' | 'uninstall';
@@ -152,12 +155,7 @@ export function App() {
() => logEntries.find((entry) => entry.id === activeLogId) ?? null,
[activeLogId, logEntries],
);
const finderStateClass = isDetectingComponents ? 'checking' : proxyfier?.installed ? 'found' : 'missing';
const finderVisualClass =
serviceVisualState === 'active' ? 'working' : serviceVisualState === 'settling' ? 'settling' : '';
const isSingBoxInstalled = Boolean(singbox?.installed);
const singBoxStateClass = isDetectingComponents ? 'checking' : singbox?.installed ? 'found' : 'missing';
const singBoxVisualClass = singBoxAction ? 'working' : '';
const selectedServerTag = singBoxStatus?.config.selectedServerTag;
const currentSnapshot = useMemo(
() => configSnapshotFromUi(routeMode, proxyInput, items, selectedServerTag),
@@ -882,24 +880,7 @@ export function App() {
{ id: 'proxy', label: 'VPN / Прокси' },
];
return (
<div className="panel-tabs" role="tablist" aria-label="Разделы Proxy">
{tabs.map((tab) => (
<button
type="button"
role="tab"
id={`tab-${tab.id}`}
aria-controls={`panel-${tab.id}`}
aria-selected={activePanel === tab.id}
className={activePanel === tab.id ? 'active' : ''}
key={tab.id}
onClick={() => switchPanel(tab.id)}
>
{tab.label}
</button>
))}
</div>
);
return <Tabs items={tabs} activeId={activePanel} onChange={switchPanel} ariaLabel="Разделы Proxy" />;
}
function renderSummaryPanel() {
@@ -985,79 +966,59 @@ export function App() {
</div>
<div className="summary-actions">
<button type="button" onClick={() => switchPanel('proxy')}>
<Button type="button" variant="neutral" onClick={() => switchPanel('proxy')}>
Настроить маршрут
</button>
<button type="button" onClick={openAppsPanel}>
</Button>
<Button type="button" variant="neutral" onClick={openAppsPanel}>
Приложения ProxiFyre
</button>
</Button>
</div>
</section>
);
}
function renderProxiFyreCard() {
const state = serviceControlState(proxyfier, isDetectingComponents);
const visualState = serviceVisualState === 'active' ? 'working' : serviceVisualState === 'settling' ? 'settling' : null;
const primaryAction = proxyfier?.installed
? {
label: proxyfier.running ? 'Остановить' : 'Запустить',
onClick: () => void setProxiFyreServiceRunning(!proxyfier.running),
variant: proxyfier.running ? 'danger' as const : 'neutral' as const,
loading: serviceAction === 'start' || serviceAction === 'stop',
loadingLabel: serviceAction === 'start' ? 'Запускаю' : 'Останавливаю',
disabled: isDetectingComponents || Boolean(serviceAction),
}
: {
label: 'Установить',
onClick: () => void installProxiFyrePackage(),
variant: 'primary' as const,
loading: serviceAction === 'install',
loadingLabel: 'Устанавливаю',
disabled: isDetectingComponents || Boolean(serviceAction),
};
return (
<div className={`finder-card proxifyre-card ${finderStateClass} ${finderVisualClass}`.trim()}>
<span className="finder-border-glow" aria-hidden="true">
<span className="finder-border-glow-segment top" />
<span className="finder-border-glow-segment right" />
<span className="finder-border-glow-segment bottom" />
<span className="finder-border-glow-segment left" />
</span>
<span className="status-light" />
<div className="finder-text">
<strong>{proxyfierTitle(proxyfier, isDetectingComponents)}</strong>
<span>{proxyfierDetails(proxyfier, isDetectingComponents)}</span>
</div>
<div className="service-actions" aria-label="Управление службой ProxiFyre">
{proxyfier?.installed ? (
<>
<button
type="button"
className={`service-button ${proxyfier.running ? 'stop' : ''}`.trim()}
onClick={() => setProxiFyreServiceRunning(!proxyfier.running)}
disabled={isDetectingComponents || Boolean(serviceAction)}
>
{serviceAction === 'start' || serviceAction === 'stop'
? '...'
: proxyfier.running
? 'Остановить'
: 'Запустить'}
</button>
<div className="service-menu">
<button
type="button"
className="service-menu-button"
onClick={() => setIsServiceMenuOpen((current) => !current)}
disabled={isDetectingComponents || Boolean(serviceAction)}
aria-label="Дополнительные действия ProxiFyre"
aria-expanded={isServiceMenuOpen}
title="Еще"
>
<MoreHorizontal size={20} strokeWidth={2} />
</button>
{isServiceMenuOpen ? (
<div className="service-menu-popover">
<button type="button" onClick={() => void uninstallProxiFyrePackage()}>
{serviceAction === 'uninstall' ? 'Удаляю...' : 'Удалить ProxiFyre'}
</button>
</div>
) : null}
</div>
</>
) : (
<button
type="button"
className="service-button install"
onClick={() => void installProxiFyrePackage()}
disabled={isDetectingComponents || Boolean(serviceAction)}
>
{serviceAction === 'install' ? '...' : 'Установить'}
</button>
)}
</div>
</div>
<ServiceControlRow
state={state}
visualState={visualState}
className="proxifyre-card"
title={proxyfierTitle(proxyfier, isDetectingComponents)}
detail={proxyfierDetails(proxyfier, isDetectingComponents)}
primaryAction={primaryAction}
menu={proxyfier?.installed ? {
label: 'Дополнительные действия ProxiFyre',
open: isServiceMenuOpen,
onOpenChange: setIsServiceMenuOpen,
disabled: isDetectingComponents || Boolean(serviceAction),
items: [{
label: serviceAction === 'uninstall' ? 'Удаляю...' : 'Удалить ProxiFyre',
danger: true,
disabled: Boolean(serviceAction),
onClick: () => void uninstallProxiFyrePackage(),
}],
} : undefined}
/>
);
}
@@ -1109,19 +1070,19 @@ export function App() {
spellCheck={false}
autoFocus
/>
<button type="button" onClick={addProcess}>
<Button type="button" variant="primary" onClick={addProcess}>
OK
</button>
<button
</Button>
<Button
type="button"
className="process-cancel-button"
variant="neutral"
onClick={() => {
setProcessInput('');
setIsProcessInputOpen(false);
}}
>
Отмена
</button>
</Button>
</div>
) : (
<div className="app-add-skeleton">
@@ -1135,38 +1096,31 @@ export function App() {
</div>
</div>
<div className="add-toolbar" aria-label="Добавить приложение">
<button
<IconButton
type="button"
className="add-tile"
variant="add"
onClick={() => setIsProcessInputOpen(true)}
aria-label="Добавить процесс"
data-tooltip="Добавить процесс"
>
<Cpu size={20} strokeWidth={1.8} />
<span className="sr-only">Добавить процесс</span>
</button>
<button
label="Добавить процесс"
icon={<Cpu size={20} strokeWidth={1.8} />}
/>
<IconButton
type="button"
className="add-tile"
variant="add"
onClick={() => void pickAndAddItem('exe')}
disabled={Boolean(pickerAction)}
aria-label="Добавить EXE-файл"
data-tooltip="Добавить EXE-файл"
>
{pickerAction === 'exe' ? <span className="tile-loading">...</span> : <FileCode2 size={20} strokeWidth={1.8} />}
<span className="sr-only">Добавить EXE-файл</span>
</button>
<button
loading={pickerAction === 'exe'}
label="Добавить EXE-файл"
icon={<FileCode2 size={20} strokeWidth={1.8} />}
/>
<IconButton
type="button"
className="add-tile"
variant="add"
onClick={() => void pickAndAddItem('folder')}
disabled={Boolean(pickerAction)}
aria-label="Добавить папку"
data-tooltip="Добавить папку"
>
{pickerAction === 'folder' ? <span className="tile-loading">...</span> : <FolderOpen size={20} strokeWidth={1.8} />}
<span className="sr-only">Добавить папку</span>
</button>
loading={pickerAction === 'folder'}
label="Добавить папку"
icon={<FolderOpen size={20} strokeWidth={1.8} />}
/>
</div>
</div>
)}
@@ -1189,9 +1143,9 @@ export function App() {
<span>{itemTypeLabel(item.type)}</span>
</div>
</div>
<button type="button" onClick={() => removeItem(item.id)} aria-label={`Удалить ${item.value}`}>
<Button type="button" variant="danger" onClick={() => removeItem(item.id)} aria-label={`Удалить ${item.value}`}>
Удалить
</button>
</Button>
</div>
))
) : (
@@ -1207,10 +1161,27 @@ export function App() {
options: { showConfigPath?: boolean } = {},
) {
const showConfigPath = options.showConfigPath ?? true;
const externalProxyError = routeMode === 'external' ? safeProxyError(proxyInput) : null;
const readiness = getApplyReadiness({
routeMode,
appCount: items.length,
proxiFyreInstalled: Boolean(proxyfier?.installed),
singBoxInstalled: isSingBoxInstalled,
selectedServerTag: singBoxStatus?.config.selectedServerTag,
externalProxyValue: proxyInput,
externalProxyError,
busy: isApplying || Boolean(serviceAction) || Boolean(singBoxAction),
});
const showBlocker = !readiness.ready && !isLoading && !isDetectingComponents;
return (
<>
{hasUnappliedChanges ? (
{showBlocker ? (
<div className="apply-state blocked" role="status">
<strong>{readiness.title}</strong>
<span>{readiness.text}</span>
</div>
) : hasUnappliedChanges ? (
<div className="apply-state pending" role="status">
<strong>Изменения еще не применены в ProxiFyre</strong>
<span>{applyStateText(routeMode, isSingBoxInstalled, Boolean(singbox?.running))}</span>
@@ -1218,17 +1189,27 @@ export function App() {
) : null}
<div className="command-row">
<button type="button" className="apply-button" onClick={updateConfig} disabled={isApplying}>
{applyButtonLabel(context, isApplying, hasUnappliedChanges, singBoxAction)}
</button>
<button
<Button
type="button"
className="open-config-button"
onClick={openConfig}
disabled={isOpeningConfig}
variant={readiness.ready ? 'primary' : 'neutral'}
size="lg"
onClick={updateConfig}
disabled={!readiness.ready}
loading={isApplying}
loadingLabel={applyButtonLabel(context, true, hasUnappliedChanges, singBoxAction)}
>
{isOpeningConfig ? '...' : 'Открыть'}
</button>
{applyButtonLabel(context, isApplying, hasUnappliedChanges, singBoxAction)}
</Button>
<Button
type="button"
variant="neutral"
size="lg"
onClick={openConfig}
loading={isOpeningConfig}
loadingLabel="Открываю"
>
Открыть
</Button>
</div>
{showConfigPath && generatedConfigPath ? <p className="config-path">{generatedConfigPath}</p> : null}
@@ -1270,35 +1251,68 @@ export function App() {
<strong>{proxyPing ? pingResultTitle(proxyPing) : proxyValidation ? 'Проверь формат' : 'Проверка не запускалась'}</strong>
<span>{proxyPing ? pingResultText(proxyPing) : proxyValidation ?? 'TCP check покажет, доступен ли host:port.'}</span>
</div>
<button
<Button
type="button"
className="open-config-button"
variant="neutral"
size="lg"
onClick={() => void pingExternalProxy()}
disabled={isProxyChecking || Boolean(proxyValidation)}
disabled={Boolean(proxyValidation)}
loading={isProxyChecking}
loadingLabel="Проверяю"
>
{isProxyChecking ? '...' : 'Проверить'}
</button>
Проверить
</Button>
</div>
</div>
);
}
function renderSingBoxCard() {
const state = serviceControlState(singbox, isDetectingComponents);
const primaryAction = singbox?.installed
? {
label: singbox.running ? 'Остановить' : 'Запустить',
onClick: () => void setSingBoxServiceRunning(!singbox.running),
variant: singbox.running ? 'danger' as const : 'neutral' as const,
loading: singBoxAction === 'start' || singBoxAction === 'stop',
loadingLabel: singBoxAction === 'start' ? 'Запускаю' : 'Останавливаю',
disabled: isDetectingComponents || Boolean(singBoxAction),
}
: {
label: 'Установить',
onClick: () => void installSingBoxPackage(),
variant: 'primary' as const,
loading: singBoxAction === 'install',
loadingLabel: 'Устанавливаю',
disabled: isDetectingComponents || Boolean(singBoxAction),
};
return (
<div className={`finder-card singbox-card ${singBoxStateClass} ${singBoxVisualClass}`.trim()}>
<span className="finder-border-glow" aria-hidden="true">
<span className="finder-border-glow-segment top" />
<span className="finder-border-glow-segment right" />
<span className="finder-border-glow-segment bottom" />
<span className="finder-border-glow-segment left" />
</span>
<span className="status-light" />
<div className="finder-text">
<strong>{singBoxTitle(singbox, isDetectingComponents)}</strong>
<span>{singBoxDetails(singbox, singBoxStatus, isDetectingComponents)}</span>
<div className="singbox-inline-actions">
<button
<ServiceControlRow
state={state}
visualState={singBoxAction ? 'working' : null}
className="singbox-card"
title={singBoxTitle(singbox, isDetectingComponents)}
detail={singBoxDetails(singbox, singBoxStatus, isDetectingComponents)}
primaryAction={primaryAction}
menu={singbox?.installed ? {
label: 'Дополнительные действия Local sing-box',
open: isSingBoxMenuOpen,
onOpenChange: setIsSingBoxMenuOpen,
disabled: isDetectingComponents || Boolean(singBoxAction),
items: [{
label: singBoxAction === 'uninstall' ? 'Удаляю...' : 'Удалить Local sing-box',
danger: true,
disabled: Boolean(singBoxAction),
onClick: () => void uninstallSingBoxPackage(),
}],
} : undefined}
inlineActions={(
<>
<Button
type="button"
variant="neutral"
size="sm"
className="setup-toggle"
onClick={() => setIsSingBoxSetupOpen((current) => !current)}
disabled={isDetectingComponents && !singBoxSetupStatus}
@@ -1308,67 +1322,20 @@ export function App() {
{singBoxSetupStatus ? (
<span>{singBoxSetupStatus.ready ? 'все есть' : `не хватает: ${singBoxSetupStatus.missingCount}`}</span>
) : null}
</button>
<button
</Button>
<IconButton
type="button"
variant="neutral"
className="info-toggle"
onClick={() => setIsSingBoxInfoOpen((current) => !current)}
aria-label="Подробности Local sing-box"
label="Подробности Local sing-box"
aria-expanded={isSingBoxInfoOpen}
title="Подробности"
>
<Info size={16} strokeWidth={2} />
</button>
</div>
</div>
<div className="service-actions" aria-label="Управление службой Local sing-box">
{singbox?.installed ? (
<>
<button
type="button"
className={`service-button ${singbox.running ? 'stop' : ''}`.trim()}
onClick={() => void setSingBoxServiceRunning(!singbox.running)}
disabled={isDetectingComponents || Boolean(singBoxAction)}
>
{singBoxAction === 'start' || singBoxAction === 'stop'
? '...'
: singbox.running
? 'Остановить'
: 'Запустить'}
</button>
<div className="service-menu">
<button
type="button"
className="service-menu-button"
onClick={() => setIsSingBoxMenuOpen((current) => !current)}
disabled={isDetectingComponents || Boolean(singBoxAction)}
aria-label="Дополнительные действия Local sing-box"
aria-expanded={isSingBoxMenuOpen}
title="Еще"
>
<MoreHorizontal size={20} strokeWidth={2} />
</button>
{isSingBoxMenuOpen ? (
<div className="service-menu-popover">
<button type="button" onClick={() => void uninstallSingBoxPackage()}>
{singBoxAction === 'uninstall' ? 'Удаляю...' : 'Удалить Local sing-box'}
</button>
</div>
) : null}
</div>
</>
) : (
<button
type="button"
className="service-button install"
onClick={() => void installSingBoxPackage()}
disabled={isDetectingComponents || Boolean(singBoxAction)}
>
{singBoxAction === 'install' ? '...' : 'Установить'}
</button>
)}
</div>
icon={<Info size={16} strokeWidth={2} />}
/>
</>
)}
>
{isSingBoxInfoOpen ? (
<div className="singbox-info-popover">
<div className="singbox-info-grid">
@@ -1388,22 +1355,26 @@ export function App() {
<strong>{singBoxStatus?.generatedConfigPath ?? 'не создан'}</strong>
</div>
<div className="singbox-info-actions">
<button
<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
</Button>
<Button
type="button"
onClick={() => void generateSingBoxNow()}
disabled={Boolean(singBoxAction) || !selectedServerTag}
variant="neutral"
size="sm"
>
<Wand2 size={15} strokeWidth={1.9} />
Конфиг
</button>
</Button>
</div>
</div>
) : null}
@@ -1438,7 +1409,7 @@ export function App() {
<span>Установи компонент, чтобы подключить подписку, выбрать сервер и включить локальный маршрут.</span>
</div>
)}
</div>
</ServiceControlRow>
);
}
@@ -1455,23 +1426,24 @@ export function App() {
placeholder={singBoxStatus?.config.subscriptionDisplayUrl ?? 'https://example.com/sub'}
spellCheck={false}
/>
<button
<Button
type="button"
onClick={() => void syncSingBoxSubscription()}
disabled={singBoxAction === 'fetch'}
loading={singBoxAction === 'fetch'}
loadingLabel="Загружаю"
variant="neutral"
>
{singBoxAction === 'fetch' ? '...' : subscriptionInput.trim() || !singBoxStatus?.config.hasSubscription ? 'Загрузить' : 'Обновить'}
</button>
<button
{subscriptionInput.trim() || !singBoxStatus?.config.hasSubscription ? 'Загрузить' : 'Обновить'}
</Button>
<IconButton
type="button"
className="icon-command"
variant="danger"
onClick={() => void forgetSingBoxSubscriptionData()}
disabled={Boolean(singBoxAction) || !singBoxStatus?.config.hasSubscription}
aria-label="Очистить подписку Local sing-box"
label="Очистить подписку Local sing-box"
title="Очистить"
>
<Trash2 size={18} strokeWidth={1.9} />
</button>
icon={<Trash2 size={18} strokeWidth={1.9} />}
/>
</div>
{singBoxStatus?.cache?.servers.length ? (
@@ -1491,16 +1463,15 @@ export function App() {
<strong>{displayServerTag(server.tag)}</strong>
{ping ? <span className="server-ping-badge">{ping.ok ? `${ping.latency ?? 0} ms` : 'fail'}</span> : null}
</button>
<button
<IconButton
type="button"
className="server-ping-button"
onClick={() => void pingSingleSingBoxServer(server)}
disabled={Boolean(serverPingTag)}
aria-label={`Проверить ${displayServerTag(server.tag)}`}
loading={serverPingTag === server.tag}
label={`Проверить ${displayServerTag(server.tag)}`}
title="Ping"
>
{serverPingTag === server.tag ? '...' : <Gauge size={14} strokeWidth={1.9} />}
</button>
icon={<Gauge size={14} strokeWidth={1.9} />}
/>
</div>
);
})}
@@ -1530,22 +1501,22 @@ export function App() {
<section className="route-panel" aria-label="Маршрут приложений">
<div className="route-switch">
<button
<Button
type="button"
className={routeMode === 'external' ? 'active' : ''}
variant={routeMode === 'external' ? 'primary' : 'neutral'}
onClick={() => changeRouteMode('external')}
aria-pressed={routeMode === 'external'}
>
Внешний прокси
</button>
<button
</Button>
<Button
type="button"
className={routeMode === 'local-singbox' ? 'active' : ''}
variant={routeMode === 'local-singbox' ? 'primary' : 'neutral'}
onClick={() => changeRouteMode('local-singbox')}
aria-pressed={routeMode === 'local-singbox'}
>
Локальный прокси
</button>
</Button>
</div>
{routeMode === 'external' ? renderExternalProxyControls() : renderSingBoxCard()}
@@ -1574,14 +1545,16 @@ export function App() {
<div>
<h1>Proxy для приложений</h1>
</div>
<button
<Button
type="button"
className="ghost-button"
variant="neutral"
size="sm"
onClick={refresh}
disabled={isLoading || isDetectingComponents}
loading={isLoading || isDetectingComponents}
loadingLabel={isLoading ? 'Загружаю' : 'Проверяю'}
>
{isLoading ? 'Загружаю...' : isDetectingComponents ? 'Проверяю...' : 'Обновить'}
</button>
Обновить
</Button>
</header>
{renderTabs()}
@@ -1590,45 +1563,13 @@ export function App() {
</div>
</section>
<footer className={`log-dock ${activeLog?.kind ?? 'idle'}`} aria-live="polite">
<div className={`log-current ${activeLog ? 'visible' : 'hidden'}`}>
{activeLog ? (
<>
<strong>{activeLog.title}</strong>
<span>{activeLog.text}</span>
</>
) : (
<span className="log-muted">Журнал событий</span>
)}
</div>
<button type="button" className="log-toggle" onClick={() => setIsLogOpen((current) => !current)}>
{isLogOpen ? 'Скрыть' : 'Посмотреть'}
<span>{logEntries.length}</span>
</button>
{isLogOpen ? (
<div className="log-history">
{logEntries.length ? (
logEntries.map((entry) => (
<div className={`log-history-row ${entry.kind}`} key={entry.id}>
<time>{formatLogTime(entry.at)}</time>
<div>
<strong>{entry.title}</strong>
<span>{entry.text}</span>
</div>
</div>
))
) : (
<div className="log-history-row">
<time>--:--:--</time>
<div>
<strong>Журнал пуст</strong>
<span>События появятся после проверок или применения конфигурации.</span>
</div>
</div>
)}
</div>
) : null}
</footer>
<LogDock
entries={logEntries}
activeEntry={activeLog}
open={isLogOpen}
onToggle={() => setIsLogOpen((current) => !current)}
formatTime={formatLogTime}
/>
</main>
);
}

View File

@@ -0,0 +1,83 @@
export type RouteMode = 'external' | 'local-singbox';
export interface ApplyReadinessInput {
routeMode: RouteMode;
appCount: number;
proxiFyreInstalled: boolean;
singBoxInstalled: boolean;
selectedServerTag?: string;
externalProxyValue: string;
externalProxyError?: string | null;
busy: boolean;
}
export interface ApplyReadiness {
ready: boolean;
title?: string;
text?: string;
}
export function getApplyReadiness(input: ApplyReadinessInput): ApplyReadiness {
if (input.busy) {
return {
ready: false,
title: 'Операция уже выполняется',
text: 'Дождись завершения текущего действия перед повторным применением.',
};
}
if (!input.proxiFyreInstalled) {
return {
ready: false,
title: 'ProxiFyre не установлен',
text: 'Установи ProxiFyre, чтобы маршрутизировать выбранные приложения.',
};
}
if (input.appCount < 1) {
return {
ready: false,
title: 'Нет приложений',
text: 'Добавь хотя бы один процесс, EXE-файл или папку.',
};
}
if (input.routeMode === 'external') {
if (!input.externalProxyValue.trim()) {
return {
ready: false,
title: 'Прокси не указан',
text: 'Введи адрес SOCKS5 прокси в формате host:port или socks5://host:port.',
};
}
if (input.externalProxyError) {
return {
ready: false,
title: 'Проверь формат прокси',
text: input.externalProxyError,
};
}
}
if (input.routeMode === 'local-singbox') {
if (!input.singBoxInstalled) {
return {
ready: false,
title: 'Local sing-box не установлен',
text: 'Установи Local sing-box, чтобы применить локальный маршрут.',
};
}
if (!input.selectedServerTag) {
return {
ready: false,
title: 'Сервер не выбран',
text: 'Выбери сервер Local sing-box перед применением маршрута.',
};
}
}
return { ready: true };
}

View File

@@ -0,0 +1,15 @@
import type { ComponentStatus } from '../domain/types';
import type { ServiceControlState } from '../ui';
export function serviceControlState(
component: ComponentStatus | undefined,
checking: boolean,
): ServiceControlState {
if (checking) return 'checking';
if (!component) return 'missing';
if (component.state === 'error') return 'error';
if (component.running) return 'running';
if (component.installed) return 'stopped';
return 'missing';
}

View File

@@ -3,6 +3,19 @@
--app-header-row-height: 50px;
--app-tab-height: 46px;
--app-header-height: calc(var(--app-header-row-height) + var(--app-tab-height));
--motion-fast: 120ms;
--motion-standard: 180ms;
--motion-panel: 220ms;
--ease-out: cubic-bezier(0.23, 1, 0.32, 1);
--ease-standard: cubic-bezier(0.22, 0.72, 0.18, 1);
--surface-canvas: #101216;
--surface-panel: #131720;
--surface-raised: #151923;
--surface-control: #242a35;
--surface-inset: #0d1016;
--border-soft: #2b3342;
--border-strong: #343b49;
--focus-ring: #3b82f6;
font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
@@ -74,6 +87,487 @@ button:disabled {
opacity: 0.56;
}
.ui-button,
.ui-icon-button {
appearance: none;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
border: 1px solid var(--border-strong);
border-radius: 4px;
background: var(--surface-control);
color: #eef2ff;
cursor: pointer;
font-weight: 750;
line-height: 1;
text-decoration: none;
transition:
background-color var(--motion-fast) var(--ease-out),
border-color var(--motion-fast) var(--ease-out),
color var(--motion-fast) var(--ease-out),
opacity var(--motion-fast) var(--ease-out),
transform var(--motion-fast) var(--ease-out);
}
.ui-button:hover,
.ui-icon-button:hover {
background: #2d3543;
}
.ui-button:active,
.ui-icon-button:active {
transform: scale(0.98);
}
.ui-button:focus-visible,
.ui-icon-button:focus-visible,
.ui-tab:focus-visible,
.ui-action-menu-popover button:focus-visible {
outline: 0;
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.72);
}
.ui-button:disabled,
.ui-icon-button:disabled {
cursor: not-allowed;
opacity: 0.56;
transform: none;
}
.ui-button--sm {
min-height: 32px;
padding: 6px 10px;
font-size: 13px;
}
.ui-button--md {
min-height: 36px;
padding: 8px 12px;
font-size: 14px;
}
.ui-button--lg {
min-height: 46px;
padding: 10px 14px;
font-size: 14px;
}
.ui-button--primary {
border-color: #16a34a;
background: #22c55e;
color: #04130a;
}
.ui-button--primary:hover {
background: #4ade80;
}
.ui-button--add,
.ui-icon-button--add {
border-color: #2b3342;
background: #202633;
color: #dbeafe;
}
.ui-button--add:hover,
.ui-icon-button--add:hover {
border-color: var(--focus-ring);
background: #182033;
}
.ui-button--danger,
.ui-icon-button--danger {
color: #fecaca;
}
.ui-button--danger:hover,
.ui-icon-button--danger:hover {
border-color: rgba(239, 68, 68, 0.52);
background: rgba(127, 29, 29, 0.34);
}
.ui-icon-button {
width: 42px;
min-width: 42px;
min-height: 38px;
padding: 0;
}
.ui-button-icon,
.ui-button-label {
display: inline-flex;
align-items: center;
min-width: 0;
}
.ui-button-spinner {
width: 14px;
height: 14px;
border: 2px solid currentColor;
border-top-color: transparent;
border-radius: 999px;
animation: spin 0.75s linear infinite;
}
.ui-tabs {
position: fixed;
top: var(--app-header-row-height);
right: 0;
left: 0;
z-index: 40;
display: flex;
align-items: stretch;
gap: 0;
min-height: var(--app-tab-height);
overflow-x: auto;
border-top: 1px solid #242a35;
border-bottom: 1px solid var(--border-soft);
background: #181b22;
scrollbar-width: none;
margin: 0;
padding: 0 18px;
}
.ui-tabs::-webkit-scrollbar {
display: none;
}
.ui-tab {
appearance: none;
position: relative;
flex: 1 1 0;
min-width: 0;
min-height: var(--app-tab-height);
border: 0;
border-radius: 0;
background: transparent;
color: #9aa8bd;
cursor: pointer;
font-weight: 750;
margin: 0;
padding: 10px 12px 12px;
transition:
background-color var(--motion-fast) var(--ease-out),
color var(--motion-fast) var(--ease-out);
white-space: nowrap;
}
.ui-tab:hover {
background: #1c212b;
color: #eef2ff;
}
.ui-tab.is-active {
color: #eff6ff;
}
.ui-tab.is-active::after {
position: absolute;
right: 12px;
bottom: 0;
left: 12px;
height: 3px;
border-radius: 999px 999px 0 0;
background: var(--focus-ring);
box-shadow: 0 -6px 18px rgba(59, 130, 246, 0.34);
content: "";
}
.ui-status-pill {
display: inline-flex;
align-items: center;
width: fit-content;
border: 1px solid var(--border-soft);
border-radius: 999px;
background: #1a202b;
color: #cbd5e1;
padding: 5px 9px;
font-size: 12px;
font-weight: 750;
}
.ui-status-pill--ok {
border-color: rgba(34, 197, 94, 0.38);
color: #86efac;
}
.ui-status-pill--warning {
border-color: rgba(245, 158, 11, 0.46);
color: #fcd34d;
}
.ui-status-pill--error {
border-color: rgba(239, 68, 68, 0.46);
color: #fecaca;
}
.ui-status-pill--checking {
border-color: rgba(59, 130, 246, 0.46);
color: #bfdbfe;
}
.ui-field {
display: grid;
gap: 7px;
}
.ui-field-label {
color: #8d99ae;
}
.ui-field-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px;
align-items: stretch;
}
.ui-field input {
min-height: 42px;
width: 100%;
border: 1px solid var(--border-strong);
border-radius: 4px;
background: var(--surface-inset);
color: #f8fafc;
outline: none;
padding: 9px 11px;
}
.ui-field input:focus {
border-color: var(--focus-ring);
box-shadow: 0 0 0 1px var(--focus-ring);
}
.ui-field-help {
color: #8d99ae;
font-size: 12px;
}
.ui-field-help.is-error {
color: #fcd34d;
}
.ui-action-menu {
position: relative;
display: flex;
}
.ui-action-menu-popover {
position: absolute;
top: calc(100% + 6px);
right: 0;
z-index: 8;
min-width: 172px;
border: 1px solid var(--border-strong);
border-radius: 4px;
background: #171c26;
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.38);
padding: 5px;
transform-origin: top right;
animation: ui-popover-in var(--motion-standard) var(--ease-out);
}
.ui-action-menu-popover button {
width: 100%;
min-height: 34px;
border: 0;
border-radius: 3px;
background: transparent;
color: #e5e7eb;
cursor: pointer;
font: inherit;
padding: 7px 9px;
text-align: left;
}
.ui-action-menu-popover button:hover {
background: #242a35;
}
.ui-action-menu-popover button.is-danger {
color: #fecaca;
}
.ui-action-menu-popover button.is-danger:hover {
background: rgba(127, 29, 29, 0.42);
}
.ui-service-row {
position: relative;
isolation: isolate;
display: grid;
grid-template-columns: auto minmax(220px, 1fr) auto;
align-items: center;
gap: 12px;
justify-content: stretch;
min-height: 56px;
overflow: visible;
border: 1px solid var(--border-soft);
border-radius: 4px;
background: var(--surface-raised);
padding: 12px;
}
.ui-service-row > :not(.ui-service-border-glow) {
position: relative;
z-index: 1;
}
.ui-service-border-glow {
position: absolute;
display: none;
z-index: 0;
inset: 0;
overflow: hidden;
border-radius: inherit;
opacity: 0;
contain: layout paint;
pointer-events: none;
transition: opacity var(--motion-panel) var(--ease-out);
}
.ui-service-row--checking .ui-service-border-glow,
.ui-service-row--working .ui-service-border-glow {
display: block;
opacity: 0.78;
}
.ui-service-row--settling .ui-service-border-glow {
display: block;
opacity: 0;
transition-duration: 0.7s;
}
.ui-service-border-glow-segment {
position: absolute;
display: block;
background: #93c5fd;
box-shadow:
0 0 8px rgba(96, 165, 250, 0.95),
0 0 16px rgba(34, 197, 94, 0.36);
opacity: 0;
}
.ui-service-border-glow-segment.top,
.ui-service-border-glow-segment.bottom {
width: 108px;
height: 2px;
background: linear-gradient(90deg, transparent, #60a5fa 24%, #bbf7d0 54%, transparent);
}
.ui-service-border-glow-segment.right,
.ui-service-border-glow-segment.left {
width: 2px;
height: 64px;
background: linear-gradient(180deg, transparent, #60a5fa 24%, #bbf7d0 54%, transparent);
}
.ui-service-border-glow-segment.top {
top: 0;
animation: finder-border-top 1.6s linear infinite;
}
.ui-service-border-glow-segment.right {
right: 0;
animation: finder-border-right 1.6s linear infinite;
}
.ui-service-border-glow-segment.bottom {
bottom: 0;
animation: finder-border-bottom 1.6s linear infinite;
}
.ui-service-border-glow-segment.left {
left: 0;
animation: finder-border-left 1.6s linear infinite;
}
.ui-service-dot {
flex: 0 0 auto;
width: 11px;
height: 11px;
border-radius: 999px;
background: #f59e0b;
box-shadow: 0 0 0 4px rgba(245, 158, 11, 0.12);
}
.ui-service-row--running .ui-service-dot,
.ui-service-row--installed .ui-service-dot {
background: #22c55e;
box-shadow: 0 0 0 4px rgba(34, 197, 94, 0.12);
}
.ui-service-row--stopped .ui-service-dot,
.ui-service-row--missing .ui-service-dot {
background: #f59e0b;
}
.ui-service-row--error .ui-service-dot {
background: #ef4444;
box-shadow: 0 0 0 4px rgba(239, 68, 68, 0.12);
}
.ui-service-row--checking .ui-service-dot {
border: 2px solid var(--focus-ring);
border-top-color: transparent;
background: transparent;
box-shadow: none;
animation: spin 0.75s linear infinite;
}
.ui-service-text {
min-width: 0;
}
.ui-service-text > strong,
.ui-service-text > span {
display: block;
overflow-wrap: anywhere;
}
.ui-service-text > span {
color: #8d99ae;
}
.ui-service-inline-actions {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
margin-top: 6px;
}
.ui-service-actions {
display: flex;
align-items: center;
gap: 6px;
}
.ui-service-row > .setup-details,
.ui-service-row > .singbox-info-popover,
.ui-service-row > .singbox-workspace,
.ui-service-row > .singbox-install-note {
grid-column: 1 / -1;
}
.log-raw-detail {
color: #7d8aa0;
margin-top: 3px;
}
@keyframes ui-popover-in {
from {
opacity: 0;
transform: scale(0.97);
}
to {
opacity: 1;
transform: scale(1);
}
}
.simple-shell {
display: block;
height: 100vh;
@@ -743,7 +1237,7 @@ button.summary-card:hover {
color: #bfdbfe;
}
.setup-toggle span {
.setup-toggle .ui-button-label > span {
display: inline-block;
border: 1px solid #343b49;
border-radius: 4px;
@@ -1132,6 +1626,12 @@ button.summary-card:hover {
color: #eff6ff;
}
.route-switch .ui-button--primary {
border-color: #2563eb;
background: #1d4ed8;
color: #eff6ff;
}
.subscription-line {
display: grid;
grid-template-columns: 38px minmax(0, 1fr) 112px 42px;
@@ -1598,6 +2098,17 @@ button.summary-card:hover {
margin-top: 14px;
}
.command-row .ui-button,
.proxy-check-line .ui-button,
.subscription-line .ui-button {
width: 100%;
}
.apps-config-actions .ui-button {
height: 48px;
min-height: 48px;
}
.apply-button {
min-height: 46px;
width: 100%;
@@ -1721,7 +2232,7 @@ button.summary-card:hover {
background: #2d3543;
}
.log-toggle span {
.log-toggle .log-count {
min-width: 22px;
border-radius: 4px;
background: #1b202b;
@@ -1958,6 +2469,11 @@ button.summary-card:hover {
padding: 0 10px;
}
.ui-tabs {
gap: 1px;
padding: 0 10px;
}
.panel-tabs button {
flex: 1 0 110px;
min-height: var(--app-tab-height);
@@ -1965,6 +2481,13 @@ button.summary-card:hover {
font-size: 13px;
}
.ui-tab {
flex: 1 0 110px;
min-height: var(--app-tab-height);
padding: 8px 10px 9px;
font-size: 13px;
}
.panel-section-head {
align-items: stretch;
flex-direction: column;
@@ -2057,10 +2580,34 @@ button.summary-card:hover {
grid-template-columns: auto minmax(0, 1fr);
}
.ui-service-row {
grid-template-columns: auto minmax(0, 1fr);
}
.service-actions {
grid-column: 1 / -1;
}
.ui-service-actions {
grid-column: 1 / -1;
display: grid;
grid-template-columns: minmax(0, 1fr) 42px;
width: 100%;
}
.ui-service-actions > .ui-button {
width: 100%;
}
.ui-service-actions > .ui-button:only-child {
grid-column: 1 / -1;
}
.setup-strip-items {
flex-wrap: wrap;
overflow: visible;
}
.setup-details {
grid-column: 1 / -1;
}
@@ -2102,11 +2649,14 @@ button.summary-card:hover {
padding: 0 4px;
}
.log-current strong,
.log-current span {
.log-current strong {
white-space: nowrap;
}
.log-current span:not(.log-muted) {
display: none;
}
.log-history {
max-height: 220px;
padding: 7px 10px;

View File

@@ -0,0 +1,54 @@
import { MoreHorizontal } from 'lucide-react';
import { IconButton } from './IconButton';
export interface ActionMenuItem {
label: string;
onClick: () => void;
danger?: boolean;
disabled?: boolean;
}
export interface ActionMenuProps {
open: boolean;
onOpenChange: (open: boolean) => void;
label: string;
items: ActionMenuItem[];
disabled?: boolean;
}
export function ActionMenu({
open,
onOpenChange,
label,
items,
disabled,
}: ActionMenuProps) {
return (
<div className="ui-action-menu">
<IconButton
label={label}
icon={<MoreHorizontal size={20} strokeWidth={2} />}
onClick={() => onOpenChange(!open)}
disabled={disabled}
aria-expanded={open}
/>
{open ? (
<div className="ui-action-menu-popover" role="menu">
{items.map((item) => (
<button
type="button"
role="menuitem"
className={item.danger ? 'is-danger' : ''}
onClick={item.onClick}
disabled={item.disabled}
key={item.label}
>
{item.label}
</button>
))}
</div>
) : null}
</div>
);
}

View File

@@ -0,0 +1,49 @@
import type { ButtonHTMLAttributes, ReactNode } from 'react';
export type ButtonVariant = 'primary' | 'neutral' | 'add' | 'danger';
export type ButtonSize = 'sm' | 'md' | 'lg';
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant;
size?: ButtonSize;
loading?: boolean;
loadingLabel?: string;
leftIcon?: ReactNode;
rightIcon?: ReactNode;
}
export function Button({
variant = 'neutral',
size = 'md',
loading = false,
loadingLabel,
leftIcon,
rightIcon,
className,
children,
disabled,
...props
}: ButtonProps) {
const classes = [
'ui-button',
`ui-button--${variant}`,
`ui-button--${size}`,
loading ? 'is-loading' : '',
className ?? '',
].filter(Boolean).join(' ');
return (
<button
{...props}
className={classes}
disabled={disabled || loading}
>
{loading ? <span className="ui-button-spinner" aria-hidden="true" /> : leftIcon ? (
<span className="ui-button-icon" aria-hidden="true">{leftIcon}</span>
) : null}
<span className="ui-button-label">{loading && loadingLabel ? loadingLabel : children}</span>
{!loading && rightIcon ? <span className="ui-button-icon" aria-hidden="true">{rightIcon}</span> : null}
</button>
);
}

View File

@@ -0,0 +1,38 @@
import type { InputHTMLAttributes, ReactNode } from 'react';
export interface FieldProps extends InputHTMLAttributes<HTMLInputElement> {
label: string;
error?: string | null;
hint?: string;
action?: ReactNode;
}
export function Field({
label,
error,
hint,
action,
className,
id,
...props
}: FieldProps) {
const inputId = id ?? `field-${label.toLowerCase().replace(/\s+/g, '-')}`;
const helpId = `${inputId}-help`;
return (
<label className={`ui-field ${className ?? ''}`.trim()} htmlFor={inputId}>
<span className="ui-field-label">{label}</span>
<div className="ui-field-row">
<input
{...props}
id={inputId}
aria-invalid={Boolean(error)}
aria-describedby={error || hint ? helpId : undefined}
/>
{action}
</div>
{error || hint ? <span id={helpId} className={`ui-field-help ${error ? 'is-error' : ''}`.trim()}>{error ?? hint}</span> : null}
</label>
);
}

View File

@@ -0,0 +1,42 @@
import type { ButtonHTMLAttributes, ReactNode } from 'react';
export type IconButtonVariant = 'neutral' | 'add' | 'danger';
export interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
label: string;
icon: ReactNode;
variant?: IconButtonVariant;
loading?: boolean;
}
export function IconButton({
label,
icon,
variant = 'neutral',
loading = false,
className,
disabled,
title,
...props
}: IconButtonProps) {
const classes = [
'ui-icon-button',
`ui-icon-button--${variant}`,
loading ? 'is-loading' : '',
className ?? '',
].filter(Boolean).join(' ');
return (
<button
{...props}
type={props.type ?? 'button'}
className={classes}
aria-label={label}
title={title ?? label}
disabled={disabled || loading}
>
{loading ? <span className="ui-button-spinner" aria-hidden="true" /> : icon}
</button>
);
}

View File

@@ -0,0 +1,82 @@
import { Button } from './Button';
export interface LogDockEntry {
id: string;
kind: 'success' | 'error' | 'info';
title: string;
text: string;
at: number;
}
export interface LogDockProps {
entries: LogDockEntry[];
activeEntry: LogDockEntry | null;
open: boolean;
onToggle: () => void;
formatTime: (timestamp: number) => string;
}
function isNativePreviewError(entry: LogDockEntry | null) {
if (!entry) return false;
return entry.text.includes("reading 'invoke'") || entry.text.includes('undefined (reading');
}
function displayEntry(entry: LogDockEntry | null) {
if (!entry) return null;
if (!isNativePreviewError(entry)) return entry;
return {
...entry,
title: 'Desktop-команды недоступны',
text: 'Запусти клиент через Tauri, чтобы управлять службами и применять конфиг.',
};
}
export function LogDock({
entries,
activeEntry,
open,
onToggle,
formatTime,
}: LogDockProps) {
const current = displayEntry(activeEntry);
return (
<footer className={`log-dock ${current?.kind ?? 'idle'}`} aria-live="polite">
<div className={`log-current ${current ? 'visible' : 'hidden'}`}>
{current ? (
<>
<strong>{current.title}</strong>
<span>{current.text}</span>
</>
) : (
<span className="log-muted">Журнал событий</span>
)}
</div>
<Button type="button" variant="neutral" size="sm" className="log-toggle" onClick={onToggle}>
{open ? 'Скрыть' : 'Посмотреть'} <span className="log-count">{entries.length}</span>
</Button>
{open ? (
<div className="log-history">
{entries.length ? entries.map((entry) => {
const friendly = displayEntry(entry);
return (
<div className={`log-history-row ${entry.kind}`} key={entry.id}>
<time>{formatTime(entry.at)}</time>
<div>
<strong>{friendly?.title ?? entry.title}</strong>
<span>{friendly?.text ?? entry.text}</span>
{isNativePreviewError(entry) ? <span className="log-raw-detail">Детали: {entry.text}</span> : null}
</div>
</div>
);
}) : (
<div className="log-history-row">
<time>--:--:--</time>
<span>Событий пока нет.</span>
</div>
)}
</div>
) : null}
</footer>
);
}

View File

@@ -0,0 +1,93 @@
import type { ReactNode } from 'react';
import { Button, type ButtonVariant } from './Button';
import { ActionMenu, type ActionMenuItem } from './ActionMenu';
export type ServiceControlState = 'checking' | 'missing' | 'installed' | 'running' | 'stopped' | 'error';
export interface ServicePrimaryAction {
label: string;
onClick: () => void;
variant?: ButtonVariant;
loading?: boolean;
loadingLabel?: string;
disabled?: boolean;
}
export interface ServiceControlRowProps {
state: ServiceControlState;
title: string;
detail: string;
primaryAction?: ServicePrimaryAction;
menu?: {
label: string;
open: boolean;
onOpenChange: (open: boolean) => void;
items: ActionMenuItem[];
disabled?: boolean;
};
visualState?: 'working' | 'settling' | null;
className?: string;
inlineActions?: ReactNode;
children?: ReactNode;
}
export function ServiceControlRow({
state,
title,
detail,
primaryAction,
menu,
visualState,
className,
inlineActions,
children,
}: ServiceControlRowProps) {
const classes = [
'ui-service-row',
`ui-service-row--${state}`,
visualState ? `ui-service-row--${visualState}` : '',
className ?? '',
].filter(Boolean).join(' ');
return (
<div className={classes}>
<span className="ui-service-border-glow" aria-hidden="true">
<span className="ui-service-border-glow-segment top" />
<span className="ui-service-border-glow-segment right" />
<span className="ui-service-border-glow-segment bottom" />
<span className="ui-service-border-glow-segment left" />
</span>
<span className="ui-service-dot" aria-hidden="true" />
<div className="ui-service-text">
<strong>{title}</strong>
<span>{detail}</span>
{inlineActions ? <div className="ui-service-inline-actions">{inlineActions}</div> : null}
</div>
<div className="ui-service-actions">
{primaryAction ? (
<Button
type="button"
variant={primaryAction.variant ?? 'neutral'}
onClick={primaryAction.onClick}
disabled={primaryAction.disabled}
loading={primaryAction.loading}
loadingLabel={primaryAction.loadingLabel}
>
{primaryAction.label}
</Button>
) : null}
{menu ? (
<ActionMenu
label={menu.label}
open={menu.open}
onOpenChange={menu.onOpenChange}
items={menu.items}
disabled={menu.disabled}
/>
) : null}
</div>
{children}
</div>
);
}

View File

@@ -0,0 +1,11 @@
export type StatusPillTone = 'ok' | 'warning' | 'error' | 'checking' | 'muted';
export interface StatusPillProps {
tone?: StatusPillTone;
children: string;
}
export function StatusPill({ tone = 'muted', children }: StatusPillProps) {
return <span className={`ui-status-pill ui-status-pill--${tone}`}>{children}</span>;
}

View File

@@ -0,0 +1,81 @@
import { useRef, type KeyboardEvent } from 'react';
export interface TabItem<T extends string> {
id: T;
label: string;
}
export interface TabsProps<T extends string> {
items: Array<TabItem<T>>;
activeId: T;
onChange: (id: T) => void;
ariaLabel: string;
}
export function Tabs<T extends string>({
items,
activeId,
onChange,
ariaLabel,
}: TabsProps<T>) {
const refs = useRef<Array<HTMLButtonElement | null>>([]);
function moveFocus(currentId: T, direction: 1 | -1) {
const currentIndex = items.findIndex((item) => item.id === currentId);
const nextIndex = (currentIndex + direction + items.length) % items.length;
const next = items[nextIndex];
if (!next) return;
onChange(next.id);
window.requestAnimationFrame(() => refs.current[nextIndex]?.focus());
}
function handleKeyDown(event: KeyboardEvent<HTMLButtonElement>, id: T) {
if (event.key === 'ArrowRight') {
event.preventDefault();
moveFocus(id, 1);
} else if (event.key === 'ArrowLeft') {
event.preventDefault();
moveFocus(id, -1);
} else if (event.key === 'Home') {
event.preventDefault();
const first = items[0];
if (!first) return;
onChange(first.id);
window.requestAnimationFrame(() => refs.current[0]?.focus());
} else if (event.key === 'End') {
event.preventDefault();
const last = items[items.length - 1];
if (!last) return;
onChange(last.id);
window.requestAnimationFrame(() => refs.current[items.length - 1]?.focus());
}
}
return (
<div className="ui-tabs" role="tablist" aria-label={ariaLabel}>
{items.map((item, index) => {
const active = item.id === activeId;
return (
<button
type="button"
role="tab"
id={`tab-${item.id}`}
aria-controls={`panel-${item.id}`}
aria-selected={active}
tabIndex={active ? 0 : -1}
className={`ui-tab ${active ? 'is-active' : ''}`.trim()}
key={item.id}
ref={(node) => {
refs.current[index] = node;
}}
onClick={() => onChange(item.id)}
onKeyDown={(event) => handleKeyDown(event, item.id)}
>
{item.label}
</button>
);
})}
</div>
);
}

View File

@@ -0,0 +1,17 @@
export { ActionMenu } from './ActionMenu';
export type { ActionMenuItem } from './ActionMenu';
export { Button } from './Button';
export type { ButtonProps, ButtonSize, ButtonVariant } from './Button';
export { Field } from './Field';
export type { FieldProps } from './Field';
export { IconButton } from './IconButton';
export type { IconButtonProps, IconButtonVariant } from './IconButton';
export { LogDock } from './LogDock';
export type { LogDockEntry, LogDockProps } from './LogDock';
export { ServiceControlRow } from './ServiceControlRow';
export type { ServiceControlRowProps, ServiceControlState } from './ServiceControlRow';
export { StatusPill } from './StatusPill';
export type { StatusPillProps, StatusPillTone } from './StatusPill';
export { Tabs } from './Tabs';
export type { TabItem, TabsProps } from './Tabs';