Refactor application structure and simplify implementation
This commit is contained in:
+76
-92
@@ -1,4 +1,4 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type {
|
||||
ActivityEntry,
|
||||
ComponentStatus,
|
||||
@@ -9,7 +9,7 @@ import type {
|
||||
SubscriptionServer,
|
||||
Target,
|
||||
TargetInput,
|
||||
} from '../domain/types';
|
||||
} from "../domain/types";
|
||||
|
||||
export interface CommandError {
|
||||
code: string;
|
||||
@@ -20,16 +20,6 @@ export interface CommandError {
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface StatusResponse {
|
||||
routeLine: string;
|
||||
activeProfileCount: number;
|
||||
routedAppCount: number;
|
||||
activeTarget?: Target;
|
||||
components: ComponentStatus[];
|
||||
recentActivity: ActivityEntry[];
|
||||
generatedConfigPath: string;
|
||||
}
|
||||
|
||||
export interface AdminStatusResponse {
|
||||
isWindows: boolean;
|
||||
isElevated: boolean;
|
||||
@@ -67,8 +57,8 @@ export interface ProxiFyreSetupStatus {
|
||||
}
|
||||
|
||||
export interface ProxiFyreSetupProgress {
|
||||
operation: 'idle' | 'install' | 'uninstall' | string;
|
||||
status: 'idle' | 'running' | 'succeeded' | 'failed' | string;
|
||||
operation: "idle" | "install" | "uninstall" | string;
|
||||
status: "idle" | "running" | "succeeded" | "failed" | string;
|
||||
activeStep?: string;
|
||||
percent: number;
|
||||
message: string;
|
||||
@@ -102,6 +92,7 @@ export interface SubscriptionRequestHeader {
|
||||
}
|
||||
|
||||
export interface PingServerResponse {
|
||||
id: string;
|
||||
tag: string;
|
||||
server: string;
|
||||
serverPort: number;
|
||||
@@ -110,6 +101,34 @@ export interface PingServerResponse {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export type ApplyPhaseStatus =
|
||||
"succeeded" | "failed" | "rolledback" | "skipped" | "warning";
|
||||
|
||||
export interface ApplyPhase {
|
||||
id: string;
|
||||
status: ApplyPhaseStatus;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface ApplyConfigurationInput {
|
||||
routeMode: "external" | "local-singbox";
|
||||
profile: ProfileInput;
|
||||
externalTarget?: TargetInput;
|
||||
disableOtherProfiles?: boolean;
|
||||
}
|
||||
|
||||
export interface ApplyConfigurationResult {
|
||||
success: boolean;
|
||||
changed: boolean;
|
||||
partialState: boolean;
|
||||
message: string;
|
||||
errorCode?: string;
|
||||
generatedConfigPath: string;
|
||||
singboxGeneratedConfigPath?: string;
|
||||
restartRequired: Array<"control-app" | "proxyfier" | "singbox">;
|
||||
phases: ApplyPhase[];
|
||||
}
|
||||
|
||||
export interface ProxyProbeResponse {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -147,98 +166,60 @@ export interface GenerateSingBoxConfigResponse {
|
||||
activity: ActivityEntry;
|
||||
}
|
||||
|
||||
export interface HelperApplyResult {
|
||||
success: boolean;
|
||||
changed: boolean;
|
||||
action: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface ApplyProfilesResponse {
|
||||
success: boolean;
|
||||
changed: boolean;
|
||||
message: string;
|
||||
adapterId: string;
|
||||
generatedConfigPath: string;
|
||||
enabledProfiles: number;
|
||||
routedApps: number;
|
||||
helper: HelperApplyResult;
|
||||
activity: ActivityEntry;
|
||||
}
|
||||
|
||||
export function getStatus(): Promise<StatusResponse> {
|
||||
return invoke<StatusResponse>('get_status');
|
||||
}
|
||||
|
||||
export function getAdminStatus(): Promise<AdminStatusResponse> {
|
||||
return invoke<AdminStatusResponse>('get_admin_status');
|
||||
}
|
||||
|
||||
export function restartAsAdmin(): Promise<void> {
|
||||
return invoke<void>('restart_as_admin');
|
||||
return invoke<void>("restart_as_admin");
|
||||
}
|
||||
|
||||
export function getStartupSnapshot(): Promise<StartupSnapshotResponse> {
|
||||
return invoke<StartupSnapshotResponse>('get_startup_snapshot');
|
||||
return invoke<StartupSnapshotResponse>("get_startup_snapshot");
|
||||
}
|
||||
|
||||
export function getSavedState(): Promise<SavedStateResponse> {
|
||||
return invoke<SavedStateResponse>('get_saved_state');
|
||||
}
|
||||
|
||||
export function getProfiles(): Promise<Profile[]> {
|
||||
return invoke<Profile[]>('get_profiles');
|
||||
}
|
||||
|
||||
export function saveProfile(input: ProfileInput): Promise<Profile> {
|
||||
return invoke<Profile>('save_profile', { input });
|
||||
}
|
||||
|
||||
export function getTargets(): Promise<Target[]> {
|
||||
return invoke<Target[]>('get_targets');
|
||||
}
|
||||
|
||||
export function saveTarget(input: TargetInput): Promise<Target> {
|
||||
return invoke<Target>('save_target', { input });
|
||||
return invoke<SavedStateResponse>("get_saved_state");
|
||||
}
|
||||
|
||||
export function getComponents(): Promise<ComponentStatus[]> {
|
||||
return invoke<ComponentStatus[]>('get_components');
|
||||
return invoke<ComponentStatus[]>("get_components");
|
||||
}
|
||||
|
||||
export function getProxiFyreSetupStatus(): Promise<ProxiFyreSetupStatus> {
|
||||
return invoke<ProxiFyreSetupStatus>('get_proxifyre_setup_status');
|
||||
return invoke<ProxiFyreSetupStatus>("get_proxifyre_setup_status");
|
||||
}
|
||||
|
||||
export function getProxiFyreSetupProgress(): Promise<ProxiFyreSetupProgress> {
|
||||
return invoke<ProxiFyreSetupProgress>('get_proxifyre_setup_progress');
|
||||
return invoke<ProxiFyreSetupProgress>("get_proxifyre_setup_progress");
|
||||
}
|
||||
|
||||
export function getSingBoxStatus(): Promise<LocalSingBoxStatusResponse> {
|
||||
return invoke<LocalSingBoxStatusResponse>('get_singbox_status');
|
||||
return invoke<LocalSingBoxStatusResponse>("get_singbox_status");
|
||||
}
|
||||
|
||||
export function getSingBoxSetupStatus(): Promise<SingBoxSetupStatus> {
|
||||
return invoke<SingBoxSetupStatus>('get_singbox_setup_status');
|
||||
return invoke<SingBoxSetupStatus>("get_singbox_setup_status");
|
||||
}
|
||||
|
||||
export function saveSingBoxSubscription(subscriptionUrl: string): Promise<LocalSingBoxStatusResponse> {
|
||||
return invoke<LocalSingBoxStatusResponse>('save_singbox_subscription', {
|
||||
export function saveSingBoxSubscription(
|
||||
subscriptionUrl: string,
|
||||
): Promise<LocalSingBoxStatusResponse> {
|
||||
return invoke<LocalSingBoxStatusResponse>("save_singbox_subscription", {
|
||||
input: { subscriptionUrl },
|
||||
});
|
||||
}
|
||||
|
||||
export function fetchSingBoxSubscription(): Promise<LocalSingBoxStatusResponse> {
|
||||
return invoke<LocalSingBoxStatusResponse>('fetch_singbox_subscription');
|
||||
return invoke<LocalSingBoxStatusResponse>("fetch_singbox_subscription");
|
||||
}
|
||||
|
||||
export function forgetSingBoxSubscription(): Promise<LocalSingBoxStatusResponse> {
|
||||
return invoke<LocalSingBoxStatusResponse>('forget_singbox_subscription');
|
||||
return invoke<LocalSingBoxStatusResponse>("forget_singbox_subscription");
|
||||
}
|
||||
|
||||
export function selectSingBoxServer(server: SubscriptionServer): Promise<LocalSingBoxStatusResponse> {
|
||||
return invoke<LocalSingBoxStatusResponse>('select_singbox_server', {
|
||||
export function selectSingBoxServer(
|
||||
server: SubscriptionServer,
|
||||
): Promise<LocalSingBoxStatusResponse> {
|
||||
return invoke<LocalSingBoxStatusResponse>("select_singbox_server", {
|
||||
input: {
|
||||
id: server.id,
|
||||
tag: server.tag,
|
||||
server: server.server,
|
||||
serverPort: server.serverPort,
|
||||
@@ -246,62 +227,65 @@ export function selectSingBoxServer(server: SubscriptionServer): Promise<LocalSi
|
||||
});
|
||||
}
|
||||
|
||||
export function pingSingBoxServer(tag: string): Promise<PingServerResponse> {
|
||||
return invoke<PingServerResponse>('ping_singbox_server', {
|
||||
input: { tag },
|
||||
export function pingSingBoxServer(
|
||||
server: SubscriptionServer,
|
||||
): Promise<PingServerResponse> {
|
||||
return invoke<PingServerResponse>("ping_singbox_server", {
|
||||
input: { id: server.id, tag: server.tag },
|
||||
});
|
||||
}
|
||||
|
||||
export function pingAllSingBoxServers(): Promise<PingServerResponse[]> {
|
||||
return invoke<PingServerResponse[]>('ping_all_singbox_servers');
|
||||
return invoke<PingServerResponse[]>("ping_all_singbox_servers");
|
||||
}
|
||||
|
||||
export function pingProxyTarget(host: string, port: number): Promise<ProxyTargetCheckResponse> {
|
||||
return invoke<ProxyTargetCheckResponse>('ping_proxy_target', {
|
||||
export function pingProxyTarget(
|
||||
host: string,
|
||||
port: number,
|
||||
): Promise<ProxyTargetCheckResponse> {
|
||||
return invoke<ProxyTargetCheckResponse>("ping_proxy_target", {
|
||||
input: { host, port },
|
||||
});
|
||||
}
|
||||
|
||||
export function generateSingBoxConfig(): Promise<GenerateSingBoxConfigResponse> {
|
||||
return invoke<GenerateSingBoxConfigResponse>('generate_singbox_config');
|
||||
return invoke<GenerateSingBoxConfigResponse>("generate_singbox_config");
|
||||
}
|
||||
|
||||
export function applyProfiles(): Promise<ApplyProfilesResponse> {
|
||||
return invoke<ApplyProfilesResponse>('apply_profiles');
|
||||
}
|
||||
|
||||
export function openConfigLocation(): Promise<string> {
|
||||
return invoke<string>('open_config_location');
|
||||
export function applyConfiguration(
|
||||
input: ApplyConfigurationInput,
|
||||
): Promise<ApplyConfigurationResult> {
|
||||
return invoke<ApplyConfigurationResult>("apply_configuration", { input });
|
||||
}
|
||||
|
||||
export function startProxiFyreService(): Promise<ComponentStatus> {
|
||||
return invoke<ComponentStatus>('start_proxifyre_service');
|
||||
return invoke<ComponentStatus>("start_proxifyre_service");
|
||||
}
|
||||
|
||||
export function stopProxiFyreService(): Promise<ComponentStatus> {
|
||||
return invoke<ComponentStatus>('stop_proxifyre_service');
|
||||
return invoke<ComponentStatus>("stop_proxifyre_service");
|
||||
}
|
||||
|
||||
export function installProxiFyre(): Promise<ComponentStatus> {
|
||||
return invoke<ComponentStatus>('install_proxifyre');
|
||||
return invoke<ComponentStatus>("install_proxifyre");
|
||||
}
|
||||
|
||||
export function uninstallProxiFyre(): Promise<ComponentStatus> {
|
||||
return invoke<ComponentStatus>('uninstall_proxifyre');
|
||||
return invoke<ComponentStatus>("uninstall_proxifyre");
|
||||
}
|
||||
|
||||
export function startSingBoxService(): Promise<ComponentStatus> {
|
||||
return invoke<ComponentStatus>('start_singbox_service');
|
||||
return invoke<ComponentStatus>("start_singbox_service");
|
||||
}
|
||||
|
||||
export function stopSingBoxService(): Promise<ComponentStatus> {
|
||||
return invoke<ComponentStatus>('stop_singbox_service');
|
||||
return invoke<ComponentStatus>("stop_singbox_service");
|
||||
}
|
||||
|
||||
export function installSingBox(): Promise<ComponentStatus> {
|
||||
return invoke<ComponentStatus>('install_singbox');
|
||||
return invoke<ComponentStatus>("install_singbox");
|
||||
}
|
||||
|
||||
export function uninstallSingBox(): Promise<ComponentStatus> {
|
||||
return invoke<ComponentStatus>('uninstall_singbox');
|
||||
return invoke<ComponentStatus>("uninstall_singbox");
|
||||
}
|
||||
|
||||
+644
-1583
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type {
|
||||
ApplyConfigurationResult,
|
||||
PingServerResponse,
|
||||
} from "../api/tauriCommands";
|
||||
import type { ComponentStatus } from "../domain/types";
|
||||
import {
|
||||
noticeFromConfigurationApply,
|
||||
pingSummary,
|
||||
routeChainSegments,
|
||||
} from "./viewModel";
|
||||
|
||||
const runningProxiFyre: ComponentStatus = {
|
||||
id: "proxyfier",
|
||||
name: "ProxiFyre",
|
||||
state: "running",
|
||||
installed: true,
|
||||
running: true,
|
||||
path: "C:\\Tools\\ProxiFyre\\ProxiFyre.exe",
|
||||
problems: [],
|
||||
actions: [],
|
||||
};
|
||||
|
||||
describe("App view helpers", () => {
|
||||
it("describes the external SOCKS5 route without Local sing-box", () => {
|
||||
const segments = routeChainSegments({
|
||||
routeMode: "external",
|
||||
proxyInput: "proxy.example.test:1080",
|
||||
proxyfier: runningProxiFyre,
|
||||
singbox: undefined,
|
||||
singBoxStatus: null,
|
||||
selectedServer: null,
|
||||
appCount: 2,
|
||||
isDetectingComponents: false,
|
||||
});
|
||||
|
||||
expect(segments.map((segment) => segment.id)).toEqual([
|
||||
"apps",
|
||||
"proxifyre",
|
||||
"endpoint",
|
||||
"exit",
|
||||
]);
|
||||
expect(segments[2]).toMatchObject({
|
||||
value: "proxy.example.test:1080",
|
||||
tone: "ok",
|
||||
});
|
||||
expect(segments[3].details).toContain(
|
||||
"Local sing-box не нужен для этого маршрута.",
|
||||
);
|
||||
});
|
||||
|
||||
it("summarizes the fastest successful ping", () => {
|
||||
const results = [
|
||||
{ tag: "slow", ok: true, latency: 90 },
|
||||
{ tag: "failed", ok: false },
|
||||
{ tag: "fast", ok: true, latency: 20 },
|
||||
] as PingServerResponse[];
|
||||
|
||||
expect(pingSummary(results)).toBe("Ответили 2/3; быстрее fast: 20 ms.");
|
||||
});
|
||||
|
||||
it("distinguishes rolled-back and partial apply failures", () => {
|
||||
const base: ApplyConfigurationResult = {
|
||||
success: false,
|
||||
changed: false,
|
||||
partialState: false,
|
||||
message: "Helper failed; previous files restored.",
|
||||
generatedConfigPath:
|
||||
"C:\\ProgramData\\ProxyWarden\\generated\\proxifyre-app-config.json",
|
||||
restartRequired: [],
|
||||
phases: [],
|
||||
};
|
||||
|
||||
expect(noticeFromConfigurationApply(base).title).toBe("Изменения отменены");
|
||||
expect(
|
||||
noticeFromConfigurationApply({ ...base, partialState: true }).title,
|
||||
).toBe("Проверь состояние файлов");
|
||||
});
|
||||
});
|
||||
@@ -1,25 +1,47 @@
|
||||
import type { ProxiFyreSetupProgress, ProxiFyreSetupStatus } from '../../api/tauriCommands';
|
||||
import type {
|
||||
ProxiFyreSetupProgress,
|
||||
ProxiFyreSetupStatus,
|
||||
} from "../../api/tauriCommands";
|
||||
|
||||
interface ProxiFyreSetupStripProps {
|
||||
setupStatus: ProxiFyreSetupStatus | null;
|
||||
progress: ProxiFyreSetupProgress | null;
|
||||
}
|
||||
|
||||
const SETUP_PLACEHOLDERS: ProxiFyreSetupStatus['items'] = [
|
||||
{ id: 'vc-runtime', name: 'Среда запуска', installed: false, details: 'Проверяю' },
|
||||
{ id: 'packet-filter', name: 'Сетевой драйвер', installed: false, details: 'Проверяю' },
|
||||
{ id: 'proxifyre', name: 'Клиент ProxiFyre', installed: false, details: 'Проверяю' },
|
||||
const SETUP_PLACEHOLDERS: ProxiFyreSetupStatus["items"] = [
|
||||
{
|
||||
id: "vc-runtime",
|
||||
name: "Среда запуска",
|
||||
installed: false,
|
||||
details: "Проверяю",
|
||||
},
|
||||
{
|
||||
id: "packet-filter",
|
||||
name: "Сетевой драйвер",
|
||||
installed: false,
|
||||
details: "Проверяю",
|
||||
},
|
||||
{
|
||||
id: "proxifyre",
|
||||
name: "Клиент ProxiFyre",
|
||||
installed: false,
|
||||
details: "Проверяю",
|
||||
},
|
||||
];
|
||||
|
||||
export function ProxiFyreSetupStrip({ setupStatus, progress }: ProxiFyreSetupStripProps) {
|
||||
export function ProxiFyreSetupStrip({
|
||||
setupStatus,
|
||||
progress,
|
||||
}: ProxiFyreSetupStripProps) {
|
||||
const stripItems = setupStatus?.items ?? SETUP_PLACEHOLDERS;
|
||||
const visibleProgress = isVisibleProgress(progress) ? progress : null;
|
||||
const progressTone = visibleProgress?.status === 'failed' ? 'failed' : 'running';
|
||||
const progressTone =
|
||||
visibleProgress?.status === "failed" ? "failed" : "running";
|
||||
const percent = clampPercent(visibleProgress?.percent ?? 0);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`setup-strip ${setupStatus?.ready ? 'ready' : 'attention'} ${visibleProgress ? 'with-progress' : ''}`}
|
||||
className={`setup-strip ${setupStatus?.ready ? "ready" : "attention"} ${visibleProgress ? "with-progress" : ""}`}
|
||||
aria-label="Состав ProxiFyre"
|
||||
>
|
||||
<span className="setup-strip-title">Состав</span>
|
||||
@@ -45,9 +67,14 @@ export function ProxiFyreSetupStrip({ setupStatus, progress }: ProxiFyreSetupStr
|
||||
aria-valuenow={percent}
|
||||
aria-label={visibleProgress.message}
|
||||
>
|
||||
<span className="setup-progress-fill" style={{ width: `${percent}%` }} />
|
||||
<span
|
||||
className="setup-progress-fill"
|
||||
style={{ width: `${percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="setup-progress-message">{visibleProgress.message}</span>
|
||||
<span className="setup-progress-message">
|
||||
{visibleProgress.message}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -55,52 +82,59 @@ export function ProxiFyreSetupStrip({ setupStatus, progress }: ProxiFyreSetupStr
|
||||
}
|
||||
|
||||
function setupItemClass(
|
||||
item: ProxiFyreSetupStatus['items'][number],
|
||||
item: ProxiFyreSetupStatus["items"][number],
|
||||
progress: ProxiFyreSetupProgress | null,
|
||||
) {
|
||||
if (progress?.activeStep === item.id) {
|
||||
if (progress.status === 'failed') return 'failed';
|
||||
return 'active';
|
||||
if (progress.status === "failed") return "failed";
|
||||
return "active";
|
||||
}
|
||||
if (item.installed) return 'installed';
|
||||
return 'missing';
|
||||
if (item.installed) return "installed";
|
||||
return "missing";
|
||||
}
|
||||
|
||||
function setupItemUserName(id: string, fallbackName: string) {
|
||||
if (id === 'vc-runtime') return 'Среда запуска';
|
||||
if (id === 'packet-filter') return 'Сетевой драйвер';
|
||||
if (id === 'proxifyre') return 'Клиент ProxiFyre';
|
||||
if (id === "vc-runtime") return "Среда запуска";
|
||||
if (id === "packet-filter") return "Сетевой драйвер";
|
||||
if (id === "proxifyre") return "Клиент ProxiFyre";
|
||||
return fallbackName;
|
||||
}
|
||||
|
||||
function setupItemShortStatus(
|
||||
item: ProxiFyreSetupStatus['items'][number],
|
||||
item: ProxiFyreSetupStatus["items"][number],
|
||||
progress: ProxiFyreSetupProgress | null,
|
||||
) {
|
||||
if (progress?.activeStep === item.id) {
|
||||
if (progress.status === 'failed') return 'ошибка';
|
||||
if (progress.status === 'succeeded') return progress.operation === 'uninstall' ? 'удалено' : 'готово';
|
||||
return 'в процессе';
|
||||
if (progress.status === "failed") return "ошибка";
|
||||
if (progress.status === "succeeded")
|
||||
return progress.operation === "uninstall" ? "удалено" : "готово";
|
||||
return "в процессе";
|
||||
}
|
||||
if (item.details === 'Проверяю') return 'проверяю';
|
||||
if (!item.installed) return progress?.operation === 'uninstall' && progress.status === 'succeeded'
|
||||
? 'удалено'
|
||||
: 'нужно установить';
|
||||
if (item.id === 'proxifyre') return proxifyreSetupServiceSummary(item.version);
|
||||
return 'готово';
|
||||
if (item.details === "Проверяю") return "проверяю";
|
||||
if (!item.installed)
|
||||
return progress?.operation === "uninstall" &&
|
||||
progress.status === "succeeded"
|
||||
? "удалено"
|
||||
: "нужно установить";
|
||||
if (item.id === "proxifyre")
|
||||
return proxifyreSetupServiceSummary(item.version);
|
||||
return "готово";
|
||||
}
|
||||
|
||||
function proxifyreSetupServiceSummary(version: string | undefined) {
|
||||
const normalized = version?.trim().toLowerCase() ?? '';
|
||||
if (normalized.includes('не установлена')) return 'служба не установлена';
|
||||
if (normalized.includes('остановлена') || normalized.includes('не запущена')) return 'служба остановлена';
|
||||
if (normalized.includes('запущена')) return 'служба запущена';
|
||||
return 'готово';
|
||||
const normalized = version?.trim().toLowerCase() ?? "";
|
||||
if (normalized.includes("не установлена")) return "служба не установлена";
|
||||
if (normalized.includes("остановлена") || normalized.includes("не запущена"))
|
||||
return "служба остановлена";
|
||||
if (normalized.includes("запущена")) return "служба запущена";
|
||||
return "готово";
|
||||
}
|
||||
|
||||
function isVisibleProgress(progress: ProxiFyreSetupProgress | null): progress is ProxiFyreSetupProgress {
|
||||
if (!progress || progress.status === 'idle') return false;
|
||||
return progress.status === 'running' || progress.status === 'failed';
|
||||
function isVisibleProgress(
|
||||
progress: ProxiFyreSetupProgress | null,
|
||||
): progress is ProxiFyreSetupProgress {
|
||||
if (!progress || progress.status === "idle") return false;
|
||||
return progress.status === "running" || progress.status === "failed";
|
||||
}
|
||||
|
||||
function clampPercent(value: number) {
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Power } from "lucide-react";
|
||||
import { BusyRing } from "../../ui";
|
||||
import type { StatusTone } from "../viewModel";
|
||||
|
||||
interface SummaryStatusControlProps {
|
||||
installed: boolean;
|
||||
running: boolean;
|
||||
working: boolean;
|
||||
checking: boolean;
|
||||
tone: StatusTone;
|
||||
onToggle: (running: boolean) => void;
|
||||
}
|
||||
|
||||
export function SummaryStatusControl({
|
||||
installed,
|
||||
running,
|
||||
working,
|
||||
checking,
|
||||
tone,
|
||||
onToggle,
|
||||
}: SummaryStatusControlProps) {
|
||||
const stateLabel =
|
||||
working || tone === "checking"
|
||||
? "Проверяю"
|
||||
: tone === "ok"
|
||||
? "Работает"
|
||||
: "Не работает";
|
||||
const buttonAriaLabel = !installed
|
||||
? "ProxiFyre не установлен"
|
||||
: running
|
||||
? "Отключить ProxyWarden"
|
||||
: "Включить ProxyWarden";
|
||||
|
||||
return (
|
||||
<div className={`summary-status-control ${tone} ${running ? "on" : "off"}`}>
|
||||
<button
|
||||
type="button"
|
||||
className="summary-toggle-button"
|
||||
onClick={() => onToggle(!running)}
|
||||
disabled={!installed || checking || working}
|
||||
aria-label={buttonAriaLabel}
|
||||
aria-pressed={installed ? running : undefined}
|
||||
>
|
||||
{tone === "checking" || working ? <BusyRing /> : null}
|
||||
<span className="summary-toggle-face" aria-hidden="true">
|
||||
<Power size={88} strokeWidth={1.45} />
|
||||
</span>
|
||||
</button>
|
||||
<strong className={`summary-state-label ${tone}`}>{stateLabel}</strong>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { LogEntry, Notice } from "../viewModel";
|
||||
|
||||
const LOG_VISIBLE_MS = 6500;
|
||||
const LOG_LIMIT = 40;
|
||||
|
||||
export function useNoticeLog() {
|
||||
const [entries, setEntries] = useState<LogEntry[]>([]);
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const activeEntry = useMemo(
|
||||
() => entries.find((entry) => entry.id === activeId) ?? null,
|
||||
[activeId, entries],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeId) return undefined;
|
||||
|
||||
const timer = window.setTimeout(() => {
|
||||
setActiveId((current) => (current === activeId ? null : current));
|
||||
}, LOG_VISIBLE_MS);
|
||||
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [activeId]);
|
||||
|
||||
function showNotice(notice: Notice) {
|
||||
const entry: LogEntry = {
|
||||
...notice,
|
||||
id: `log-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
at: Date.now(),
|
||||
};
|
||||
|
||||
setEntries((current) => [entry, ...current].slice(0, LOG_LIMIT));
|
||||
setActiveId(entry.id);
|
||||
}
|
||||
|
||||
return {
|
||||
entries,
|
||||
activeEntry,
|
||||
open,
|
||||
showNotice,
|
||||
toggle: () => setOpen((current) => !current),
|
||||
};
|
||||
}
|
||||
@@ -1,43 +1,45 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { parseProxy } from './parseProxy';
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseProxy } from "./parseProxy";
|
||||
|
||||
describe('parseProxy', () => {
|
||||
it('parses host and port without explicit protocol', () => {
|
||||
expect(parseProxy('proxy.example.test:1080')).toEqual({
|
||||
protocol: 'socks5',
|
||||
host: 'proxy.example.test',
|
||||
describe("parseProxy", () => {
|
||||
it("parses host and port without explicit protocol", () => {
|
||||
expect(parseProxy("proxy.example.test:1080")).toEqual({
|
||||
protocol: "socks5",
|
||||
host: "proxy.example.test",
|
||||
port: 1080,
|
||||
});
|
||||
});
|
||||
|
||||
it('parses socks5 URLs', () => {
|
||||
expect(parseProxy('socks5://127.0.0.1:1080')).toEqual({
|
||||
protocol: 'socks5',
|
||||
host: '127.0.0.1',
|
||||
it("parses socks5 URLs", () => {
|
||||
expect(parseProxy("socks5://127.0.0.1:1080")).toEqual({
|
||||
protocol: "socks5",
|
||||
host: "127.0.0.1",
|
||||
port: 1080,
|
||||
});
|
||||
});
|
||||
|
||||
it('parses bracketed IPv6 hosts', () => {
|
||||
expect(parseProxy('socks5://[::1]:1080')).toEqual({
|
||||
protocol: 'socks5',
|
||||
host: '::1',
|
||||
it("parses bracketed IPv6 hosts", () => {
|
||||
expect(parseProxy("socks5://[::1]:1080")).toEqual({
|
||||
protocol: "socks5",
|
||||
host: "::1",
|
||||
port: 1080,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects unsupported schemes', () => {
|
||||
expect(() => parseProxy('http://proxy.example.test:8080')).toThrow('SOCKS5');
|
||||
});
|
||||
|
||||
it('rejects missing or invalid ports', () => {
|
||||
expect(() => parseProxy('proxy.example.test')).toThrow('хост и порт');
|
||||
expect(() => parseProxy('proxy.example.test:70000')).toThrow('Формат');
|
||||
});
|
||||
|
||||
it('rejects userinfo credentials', () => {
|
||||
expect(() => parseProxy('socks5://user:password@proxy.example.test:1080')).toThrow(
|
||||
'логином и паролем',
|
||||
it("rejects unsupported schemes", () => {
|
||||
expect(() => parseProxy("http://proxy.example.test:8080")).toThrow(
|
||||
"SOCKS5",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects missing or invalid ports", () => {
|
||||
expect(() => parseProxy("proxy.example.test")).toThrow("хост и порт");
|
||||
expect(() => parseProxy("proxy.example.test:70000")).toThrow("Формат");
|
||||
});
|
||||
|
||||
it("rejects userinfo credentials", () => {
|
||||
expect(() =>
|
||||
parseProxy("socks5://user:password@proxy.example.test:1080"),
|
||||
).toThrow("логином и паролем");
|
||||
});
|
||||
});
|
||||
|
||||
+13
-11
@@ -1,34 +1,36 @@
|
||||
export interface ParsedProxy {
|
||||
protocol: 'socks5';
|
||||
protocol: "socks5";
|
||||
host: string;
|
||||
port: number;
|
||||
}
|
||||
|
||||
export function parseProxy(rawValue: string): ParsedProxy {
|
||||
const value = rawValue.trim();
|
||||
if (!value) throw new Error('Введи адрес прокси.');
|
||||
if (!value) throw new Error("Введи адрес прокси.");
|
||||
|
||||
const withProtocol = /^[a-z][a-z0-9+.-]*:\/\//i.test(value) ? value : `socks5://${value}`;
|
||||
const withProtocol = /^[a-z][a-z0-9+.-]*:\/\//i.test(value)
|
||||
? value
|
||||
: `socks5://${value}`;
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(withProtocol);
|
||||
} catch {
|
||||
throw new Error('Формат: socks5://host:port или host:port.');
|
||||
throw new Error("Формат: socks5://host:port или host:port.");
|
||||
}
|
||||
|
||||
const protocol = parsed.protocol.replace(':', '').toLowerCase();
|
||||
if (protocol !== 'socks5') {
|
||||
throw new Error('Сейчас поддерживается только SOCKS5.');
|
||||
const protocol = parsed.protocol.replace(":", "").toLowerCase();
|
||||
if (protocol !== "socks5") {
|
||||
throw new Error("Сейчас поддерживается только SOCKS5.");
|
||||
}
|
||||
if (parsed.username || parsed.password) {
|
||||
throw new Error('Прокси с логином и паролем пока не поддерживаются.');
|
||||
throw new Error("Прокси с логином и паролем пока не поддерживаются.");
|
||||
}
|
||||
|
||||
const host = parsed.hostname.replace(/^\[|\]$/g, '');
|
||||
const host = parsed.hostname.replace(/^\[|\]$/g, "");
|
||||
const port = Number(parsed.port);
|
||||
if (!host || !Number.isInteger(port) || port < 1 || port > 65535) {
|
||||
throw new Error('Укажи хост и порт прокси.');
|
||||
throw new Error("Укажи хост и порт прокси.");
|
||||
}
|
||||
|
||||
return { protocol: 'socks5', host, port };
|
||||
return { protocol: "socks5", host, port };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { ProfileItemType } from "../../domain/types";
|
||||
|
||||
export type DraftItemType = Extract<
|
||||
ProfileItemType,
|
||||
"process" | "folder" | "exe"
|
||||
>;
|
||||
|
||||
export function normalizeItemValue(value: string, type: DraftItemType) {
|
||||
const clean = value.trim().replace(/^"|"$/g, "");
|
||||
if (!clean) return "";
|
||||
if (type === "folder" || type === "exe") return clean;
|
||||
|
||||
return (
|
||||
clean
|
||||
.split(/[\\/]/)
|
||||
.pop()
|
||||
?.replace(/\.exe$/i, "")
|
||||
.trim() ?? ""
|
||||
);
|
||||
}
|
||||
|
||||
export function itemTypeLabel(type: DraftItemType) {
|
||||
if (type === "process") return "процесс";
|
||||
if (type === "folder") return "папка";
|
||||
return "EXE-файл";
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
configChangeRows,
|
||||
configSnapshotFromUi,
|
||||
sameConfigSnapshot,
|
||||
} from "./snapshots";
|
||||
|
||||
describe("configuration snapshots", () => {
|
||||
it("normalizes proxy and Windows app values", () => {
|
||||
const snapshot = configSnapshotFromUi(
|
||||
"external",
|
||||
" SOCKS5://Proxy.Example.Test:1080 ",
|
||||
[
|
||||
{ type: "process", value: "C:\\Apps\\Discord.exe" },
|
||||
{ type: "folder", value: " C:\\Games " },
|
||||
],
|
||||
);
|
||||
|
||||
expect(snapshot.proxy).toBe("socks5://proxy.example.test:1080");
|
||||
expect(snapshot.items).toEqual([
|
||||
{ type: "folder", value: "c:\\games" },
|
||||
{ type: "process", value: "discord" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("detects a server change by stable id even when tags match", () => {
|
||||
const applied = configSnapshotFromUi(
|
||||
"local-singbox",
|
||||
"",
|
||||
[],
|
||||
"server-a",
|
||||
"Same tag",
|
||||
);
|
||||
const current = configSnapshotFromUi(
|
||||
"local-singbox",
|
||||
"",
|
||||
[],
|
||||
"server-b",
|
||||
"Same tag",
|
||||
);
|
||||
|
||||
expect(sameConfigSnapshot(applied, current)).toBe(false);
|
||||
expect(configChangeRows(applied, current).map((row) => row.id)).toContain(
|
||||
"vpn-server",
|
||||
);
|
||||
});
|
||||
|
||||
it("reports added and removed app items independent of input order", () => {
|
||||
const applied = configSnapshotFromUi("external", "proxy.test:1080", [
|
||||
{ type: "process", value: "Discord.exe" },
|
||||
]);
|
||||
const current = configSnapshotFromUi("external", "proxy.test:1080", [
|
||||
{ type: "process", value: "Telegram.exe" },
|
||||
]);
|
||||
|
||||
expect(configChangeRows(applied, current).map((row) => row.tone)).toEqual([
|
||||
"added",
|
||||
"removed",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,201 @@
|
||||
import { parseProxy } from "./parseProxy";
|
||||
import {
|
||||
itemTypeLabel,
|
||||
normalizeItemValue,
|
||||
type DraftItemType,
|
||||
} from "./profileItems";
|
||||
|
||||
export type RouteMode = "external" | "local-singbox";
|
||||
|
||||
export interface ConfigSnapshotItem {
|
||||
type: DraftItemType;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface ConfigSnapshot {
|
||||
routeMode: RouteMode;
|
||||
proxy: string;
|
||||
selectedServerId: string;
|
||||
selectedServerTag: string;
|
||||
items: ConfigSnapshotItem[];
|
||||
}
|
||||
|
||||
export interface PendingChangeRow {
|
||||
id: string;
|
||||
label: string;
|
||||
before?: string;
|
||||
after: string;
|
||||
tone?: "added" | "removed" | "changed";
|
||||
}
|
||||
|
||||
export function configSnapshotFromUi(
|
||||
routeMode: RouteMode,
|
||||
proxyInput: string,
|
||||
items: Array<{ type: DraftItemType; value: string }>,
|
||||
selectedServerId?: string,
|
||||
selectedServerTag?: string,
|
||||
): ConfigSnapshot {
|
||||
return {
|
||||
routeMode,
|
||||
proxy: routeMode === "external" ? normalizeProxySnapshot(proxyInput) : "",
|
||||
selectedServerId:
|
||||
routeMode === "local-singbox" ? (selectedServerId?.trim() ?? "") : "",
|
||||
selectedServerTag:
|
||||
routeMode === "local-singbox" ? (selectedServerTag?.trim() ?? "") : "",
|
||||
items: normalizeSnapshotItems(items),
|
||||
};
|
||||
}
|
||||
|
||||
export function configChangeRows(
|
||||
applied: ConfigSnapshot,
|
||||
current: ConfigSnapshot,
|
||||
): PendingChangeRow[] {
|
||||
if (sameConfigSnapshot(applied, current)) return [];
|
||||
|
||||
const rows: PendingChangeRow[] = [];
|
||||
if (applied.routeMode !== current.routeMode) {
|
||||
rows.push({
|
||||
id: "route-mode",
|
||||
label: "Маршрут",
|
||||
before: routeModeLabel(applied.routeMode),
|
||||
after: routeModeLabel(current.routeMode),
|
||||
});
|
||||
}
|
||||
if (
|
||||
applied.proxy !== current.proxy &&
|
||||
(applied.routeMode === "external" || current.routeMode === "external")
|
||||
) {
|
||||
rows.push({
|
||||
id: "external-proxy",
|
||||
label: "SOCKS5",
|
||||
before: snapshotProxyChangeText(applied),
|
||||
after: snapshotProxyChangeText(current),
|
||||
});
|
||||
}
|
||||
if (
|
||||
applied.selectedServerId !== current.selectedServerId &&
|
||||
(applied.routeMode === "local-singbox" ||
|
||||
current.routeMode === "local-singbox")
|
||||
) {
|
||||
rows.push({
|
||||
id: "vpn-server",
|
||||
label: "VPN сервер",
|
||||
before: snapshotServerChangeText(applied),
|
||||
after: snapshotServerChangeText(current),
|
||||
});
|
||||
}
|
||||
rows.push(...snapshotItemChangeRows(applied.items, current.items));
|
||||
return rows;
|
||||
}
|
||||
|
||||
export function sameConfigSnapshot(
|
||||
left: ConfigSnapshot,
|
||||
right: ConfigSnapshot,
|
||||
) {
|
||||
return (
|
||||
left.routeMode === right.routeMode &&
|
||||
left.proxy === right.proxy &&
|
||||
left.selectedServerId === right.selectedServerId &&
|
||||
left.selectedServerTag === right.selectedServerTag &&
|
||||
left.items.length === right.items.length &&
|
||||
left.items.every((item, index) => {
|
||||
const other = right.items[index];
|
||||
return item.type === other.type && item.value === other.value;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export function routeModeLabel(routeMode: RouteMode) {
|
||||
return routeMode === "local-singbox" ? "Локальный прокси" : "Внешний прокси";
|
||||
}
|
||||
|
||||
export function displaySnapshotProxy(proxy: string) {
|
||||
return proxy.replace(/^socks5:\/\//, "") || "не указан";
|
||||
}
|
||||
|
||||
export function displayServerTag(tag: string) {
|
||||
const withoutFlags = tag
|
||||
.replace(/[\u{1f1e6}-\u{1f1ff}]/gu, "")
|
||||
.replace(/\s*->\s*/g, " -> ")
|
||||
.replace(/\s*->\s*$/g, "")
|
||||
.replace(/^\s*->\s*/g, "")
|
||||
.replace(/\s{2,}/g, " ")
|
||||
.trim();
|
||||
|
||||
return withoutFlags || tag;
|
||||
}
|
||||
|
||||
function normalizeProxySnapshot(value: string) {
|
||||
try {
|
||||
const parsed = parseProxy(value);
|
||||
return `${parsed.protocol}://${parsed.host.trim().toLowerCase()}:${parsed.port}`;
|
||||
} catch {
|
||||
return value.trim().toLowerCase();
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeSnapshotItems(
|
||||
items: Array<{ type: DraftItemType; value: string }>,
|
||||
): ConfigSnapshotItem[] {
|
||||
return items
|
||||
.map((item) => ({
|
||||
type: item.type,
|
||||
value: normalizeItemValue(item.value, item.type).toLowerCase(),
|
||||
}))
|
||||
.filter((item) => item.value)
|
||||
.sort((left, right) =>
|
||||
`${left.type}:${left.value}`.localeCompare(
|
||||
`${right.type}:${right.value}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function snapshotProxyChangeText(snapshot: ConfigSnapshot) {
|
||||
return snapshot.routeMode === "external"
|
||||
? displaySnapshotProxy(snapshot.proxy)
|
||||
: "не используется";
|
||||
}
|
||||
|
||||
function snapshotServerChangeText(snapshot: ConfigSnapshot) {
|
||||
if (snapshot.routeMode !== "local-singbox") return "не используется";
|
||||
return snapshot.selectedServerTag
|
||||
? displayServerTag(snapshot.selectedServerTag)
|
||||
: "сервер не выбран";
|
||||
}
|
||||
|
||||
function snapshotItemChangeRows(
|
||||
appliedItems: ConfigSnapshotItem[],
|
||||
currentItems: ConfigSnapshotItem[],
|
||||
): PendingChangeRow[] {
|
||||
const appliedKeys = new Set(appliedItems.map(snapshotItemKey));
|
||||
const currentKeys = new Set(currentItems.map(snapshotItemKey));
|
||||
const added = currentItems.filter(
|
||||
(item) => !appliedKeys.has(snapshotItemKey(item)),
|
||||
);
|
||||
const removed = appliedItems.filter(
|
||||
(item) => !currentKeys.has(snapshotItemKey(item)),
|
||||
);
|
||||
|
||||
return [
|
||||
...added.map((item) => ({
|
||||
id: `app-add-${snapshotItemKey(item)}`,
|
||||
label: "Добавлено",
|
||||
after: `+ ${formatSnapshotItem(item)}`,
|
||||
tone: "added" as const,
|
||||
})),
|
||||
...removed.map((item) => ({
|
||||
id: `app-remove-${snapshotItemKey(item)}`,
|
||||
label: "Удалено",
|
||||
after: `- ${formatSnapshotItem(item)}`,
|
||||
tone: "removed" as const,
|
||||
})),
|
||||
];
|
||||
}
|
||||
|
||||
function snapshotItemKey(item: ConfigSnapshotItem) {
|
||||
return `${item.type}:${item.value}`;
|
||||
}
|
||||
|
||||
function formatSnapshotItem(item: ConfigSnapshotItem) {
|
||||
return `${itemTypeLabel(item.type)} ${item.value}`;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getApplyReadiness, type ApplyReadinessInput } from "./readiness";
|
||||
|
||||
const base: ApplyReadinessInput = {
|
||||
routeMode: "external",
|
||||
appCount: 1,
|
||||
proxiFyreInstalled: true,
|
||||
singBoxInstalled: false,
|
||||
singBoxRunning: false,
|
||||
selectedServerTag: undefined,
|
||||
externalProxyValue: "proxy.example.test:1080",
|
||||
externalProxyError: null,
|
||||
busy: false,
|
||||
};
|
||||
|
||||
describe("getApplyReadiness", () => {
|
||||
it("keeps external SOCKS5 independent from Local sing-box", () => {
|
||||
expect(getApplyReadiness(base)).toEqual({ ready: true });
|
||||
});
|
||||
|
||||
it("requires an explicitly running Local sing-box service", () => {
|
||||
const readiness = getApplyReadiness({
|
||||
...base,
|
||||
routeMode: "local-singbox",
|
||||
singBoxInstalled: true,
|
||||
singBoxRunning: false,
|
||||
selectedServerTag: "nl-1",
|
||||
externalProxyValue: "",
|
||||
});
|
||||
|
||||
expect(readiness.ready).toBe(false);
|
||||
expect(readiness.title).toBe("Local sing-box остановлен");
|
||||
});
|
||||
|
||||
it("allows Local sing-box only after explicit start and server selection", () => {
|
||||
expect(
|
||||
getApplyReadiness({
|
||||
...base,
|
||||
routeMode: "local-singbox",
|
||||
singBoxInstalled: true,
|
||||
singBoxRunning: true,
|
||||
selectedServerTag: "nl-1",
|
||||
externalProxyValue: "",
|
||||
}),
|
||||
).toEqual({ ready: true });
|
||||
});
|
||||
});
|
||||
+25
-17
@@ -1,10 +1,11 @@
|
||||
export type RouteMode = 'external' | 'local-singbox';
|
||||
export type RouteMode = "external" | "local-singbox";
|
||||
|
||||
export interface ApplyReadinessInput {
|
||||
routeMode: RouteMode;
|
||||
appCount: number;
|
||||
proxiFyreInstalled: boolean;
|
||||
singBoxInstalled: boolean;
|
||||
singBoxRunning: boolean;
|
||||
selectedServerTag?: string;
|
||||
externalProxyValue: string;
|
||||
externalProxyError?: string | null;
|
||||
@@ -21,63 +22,70 @@ export function getApplyReadiness(input: ApplyReadinessInput): ApplyReadiness {
|
||||
if (input.busy) {
|
||||
return {
|
||||
ready: false,
|
||||
title: 'Операция уже выполняется',
|
||||
text: 'Дождись завершения текущего действия перед повторным применением.',
|
||||
title: "Операция уже выполняется",
|
||||
text: "Дождись завершения текущего действия перед повторным применением.",
|
||||
};
|
||||
}
|
||||
|
||||
if (!input.proxiFyreInstalled) {
|
||||
return {
|
||||
ready: false,
|
||||
title: 'ProxiFyre не установлен',
|
||||
text: 'Установи ProxiFyre, чтобы маршрутизировать выбранные приложения.',
|
||||
title: "ProxiFyre не установлен",
|
||||
text: "Установи ProxiFyre, чтобы маршрутизировать выбранные приложения.",
|
||||
};
|
||||
}
|
||||
|
||||
if (input.appCount < 1) {
|
||||
return {
|
||||
ready: false,
|
||||
title: 'Нет приложений',
|
||||
text: 'Добавь хотя бы один процесс, EXE-файл или папку.',
|
||||
title: "Нет приложений",
|
||||
text: "Добавь хотя бы один процесс, EXE-файл или папку.",
|
||||
};
|
||||
}
|
||||
|
||||
if (input.routeMode === 'external') {
|
||||
if (input.routeMode === "external") {
|
||||
if (!input.externalProxyValue.trim()) {
|
||||
return {
|
||||
ready: false,
|
||||
title: 'Прокси не указан',
|
||||
text: 'Введи адрес SOCKS5 прокси в формате host:port или socks5://host:port.',
|
||||
title: "Прокси не указан",
|
||||
text: "Введи адрес SOCKS5 прокси в формате host:port или socks5://host:port.",
|
||||
};
|
||||
}
|
||||
|
||||
if (input.externalProxyError) {
|
||||
return {
|
||||
ready: false,
|
||||
title: 'Проверь формат прокси',
|
||||
title: "Проверь формат прокси",
|
||||
text: input.externalProxyError,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (input.routeMode === 'local-singbox') {
|
||||
if (input.routeMode === "local-singbox") {
|
||||
if (!input.singBoxInstalled) {
|
||||
return {
|
||||
ready: false,
|
||||
title: 'Local sing-box не установлен',
|
||||
text: 'Установи Local sing-box, чтобы применить локальный маршрут.',
|
||||
title: "Local sing-box не установлен",
|
||||
text: "Установи Local sing-box, чтобы применить локальный маршрут.",
|
||||
};
|
||||
}
|
||||
|
||||
if (!input.singBoxRunning) {
|
||||
return {
|
||||
ready: false,
|
||||
title: "Local sing-box остановлен",
|
||||
text: "Явно запусти службу Local sing-box перед применением маршрута.",
|
||||
};
|
||||
}
|
||||
|
||||
if (!input.selectedServerTag) {
|
||||
return {
|
||||
ready: false,
|
||||
title: 'Сервер не выбран',
|
||||
text: 'Выбери сервер Local sing-box перед применением маршрута.',
|
||||
title: "Сервер не выбран",
|
||||
text: "Выбери сервер Local sing-box перед применением маршрута.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { ready: true };
|
||||
}
|
||||
|
||||
|
||||
+1057
-9
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
Before Width: | Height: | Size: 1.1 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 873 KiB |
+10
-7
@@ -1,10 +1,11 @@
|
||||
export type Protocol = 'TCP' | 'UDP';
|
||||
export type ProfileItemType = 'process' | 'folder' | 'exe';
|
||||
export type TargetKind = 'local' | 'external';
|
||||
export type ProxyProtocol = 'socks5' | 'http';
|
||||
export type ComponentId = 'control-app' | 'proxyfier' | 'singbox';
|
||||
export type ComponentState = 'installed' | 'missing' | 'stopped' | 'running' | 'error';
|
||||
export type ActivityLevel = 'info' | 'warning' | 'error' | 'success';
|
||||
export type Protocol = "TCP" | "UDP";
|
||||
export type ProfileItemType = "process" | "folder" | "exe";
|
||||
export type TargetKind = "local" | "external";
|
||||
export type ProxyProtocol = "socks5" | "http";
|
||||
export type ComponentId = "control-app" | "proxyfier" | "singbox";
|
||||
export type ComponentState =
|
||||
"installed" | "missing" | "stopped" | "running" | "error";
|
||||
export type ActivityLevel = "info" | "warning" | "error" | "success";
|
||||
|
||||
export interface ProfileItemInput {
|
||||
type: ProfileItemType | string;
|
||||
@@ -74,6 +75,7 @@ export interface LocalSingBoxConfig {
|
||||
subscriptionDisplayUrl?: string;
|
||||
hasSubscription: boolean;
|
||||
selectedServerTag?: string;
|
||||
selectedServerId?: string;
|
||||
listenHost: string;
|
||||
listenPort: number;
|
||||
serviceName: string;
|
||||
@@ -82,6 +84,7 @@ export interface LocalSingBoxConfig {
|
||||
}
|
||||
|
||||
export interface SubscriptionServer {
|
||||
id: string;
|
||||
tag: string;
|
||||
type: string;
|
||||
server: string;
|
||||
|
||||
+6
-7
@@ -1,12 +1,11 @@
|
||||
import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import '@fontsource-variable/jetbrains-mono';
|
||||
import { App } from './app/App';
|
||||
import './styles/app.css';
|
||||
import React from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import "@fontsource-variable/jetbrains-mono";
|
||||
import { App } from "./app/App";
|
||||
import "./styles/app.css";
|
||||
|
||||
createRoot(document.getElementById('root') as HTMLElement).render(
|
||||
createRoot(document.getElementById("root") as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
|
||||
|
||||
+136
-25
@@ -1,3 +1,4 @@
|
||||
/* Foundations: tokens, document defaults, and scrollbars. */
|
||||
:root {
|
||||
--app-footer-height: 54px;
|
||||
--app-change-dock-height: 0px;
|
||||
@@ -5,7 +6,9 @@
|
||||
--change-row-count: 1;
|
||||
--app-header-row-height: 0px;
|
||||
--app-tab-height: 46px;
|
||||
--app-header-height: calc(var(--app-header-row-height) + var(--app-tab-height));
|
||||
--app-header-height: calc(
|
||||
var(--app-header-row-height) + var(--app-tab-height)
|
||||
);
|
||||
--motion-fast: 120ms;
|
||||
--motion-standard: 180ms;
|
||||
--motion-panel: 220ms;
|
||||
@@ -24,8 +27,8 @@
|
||||
--border-strong: #343b49;
|
||||
--focus-ring: #3b82f6;
|
||||
font-family:
|
||||
"JetBrains Mono Variable", "JetBrains Mono", ui-monospace, SFMono-Regular, Consolas,
|
||||
"Liberation Mono", monospace;
|
||||
"JetBrains Mono Variable", "JetBrains Mono", ui-monospace, SFMono-Regular,
|
||||
Consolas, "Liberation Mono", monospace;
|
||||
color: #e5e7eb;
|
||||
background: #101216;
|
||||
font-synthesis: none;
|
||||
@@ -93,6 +96,7 @@ button:disabled {
|
||||
opacity: 0.56;
|
||||
}
|
||||
|
||||
/* UI primitives shared by buttons, menus, popovers, tabs, and service rows. */
|
||||
.ui-button,
|
||||
.ui-icon-button {
|
||||
appearance: none;
|
||||
@@ -357,14 +361,26 @@ button:disabled {
|
||||
.ui-busy-ring-segment.bottom {
|
||||
width: var(--busy-ring-long);
|
||||
height: var(--busy-ring-thickness);
|
||||
background: linear-gradient(90deg, transparent, #60a5fa 24%, #bbf7d0 54%, transparent);
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
#60a5fa 24%,
|
||||
#bbf7d0 54%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
.ui-busy-ring-segment.right,
|
||||
.ui-busy-ring-segment.left {
|
||||
width: var(--busy-ring-thickness);
|
||||
height: var(--busy-ring-short);
|
||||
background: linear-gradient(180deg, transparent, #60a5fa 24%, #bbf7d0 54%, transparent);
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
transparent,
|
||||
#60a5fa 24%,
|
||||
#bbf7d0 54%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
.ui-busy-ring-segment.top {
|
||||
@@ -639,14 +655,26 @@ button:disabled {
|
||||
.ui-service-border-glow-segment.bottom {
|
||||
width: var(--busy-ring-long);
|
||||
height: var(--busy-ring-thickness);
|
||||
background: linear-gradient(90deg, transparent, #60a5fa 24%, #bbf7d0 54%, transparent);
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
#60a5fa 24%,
|
||||
#bbf7d0 54%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
.ui-service-border-glow-segment.right,
|
||||
.ui-service-border-glow-segment.left {
|
||||
width: var(--busy-ring-thickness);
|
||||
height: var(--busy-ring-short);
|
||||
background: linear-gradient(180deg, transparent, #60a5fa 24%, #bbf7d0 54%, transparent);
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
transparent,
|
||||
#60a5fa 24%,
|
||||
#bbf7d0 54%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
.ui-service-border-glow-segment.top {
|
||||
@@ -758,16 +786,25 @@ button:disabled {
|
||||
}
|
||||
}
|
||||
|
||||
/* Application shell, tab panels, and shared workspace layout. */
|
||||
.simple-shell {
|
||||
display: block;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
background: #101216;
|
||||
padding: var(--app-header-height) 0 calc(var(--app-footer-height) + var(--app-change-dock-height) + var(--app-admin-prompt-height));
|
||||
padding: var(--app-header-height) 0
|
||||
calc(
|
||||
var(--app-footer-height) + var(--app-change-dock-height) +
|
||||
var(--app-admin-prompt-height)
|
||||
);
|
||||
}
|
||||
|
||||
.simple-shell.has-change-dock {
|
||||
--app-change-dock-height: clamp(60px, calc(20px + (var(--change-row-count) * 26px)), 220px);
|
||||
--app-change-dock-height: clamp(
|
||||
60px,
|
||||
calc(20px + (var(--change-row-count) * 26px)),
|
||||
220px
|
||||
);
|
||||
}
|
||||
|
||||
.simple-shell.has-admin-prompt {
|
||||
@@ -779,7 +816,10 @@ button:disabled {
|
||||
grid-template-rows: minmax(0, 1fr);
|
||||
align-content: stretch;
|
||||
min-height: 0;
|
||||
height: calc(100vh - var(--app-header-height) - var(--app-footer-height) - var(--app-change-dock-height) - var(--app-admin-prompt-height));
|
||||
height: calc(
|
||||
100vh - var(--app-header-height) - var(--app-footer-height) -
|
||||
var(--app-change-dock-height) - var(--app-admin-prompt-height)
|
||||
);
|
||||
width: 100%;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
@@ -1076,6 +1116,7 @@ button:disabled {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Read-only summary panel and power state. */
|
||||
.summary-main {
|
||||
align-self: stretch;
|
||||
display: grid;
|
||||
@@ -1116,9 +1157,14 @@ button:disabled {
|
||||
width: clamp(180px, 30vw, 268px);
|
||||
aspect-ratio: 1;
|
||||
overflow: hidden;
|
||||
border: 0;
|
||||
border: 1px solid #3f4b5e;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
background: radial-gradient(
|
||||
circle at 48% 38%,
|
||||
#263244 0%,
|
||||
#151c28 62%,
|
||||
#0c1119 100%
|
||||
);
|
||||
box-shadow:
|
||||
0 22px 54px rgba(0, 0, 0, 0.48),
|
||||
0 0 38px rgba(59, 130, 246, 0.1);
|
||||
@@ -1154,17 +1200,42 @@ button:disabled {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.summary-toggle-button img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
.summary-toggle-face {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 78%;
|
||||
aspect-ratio: 1;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
border: 1px solid #4b596e;
|
||||
color: #98a4b8;
|
||||
background: radial-gradient(circle at 48% 38%, #283446 0%, #151c27 72%);
|
||||
box-shadow: inset 0 0 28px rgba(0, 0, 0, 0.46);
|
||||
pointer-events: none;
|
||||
transform: scale(1.18);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.summary-status-control.on .summary-toggle-button {
|
||||
border-color: #19b985;
|
||||
background: radial-gradient(
|
||||
circle at 48% 38%,
|
||||
#193b36 0%,
|
||||
#12251f 62%,
|
||||
#0b1513 100%
|
||||
);
|
||||
box-shadow:
|
||||
0 22px 54px rgba(0, 0, 0, 0.48),
|
||||
0 0 42px rgba(25, 185, 133, 0.24);
|
||||
}
|
||||
|
||||
.summary-status-control.on .summary-toggle-face {
|
||||
border-color: #2fd5a0;
|
||||
color: #5ce6b8;
|
||||
background: radial-gradient(circle at 48% 38%, #205044 0%, #143029 72%);
|
||||
box-shadow:
|
||||
inset 0 0 28px rgba(0, 0, 0, 0.34),
|
||||
0 0 30px rgba(47, 213, 160, 0.18);
|
||||
}
|
||||
|
||||
.summary-state-label {
|
||||
color: #e5e7eb;
|
||||
font-size: clamp(22px, 3.4vw, 34px);
|
||||
@@ -1173,6 +1244,14 @@ button:disabled {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.network-disclosure {
|
||||
grid-column: 1 / -1;
|
||||
margin: 0;
|
||||
color: #8d99ae;
|
||||
font-size: 11px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.summary-state-label.ok {
|
||||
color: #bbf7d0;
|
||||
}
|
||||
@@ -1422,14 +1501,26 @@ button.summary-card:hover {
|
||||
.finder-border-glow-segment.bottom {
|
||||
width: var(--busy-ring-long);
|
||||
height: var(--busy-ring-thickness);
|
||||
background: linear-gradient(90deg, transparent, #60a5fa 24%, #bbf7d0 54%, transparent);
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
#60a5fa 24%,
|
||||
#bbf7d0 54%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
.finder-border-glow-segment.right,
|
||||
.finder-border-glow-segment.left {
|
||||
width: var(--busy-ring-thickness);
|
||||
height: var(--busy-ring-short);
|
||||
background: linear-gradient(180deg, transparent, #60a5fa 24%, #bbf7d0 54%, transparent);
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
transparent,
|
||||
#60a5fa 24%,
|
||||
#bbf7d0 54%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
.finder-border-glow-segment.top {
|
||||
@@ -1708,9 +1799,12 @@ button.summary-card:hover {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
/* Proxy endpoint inputs, subscription workspace, and route checks. */
|
||||
.connection-check {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(190px, 1fr) minmax(220px, auto) minmax(132px, auto) auto;
|
||||
grid-template-columns:
|
||||
minmax(190px, 1fr) minmax(220px, auto) minmax(132px, auto)
|
||||
auto;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
@@ -2081,6 +2175,7 @@ button.summary-card:hover {
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
/* Route-chain visualization and packet states. */
|
||||
.route-chain {
|
||||
position: relative;
|
||||
display: grid;
|
||||
@@ -2300,6 +2395,7 @@ button.summary-card:hover {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
/* Pending configuration diff and apply controls. */
|
||||
.changes-dock {
|
||||
position: fixed;
|
||||
right: 0;
|
||||
@@ -2730,6 +2826,7 @@ button.summary-card:hover {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* Activity log dock and administrator prompt. */
|
||||
.log-dock {
|
||||
position: fixed;
|
||||
right: 0;
|
||||
@@ -2766,7 +2863,9 @@ button.summary-card:hover {
|
||||
gap: 10px;
|
||||
align-items: baseline;
|
||||
padding: 0 8px;
|
||||
transition: opacity 180ms ease, transform 180ms ease;
|
||||
transition:
|
||||
opacity 180ms ease,
|
||||
transform 180ms ease;
|
||||
}
|
||||
|
||||
.log-current.hidden {
|
||||
@@ -2880,6 +2979,7 @@ button.summary-card:hover {
|
||||
color: #b6c2d4;
|
||||
}
|
||||
|
||||
/* Motion and responsive overrides. */
|
||||
@keyframes finder-border-top {
|
||||
0% {
|
||||
left: -116px;
|
||||
@@ -3014,11 +3114,19 @@ button.summary-card:hover {
|
||||
|
||||
@media (max-width: 680px) {
|
||||
.simple-shell {
|
||||
padding: var(--app-header-height) 0 calc(var(--app-footer-height) + var(--app-change-dock-height) + var(--app-admin-prompt-height));
|
||||
padding: var(--app-header-height) 0
|
||||
calc(
|
||||
var(--app-footer-height) + var(--app-change-dock-height) +
|
||||
var(--app-admin-prompt-height)
|
||||
);
|
||||
}
|
||||
|
||||
.simple-shell.has-change-dock {
|
||||
--app-change-dock-height: clamp(104px, calc(82px + (var(--change-row-count) * 25px)), 260px);
|
||||
--app-change-dock-height: clamp(
|
||||
104px,
|
||||
calc(82px + (var(--change-row-count) * 25px)),
|
||||
260px
|
||||
);
|
||||
}
|
||||
|
||||
.simple-shell.has-admin-prompt {
|
||||
@@ -3026,7 +3134,10 @@ button.summary-card:hover {
|
||||
}
|
||||
|
||||
.simple-panel {
|
||||
height: calc(100vh - var(--app-header-height) - var(--app-footer-height) - var(--app-change-dock-height) - var(--app-admin-prompt-height));
|
||||
height: calc(
|
||||
100vh - var(--app-header-height) - var(--app-footer-height) -
|
||||
var(--app-change-dock-height) - var(--app-admin-prompt-height)
|
||||
);
|
||||
padding: 12px 12px 16px;
|
||||
}
|
||||
|
||||
|
||||
+56
-49
@@ -1,4 +1,4 @@
|
||||
import { MoreHorizontal } from 'lucide-react';
|
||||
import { MoreHorizontal } from "lucide-react";
|
||||
import {
|
||||
useEffect,
|
||||
useId,
|
||||
@@ -6,9 +6,9 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
type CSSProperties,
|
||||
} from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { IconButton } from './IconButton';
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { IconButton } from "./IconButton";
|
||||
|
||||
export interface ActionMenuItem {
|
||||
label: string;
|
||||
@@ -29,7 +29,7 @@ interface ActionMenuPosition {
|
||||
top: number;
|
||||
left: number;
|
||||
width: number;
|
||||
placement: 'top' | 'bottom';
|
||||
placement: "top" | "bottom";
|
||||
}
|
||||
|
||||
const MENU_WIDTH = 190;
|
||||
@@ -50,7 +50,7 @@ export function ActionMenu({
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: MENU_WIDTH,
|
||||
placement: 'bottom',
|
||||
placement: "bottom",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -65,22 +65,28 @@ export function ActionMenu({
|
||||
if (!trigger) return;
|
||||
|
||||
const rect = trigger.getBoundingClientRect();
|
||||
const width = Math.min(MENU_WIDTH, Math.max(180, window.innerWidth - VIEWPORT_MARGIN * 2));
|
||||
const width = Math.min(
|
||||
MENU_WIDTH,
|
||||
Math.max(180, window.innerWidth - VIEWPORT_MARGIN * 2),
|
||||
);
|
||||
const popoverHeight = popoverRef.current?.offsetHeight ?? 0;
|
||||
const left = Math.max(
|
||||
VIEWPORT_MARGIN,
|
||||
Math.min(rect.right - width, window.innerWidth - width - VIEWPORT_MARGIN),
|
||||
Math.min(
|
||||
rect.right - width,
|
||||
window.innerWidth - width - VIEWPORT_MARGIN,
|
||||
),
|
||||
);
|
||||
let top = rect.bottom + MENU_OFFSET;
|
||||
let placement: ActionMenuPosition['placement'] = 'bottom';
|
||||
let placement: ActionMenuPosition["placement"] = "bottom";
|
||||
|
||||
if (
|
||||
popoverHeight
|
||||
&& top + popoverHeight > window.innerHeight - VIEWPORT_MARGIN
|
||||
&& rect.top > popoverHeight + VIEWPORT_MARGIN + MENU_OFFSET
|
||||
popoverHeight &&
|
||||
top + popoverHeight > window.innerHeight - VIEWPORT_MARGIN &&
|
||||
rect.top > popoverHeight + VIEWPORT_MARGIN + MENU_OFFSET
|
||||
) {
|
||||
top = rect.top - popoverHeight - MENU_OFFSET;
|
||||
placement = 'top';
|
||||
placement = "top";
|
||||
}
|
||||
|
||||
const maxTop = popoverHeight
|
||||
@@ -97,13 +103,13 @@ export function ActionMenu({
|
||||
|
||||
updatePosition();
|
||||
const frame = window.requestAnimationFrame(updatePosition);
|
||||
window.addEventListener('resize', updatePosition);
|
||||
window.addEventListener('scroll', updatePosition, true);
|
||||
window.addEventListener("resize", updatePosition);
|
||||
window.addEventListener("scroll", updatePosition, true);
|
||||
|
||||
return () => {
|
||||
window.cancelAnimationFrame(frame);
|
||||
window.removeEventListener('resize', updatePosition);
|
||||
window.removeEventListener('scroll', updatePosition, true);
|
||||
window.removeEventListener("resize", updatePosition);
|
||||
window.removeEventListener("scroll", updatePosition, true);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
@@ -118,16 +124,16 @@ export function ActionMenu({
|
||||
};
|
||||
|
||||
const closeOnEscape = (event: KeyboardEvent) => {
|
||||
if (event.key !== 'Escape') return;
|
||||
if (event.key !== "Escape") return;
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
document.addEventListener('pointerdown', closeOnOutsidePointer);
|
||||
document.addEventListener('keydown', closeOnEscape);
|
||||
document.addEventListener("pointerdown", closeOnOutsidePointer);
|
||||
document.addEventListener("keydown", closeOnEscape);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', closeOnOutsidePointer);
|
||||
document.removeEventListener('keydown', closeOnEscape);
|
||||
document.removeEventListener("pointerdown", closeOnOutsidePointer);
|
||||
document.removeEventListener("keydown", closeOnEscape);
|
||||
};
|
||||
}, [onOpenChange, open]);
|
||||
|
||||
@@ -148,34 +154,35 @@ export function ActionMenu({
|
||||
aria-expanded={open}
|
||||
aria-haspopup="menu"
|
||||
/>
|
||||
{open && typeof document !== 'undefined' ? createPortal(
|
||||
<div
|
||||
className="ui-action-menu-popover"
|
||||
data-placement={position.placement}
|
||||
id={menuId}
|
||||
ref={popoverRef}
|
||||
role="menu"
|
||||
style={popoverStyle}
|
||||
>
|
||||
{items.map((item) => (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={item.danger ? 'is-danger' : ''}
|
||||
onClick={() => {
|
||||
onOpenChange(false);
|
||||
item.onClick();
|
||||
}}
|
||||
disabled={item.disabled}
|
||||
key={item.label}
|
||||
{open && typeof document !== "undefined"
|
||||
? createPortal(
|
||||
<div
|
||||
className="ui-action-menu-popover"
|
||||
data-placement={position.placement}
|
||||
id={menuId}
|
||||
ref={popoverRef}
|
||||
role="menu"
|
||||
style={popoverStyle}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>,
|
||||
document.body,
|
||||
) : null}
|
||||
{items.map((item) => (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={item.danger ? "is-danger" : ""}
|
||||
onClick={() => {
|
||||
onOpenChange(false);
|
||||
item.onClick();
|
||||
}}
|
||||
disabled={item.disabled}
|
||||
key={item.label}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
: null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ export interface BusyRingProps {
|
||||
}
|
||||
|
||||
export function BusyRing({ className }: BusyRingProps) {
|
||||
const classes = ['ui-busy-ring', className ?? ''].filter(Boolean).join(' ');
|
||||
const classes = ["ui-busy-ring", className ?? ""].filter(Boolean).join(" ");
|
||||
|
||||
return (
|
||||
<span className={classes} aria-hidden="true">
|
||||
|
||||
+23
-14
@@ -1,8 +1,8 @@
|
||||
import type { ButtonHTMLAttributes, ReactNode } from 'react';
|
||||
import { BusyRing } from './BusyRing';
|
||||
import type { ButtonHTMLAttributes, ReactNode } from "react";
|
||||
import { BusyRing } from "./BusyRing";
|
||||
|
||||
export type ButtonVariant = 'primary' | 'neutral' | 'add' | 'danger';
|
||||
export type ButtonSize = 'sm' | 'md' | 'lg';
|
||||
export type ButtonVariant = "primary" | "neutral" | "add" | "danger";
|
||||
export type ButtonSize = "sm" | "md" | "lg";
|
||||
|
||||
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: ButtonVariant;
|
||||
@@ -14,8 +14,8 @@ export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
}
|
||||
|
||||
export function Button({
|
||||
variant = 'neutral',
|
||||
size = 'md',
|
||||
variant = "neutral",
|
||||
size = "md",
|
||||
loading = false,
|
||||
loadingLabel,
|
||||
leftIcon,
|
||||
@@ -26,12 +26,14 @@ export function Button({
|
||||
...props
|
||||
}: ButtonProps) {
|
||||
const classes = [
|
||||
'ui-button',
|
||||
"ui-button",
|
||||
`ui-button--${variant}`,
|
||||
`ui-button--${size}`,
|
||||
loading ? 'is-loading' : '',
|
||||
className ?? '',
|
||||
].filter(Boolean).join(' ');
|
||||
loading ? "is-loading" : "",
|
||||
className ?? "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
return (
|
||||
<button
|
||||
@@ -42,11 +44,18 @@ export function Button({
|
||||
>
|
||||
{loading ? <BusyRing /> : null}
|
||||
{!loading && leftIcon ? (
|
||||
<span className="ui-button-icon" aria-hidden="true">{leftIcon}</span>
|
||||
<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}
|
||||
<span className="ui-button-label">{loading && loadingLabel ? loadingLabel : children}</span>
|
||||
{!loading && rightIcon ? <span className="ui-button-icon" aria-hidden="true">{rightIcon}</span> : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+45
-31
@@ -8,12 +8,15 @@ import {
|
||||
type CSSProperties,
|
||||
type MouseEvent,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
export type DetailsPopoverAlign = 'start' | 'center' | 'end';
|
||||
export type DetailsPopoverAlign = "start" | "center" | "end";
|
||||
|
||||
export interface DetailsPopoverProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'title'> {
|
||||
export interface DetailsPopoverProps extends Omit<
|
||||
ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
"title"
|
||||
> {
|
||||
details: string | string[];
|
||||
children: ReactNode;
|
||||
popoverLabel?: string;
|
||||
@@ -26,7 +29,7 @@ interface DetailsPopoverPosition {
|
||||
left: number;
|
||||
width: number;
|
||||
arrowLeft: number;
|
||||
placement: 'top' | 'bottom';
|
||||
placement: "top" | "bottom";
|
||||
}
|
||||
|
||||
const VIEWPORT_MARGIN = 12;
|
||||
@@ -35,8 +38,8 @@ export function DetailsPopover({
|
||||
details,
|
||||
children,
|
||||
className,
|
||||
popoverLabel = 'Детали',
|
||||
align = 'start',
|
||||
popoverLabel = "Детали",
|
||||
align = "start",
|
||||
maxWidth = 360,
|
||||
disabled,
|
||||
onClick,
|
||||
@@ -51,12 +54,14 @@ export function DetailsPopover({
|
||||
left: 0,
|
||||
width: Math.min(maxWidth, 360),
|
||||
arrowLeft: 24,
|
||||
placement: 'bottom',
|
||||
placement: "bottom",
|
||||
});
|
||||
const detailLines = Array.isArray(details)
|
||||
? details.filter(Boolean)
|
||||
: [details].filter(Boolean);
|
||||
const classes = ['ui-details-popover-trigger', className ?? ''].filter(Boolean).join(' ');
|
||||
const classes = ["ui-details-popover-trigger", className ?? ""]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
useEffect(() => {
|
||||
if (disabled && open) setOpen(false);
|
||||
@@ -70,26 +75,35 @@ export function DetailsPopover({
|
||||
if (!trigger) return;
|
||||
|
||||
const rect = trigger.getBoundingClientRect();
|
||||
const width = Math.min(maxWidth, Math.max(220, window.innerWidth - VIEWPORT_MARGIN * 2));
|
||||
const width = Math.min(
|
||||
maxWidth,
|
||||
Math.max(220, window.innerWidth - VIEWPORT_MARGIN * 2),
|
||||
);
|
||||
let left = rect.left;
|
||||
if (align === 'center') left = rect.left + rect.width / 2 - width / 2;
|
||||
if (align === 'end') left = rect.right - width;
|
||||
if (align === "center") left = rect.left + rect.width / 2 - width / 2;
|
||||
if (align === "end") left = rect.right - width;
|
||||
|
||||
left = Math.max(VIEWPORT_MARGIN, Math.min(left, window.innerWidth - width - VIEWPORT_MARGIN));
|
||||
left = Math.max(
|
||||
VIEWPORT_MARGIN,
|
||||
Math.min(left, window.innerWidth - width - VIEWPORT_MARGIN),
|
||||
);
|
||||
|
||||
const popoverHeight = popoverRef.current?.offsetHeight ?? 0;
|
||||
let top = rect.bottom + 8;
|
||||
let placement: DetailsPopoverPosition['placement'] = 'bottom';
|
||||
let placement: DetailsPopoverPosition["placement"] = "bottom";
|
||||
|
||||
if (
|
||||
popoverHeight
|
||||
&& top + popoverHeight > window.innerHeight - VIEWPORT_MARGIN
|
||||
&& rect.top > popoverHeight + VIEWPORT_MARGIN + 8
|
||||
popoverHeight &&
|
||||
top + popoverHeight > window.innerHeight - VIEWPORT_MARGIN &&
|
||||
rect.top > popoverHeight + VIEWPORT_MARGIN + 8
|
||||
) {
|
||||
top = rect.top - popoverHeight - 8;
|
||||
placement = 'top';
|
||||
placement = "top";
|
||||
} else if (popoverHeight) {
|
||||
top = Math.min(top, window.innerHeight - popoverHeight - VIEWPORT_MARGIN);
|
||||
top = Math.min(
|
||||
top,
|
||||
window.innerHeight - popoverHeight - VIEWPORT_MARGIN,
|
||||
);
|
||||
}
|
||||
|
||||
const arrowLeft = Math.max(
|
||||
@@ -108,13 +122,13 @@ export function DetailsPopover({
|
||||
|
||||
updatePosition();
|
||||
const frame = window.requestAnimationFrame(updatePosition);
|
||||
window.addEventListener('resize', updatePosition);
|
||||
window.addEventListener('scroll', updatePosition, true);
|
||||
window.addEventListener("resize", updatePosition);
|
||||
window.addEventListener("scroll", updatePosition, true);
|
||||
|
||||
return () => {
|
||||
window.cancelAnimationFrame(frame);
|
||||
window.removeEventListener('resize', updatePosition);
|
||||
window.removeEventListener('scroll', updatePosition, true);
|
||||
window.removeEventListener("resize", updatePosition);
|
||||
window.removeEventListener("scroll", updatePosition, true);
|
||||
};
|
||||
}, [align, maxWidth, open]);
|
||||
|
||||
@@ -129,17 +143,17 @@ export function DetailsPopover({
|
||||
};
|
||||
|
||||
const closeOnEscape = (event: KeyboardEvent) => {
|
||||
if (event.key !== 'Escape') return;
|
||||
if (event.key !== "Escape") return;
|
||||
setOpen(false);
|
||||
triggerRef.current?.focus();
|
||||
};
|
||||
|
||||
document.addEventListener('pointerdown', closeOnOutsidePointer);
|
||||
document.addEventListener('keydown', closeOnEscape);
|
||||
document.addEventListener("pointerdown", closeOnOutsidePointer);
|
||||
document.addEventListener("keydown", closeOnEscape);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', closeOnOutsidePointer);
|
||||
document.removeEventListener('keydown', closeOnEscape);
|
||||
document.removeEventListener("pointerdown", closeOnOutsidePointer);
|
||||
document.removeEventListener("keydown", closeOnEscape);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
@@ -152,7 +166,7 @@ export function DetailsPopover({
|
||||
top: position.top,
|
||||
left: position.left,
|
||||
width: position.width,
|
||||
'--details-popover-arrow-left': `${position.arrowLeft}px`,
|
||||
"--details-popover-arrow-left": `${position.arrowLeft}px`,
|
||||
} as CSSProperties;
|
||||
|
||||
return (
|
||||
@@ -160,7 +174,7 @@ export function DetailsPopover({
|
||||
<button
|
||||
{...props}
|
||||
ref={triggerRef}
|
||||
type={props.type ?? 'button'}
|
||||
type={props.type ?? "button"}
|
||||
className={classes}
|
||||
aria-controls={open ? detailsId : undefined}
|
||||
aria-expanded={open}
|
||||
@@ -170,7 +184,7 @@ export function DetailsPopover({
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
{open && detailLines.length && typeof document !== 'undefined'
|
||||
{open && detailLines.length && typeof document !== "undefined"
|
||||
? createPortal(
|
||||
<div
|
||||
ref={popoverRef}
|
||||
|
||||
+11
-5
@@ -1,4 +1,4 @@
|
||||
import type { InputHTMLAttributes, ReactNode } from 'react';
|
||||
import type { InputHTMLAttributes, ReactNode } from "react";
|
||||
|
||||
export interface FieldProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||
label: string;
|
||||
@@ -16,11 +16,11 @@ export function Field({
|
||||
id,
|
||||
...props
|
||||
}: FieldProps) {
|
||||
const inputId = id ?? `field-${label.toLowerCase().replace(/\s+/g, '-')}`;
|
||||
const inputId = id ?? `field-${label.toLowerCase().replace(/\s+/g, "-")}`;
|
||||
const helpId = `${inputId}-help`;
|
||||
|
||||
return (
|
||||
<label className={`ui-field ${className ?? ''}`.trim()} htmlFor={inputId}>
|
||||
<label className={`ui-field ${className ?? ""}`.trim()} htmlFor={inputId}>
|
||||
<span className="ui-field-label">{label}</span>
|
||||
<div className="ui-field-row">
|
||||
<input
|
||||
@@ -31,8 +31,14 @@ export function Field({
|
||||
/>
|
||||
{action}
|
||||
</div>
|
||||
{error || hint ? <span id={helpId} className={`ui-field-help ${error ? 'is-error' : ''}`.trim()}>{error ?? hint}</span> : null}
|
||||
{error || hint ? (
|
||||
<span
|
||||
id={helpId}
|
||||
className={`ui-field-help ${error ? "is-error" : ""}`.trim()}
|
||||
>
|
||||
{error ?? hint}
|
||||
</span>
|
||||
) : null}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { HTMLAttributes, ReactNode } from 'react';
|
||||
import type { HTMLAttributes, ReactNode } from "react";
|
||||
|
||||
export interface HoverDetailsProps extends HTMLAttributes<HTMLSpanElement> {
|
||||
details: string | string[];
|
||||
@@ -12,9 +12,11 @@ export function HoverDetails({
|
||||
...props
|
||||
}: HoverDetailsProps) {
|
||||
const detailText = Array.isArray(details)
|
||||
? details.filter(Boolean).join('\n')
|
||||
? details.filter(Boolean).join("\n")
|
||||
: details;
|
||||
const classes = ['ui-hover-details', className ?? ''].filter(Boolean).join(' ');
|
||||
const classes = ["ui-hover-details", className ?? ""]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
return (
|
||||
<span
|
||||
|
||||
+16
-12
@@ -1,9 +1,12 @@
|
||||
import type { ButtonHTMLAttributes, ReactNode } from 'react';
|
||||
import { BusyRing } from './BusyRing';
|
||||
import type { ButtonHTMLAttributes, ReactNode } from "react";
|
||||
import { BusyRing } from "./BusyRing";
|
||||
|
||||
export type IconButtonVariant = 'neutral' | 'add' | 'danger';
|
||||
export type IconButtonVariant = "neutral" | "add" | "danger";
|
||||
|
||||
export interface IconButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'title'> {
|
||||
export interface IconButtonProps extends Omit<
|
||||
ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
"title"
|
||||
> {
|
||||
label: string;
|
||||
icon: ReactNode;
|
||||
variant?: IconButtonVariant;
|
||||
@@ -14,25 +17,27 @@ export interface IconButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonEle
|
||||
export function IconButton({
|
||||
label,
|
||||
icon,
|
||||
variant = 'neutral',
|
||||
variant = "neutral",
|
||||
loading = false,
|
||||
tooltip,
|
||||
className,
|
||||
disabled,
|
||||
...props
|
||||
}: IconButtonProps) {
|
||||
const tooltipText = tooltip === '' ? undefined : tooltip ?? label;
|
||||
const tooltipText = tooltip === "" ? undefined : (tooltip ?? label);
|
||||
const classes = [
|
||||
'ui-icon-button',
|
||||
"ui-icon-button",
|
||||
`ui-icon-button--${variant}`,
|
||||
loading ? 'is-loading' : '',
|
||||
className ?? '',
|
||||
].filter(Boolean).join(' ');
|
||||
loading ? "is-loading" : "",
|
||||
className ?? "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
return (
|
||||
<button
|
||||
{...props}
|
||||
type={props.type ?? 'button'}
|
||||
type={props.type ?? "button"}
|
||||
className={classes}
|
||||
aria-label={label}
|
||||
data-tooltip={tooltipText}
|
||||
@@ -44,4 +49,3 @@ export function IconButton({
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+40
-21
@@ -1,8 +1,8 @@
|
||||
import { Button } from './Button';
|
||||
import { Button } from "./Button";
|
||||
|
||||
export interface LogDockEntry {
|
||||
id: string;
|
||||
kind: 'success' | 'error' | 'info';
|
||||
kind: "success" | "error" | "info";
|
||||
title: string;
|
||||
text: string;
|
||||
at: number;
|
||||
@@ -18,7 +18,10 @@ export interface LogDockProps {
|
||||
|
||||
function isNativePreviewError(entry: LogDockEntry | null) {
|
||||
if (!entry) return false;
|
||||
return entry.text.includes("reading 'invoke'") || entry.text.includes('undefined (reading');
|
||||
return (
|
||||
entry.text.includes("reading 'invoke'") ||
|
||||
entry.text.includes("undefined (reading")
|
||||
);
|
||||
}
|
||||
|
||||
function displayEntry(entry: LogDockEntry | null) {
|
||||
@@ -26,8 +29,8 @@ function displayEntry(entry: LogDockEntry | null) {
|
||||
if (!isNativePreviewError(entry)) return entry;
|
||||
return {
|
||||
...entry,
|
||||
title: 'Desktop-команды недоступны',
|
||||
text: 'Запусти клиент через Tauri, чтобы управлять службами и применять конфиг.',
|
||||
title: "Desktop-команды недоступны",
|
||||
text: "Запусти клиент через Tauri, чтобы управлять службами и применять конфиг.",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -41,8 +44,11 @@ export function LogDock({
|
||||
const current = displayEntry(activeEntry);
|
||||
|
||||
return (
|
||||
<footer className={`log-dock ${current?.kind ?? 'idle'}`} aria-live="polite">
|
||||
<div className={`log-current ${current ? 'visible' : 'hidden'}`}>
|
||||
<footer
|
||||
className={`log-dock ${current?.kind ?? "idle"}`}
|
||||
aria-live="polite"
|
||||
>
|
||||
<div className={`log-current ${current ? "visible" : "hidden"}`}>
|
||||
{current ? (
|
||||
<>
|
||||
<strong>{current.title}</strong>
|
||||
@@ -52,24 +58,37 @@ export function LogDock({
|
||||
<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
|
||||
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}
|
||||
{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>
|
||||
);
|
||||
}) : (
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="log-history-row">
|
||||
<time>--:--:--</time>
|
||||
<span>Событий пока нет.</span>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Button, type ButtonVariant } from './Button';
|
||||
import { ActionMenu, type ActionMenuItem } from './ActionMenu';
|
||||
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 type ServiceControlState =
|
||||
"checking" | "missing" | "installed" | "running" | "stopped" | "error";
|
||||
|
||||
export interface ServicePrimaryAction {
|
||||
label: string;
|
||||
@@ -25,7 +26,7 @@ export interface ServiceControlRowProps {
|
||||
items: ActionMenuItem[];
|
||||
disabled?: boolean;
|
||||
};
|
||||
visualState?: 'working' | 'settling' | null;
|
||||
visualState?: "working" | "settling" | null;
|
||||
className?: string;
|
||||
inlineActions?: ReactNode;
|
||||
children?: ReactNode;
|
||||
@@ -43,11 +44,13 @@ export function ServiceControlRow({
|
||||
children,
|
||||
}: ServiceControlRowProps) {
|
||||
const classes = [
|
||||
'ui-service-row',
|
||||
"ui-service-row",
|
||||
`ui-service-row--${state}`,
|
||||
visualState ? `ui-service-row--${visualState}` : '',
|
||||
className ?? '',
|
||||
].filter(Boolean).join(' ');
|
||||
visualState ? `ui-service-row--${visualState}` : "",
|
||||
className ?? "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
return (
|
||||
<div className={classes}>
|
||||
@@ -61,7 +64,9 @@ export function ServiceControlRow({
|
||||
<div className="ui-service-text">
|
||||
<div className="ui-service-title-line">
|
||||
<strong>{title}</strong>
|
||||
{inlineActions ? <div className="ui-service-inline-actions">{inlineActions}</div> : null}
|
||||
{inlineActions ? (
|
||||
<div className="ui-service-inline-actions">{inlineActions}</div>
|
||||
) : null}
|
||||
</div>
|
||||
<span>{detail}</span>
|
||||
</div>
|
||||
@@ -69,7 +74,7 @@ export function ServiceControlRow({
|
||||
{primaryAction ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant={primaryAction.variant ?? 'neutral'}
|
||||
variant={primaryAction.variant ?? "neutral"}
|
||||
onClick={primaryAction.onClick}
|
||||
disabled={primaryAction.disabled}
|
||||
loading={primaryAction.loading}
|
||||
@@ -92,4 +97,3 @@ export function ServiceControlRow({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,21 +1,20 @@
|
||||
import { BusyRing } from './BusyRing';
|
||||
import { BusyRing } from "./BusyRing";
|
||||
|
||||
export type StatusPillTone = 'ok' | 'warning' | 'error' | 'checking' | 'muted';
|
||||
export type StatusPillTone = "ok" | "warning" | "error" | "checking" | "muted";
|
||||
|
||||
export interface StatusPillProps {
|
||||
tone?: StatusPillTone;
|
||||
children: string;
|
||||
}
|
||||
|
||||
export function StatusPill({ tone = 'muted', children }: StatusPillProps) {
|
||||
export function StatusPill({ tone = "muted", children }: StatusPillProps) {
|
||||
return (
|
||||
<span
|
||||
className={`ui-status-pill ui-status-pill--${tone}`}
|
||||
aria-busy={tone === 'checking' || undefined}
|
||||
aria-busy={tone === "checking" || undefined}
|
||||
>
|
||||
{tone === 'checking' ? <BusyRing /> : null}
|
||||
{tone === "checking" ? <BusyRing /> : null}
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+9
-8
@@ -1,4 +1,4 @@
|
||||
import { useRef, type KeyboardEvent } from 'react';
|
||||
import { useRef, type KeyboardEvent } from "react";
|
||||
|
||||
export interface TabItem<T extends string> {
|
||||
id: T;
|
||||
@@ -30,24 +30,26 @@ export function Tabs<T extends string>({
|
||||
}
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent<HTMLButtonElement>, id: T) {
|
||||
if (event.key === 'ArrowRight') {
|
||||
if (event.key === "ArrowRight") {
|
||||
event.preventDefault();
|
||||
moveFocus(id, 1);
|
||||
} else if (event.key === 'ArrowLeft') {
|
||||
} else if (event.key === "ArrowLeft") {
|
||||
event.preventDefault();
|
||||
moveFocus(id, -1);
|
||||
} else if (event.key === 'Home') {
|
||||
} 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') {
|
||||
} 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());
|
||||
window.requestAnimationFrame(() =>
|
||||
refs.current[items.length - 1]?.focus(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,7 +65,7 @@ export function Tabs<T extends string>({
|
||||
aria-controls={`panel-${item.id}`}
|
||||
aria-selected={active}
|
||||
tabIndex={active ? 0 : -1}
|
||||
className={`ui-tab ${active ? 'is-active' : ''}`.trim()}
|
||||
className={`ui-tab ${active ? "is-active" : ""}`.trim()}
|
||||
key={item.id}
|
||||
ref={(node) => {
|
||||
refs.current[index] = node;
|
||||
@@ -78,4 +80,3 @@ export function Tabs<T extends string>({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+28
-23
@@ -1,23 +1,28 @@
|
||||
export { ActionMenu } from './ActionMenu';
|
||||
export type { ActionMenuItem } from './ActionMenu';
|
||||
export { BusyRing } from './BusyRing';
|
||||
export type { BusyRingProps } from './BusyRing';
|
||||
export { Button } from './Button';
|
||||
export type { ButtonProps, ButtonSize, ButtonVariant } from './Button';
|
||||
export { DetailsPopover } from './DetailsPopover';
|
||||
export type { DetailsPopoverAlign, DetailsPopoverProps } from './DetailsPopover';
|
||||
export { Field } from './Field';
|
||||
export type { FieldProps } from './Field';
|
||||
export { HoverDetails } from './HoverDetails';
|
||||
export type { HoverDetailsProps } from './HoverDetails';
|
||||
export { IconButton } from './IconButton';
|
||||
export type { IconButtonProps, IconButtonVariant } from './IconButton';
|
||||
export { LogDock } from './LogDock';
|
||||
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';
|
||||
|
||||
export { ActionMenu } from "./ActionMenu";
|
||||
export type { ActionMenuItem } from "./ActionMenu";
|
||||
export { BusyRing } from "./BusyRing";
|
||||
export type { BusyRingProps } from "./BusyRing";
|
||||
export { Button } from "./Button";
|
||||
export type { ButtonProps, ButtonSize, ButtonVariant } from "./Button";
|
||||
export { DetailsPopover } from "./DetailsPopover";
|
||||
export type {
|
||||
DetailsPopoverAlign,
|
||||
DetailsPopoverProps,
|
||||
} from "./DetailsPopover";
|
||||
export { Field } from "./Field";
|
||||
export type { FieldProps } from "./Field";
|
||||
export { HoverDetails } from "./HoverDetails";
|
||||
export type { HoverDetailsProps } from "./HoverDetails";
|
||||
export { IconButton } from "./IconButton";
|
||||
export type { IconButtonProps, IconButtonVariant } from "./IconButton";
|
||||
export { LogDock } from "./LogDock";
|
||||
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