567 lines
21 KiB
TypeScript
567 lines
21 KiB
TypeScript
import {
|
||
useEffect,
|
||
useRef,
|
||
useState,
|
||
type ReactNode,
|
||
} from 'react';
|
||
import { ERROR_DEFINITIONS } from '../../../shared/errors.js';
|
||
import type { ProfileSnapshot, StateSnapshot } from '../../../shared/contracts/state.js';
|
||
import {
|
||
operationBlocked,
|
||
type OperationKey,
|
||
type OperationRegistrySnapshot,
|
||
} from '../../state/operations.js';
|
||
import {
|
||
isSubscriptionUrlValid,
|
||
subscriptionDaysLeft,
|
||
subscriptionDomain,
|
||
subscriptionUsage,
|
||
} from '../../utils/clientControls.js';
|
||
import { formatBytes } from '../../utils/format.js';
|
||
import { ConfirmationDialog } from '../../ui/ConfirmationDialog.js';
|
||
import { Drawer } from '../../ui/Drawer.js';
|
||
import { RailAction } from '../../ui/RailAction.js';
|
||
import type { RequestError } from './requestError.js';
|
||
|
||
interface ProfileError extends RequestError {
|
||
profileId?: string;
|
||
}
|
||
|
||
interface ServerPickerRenderState {
|
||
disabled: boolean;
|
||
leaving: boolean;
|
||
revealVersion: number;
|
||
anchorServerId: string;
|
||
selectedServerId: string;
|
||
}
|
||
|
||
interface SubscriptionFeatureOptions {
|
||
profiles: ProfileSnapshot[];
|
||
selection: StateSnapshot['selection'];
|
||
connected: boolean;
|
||
isGateway: boolean;
|
||
gatewayDirect: boolean;
|
||
operations: OperationRegistrySnapshot;
|
||
error?: ProfileError | null;
|
||
onAdd: (label: string, url: string) => Promise<unknown>;
|
||
onRefresh: (profileId: string) => Promise<unknown>;
|
||
onForget: (profileId: string, mode: 'delete' | 'stop-and-delete') => Promise<unknown>;
|
||
onDismissError: () => void;
|
||
}
|
||
|
||
function profileServer(profile: ProfileSnapshot | undefined, serverId: string) {
|
||
return profile?.servers.find((server) => server.id === serverId) || null;
|
||
}
|
||
|
||
export function useSubscriptionFeature({
|
||
profiles,
|
||
selection,
|
||
connected,
|
||
isGateway,
|
||
gatewayDirect,
|
||
operations,
|
||
error,
|
||
onAdd,
|
||
onRefresh,
|
||
onForget,
|
||
onDismissError,
|
||
}: SubscriptionFeatureOptions) {
|
||
const [open, setOpen] = useState(false);
|
||
const [expanded, setExpanded] = useState<string[]>([]);
|
||
const [closing, setClosing] = useState<string[]>([]);
|
||
const [visited, setVisited] = useState<string[]>([]);
|
||
const [adding, setAdding] = useState(profiles.length === 0);
|
||
const [addClosing, setAddClosing] = useState(false);
|
||
const [url, setUrl] = useState('');
|
||
const [deleteId, setDeleteId] = useState('');
|
||
const [refreshingIds, setRefreshingIds] = useState<string[]>([]);
|
||
const [revealVersions, setRevealVersions] = useState<Record<string, number>>({});
|
||
const panelRef = useRef<HTMLElement>(null);
|
||
const toggleRef = useRef<HTMLButtonElement>(null);
|
||
const closeRef = useRef<HTMLButtonElement>(null);
|
||
const labelRef = useRef<HTMLInputElement>(null);
|
||
const addFormRef = useRef<HTMLFormElement>(null);
|
||
const collapseTimersRef = useRef(new Map<string, ReturnType<typeof setTimeout>>());
|
||
const addCloseTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||
const deleteIdRef = useRef(deleteId);
|
||
const previousProfileCountRef = useRef(profiles.length);
|
||
deleteIdRef.current = deleteId;
|
||
|
||
const normalizedUrl = url.trim();
|
||
const addError = !error?.profileId && error?.context === 'subscription' ? error : null;
|
||
const validationStatus = !normalizedUrl
|
||
? 'idle'
|
||
: isSubscriptionUrlValid(normalizedUrl) ? 'valid' : 'invalid';
|
||
const deleteProfile = profiles.find((profile) => profile.id === deleteId) || null;
|
||
const deleteStopsVpn = connected && selection.appliedProfileId === deleteId;
|
||
useEffect(() => {
|
||
const ids = new Set(profiles.map((profile) => profile.id));
|
||
setExpanded((current) => current.filter((id) => ids.has(id)));
|
||
setClosing((current) => current.filter((id) => ids.has(id)));
|
||
setVisited((current) => current.filter((id) => ids.has(id)));
|
||
if (profiles.length === 0) {
|
||
setAdding(true);
|
||
setAddClosing(false);
|
||
if (previousProfileCountRef.current > 0) setOpen(false);
|
||
}
|
||
if (previousProfileCountRef.current === 0 && profiles.length > 0) {
|
||
const first = profiles[0].id;
|
||
setOpen(true);
|
||
setAdding(false);
|
||
setExpanded((current) => current.includes(first) ? current : [...current, first]);
|
||
setVisited((current) => current.includes(first) ? current : [...current, first]);
|
||
}
|
||
previousProfileCountRef.current = profiles.length;
|
||
}, [profiles, isGateway]);
|
||
|
||
useEffect(() => {
|
||
if (!adding || (profiles.length > 0 && !open)) return undefined;
|
||
const frame = requestAnimationFrame(() => labelRef.current?.focus());
|
||
return () => cancelAnimationFrame(frame);
|
||
}, [adding, open, profiles.length, isGateway]);
|
||
|
||
useEffect(() => {
|
||
if (!open) return undefined;
|
||
const frame = requestAnimationFrame(() => closeRef.current?.focus());
|
||
const closeDrawer = (event: PointerEvent | KeyboardEvent) => {
|
||
if (deleteIdRef.current) return;
|
||
if (event.type === 'keydown' && (event as KeyboardEvent).key !== 'Escape') return;
|
||
const target = event.target as Node | null;
|
||
if (event.type !== 'keydown' && (
|
||
panelRef.current?.contains(target) || toggleRef.current?.contains(target)
|
||
)) return;
|
||
setOpen(false);
|
||
};
|
||
document.addEventListener('pointerdown', closeDrawer);
|
||
document.addEventListener('keydown', closeDrawer);
|
||
return () => {
|
||
cancelAnimationFrame(frame);
|
||
document.removeEventListener('pointerdown', closeDrawer);
|
||
document.removeEventListener('keydown', closeDrawer);
|
||
requestAnimationFrame(() => {
|
||
if (panelRef.current?.contains(document.activeElement)) toggleRef.current?.focus();
|
||
});
|
||
};
|
||
}, [open]);
|
||
|
||
useEffect(() => {
|
||
if (!adding || profiles.length === 0) return undefined;
|
||
const closeAdd = (event: PointerEvent | KeyboardEvent) => {
|
||
if (event.type === 'keydown' && (event as KeyboardEvent).key !== 'Escape') return;
|
||
const target = event.target as Node | null;
|
||
if (event.type !== 'keydown' && addFormRef.current?.contains(target)) return;
|
||
resetAdd(Boolean(target && panelRef.current?.contains(target)));
|
||
};
|
||
document.addEventListener('pointerdown', closeAdd);
|
||
document.addEventListener('keydown', closeAdd);
|
||
return () => {
|
||
document.removeEventListener('pointerdown', closeAdd);
|
||
document.removeEventListener('keydown', closeAdd);
|
||
};
|
||
}, [adding, profiles.length]);
|
||
|
||
useEffect(() => () => {
|
||
for (const timer of collapseTimersRef.current.values()) clearTimeout(timer);
|
||
if (addCloseTimerRef.current) clearTimeout(addCloseTimerRef.current);
|
||
}, []);
|
||
|
||
function resetAdd(restoreFocus = true) {
|
||
if (!profiles.length) {
|
||
setUrl('');
|
||
return;
|
||
}
|
||
setAdding(false);
|
||
const finish = () => {
|
||
setAddClosing(false);
|
||
setUrl('');
|
||
addCloseTimerRef.current = null;
|
||
if (restoreFocus) requestAnimationFrame(() => (
|
||
document.getElementById('client-profile-add-trigger')?.focus()
|
||
));
|
||
};
|
||
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||
finish();
|
||
return;
|
||
}
|
||
setAddClosing(true);
|
||
if (addCloseTimerRef.current) clearTimeout(addCloseTimerRef.current);
|
||
addCloseTimerRef.current = setTimeout(finish, 320);
|
||
}
|
||
|
||
function showAdd() {
|
||
if (addCloseTimerRef.current) clearTimeout(addCloseTimerRef.current);
|
||
addCloseTimerRef.current = null;
|
||
setAddClosing(false);
|
||
setAdding(true);
|
||
onDismissError();
|
||
}
|
||
|
||
async function addProfile() {
|
||
if (validationStatus !== 'valid') return;
|
||
const host = new URL(normalizedUrl).hostname;
|
||
const usedLabels = new Set(profiles.map((profile) => profile.label.toLocaleLowerCase('ru-RU')));
|
||
let label = host;
|
||
for (let suffix = 2; usedLabels.has(label.toLocaleLowerCase('ru-RU')); suffix += 1) {
|
||
label = `${host} ${suffix}`;
|
||
}
|
||
if (!await onAdd(label, normalizedUrl)) return;
|
||
resetAdd();
|
||
}
|
||
|
||
async function refresh(profileId: string) {
|
||
const startedAt = performance.now();
|
||
setRefreshingIds((current) => current.includes(profileId) ? current : [...current, profileId]);
|
||
try {
|
||
if (!await onRefresh(profileId)) return;
|
||
setRevealVersions((current) => ({
|
||
...current,
|
||
[profileId]: (current[profileId] || 0) + 1,
|
||
}));
|
||
} finally {
|
||
const elapsed = performance.now() - startedAt;
|
||
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||
const completeAt = reduced ? elapsed : Math.max(900, Math.ceil(elapsed / 900) * 900);
|
||
await new Promise((resolve) => setTimeout(resolve, Math.max(0, completeAt - elapsed)));
|
||
setRefreshingIds((current) => current.filter((id) => id !== profileId));
|
||
}
|
||
}
|
||
|
||
async function confirmDelete() {
|
||
if (!deleteProfile) return;
|
||
if (!await onForget(deleteProfile.id, deleteStopsVpn ? 'stop-and-delete' : 'delete')) return;
|
||
setDeleteId('');
|
||
}
|
||
|
||
function toggleProfile(profileId: string) {
|
||
const activeTimer = collapseTimersRef.current.get(profileId);
|
||
if (activeTimer) clearTimeout(activeTimer);
|
||
collapseTimersRef.current.delete(profileId);
|
||
setVisited((current) => current.includes(profileId) ? current : [...current, profileId]);
|
||
if (!expanded.includes(profileId)) {
|
||
setClosing((current) => current.filter((id) => id !== profileId));
|
||
setExpanded((current) => current.includes(profileId) ? current : [...current, profileId]);
|
||
return;
|
||
}
|
||
setExpanded((current) => current.filter((id) => id !== profileId));
|
||
if (matchMedia('(prefers-reduced-motion: reduce)').matches) return;
|
||
setClosing((current) => current.includes(profileId) ? current : [...current, profileId]);
|
||
collapseTimersRef.current.set(profileId, setTimeout(() => {
|
||
setClosing((current) => current.filter((id) => id !== profileId));
|
||
collapseTimersRef.current.delete(profileId);
|
||
}, 950));
|
||
}
|
||
|
||
return {
|
||
profiles,
|
||
selection,
|
||
connected,
|
||
isGateway,
|
||
gatewayDirect,
|
||
operations,
|
||
error,
|
||
open,
|
||
expanded,
|
||
closing,
|
||
visited,
|
||
adding,
|
||
addClosing,
|
||
url,
|
||
validationStatus,
|
||
addError,
|
||
deleteProfile,
|
||
deleteStopsVpn,
|
||
refreshingIds,
|
||
revealVersions,
|
||
panelRef,
|
||
toggleRef,
|
||
closeRef,
|
||
labelRef,
|
||
addFormRef,
|
||
addBlocked: operationBlocked(operations, 'profileAdd'),
|
||
refreshBlocked: operationBlocked(operations, 'profileRefresh'),
|
||
deleteBlocked: operationBlocked(operations, 'profileDelete'),
|
||
toggle: () => setOpen((current) => !current),
|
||
close: () => setOpen(false),
|
||
showAdd,
|
||
cancelAdd: () => resetAdd(),
|
||
setUrl: (value: string) => {
|
||
setUrl(value);
|
||
onDismissError();
|
||
},
|
||
addProfile,
|
||
toggleProfile,
|
||
requestDelete: (profileId: string) => setDeleteId(profileId),
|
||
cancelDelete: () => setDeleteId(''),
|
||
confirmDelete,
|
||
refresh,
|
||
};
|
||
}
|
||
|
||
type SubscriptionFeatureController = ReturnType<typeof useSubscriptionFeature>;
|
||
|
||
export function SubscriptionToggle({
|
||
feature,
|
||
onToggle,
|
||
}: {
|
||
feature: SubscriptionFeatureController;
|
||
onToggle: () => void;
|
||
}) {
|
||
return <RailAction
|
||
buttonRef={feature.toggleRef}
|
||
className="client-instructions-toggle client-subscription-toggle"
|
||
open={feature.open}
|
||
controls="client-subscription-drawer"
|
||
ariaLabel={feature.open ? 'Закрыть подписки' : 'Управление подписками'}
|
||
label="Подписки"
|
||
onClick={onToggle}
|
||
>
|
||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||
<path d="M5 5h14v14H5zM8 9h8M8 13h5" />
|
||
</svg>
|
||
</RailAction>;
|
||
}
|
||
|
||
function AddProfileForm({ feature }: { feature: SubscriptionFeatureController }) {
|
||
const message = feature.validationStatus === 'invalid'
|
||
? ERROR_DEFINITIONS.SUBSCRIPTION_INVALID.message
|
||
: feature.addError?.message || '';
|
||
return <form
|
||
ref={feature.addFormRef}
|
||
className={`client-profile-add${feature.addClosing ? ' is-closing' : ''}`}
|
||
autoComplete="off"
|
||
aria-hidden={feature.addClosing}
|
||
inert={feature.addClosing ? true : undefined}
|
||
onSubmit={(event) => {
|
||
event.preventDefault();
|
||
feature.addProfile();
|
||
}}
|
||
>
|
||
<input
|
||
ref={feature.labelRef}
|
||
type="url"
|
||
inputMode="url"
|
||
value={feature.url}
|
||
placeholder="https://…"
|
||
aria-label="Ссылка подписки"
|
||
aria-invalid={feature.validationStatus === 'invalid'}
|
||
onChange={(event) => feature.setUrl(event.target.value)}
|
||
/>
|
||
{message && <div className="client-profile-form-status" role="status">{message}</div>}
|
||
<div className="client-profile-form-actions">
|
||
{feature.profiles.length > 0 && <button type="button" onClick={feature.cancelAdd}>Отмена</button>}
|
||
<button
|
||
className="is-primary"
|
||
type="submit"
|
||
disabled={feature.addBlocked || feature.validationStatus !== 'valid'}
|
||
>Добавить</button>
|
||
</div>
|
||
</form>;
|
||
}
|
||
|
||
function ProfileUsage({ profile }: { profile: ProfileSnapshot }) {
|
||
const usage = subscriptionUsage(profile.subscription.userInfo);
|
||
const expires = usage.expiresAt && !Number.isNaN(usage.expiresAt.getTime())
|
||
? subscriptionDaysLeft(usage.expiresAt)
|
||
: '';
|
||
if (!usage.used && !usage.total && !expires) return null;
|
||
return <span className="client-profile-usage">
|
||
{formatBytes(usage.used)}{usage.total ? ` / ${formatBytes(usage.total)}` : ''}{expires ? ` · ${expires}` : ''}
|
||
</span>;
|
||
}
|
||
|
||
function ProfileGroup({
|
||
feature,
|
||
profile,
|
||
renderServerPicker,
|
||
}: {
|
||
feature: SubscriptionFeatureController;
|
||
profile: ProfileSnapshot;
|
||
renderServerPicker: (profile: ProfileSnapshot, state: ServerPickerRenderState) => ReactNode;
|
||
}) {
|
||
const expired = profile.subscription.errorCode === 'SUBSCRIPTION_EXPIRED';
|
||
const expanded = !expired && feature.expanded.includes(profile.id);
|
||
const closing = feature.closing.includes(profile.id);
|
||
const visited = feature.visited.includes(profile.id);
|
||
const applied = feature.connected
|
||
&& !feature.gatewayDirect
|
||
&& feature.selection.appliedProfileId === profile.id;
|
||
const desired = !feature.connected
|
||
&& !feature.gatewayDirect
|
||
&& feature.selection.desiredProfileId === profile.id;
|
||
const profileError = feature.error?.profileId === profile.id ? feature.error : null;
|
||
const refreshing = feature.refreshingIds.includes(profile.id)
|
||
|| feature.operations.profileRefresh?.target === profile.id;
|
||
const controlsBlocked = operationBlocked(feature.operations, 'profileSelect');
|
||
const visibleServerId = applied
|
||
? feature.selection.appliedServerId
|
||
: desired ? profile.desiredServerId : '';
|
||
const visibleServer = profileServer(profile, visibleServerId)
|
||
|| (applied && feature.selection.appliedServerSnapshot?.id === visibleServerId
|
||
? feature.selection.appliedServerSnapshot
|
||
: null);
|
||
const visibleServerLocation = [visibleServer?.city, visibleServer?.country]
|
||
.filter((value): value is string => typeof value === 'string' && Boolean(value))
|
||
.join(' · ');
|
||
const localStatus = !expired && (profileError?.message || (profile.subscription.status === 'stale'
|
||
? `Последнее обновление не удалось. Данные от ${profile.subscription.fetchedAt
|
||
? new Date(profile.subscription.fetchedAt).toLocaleString('ru-RU', { dateStyle: 'short', timeStyle: 'short' })
|
||
: 'предыдущей загрузки'}.`
|
||
: ''));
|
||
const profileDomain = subscriptionDomain(profile.subscription.host);
|
||
const profileDetails = expired
|
||
? 'Срок действия подписки истёк'
|
||
: `Серверов: ${profile.servers.length}${profile.subscription.fetchedAt
|
||
? ` · обновлено ${new Date(profile.subscription.fetchedAt).toLocaleDateString('ru-RU')}`
|
||
: ''}`;
|
||
const profileTitle = <span className="client-profile-title">
|
||
<span>
|
||
<strong>{profileDomain}</strong>
|
||
{(applied || desired) && <em>{applied ? 'АКТИВНА' : 'ВЫБРАНА'}</em>}
|
||
</span>
|
||
<small>{profileDetails}</small>
|
||
</span>;
|
||
return <section className={`client-profile-group${expanded ? ' is-expanded' : ''}${applied ? ' is-active' : ''}${expired ? ' is-expired' : ''}`}>
|
||
<div className="client-profile-header">
|
||
{expired ? <div className="client-profile-disclosure is-static">
|
||
{profileTitle}
|
||
</div> : <button
|
||
className="client-profile-disclosure"
|
||
type="button"
|
||
aria-expanded={expanded}
|
||
aria-controls={`client-profile-${profile.id}`}
|
||
onClick={() => feature.toggleProfile(profile.id)}
|
||
>
|
||
<span className="client-profile-chevron" aria-hidden="true">›</span>
|
||
{profileTitle}
|
||
</button>}
|
||
{!expired && (applied || desired) && <ProfileUsage profile={profile} />}
|
||
<button
|
||
className={`client-profile-refresh${refreshing ? ' is-refreshing' : ''}`}
|
||
type="button"
|
||
aria-label={`Обновить подписку ${profileDomain}`}
|
||
disabled={feature.refreshBlocked || refreshing}
|
||
onClick={() => feature.refresh(profile.id)}
|
||
>
|
||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||
<path d="M21 12a9 9 0 0 0-15.2-6.5L3 8m0-5v5h5M3 12a9 9 0 0 0 15.2 6.5L21 16m0 5v-5h-5" />
|
||
</svg>
|
||
</button>
|
||
<button
|
||
className="client-profile-delete"
|
||
type="button"
|
||
aria-label={`Удалить подписку ${profileDomain}`}
|
||
disabled={feature.deleteBlocked}
|
||
onClick={() => feature.requestDelete(profile.id)}
|
||
>
|
||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||
<path className="client-profile-delete-lid" d="M8 7V5h8v2m-11 0h14" />
|
||
<path d="M7 7l1 13h8l1-13M10 10v7m4-7v7" />
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
|
||
{localStatus && <div className="client-profile-local-status" role="status">{localStatus}</div>}
|
||
|
||
{!expired && visibleServer && <button
|
||
className="client-profile-selected-server"
|
||
type="button"
|
||
aria-expanded={expanded}
|
||
aria-controls={`client-profile-${profile.id}`}
|
||
onClick={() => feature.toggleProfile(profile.id)}
|
||
>
|
||
<span aria-hidden="true" />
|
||
<strong>{visibleServer.label}</strong>
|
||
{visibleServerLocation && <small>{visibleServerLocation}</small>}
|
||
</button>}
|
||
|
||
{!expired && visited && <div
|
||
id={`client-profile-${profile.id}`}
|
||
className={`client-profile-body${expanded ? ' is-open' : ''}${closing ? ' is-closing' : ''}`}
|
||
aria-hidden={!expanded}
|
||
inert={!expanded ? true : undefined}
|
||
>
|
||
<div className="client-profile-body-inner">
|
||
{!localStatus && renderServerPicker(profile, {
|
||
disabled: controlsBlocked,
|
||
leaving: false,
|
||
revealVersion: feature.revealVersions[profile.id] || 0,
|
||
anchorServerId: visibleServer?.id || '',
|
||
selectedServerId: visibleServerId,
|
||
})}
|
||
</div>
|
||
</div>}
|
||
</section>;
|
||
}
|
||
|
||
export function SubscriptionPanel({
|
||
feature,
|
||
statusSlot,
|
||
renderServerPicker,
|
||
}: {
|
||
feature: SubscriptionFeatureController;
|
||
statusSlot?: ReactNode;
|
||
renderServerPicker: (profile: ProfileSnapshot, state: ServerPickerRenderState) => ReactNode;
|
||
}) {
|
||
const drawerOpen = feature.open && feature.profiles.length > 0;
|
||
|
||
return <>
|
||
{feature.profiles.length === 0 && <div className="client-form client-subscription-first-run">
|
||
<div className="client-first-run-copy">
|
||
<span>Подписки</span>
|
||
<strong>Добавьте первую подписку</strong>
|
||
</div>
|
||
<AddProfileForm feature={feature} />
|
||
</div>}
|
||
|
||
<Drawer
|
||
panelRef={feature.panelRef}
|
||
closeRef={feature.closeRef}
|
||
id="client-subscription-drawer"
|
||
className="client-subscription-drawer"
|
||
sheetClassName="client-subscription-sheet"
|
||
open={drawerOpen}
|
||
label="Управление подписками"
|
||
closeLabel="Закрыть подписки"
|
||
onClose={feature.close}
|
||
>
|
||
<header className="client-profiles-header">
|
||
<h2>ПОДПИСКИ</h2>
|
||
</header>
|
||
{statusSlot}
|
||
<div className="client-profile-list">
|
||
{feature.profiles.map((profile) => <ProfileGroup
|
||
key={profile.id}
|
||
feature={feature}
|
||
profile={profile}
|
||
renderServerPicker={renderServerPicker}
|
||
/>)}
|
||
</div>
|
||
{(feature.adding || feature.addClosing) && feature.profiles.length > 0
|
||
? <AddProfileForm feature={feature} />
|
||
: <button
|
||
id="client-profile-add-trigger"
|
||
className="client-profile-add-trigger"
|
||
type="button"
|
||
onClick={feature.showAdd}
|
||
>+ Добавить подписку</button>}
|
||
</Drawer>
|
||
</>;
|
||
}
|
||
|
||
export function SubscriptionDeleteDialog({ feature }: { feature: SubscriptionFeatureController }) {
|
||
return <ConfirmationDialog
|
||
open={Boolean(feature.deleteProfile)}
|
||
id="delete-subscription"
|
||
kicker="Необратимое действие"
|
||
title={`Удалить «${subscriptionDomain(feature.deleteProfile?.subscription.host)}»?`}
|
||
description={feature.deleteStopsVpn
|
||
? 'Эта подписка сейчас работает. Harbor остановит VPN и удалит её одной операцией.'
|
||
: 'Подписка и её локальные настройки будут удалены. Работающий VPN другой подписки не изменится.'}
|
||
cancelLabel="Отмена"
|
||
confirmLabel={feature.deleteStopsVpn ? 'Остановить VPN и удалить' : 'Удалить'}
|
||
busy={feature.deleteBlocked}
|
||
onCancel={feature.cancelDelete}
|
||
onConfirm={feature.confirmDelete}
|
||
/>;
|
||
}
|