Refactor VPN proxy routing logic
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
83
apps/windows-client/src/app/readiness.ts
Normal file
83
apps/windows-client/src/app/readiness.ts
Normal 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 };
|
||||
}
|
||||
|
||||
15
apps/windows-client/src/app/viewModel.ts
Normal file
15
apps/windows-client/src/app/viewModel.ts
Normal 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';
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
54
apps/windows-client/src/ui/ActionMenu.tsx
Normal file
54
apps/windows-client/src/ui/ActionMenu.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
49
apps/windows-client/src/ui/Button.tsx
Normal file
49
apps/windows-client/src/ui/Button.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
38
apps/windows-client/src/ui/Field.tsx
Normal file
38
apps/windows-client/src/ui/Field.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
42
apps/windows-client/src/ui/IconButton.tsx
Normal file
42
apps/windows-client/src/ui/IconButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
82
apps/windows-client/src/ui/LogDock.tsx
Normal file
82
apps/windows-client/src/ui/LogDock.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
93
apps/windows-client/src/ui/ServiceControlRow.tsx
Normal file
93
apps/windows-client/src/ui/ServiceControlRow.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
11
apps/windows-client/src/ui/StatusPill.tsx
Normal file
11
apps/windows-client/src/ui/StatusPill.tsx
Normal 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>;
|
||||
}
|
||||
|
||||
81
apps/windows-client/src/ui/Tabs.tsx
Normal file
81
apps/windows-client/src/ui/Tabs.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
17
apps/windows-client/src/ui/index.ts
Normal file
17
apps/windows-client/src/ui/index.ts
Normal 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';
|
||||
|
||||
Reference in New Issue
Block a user