603 lines
22 KiB
TypeScript
603 lines
22 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 type { RequestError } from './requestError.js';
|
||
|
||
interface ProfileError extends RequestError {
|
||
profileId?: string;
|
||
}
|
||
|
||
interface ServerPickerRenderState {
|
||
disabled: boolean;
|
||
leaving: boolean;
|
||
revealVersion: number;
|
||
}
|
||
|
||
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>;
|
||
onRename: (profileId: string, label: string) => Promise<unknown>;
|
||
onRefresh: (profileId: string) => Promise<unknown>;
|
||
onForget: (profileId: string, mode: 'delete' | 'stop-and-delete') => Promise<unknown>;
|
||
onActivate: (profileId: string) => Promise<unknown>;
|
||
onDismissError: () => void;
|
||
}
|
||
|
||
const foldLabel = (value: string) => value.trim().toLocaleLowerCase('ru-RU');
|
||
|
||
function operationText(key: OperationKey) {
|
||
return ({
|
||
profileAdd: 'Добавляем подписку…',
|
||
profileRename: 'Переименовываем подписку…',
|
||
profileSelect: 'Сохраняем выбор сервера…',
|
||
profileActivate: 'Переключаем подписку…',
|
||
profileRefresh: 'Обновляем подписку…',
|
||
profileDelete: 'Удаляем подписку…',
|
||
serverApply: 'Применяем сервер…',
|
||
} as Partial<Record<OperationKey, string>>)[key] || '';
|
||
}
|
||
|
||
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,
|
||
onRename,
|
||
onRefresh,
|
||
onForget,
|
||
onActivate,
|
||
onDismissError,
|
||
}: SubscriptionFeatureOptions) {
|
||
const [open, setOpen] = useState(false);
|
||
const [expanded, setExpanded] = useState<string[]>([]);
|
||
const [visited, setVisited] = useState<string[]>([]);
|
||
const [adding, setAdding] = useState(profiles.length === 0);
|
||
const [label, setLabel] = useState('');
|
||
const [url, setUrl] = useState('');
|
||
const [renamingId, setRenamingId] = useState('');
|
||
const [renameLabel, setRenameLabel] = useState('');
|
||
const [menuId, setMenuId] = useState('');
|
||
const [deleteId, setDeleteId] = useState('');
|
||
const [refreshingIds, setRefreshingIds] = useState<string[]>([]);
|
||
const [revealVersions, setRevealVersions] = useState<Record<string, number>>({});
|
||
const panelRef = useRef<HTMLDivElement>(null);
|
||
const toggleRef = useRef<HTMLButtonElement>(null);
|
||
const closeRef = useRef<HTMLButtonElement>(null);
|
||
const labelRef = useRef<HTMLInputElement>(null);
|
||
const addInvokerRef = useRef<HTMLElement | null>(null);
|
||
const deleteIdRef = useRef(deleteId);
|
||
const previousProfileCountRef = useRef(profiles.length);
|
||
deleteIdRef.current = deleteId;
|
||
|
||
const normalizedUrl = url.trim();
|
||
const normalizedLabel = label.trim();
|
||
const duplicateLabel = Boolean(normalizedLabel && profiles.some(
|
||
(profile) => foldLabel(profile.label) === foldLabel(normalizedLabel),
|
||
));
|
||
const addError = !error?.profileId && error?.context === 'subscription' ? error : null;
|
||
const validationStatus = !normalizedUrl
|
||
? 'idle'
|
||
: isSubscriptionUrlValid(normalizedUrl) ? 'valid' : 'invalid';
|
||
const renameDuplicate = Boolean(renameLabel.trim() && profiles.some((profile) => (
|
||
profile.id !== renamingId && foldLabel(profile.label) === foldLabel(renameLabel)
|
||
)));
|
||
const deleteProfile = profiles.find((profile) => profile.id === deleteId) || null;
|
||
const deleteStopsVpn = connected && selection.appliedProfileId === deleteId;
|
||
const activeOperation = (Object.entries(operations) as Array<[
|
||
OperationKey,
|
||
OperationRegistrySnapshot[OperationKey],
|
||
]>).find(([, operation]) => operation?.status === 'running');
|
||
|
||
useEffect(() => {
|
||
const ids = new Set(profiles.map((profile) => profile.id));
|
||
setExpanded((current) => current.filter((id) => ids.has(id)));
|
||
setVisited((current) => current.filter((id) => ids.has(id)));
|
||
if (profiles.length === 0) {
|
||
setAdding(true);
|
||
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);
|
||
setMenuId('');
|
||
};
|
||
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]);
|
||
|
||
function resetAdd() {
|
||
setLabel('');
|
||
setUrl('');
|
||
if (profiles.length) setAdding(false);
|
||
const invoker = addInvokerRef.current;
|
||
addInvokerRef.current = null;
|
||
if (invoker) requestAnimationFrame(() => invoker.focus());
|
||
}
|
||
|
||
async function addProfile() {
|
||
if (!normalizedLabel || duplicateLabel || validationStatus !== 'valid') return;
|
||
if (!await onAdd(normalizedLabel, normalizedUrl)) return;
|
||
resetAdd();
|
||
}
|
||
|
||
function startRename(profile: ProfileSnapshot) {
|
||
setMenuId('');
|
||
setRenamingId(profile.id);
|
||
setRenameLabel(profile.label);
|
||
onDismissError();
|
||
}
|
||
|
||
function cancelRename() {
|
||
const profileId = renamingId;
|
||
setRenamingId('');
|
||
requestAnimationFrame(() => document.getElementById(`client-profile-menu-${profileId}`)?.focus());
|
||
}
|
||
|
||
async function saveRename() {
|
||
const next = renameLabel.trim();
|
||
if (!next || renameDuplicate) return;
|
||
if (!await onRename(renamingId, next)) return;
|
||
cancelRename();
|
||
}
|
||
|
||
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('');
|
||
setMenuId('');
|
||
}
|
||
|
||
function toggleProfile(profileId: string) {
|
||
setVisited((current) => current.includes(profileId) ? current : [...current, profileId]);
|
||
setExpanded((current) => current.includes(profileId)
|
||
? current.filter((id) => id !== profileId)
|
||
: [...current, profileId]);
|
||
}
|
||
|
||
return {
|
||
profiles,
|
||
selection,
|
||
connected,
|
||
isGateway,
|
||
gatewayDirect,
|
||
operations,
|
||
error,
|
||
open,
|
||
expanded,
|
||
visited,
|
||
adding,
|
||
label,
|
||
url,
|
||
validationStatus,
|
||
duplicateLabel,
|
||
addError,
|
||
renamingId,
|
||
renameLabel,
|
||
renameDuplicate,
|
||
menuId,
|
||
deleteProfile,
|
||
deleteStopsVpn,
|
||
activeOperation,
|
||
refreshingIds,
|
||
revealVersions,
|
||
panelRef,
|
||
toggleRef,
|
||
closeRef,
|
||
labelRef,
|
||
addBlocked: operationBlocked(operations, 'profileAdd'),
|
||
renameBlocked: operationBlocked(operations, 'profileRename'),
|
||
refreshBlocked: operationBlocked(operations, 'profileRefresh'),
|
||
deleteBlocked: operationBlocked(operations, 'profileDelete'),
|
||
activateBlocked: operationBlocked(operations, 'profileActivate'),
|
||
toggle: () => setOpen((current) => !current),
|
||
close: () => { setOpen(false); setMenuId(''); },
|
||
showAdd: () => {
|
||
addInvokerRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||
setAdding(true);
|
||
setMenuId('');
|
||
onDismissError();
|
||
},
|
||
cancelAdd: resetAdd,
|
||
setLabel: (value: string) => { setLabel(value); onDismissError(); },
|
||
setUrl: (value: string) => {
|
||
setUrl(value);
|
||
onDismissError();
|
||
},
|
||
addProfile,
|
||
toggleProfile,
|
||
startRename,
|
||
setRenameLabel,
|
||
cancelRename,
|
||
saveRename,
|
||
toggleMenu: (profileId: string) => setMenuId((current) => current === profileId ? '' : profileId),
|
||
requestDelete: (profileId: string) => setDeleteId(profileId),
|
||
cancelDelete: () => setDeleteId(''),
|
||
confirmDelete,
|
||
refresh,
|
||
onActivate,
|
||
};
|
||
}
|
||
|
||
type SubscriptionFeatureController = ReturnType<typeof useSubscriptionFeature>;
|
||
|
||
export function SubscriptionToggle({
|
||
feature,
|
||
onToggle,
|
||
}: {
|
||
feature: SubscriptionFeatureController;
|
||
onToggle: () => void;
|
||
}) {
|
||
return <button
|
||
ref={feature.toggleRef}
|
||
className={`client-instructions-toggle client-subscription-toggle${feature.open ? ' is-open' : ''}`}
|
||
type="button"
|
||
aria-expanded={feature.open}
|
||
aria-controls="client-subscription-drawer"
|
||
aria-label={feature.open ? 'Закрыть подписки' : 'Управление подписками'}
|
||
onClick={onToggle}
|
||
>
|
||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||
<path d="M5 5h14v14H5zM8 9h8M8 13h5" />
|
||
</svg>
|
||
<span>Подписки</span>
|
||
</button>;
|
||
}
|
||
|
||
function AddProfileForm({ feature }: { feature: SubscriptionFeatureController }) {
|
||
const message = feature.duplicateLabel
|
||
? 'Такое имя уже используется.'
|
||
: feature.validationStatus === 'invalid'
|
||
? ERROR_DEFINITIONS.SUBSCRIPTION_INVALID.message
|
||
: feature.addError?.message || '';
|
||
return <form
|
||
className="client-profile-add"
|
||
autoComplete="off"
|
||
onSubmit={(event) => {
|
||
event.preventDefault();
|
||
feature.addProfile();
|
||
}}
|
||
>
|
||
<label>
|
||
<span>Название</span>
|
||
<input
|
||
ref={feature.labelRef}
|
||
value={feature.label}
|
||
maxLength={48}
|
||
placeholder="Например, Личный"
|
||
aria-invalid={feature.duplicateLabel}
|
||
onChange={(event) => feature.setLabel(event.target.value)}
|
||
/>
|
||
</label>
|
||
<label>
|
||
<span>Ссылка подписки</span>
|
||
<input
|
||
type="url"
|
||
inputMode="url"
|
||
value={feature.url}
|
||
placeholder="https://…"
|
||
aria-invalid={feature.validationStatus === 'invalid'}
|
||
onChange={(event) => feature.setUrl(event.target.value)}
|
||
/>
|
||
</label>
|
||
<div className="client-profile-form-status" role="status">
|
||
{message || '\u00a0'}
|
||
</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.label.trim() || feature.duplicateLabel || 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 expanded = feature.expanded.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 selectedServer = profileServer(profile, profile.desiredServerId);
|
||
return <section className={`client-profile-group${expanded ? ' is-expanded' : ''}${applied ? ' is-active' : ''}`}>
|
||
<div className="client-profile-header">
|
||
<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>
|
||
<span className="client-profile-title">
|
||
<span>
|
||
<strong>{profile.label}</strong>
|
||
{(applied || desired) && <em>{applied ? 'АКТИВНА' : 'ВЫБРАНА'}</em>}
|
||
</span>
|
||
<small>{subscriptionDomain(profile.subscription.host)}</small>
|
||
</span>
|
||
</button>
|
||
<ProfileUsage profile={profile} />
|
||
<button
|
||
className={`client-profile-refresh${refreshing ? ' is-refreshing' : ''}`}
|
||
type="button"
|
||
aria-label={`Обновить подписку ${profile.label}`}
|
||
disabled={feature.refreshBlocked || refreshing}
|
||
onClick={() => feature.refresh(profile.id)}
|
||
>↻</button>
|
||
<div className="client-profile-menu-wrap">
|
||
<button
|
||
id={`client-profile-menu-${profile.id}`}
|
||
className="client-profile-menu-toggle"
|
||
type="button"
|
||
aria-label={`Действия с подпиской ${profile.label}`}
|
||
aria-expanded={feature.menuId === profile.id}
|
||
onClick={() => feature.toggleMenu(profile.id)}
|
||
>⋮</button>
|
||
{feature.menuId === profile.id && <div className="client-profile-menu">
|
||
<button type="button" onClick={() => feature.startRename(profile)}>Переименовать</button>
|
||
<button type="button" className="is-danger" onClick={() => feature.requestDelete(profile.id)}>Удалить</button>
|
||
</div>}
|
||
</div>
|
||
</div>
|
||
|
||
{feature.renamingId === profile.id && <form
|
||
className="client-profile-rename"
|
||
onSubmit={(event) => { event.preventDefault(); feature.saveRename(); }}
|
||
>
|
||
<input
|
||
autoFocus
|
||
value={feature.renameLabel}
|
||
maxLength={48}
|
||
aria-label="Новое название подписки"
|
||
aria-invalid={feature.renameDuplicate}
|
||
onChange={(event) => feature.setRenameLabel(event.target.value)}
|
||
onKeyDown={(event) => {
|
||
if (event.key !== 'Escape') return;
|
||
event.stopPropagation();
|
||
feature.cancelRename();
|
||
}}
|
||
/>
|
||
<button type="submit" disabled={feature.renameBlocked || !feature.renameLabel.trim() || feature.renameDuplicate}>Сохранить</button>
|
||
<button type="button" onClick={feature.cancelRename}>Отмена</button>
|
||
<span role="status">{feature.renameDuplicate ? 'Такое имя уже используется.' : ''}</span>
|
||
</form>}
|
||
|
||
<div className="client-profile-local-status" role="status">
|
||
{profileError?.message || (profile.subscription.status === 'stale'
|
||
? `Последнее обновление не удалось. Данные от ${profile.subscription.fetchedAt
|
||
? new Date(profile.subscription.fetchedAt).toLocaleString('ru-RU', { dateStyle: 'short', timeStyle: 'short' })
|
||
: 'предыдущей загрузки'}.`
|
||
: '\u00a0')}
|
||
</div>
|
||
|
||
{visited && <div
|
||
id={`client-profile-${profile.id}`}
|
||
className="client-profile-body"
|
||
aria-hidden={!expanded}
|
||
inert={!expanded ? true : undefined}
|
||
>
|
||
{!applied && feature.selection.desiredProfileId !== profile.id && <button
|
||
className="client-profile-activate"
|
||
type="button"
|
||
disabled={feature.activateBlocked || !selectedServer}
|
||
onClick={() => feature.onActivate(profile.id)}
|
||
>Сделать активной</button>}
|
||
{!profile.desiredServerId && <p className="client-profile-server-hint">Выберите сервер этой подписки</p>}
|
||
{renderServerPicker(profile, {
|
||
disabled: controlsBlocked,
|
||
leaving: false,
|
||
revealVersion: feature.revealVersions[profile.id] || 0,
|
||
})}
|
||
</div>}
|
||
</section>;
|
||
}
|
||
|
||
export function SubscriptionPanel({
|
||
feature,
|
||
statusSlot,
|
||
renderServerPicker,
|
||
}: {
|
||
feature: SubscriptionFeatureController;
|
||
statusSlot?: ReactNode;
|
||
renderServerPicker: (profile: ProfileSnapshot, state: ServerPickerRenderState) => ReactNode;
|
||
}) {
|
||
const currentProfileId = feature.connected
|
||
? feature.selection.appliedProfileId
|
||
: feature.selection.desiredProfileId;
|
||
const currentProfile = feature.profiles.find((profile) => profile.id === currentProfileId);
|
||
const currentServerId = feature.connected
|
||
? feature.selection.appliedServerId
|
||
: currentProfile?.desiredServerId || '';
|
||
const currentServer = profileServer(currentProfile, currentServerId)
|
||
|| (feature.connected && feature.selection.appliedServerSnapshot?.id === currentServerId
|
||
? feature.selection.appliedServerSnapshot
|
||
: null);
|
||
const currentLabel = feature.gatewayDirect
|
||
? 'Gateway · сервер не определён'
|
||
: currentProfile && currentServer
|
||
? `${currentProfile.label} · ${currentServer.label}`
|
||
: 'сервер не выбран';
|
||
|
||
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>}
|
||
|
||
<div
|
||
ref={feature.panelRef}
|
||
id="client-subscription-drawer"
|
||
className={`client-drawer client-subscription-drawer${drawerOpen ? ' is-open' : ''}`}
|
||
aria-label="Управление подписками"
|
||
aria-hidden={!drawerOpen}
|
||
inert={!drawerOpen ? true : undefined}
|
||
>
|
||
<div className="client-drawer-sheet client-subscription-sheet">
|
||
<header className="client-profiles-header">
|
||
<h2>ПОДПИСКИ</h2>
|
||
<button type="button" aria-label="Добавить подписку" onClick={feature.showAdd}>+</button>
|
||
<button
|
||
ref={feature.closeRef}
|
||
className="client-drawer-close"
|
||
type="button"
|
||
aria-label="Закрыть подписки"
|
||
onClick={feature.close}
|
||
>×</button>
|
||
</header>
|
||
<div className={`client-profiles-operation${feature.activeOperation ? ' is-active' : ''}`} role="status">
|
||
{feature.activeOperation ? operationText(feature.activeOperation[0]) : '\u00a0'}
|
||
</div>
|
||
<div className="client-profiles-current">
|
||
<span>{feature.connected || feature.gatewayDirect ? 'Сейчас работает:' : 'Выбрано:'}</span>
|
||
<strong>{currentLabel}</strong>
|
||
</div>
|
||
{statusSlot}
|
||
{feature.adding && feature.profiles.length > 0 && <AddProfileForm feature={feature} />}
|
||
<div className="client-profile-list">
|
||
{feature.profiles.map((profile) => <ProfileGroup
|
||
key={profile.id}
|
||
feature={feature}
|
||
profile={profile}
|
||
renderServerPicker={renderServerPicker}
|
||
/>)}
|
||
</div>
|
||
<button className="client-profile-add-trigger" type="button" onClick={feature.showAdd}>
|
||
+ Добавить подписку
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</>;
|
||
}
|
||
|
||
export function SubscriptionDeleteDialog({ feature }: { feature: SubscriptionFeatureController }) {
|
||
return <ConfirmationDialog
|
||
open={Boolean(feature.deleteProfile)}
|
||
id="delete-subscription"
|
||
kicker="Необратимое действие"
|
||
title={`Удалить «${feature.deleteProfile?.label || ''}»?`}
|
||
description={feature.deleteStopsVpn
|
||
? 'Эта подписка сейчас работает. Harbor остановит VPN и удалит её одной операцией.'
|
||
: 'Подписка и её локальные настройки будут удалены. Работающий VPN другой подписки не изменится.'}
|
||
cancelLabel="Отмена"
|
||
confirmLabel={feature.deleteStopsVpn ? 'Остановить VPN и удалить' : 'Удалить'}
|
||
busy={feature.deleteBlocked}
|
||
onCancel={feature.cancelDelete}
|
||
onConfirm={feature.confirmDelete}
|
||
/>;
|
||
}
|