Simplify subscription profile management UI
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
export const HARBOR_VERSIONS = Object.freeze({
|
export const HARBOR_VERSIONS = Object.freeze({
|
||||||
macClient: '0.23.2',
|
macClient: '0.23.3',
|
||||||
gatewayClient: '0.24.2',
|
gatewayClient: '0.24.3',
|
||||||
gatewayBackend: '0.24.0',
|
gatewayBackend: '0.24.0',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -248,13 +248,6 @@ export function App() {
|
|||||||
'subscription',
|
'subscription',
|
||||||
label,
|
label,
|
||||||
)}
|
)}
|
||||||
onRenameProfile={(profileId: string, label: string) => run(
|
|
||||||
'profileRename',
|
|
||||||
() => api.profiles.rename(profileId, label, revisionRef.current),
|
|
||||||
'subscription',
|
|
||||||
profileId,
|
|
||||||
profileId,
|
|
||||||
)}
|
|
||||||
onSelectProfileServer={(profileId: string, serverId: string) => run(
|
onSelectProfileServer={(profileId: string, serverId: string) => run(
|
||||||
'profileSelect',
|
'profileSelect',
|
||||||
() => api.profiles.selectServer(profileId, serverId, revisionRef.current),
|
() => api.profiles.selectServer(profileId, serverId, revisionRef.current),
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import React, {
|
|||||||
import {
|
import {
|
||||||
copyText,
|
copyText,
|
||||||
localProxyUrls,
|
localProxyUrls,
|
||||||
|
subscriptionDomain,
|
||||||
} from '../utils/clientControls.js';
|
} from '../utils/clientControls.js';
|
||||||
import {
|
import {
|
||||||
operationBlocked,
|
operationBlocked,
|
||||||
@@ -103,7 +104,6 @@ interface ClientOverviewPageProps {
|
|||||||
operations?: OperationRegistrySnapshot;
|
operations?: OperationRegistrySnapshot;
|
||||||
error: UiError | null;
|
error: UiError | null;
|
||||||
onAddProfile: (label: string, url: string) => Promise<unknown>;
|
onAddProfile: (label: string, url: string) => Promise<unknown>;
|
||||||
onRenameProfile: (profileId: string, label: string) => Promise<unknown>;
|
|
||||||
onSelectProfileServer: (profileId: string, serverId: string) => Promise<unknown>;
|
onSelectProfileServer: (profileId: string, serverId: string) => Promise<unknown>;
|
||||||
onActivateProfile: (profileId: string) => Promise<unknown>;
|
onActivateProfile: (profileId: string) => Promise<unknown>;
|
||||||
onRefreshProfile: (profileId: string) => Promise<unknown>;
|
onRefreshProfile: (profileId: string) => Promise<unknown>;
|
||||||
@@ -410,7 +410,6 @@ export function ClientOverviewPage({
|
|||||||
operations = {},
|
operations = {},
|
||||||
error,
|
error,
|
||||||
onAddProfile,
|
onAddProfile,
|
||||||
onRenameProfile,
|
|
||||||
onSelectProfileServer,
|
onSelectProfileServer,
|
||||||
onActivateProfile,
|
onActivateProfile,
|
||||||
onRefreshProfile,
|
onRefreshProfile,
|
||||||
@@ -493,7 +492,6 @@ export function ClientOverviewPage({
|
|||||||
isGateway,
|
isGateway,
|
||||||
gatewayDirect,
|
gatewayDirect,
|
||||||
onAdd: onAddProfile,
|
onAdd: onAddProfile,
|
||||||
onRename: onRenameProfile,
|
|
||||||
onRefresh: onRefreshProfile,
|
onRefresh: onRefreshProfile,
|
||||||
onForget: onForgetProfile,
|
onForget: onForgetProfile,
|
||||||
onActivate: onActivateProfile,
|
onActivate: onActivateProfile,
|
||||||
@@ -608,15 +606,15 @@ export function ClientOverviewPage({
|
|||||||
? 'Gateway · сервер не определён'
|
? 'Gateway · сервер не определён'
|
||||||
: connected
|
: connected
|
||||||
? appliedProfile && appliedServer
|
? appliedProfile && appliedServer
|
||||||
? `${appliedProfile.label} · ${appliedServer.label}`
|
? `${subscriptionDomain(appliedProfile.subscription.host)} · ${appliedServer.label}`
|
||||||
: 'VPN · сервер не определён'
|
: 'VPN · сервер не определён'
|
||||||
: desiredProfile && desiredServer
|
: desiredProfile && desiredServer
|
||||||
? `Выбран: ${desiredProfile.label} · ${desiredServer.label}`
|
? `Выбран: ${subscriptionDomain(desiredProfile.subscription.host)} · ${desiredServer.label}`
|
||||||
: 'Сервер не выбран';
|
: 'Сервер не выбран';
|
||||||
const switchIdentity = gatewayDirect
|
const switchIdentity = gatewayDirect
|
||||||
? 'Данные применённого сервера Gateway недоступны'
|
? 'Данные применённого сервера Gateway недоступны'
|
||||||
: switchingServer && operationProfile && operationServer
|
: switchingServer && operationProfile && operationServer
|
||||||
? `Переключаем на ${operationProfile.label} · ${operationServer.label}`
|
? `Переключаем на ${subscriptionDomain(operationProfile.subscription.host)} · ${operationServer.label}`
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ function ServerCheckButton({
|
|||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
}) {
|
}) {
|
||||||
return <button
|
return <button
|
||||||
className={`client-server-check client-tooltip-anchor${checking ? ' is-checking' : ''}`}
|
className={`client-server-check${checking ? ' is-checking' : ''}`}
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={checking ? 'Проверяем пинг серверов' : 'Проверить пинг серверов'}
|
aria-label={checking ? 'Проверяем пинг серверов' : 'Проверить пинг серверов'}
|
||||||
disabled={checking || disabled}
|
disabled={checking || disabled}
|
||||||
@@ -102,7 +102,7 @@ function ServerCheckButton({
|
|||||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
<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" />
|
<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>
|
</svg>
|
||||||
<span className="client-tooltip" role="tooltip">{checking ? 'Проверяем пинг…' : 'Проверить пинг'}</span>
|
<span className="client-server-check-label">{checking ? 'Проверяем пинг…' : 'Проверить пинг'}</span>
|
||||||
</button>;
|
</button>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -40,15 +40,12 @@ interface SubscriptionFeatureOptions {
|
|||||||
operations: OperationRegistrySnapshot;
|
operations: OperationRegistrySnapshot;
|
||||||
error?: ProfileError | null;
|
error?: ProfileError | null;
|
||||||
onAdd: (label: string, url: string) => Promise<unknown>;
|
onAdd: (label: string, url: string) => Promise<unknown>;
|
||||||
onRename: (profileId: string, label: string) => Promise<unknown>;
|
|
||||||
onRefresh: (profileId: string) => Promise<unknown>;
|
onRefresh: (profileId: string) => Promise<unknown>;
|
||||||
onForget: (profileId: string, mode: 'delete' | 'stop-and-delete') => Promise<unknown>;
|
onForget: (profileId: string, mode: 'delete' | 'stop-and-delete') => Promise<unknown>;
|
||||||
onActivate: (profileId: string) => Promise<unknown>;
|
onActivate: (profileId: string) => Promise<unknown>;
|
||||||
onDismissError: () => void;
|
onDismissError: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const foldLabel = (value: string) => value.trim().toLocaleLowerCase('ru-RU');
|
|
||||||
|
|
||||||
function operationText(key: OperationKey) {
|
function operationText(key: OperationKey) {
|
||||||
return ({
|
return ({
|
||||||
profileAdd: 'Добавляем подписку…',
|
profileAdd: 'Добавляем подписку…',
|
||||||
@@ -74,7 +71,6 @@ export function useSubscriptionFeature({
|
|||||||
operations,
|
operations,
|
||||||
error,
|
error,
|
||||||
onAdd,
|
onAdd,
|
||||||
onRename,
|
|
||||||
onRefresh,
|
onRefresh,
|
||||||
onForget,
|
onForget,
|
||||||
onActivate,
|
onActivate,
|
||||||
@@ -84,11 +80,7 @@ export function useSubscriptionFeature({
|
|||||||
const [expanded, setExpanded] = useState<string[]>([]);
|
const [expanded, setExpanded] = useState<string[]>([]);
|
||||||
const [visited, setVisited] = useState<string[]>([]);
|
const [visited, setVisited] = useState<string[]>([]);
|
||||||
const [adding, setAdding] = useState(profiles.length === 0);
|
const [adding, setAdding] = useState(profiles.length === 0);
|
||||||
const [label, setLabel] = useState('');
|
|
||||||
const [url, setUrl] = useState('');
|
const [url, setUrl] = useState('');
|
||||||
const [renamingId, setRenamingId] = useState('');
|
|
||||||
const [renameLabel, setRenameLabel] = useState('');
|
|
||||||
const [menuId, setMenuId] = useState('');
|
|
||||||
const [deleteId, setDeleteId] = useState('');
|
const [deleteId, setDeleteId] = useState('');
|
||||||
const [refreshingIds, setRefreshingIds] = useState<string[]>([]);
|
const [refreshingIds, setRefreshingIds] = useState<string[]>([]);
|
||||||
const [revealVersions, setRevealVersions] = useState<Record<string, number>>({});
|
const [revealVersions, setRevealVersions] = useState<Record<string, number>>({});
|
||||||
@@ -101,17 +93,10 @@ export function useSubscriptionFeature({
|
|||||||
deleteIdRef.current = deleteId;
|
deleteIdRef.current = deleteId;
|
||||||
|
|
||||||
const normalizedUrl = url.trim();
|
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 addError = !error?.profileId && error?.context === 'subscription' ? error : null;
|
||||||
const validationStatus = !normalizedUrl
|
const validationStatus = !normalizedUrl
|
||||||
? 'idle'
|
? 'idle'
|
||||||
: isSubscriptionUrlValid(normalizedUrl) ? 'valid' : 'invalid';
|
: 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 deleteProfile = profiles.find((profile) => profile.id === deleteId) || null;
|
||||||
const deleteStopsVpn = connected && selection.appliedProfileId === deleteId;
|
const deleteStopsVpn = connected && selection.appliedProfileId === deleteId;
|
||||||
const activeOperation = (Object.entries(operations) as Array<[
|
const activeOperation = (Object.entries(operations) as Array<[
|
||||||
@@ -154,7 +139,6 @@ export function useSubscriptionFeature({
|
|||||||
panelRef.current?.contains(target) || toggleRef.current?.contains(target)
|
panelRef.current?.contains(target) || toggleRef.current?.contains(target)
|
||||||
)) return;
|
)) return;
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
setMenuId('');
|
|
||||||
};
|
};
|
||||||
document.addEventListener('pointerdown', closeDrawer);
|
document.addEventListener('pointerdown', closeDrawer);
|
||||||
document.addEventListener('keydown', closeDrawer);
|
document.addEventListener('keydown', closeDrawer);
|
||||||
@@ -169,7 +153,6 @@ export function useSubscriptionFeature({
|
|||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
function resetAdd() {
|
function resetAdd() {
|
||||||
setLabel('');
|
|
||||||
setUrl('');
|
setUrl('');
|
||||||
if (profiles.length) setAdding(false);
|
if (profiles.length) setAdding(false);
|
||||||
if (profiles.length) requestAnimationFrame(() => (
|
if (profiles.length) requestAnimationFrame(() => (
|
||||||
@@ -178,31 +161,17 @@ export function useSubscriptionFeature({
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function addProfile() {
|
async function addProfile() {
|
||||||
if (!normalizedLabel || duplicateLabel || validationStatus !== 'valid') return;
|
if (validationStatus !== 'valid') return;
|
||||||
if (!await onAdd(normalizedLabel, normalizedUrl)) 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();
|
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) {
|
async function refresh(profileId: string) {
|
||||||
const startedAt = performance.now();
|
const startedAt = performance.now();
|
||||||
setRefreshingIds((current) => current.includes(profileId) ? current : [...current, profileId]);
|
setRefreshingIds((current) => current.includes(profileId) ? current : [...current, profileId]);
|
||||||
@@ -225,7 +194,6 @@ export function useSubscriptionFeature({
|
|||||||
if (!deleteProfile) return;
|
if (!deleteProfile) return;
|
||||||
if (!await onForget(deleteProfile.id, deleteStopsVpn ? 'stop-and-delete' : 'delete')) return;
|
if (!await onForget(deleteProfile.id, deleteStopsVpn ? 'stop-and-delete' : 'delete')) return;
|
||||||
setDeleteId('');
|
setDeleteId('');
|
||||||
setMenuId('');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleProfile(profileId: string) {
|
function toggleProfile(profileId: string) {
|
||||||
@@ -247,15 +215,9 @@ export function useSubscriptionFeature({
|
|||||||
expanded,
|
expanded,
|
||||||
visited,
|
visited,
|
||||||
adding,
|
adding,
|
||||||
label,
|
|
||||||
url,
|
url,
|
||||||
validationStatus,
|
validationStatus,
|
||||||
duplicateLabel,
|
|
||||||
addError,
|
addError,
|
||||||
renamingId,
|
|
||||||
renameLabel,
|
|
||||||
renameDuplicate,
|
|
||||||
menuId,
|
|
||||||
deleteProfile,
|
deleteProfile,
|
||||||
deleteStopsVpn,
|
deleteStopsVpn,
|
||||||
activeOperation,
|
activeOperation,
|
||||||
@@ -266,30 +228,22 @@ export function useSubscriptionFeature({
|
|||||||
closeRef,
|
closeRef,
|
||||||
labelRef,
|
labelRef,
|
||||||
addBlocked: operationBlocked(operations, 'profileAdd'),
|
addBlocked: operationBlocked(operations, 'profileAdd'),
|
||||||
renameBlocked: operationBlocked(operations, 'profileRename'),
|
|
||||||
refreshBlocked: operationBlocked(operations, 'profileRefresh'),
|
refreshBlocked: operationBlocked(operations, 'profileRefresh'),
|
||||||
deleteBlocked: operationBlocked(operations, 'profileDelete'),
|
deleteBlocked: operationBlocked(operations, 'profileDelete'),
|
||||||
activateBlocked: operationBlocked(operations, 'profileActivate'),
|
activateBlocked: operationBlocked(operations, 'profileActivate'),
|
||||||
toggle: () => setOpen((current) => !current),
|
toggle: () => setOpen((current) => !current),
|
||||||
close: () => { setOpen(false); setMenuId(''); },
|
close: () => setOpen(false),
|
||||||
showAdd: () => {
|
showAdd: () => {
|
||||||
setAdding(true);
|
setAdding(true);
|
||||||
setMenuId('');
|
|
||||||
onDismissError();
|
onDismissError();
|
||||||
},
|
},
|
||||||
cancelAdd: resetAdd,
|
cancelAdd: resetAdd,
|
||||||
setLabel: (value: string) => { setLabel(value); onDismissError(); },
|
|
||||||
setUrl: (value: string) => {
|
setUrl: (value: string) => {
|
||||||
setUrl(value);
|
setUrl(value);
|
||||||
onDismissError();
|
onDismissError();
|
||||||
},
|
},
|
||||||
addProfile,
|
addProfile,
|
||||||
toggleProfile,
|
toggleProfile,
|
||||||
startRename,
|
|
||||||
setRenameLabel,
|
|
||||||
cancelRename,
|
|
||||||
saveRename,
|
|
||||||
toggleMenu: (profileId: string) => setMenuId((current) => current === profileId ? '' : profileId),
|
|
||||||
requestDelete: (profileId: string) => setDeleteId(profileId),
|
requestDelete: (profileId: string) => setDeleteId(profileId),
|
||||||
cancelDelete: () => setDeleteId(''),
|
cancelDelete: () => setDeleteId(''),
|
||||||
confirmDelete,
|
confirmDelete,
|
||||||
@@ -324,10 +278,8 @@ export function SubscriptionToggle({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function AddProfileForm({ feature }: { feature: SubscriptionFeatureController }) {
|
function AddProfileForm({ feature }: { feature: SubscriptionFeatureController }) {
|
||||||
const message = feature.duplicateLabel
|
const message = feature.validationStatus === 'invalid'
|
||||||
? 'Такое имя уже используется.'
|
? ERROR_DEFINITIONS.SUBSCRIPTION_INVALID.message
|
||||||
: feature.validationStatus === 'invalid'
|
|
||||||
? ERROR_DEFINITIONS.SUBSCRIPTION_INVALID.message
|
|
||||||
: feature.addError?.message || '';
|
: feature.addError?.message || '';
|
||||||
return <form
|
return <form
|
||||||
className="client-profile-add"
|
className="client-profile-add"
|
||||||
@@ -337,20 +289,10 @@ function AddProfileForm({ feature }: { feature: SubscriptionFeatureController })
|
|||||||
feature.addProfile();
|
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>
|
<label>
|
||||||
<span>Ссылка подписки</span>
|
<span>Ссылка подписки</span>
|
||||||
<input
|
<input
|
||||||
|
ref={feature.labelRef}
|
||||||
type="url"
|
type="url"
|
||||||
inputMode="url"
|
inputMode="url"
|
||||||
value={feature.url}
|
value={feature.url}
|
||||||
@@ -367,7 +309,7 @@ function AddProfileForm({ feature }: { feature: SubscriptionFeatureController })
|
|||||||
<button
|
<button
|
||||||
className="is-primary"
|
className="is-primary"
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={feature.addBlocked || !feature.label.trim() || feature.duplicateLabel || feature.validationStatus !== 'valid'}
|
disabled={feature.addBlocked || feature.validationStatus !== 'valid'}
|
||||||
>Добавить</button>
|
>Добавить</button>
|
||||||
</div>
|
</div>
|
||||||
</form>;
|
</form>;
|
||||||
@@ -420,6 +362,10 @@ function ProfileGroup({
|
|||||||
? new Date(profile.subscription.fetchedAt).toLocaleString('ru-RU', { dateStyle: 'short', timeStyle: 'short' })
|
? new Date(profile.subscription.fetchedAt).toLocaleString('ru-RU', { dateStyle: 'short', timeStyle: 'short' })
|
||||||
: 'предыдущей загрузки'}.`
|
: 'предыдущей загрузки'}.`
|
||||||
: '');
|
: '');
|
||||||
|
const profileDomain = subscriptionDomain(profile.subscription.host);
|
||||||
|
const profileDetails = `Серверов: ${profile.servers.length}${profile.subscription.fetchedAt
|
||||||
|
? ` · обновлено ${new Date(profile.subscription.fetchedAt).toLocaleDateString('ru-RU')}`
|
||||||
|
: ''}`;
|
||||||
return <section className={`client-profile-group${expanded ? ' is-expanded' : ''}${applied ? ' is-active' : ''}`}>
|
return <section className={`client-profile-group${expanded ? ' is-expanded' : ''}${applied ? ' is-active' : ''}`}>
|
||||||
<div className="client-profile-header">
|
<div className="client-profile-header">
|
||||||
<button
|
<button
|
||||||
@@ -432,10 +378,10 @@ function ProfileGroup({
|
|||||||
<span className="client-profile-chevron" aria-hidden="true">›</span>
|
<span className="client-profile-chevron" aria-hidden="true">›</span>
|
||||||
<span className="client-profile-title">
|
<span className="client-profile-title">
|
||||||
<span>
|
<span>
|
||||||
<strong>{profile.label}</strong>
|
<strong>{profileDomain}</strong>
|
||||||
{(applied || desired) && <em>{applied ? 'АКТИВНА' : 'ВЫБРАНА'}</em>}
|
{(applied || desired) && <em>{applied ? 'АКТИВНА' : 'ВЫБРАНА'}</em>}
|
||||||
</span>
|
</span>
|
||||||
<small>{subscriptionDomain(profile.subscription.host)}</small>
|
<small>{profileDetails}</small>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
{canActivate ? <button
|
{canActivate ? <button
|
||||||
@@ -447,48 +393,28 @@ function ProfileGroup({
|
|||||||
<button
|
<button
|
||||||
className={`client-profile-refresh${refreshing ? ' is-refreshing' : ''}`}
|
className={`client-profile-refresh${refreshing ? ' is-refreshing' : ''}`}
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={`Обновить подписку ${profile.label}`}
|
aria-label={`Обновить подписку ${profileDomain}`}
|
||||||
disabled={feature.refreshBlocked || refreshing}
|
disabled={feature.refreshBlocked || refreshing}
|
||||||
onClick={() => feature.refresh(profile.id)}
|
onClick={() => feature.refresh(profile.id)}
|
||||||
>↻</button>
|
>
|
||||||
<div className="client-profile-menu-wrap">
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
<button
|
<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" />
|
||||||
id={`client-profile-menu-${profile.id}`}
|
</svg>
|
||||||
className="client-profile-menu-toggle"
|
</button>
|
||||||
type="button"
|
<button
|
||||||
aria-label={`Действия с подпиской ${profile.label}`}
|
className="client-profile-delete"
|
||||||
aria-expanded={feature.menuId === profile.id}
|
type="button"
|
||||||
onClick={() => feature.toggleMenu(profile.id)}
|
aria-label={`Удалить подписку ${profileDomain}`}
|
||||||
>⋮</button>
|
disabled={feature.deleteBlocked}
|
||||||
{feature.menuId === profile.id && <div className="client-profile-menu">
|
onClick={() => feature.requestDelete(profile.id)}
|
||||||
<button type="button" onClick={() => feature.startRename(profile)}>Переименовать</button>
|
>
|
||||||
<button type="button" className="is-danger" onClick={() => feature.requestDelete(profile.id)}>Удалить</button>
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
</div>}
|
<path className="client-profile-delete-lid" d="M8 7V5h8v2m-11 0h14" />
|
||||||
</div>
|
<path d="M7 7l1 13h8l1-13M10 10v7m4-7v7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
</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>}
|
|
||||||
|
|
||||||
{localStatus && <div className="client-profile-local-status" role="status">{localStatus}</div>}
|
{localStatus && <div className="client-profile-local-status" role="status">{localStatus}</div>}
|
||||||
|
|
||||||
{!expanded && visibleServer && <button
|
{!expanded && visibleServer && <button
|
||||||
@@ -586,7 +512,7 @@ export function SubscriptionDeleteDialog({ feature }: { feature: SubscriptionFea
|
|||||||
open={Boolean(feature.deleteProfile)}
|
open={Boolean(feature.deleteProfile)}
|
||||||
id="delete-subscription"
|
id="delete-subscription"
|
||||||
kicker="Необратимое действие"
|
kicker="Необратимое действие"
|
||||||
title={`Удалить «${feature.deleteProfile?.label || ''}»?`}
|
title={`Удалить «${subscriptionDomain(feature.deleteProfile?.subscription.host)}»?`}
|
||||||
description={feature.deleteStopsVpn
|
description={feature.deleteStopsVpn
|
||||||
? 'Эта подписка сейчас работает. Harbor остановит VPN и удалит её одной операцией.'
|
? 'Эта подписка сейчас работает. Harbor остановит VPN и удалит её одной операцией.'
|
||||||
: 'Подписка и её локальные настройки будут удалены. Работающий VPN другой подписки не изменится.'}
|
: 'Подписка и её локальные настройки будут удалены. Работающий VPN другой подписки не изменится.'}
|
||||||
|
|||||||
@@ -231,6 +231,10 @@
|
|||||||
transition: transform 600ms cubic-bezier(0.16, 1, 0.3, 1);
|
transition: transform 600ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.client-server-check-label {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.client-server-check.is-checking {
|
.client-server-check.is-checking {
|
||||||
color: var(--client-accent);
|
color: var(--client-accent);
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
@@ -571,8 +575,7 @@
|
|||||||
order: 2;
|
order: 2;
|
||||||
grid-template-columns: auto 1fr;
|
grid-template-columns: auto 1fr;
|
||||||
grid-template-rows: 40px;
|
grid-template-rows: 40px;
|
||||||
margin-top: 6px;
|
margin-top: 0;
|
||||||
border-top: 1px solid var(--client-border);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-profile-body .client-server-toolbar-title {
|
.client-profile-body .client-server-toolbar-title {
|
||||||
@@ -588,10 +591,11 @@
|
|||||||
padding: 0 8px;
|
padding: 0 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-profile-body .client-server-check::after {
|
.client-profile-body .client-server-check-label {
|
||||||
content: 'ПРОВЕРИТЬ ПИНГ';
|
display: inline;
|
||||||
font: 600 9px/1 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace;
|
font: 600 9px/1 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace;
|
||||||
letter-spacing: 0.03em;
|
letter-spacing: 0.03em;
|
||||||
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-profile-body .client-server-mode-toggle {
|
.client-profile-body .client-server-mode-toggle {
|
||||||
@@ -619,8 +623,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.client-profile-body .client-server-row.is-selected {
|
.client-profile-body .client-server-row.is-selected {
|
||||||
background: color-mix(in oklch, var(--client-accent) 7%, transparent);
|
background: color-mix(in oklch, var(--client-accent) 5%, transparent);
|
||||||
box-shadow: inset 2px 0 var(--client-accent);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-profile-body .client-server-row .client-server {
|
.client-profile-body .client-server-row .client-server {
|
||||||
@@ -654,6 +657,10 @@
|
|||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.client-profile-body .client-server strong {
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
.client-profile-body .client-server small {
|
.client-profile-body .client-server small {
|
||||||
grid-column: 3;
|
grid-column: 3;
|
||||||
font-size: 8px;
|
font-size: 8px;
|
||||||
|
|||||||
@@ -61,17 +61,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.client-profiles-operation {
|
.client-profiles-operation {
|
||||||
min-height: 42px;
|
min-height: 22px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-top: 14px;
|
margin-top: 8px;
|
||||||
padding: 8px 12px;
|
|
||||||
border: 1px solid var(--client-border);
|
|
||||||
color: var(--client-muted);
|
color: var(--client-muted);
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
}
|
|
||||||
|
|
||||||
.client-profiles-operation {
|
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,7 +79,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.client-profile-list {
|
.client-profile-list {
|
||||||
margin-top: 18px;
|
margin-top: 8px;
|
||||||
border-top: 1px solid var(--client-border);
|
border-top: 1px solid var(--client-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,8 +90,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.client-profile-group.is-active {
|
.client-profile-group.is-active {
|
||||||
background: color-mix(in oklch, var(--client-accent) 5%, transparent);
|
background: color-mix(in oklch, var(--client-accent) 3%, transparent);
|
||||||
box-shadow: inset 2px 0 var(--client-accent);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-profile-group.is-active .client-profile-title strong,
|
.client-profile-group.is-active .client-profile-title strong,
|
||||||
@@ -198,75 +192,71 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.client-profile-refresh,
|
.client-profile-refresh,
|
||||||
.client-profile-menu-toggle {
|
.client-profile-delete {
|
||||||
width: 36px;
|
width: 36px;
|
||||||
height: 36px;
|
height: 36px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border: 0;
|
border: 0;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--client-muted);
|
color: var(--client-muted);
|
||||||
font: 400 18px/1 inherit;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.client-profile-refresh svg,
|
||||||
|
.client-profile-delete svg {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
overflow: visible;
|
||||||
|
fill: none;
|
||||||
|
stroke: currentColor;
|
||||||
|
stroke-width: 1.6;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-profile-refresh svg {
|
||||||
|
transition: transform 600ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-profile-delete-lid {
|
||||||
|
transform-origin: center 7px;
|
||||||
|
transition: transform 260ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
}
|
||||||
|
|
||||||
.client-profile-refresh:hover:not(:disabled),
|
.client-profile-refresh:hover:not(:disabled),
|
||||||
.client-profile-refresh:focus-visible,
|
.client-profile-refresh:focus-visible,
|
||||||
.client-profile-menu-toggle:hover,
|
.client-profile-delete:hover:not(:disabled),
|
||||||
.client-profile-menu-toggle:focus-visible {
|
.client-profile-delete:focus-visible {
|
||||||
color: var(--client-accent);
|
color: var(--client-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.client-profile-refresh:hover:not(:disabled) svg,
|
||||||
|
.client-profile-refresh:focus-visible:not(.is-refreshing) svg {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-profile-delete:hover:not(:disabled) .client-profile-delete-lid,
|
||||||
|
.client-profile-delete:focus-visible .client-profile-delete-lid {
|
||||||
|
transform: translateY(-2px) rotate(-8deg);
|
||||||
|
}
|
||||||
|
|
||||||
.client-profile-refresh.is-refreshing {
|
.client-profile-refresh.is-refreshing {
|
||||||
color: var(--client-accent);
|
color: var(--client-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-profile-refresh.is-refreshing svg {
|
||||||
animation: client-spin 900ms linear infinite;
|
animation: client-spin 900ms linear infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-profile-refresh:disabled,
|
.client-profile-refresh:disabled,
|
||||||
.client-profile-menu-toggle:disabled {
|
.client-profile-delete:disabled {
|
||||||
opacity: 0.35;
|
opacity: 0.35;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-profile-menu-wrap {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.client-profile-menu {
|
|
||||||
position: absolute;
|
|
||||||
top: 34px;
|
|
||||||
right: 0;
|
|
||||||
z-index: 4;
|
|
||||||
width: 150px;
|
|
||||||
display: grid;
|
|
||||||
padding: 6px;
|
|
||||||
border: 1px solid var(--client-border);
|
|
||||||
background: var(--client-bg);
|
|
||||||
box-shadow: 0 16px 38px color-mix(in oklch, var(--client-bg) 80%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.client-profile-menu button {
|
|
||||||
min-height: 34px;
|
|
||||||
padding: 0 8px;
|
|
||||||
border: 0;
|
|
||||||
background: transparent;
|
|
||||||
color: var(--client-text);
|
|
||||||
font: 500 9px/1.2 inherit;
|
|
||||||
text-align: left;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.client-profile-menu button:hover,
|
|
||||||
.client-profile-menu button:focus-visible {
|
|
||||||
background: color-mix(in oklch, var(--client-accent) 7%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.client-profile-menu button.is-danger {
|
|
||||||
color: oklch(0.68 0.15 28);
|
|
||||||
}
|
|
||||||
|
|
||||||
.client-profile-body {
|
.client-profile-body {
|
||||||
max-height: 3200px;
|
max-height: 3200px;
|
||||||
padding: 0 0 20px;
|
padding: 0 0 4px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
visibility: visible;
|
visibility: visible;
|
||||||
@@ -294,7 +284,7 @@
|
|||||||
grid-template-columns: 18px minmax(0, 1fr) auto;
|
grid-template-columns: 18px minmax(0, 1fr) auto;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 0 10px 0 26px;
|
padding: 0 8px;
|
||||||
border: 0;
|
border: 0;
|
||||||
border-top: 1px solid var(--client-border);
|
border-top: 1px solid var(--client-border);
|
||||||
background: transparent;
|
background: transparent;
|
||||||
@@ -395,8 +385,7 @@
|
|||||||
color: oklch(0.68 0.14 72);
|
color: oklch(0.68 0.14 72);
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-profile-add,
|
.client-profile-add {
|
||||||
.client-profile-rename {
|
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding: 16px 0 20px;
|
padding: 16px 0 20px;
|
||||||
@@ -413,8 +402,7 @@
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-profile-add input,
|
.client-profile-add input {
|
||||||
.client-profile-rename input {
|
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
height: 40px;
|
height: 40px;
|
||||||
padding: 0 10px;
|
padding: 0 10px;
|
||||||
@@ -426,30 +414,21 @@
|
|||||||
font: 500 10px/1 inherit;
|
font: 500 10px/1 inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-profile-add input:focus,
|
.client-profile-add input:focus {
|
||||||
.client-profile-rename input:focus {
|
|
||||||
border: 1px solid var(--client-accent);
|
border: 1px solid var(--client-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-profile-add input[aria-invalid='true'],
|
.client-profile-add input[aria-invalid='true'] {
|
||||||
.client-profile-rename input[aria-invalid='true'] {
|
|
||||||
border: 1px solid oklch(0.68 0.15 28);
|
border: 1px solid oklch(0.68 0.15 28);
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-profile-form-actions,
|
|
||||||
.client-profile-rename {
|
|
||||||
grid-template-columns: 1fr auto auto;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.client-profile-form-actions {
|
.client-profile-form-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-profile-form-actions button,
|
.client-profile-form-actions button {
|
||||||
.client-profile-rename button {
|
|
||||||
min-height: 34px;
|
min-height: 34px;
|
||||||
padding: 0 10px;
|
padding: 0 10px;
|
||||||
border: 0;
|
border: 0;
|
||||||
@@ -459,29 +438,15 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-profile-form-actions button.is-primary,
|
.client-profile-form-actions button.is-primary {
|
||||||
.client-profile-rename button[type='submit'] {
|
|
||||||
color: var(--client-accent);
|
color: var(--client-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-profile-form-actions button:disabled,
|
.client-profile-form-actions button:disabled {
|
||||||
.client-profile-rename button:disabled {
|
|
||||||
opacity: 0.35;
|
opacity: 0.35;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-profile-rename {
|
|
||||||
grid-template-columns: minmax(0, 1fr) auto auto;
|
|
||||||
padding-left: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.client-profile-rename > span {
|
|
||||||
grid-column: 1 / -1;
|
|
||||||
min-height: 12px;
|
|
||||||
color: oklch(0.68 0.15 28);
|
|
||||||
font-size: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.client-subscription-first-run {
|
.client-subscription-first-run {
|
||||||
gap: 18px;
|
gap: 18px;
|
||||||
}
|
}
|
||||||
@@ -514,7 +479,7 @@
|
|||||||
|
|
||||||
.client-profile-disclosure,
|
.client-profile-disclosure,
|
||||||
.client-profile-refresh,
|
.client-profile-refresh,
|
||||||
.client-profile-menu-toggle,
|
.client-profile-delete,
|
||||||
.client-profiles-header > button:not(.client-drawer-close) {
|
.client-profiles-header > button:not(.client-drawer-close) {
|
||||||
min-height: 44px;
|
min-height: 44px;
|
||||||
}
|
}
|
||||||
@@ -524,6 +489,8 @@
|
|||||||
.client-profile-chevron,
|
.client-profile-chevron,
|
||||||
.client-profile-body,
|
.client-profile-body,
|
||||||
.client-profile-refresh,
|
.client-profile-refresh,
|
||||||
|
.client-profile-refresh svg,
|
||||||
|
.client-profile-delete-lid,
|
||||||
.client-profile-group,
|
.client-profile-group,
|
||||||
.client-subscription-sheet > .client-profile-add {
|
.client-subscription-sheet > .client-profile-add {
|
||||||
transition: none;
|
transition: none;
|
||||||
|
|||||||
@@ -202,8 +202,8 @@ test('Gateway Home reuses the canonical device snapshot for applied route and gl
|
|||||||
|
|
||||||
assert.match(overview, /const appliedServerId = state\?\.selection\?\.appliedServerId \|\| ''/);
|
assert.match(overview, /const appliedServerId = state\?\.selection\?\.appliedServerId \|\| ''/);
|
||||||
assert.match(overview, /const appliedServer = appliedProfile\?\.servers\.find[\s\S]*state\.selection\.appliedServerSnapshot/);
|
assert.match(overview, /const appliedServer = appliedProfile\?\.servers\.find[\s\S]*state\.selection\.appliedServerSnapshot/);
|
||||||
assert.match(overview, /const mainIdentity = gatewayDirect[\s\S]*: connected[\s\S]*appliedProfile && appliedServer[\s\S]*`\$\{appliedProfile\.label\} · \$\{appliedServer\.label\}`/);
|
assert.match(overview, /const mainIdentity = gatewayDirect[\s\S]*: connected[\s\S]*appliedProfile && appliedServer[\s\S]*`\$\{subscriptionDomain\(appliedProfile\.subscription\.host\)\} · \$\{appliedServer\.label\}`/);
|
||||||
assert.match(overview, /const switchIdentity = gatewayDirect[\s\S]*switchingServer && operationProfile && operationServer[\s\S]*`Переключаем на \$\{operationProfile\.label\} · \$\{operationServer\.label\}`/);
|
assert.match(overview, /const switchIdentity = gatewayDirect[\s\S]*switchingServer && operationProfile && operationServer[\s\S]*`Переключаем на \$\{subscriptionDomain\(operationProfile\.subscription\.host\)\} · \$\{operationServer\.label\}`/);
|
||||||
assert.match(overview, /serverSlot=\{<AppliedIdentity identity=\{mainIdentity\} operation=\{switchIdentity\} \/>\}/);
|
assert.match(overview, /serverSlot=\{<AppliedIdentity identity=\{mainIdentity\} operation=\{switchIdentity\} \/>\}/);
|
||||||
assert.match(feature, /formatByteString\(globalTraffic\?\.totalBytes \|\| '0'\)/);
|
assert.match(feature, /formatByteString\(globalTraffic\?\.totalBytes \|\| '0'\)/);
|
||||||
assert.match(feature, /const history = globalTraffic\?\.history \|\| \[\][\s\S]*samples=\{history\}[\s\S]*routeLabel="Gateway"[\s\S]*series="speed"/);
|
assert.match(feature, /const history = globalTraffic\?\.history \|\| \[\][\s\S]*samples=\{history\}[\s\S]*routeLabel="Gateway"[\s\S]*series="speed"/);
|
||||||
|
|||||||
@@ -244,10 +244,11 @@ test('tooltips stay opaque, above adjacent content, and do not stick after point
|
|||||||
test('subscription validation is local and provider errors keep stable detail slots', () => {
|
test('subscription validation is local and provider errors keep stable detail slots', () => {
|
||||||
assert.match(subscription, /const validationStatus = !normalizedUrl[\s\S]*isSubscriptionUrlValid\(normalizedUrl\) \? 'valid' : 'invalid'/);
|
assert.match(subscription, /const validationStatus = !normalizedUrl[\s\S]*isSubscriptionUrlValid\(normalizedUrl\) \? 'valid' : 'invalid'/);
|
||||||
assert.doesNotMatch(subscription, /validateSubscription\(|AbortController|status: 'checking'/);
|
assert.doesNotMatch(subscription, /validateSubscription\(|AbortController|status: 'checking'/);
|
||||||
assert.match(subscription, /const message = feature\.duplicateLabel[\s\S]*feature\.validationStatus === 'invalid'[\s\S]*ERROR_DEFINITIONS\.SUBSCRIPTION_INVALID\.message[\s\S]*feature\.addError\?\.message/);
|
assert.match(subscription, /const message = feature\.validationStatus === 'invalid'[\s\S]*ERROR_DEFINITIONS\.SUBSCRIPTION_INVALID\.message[\s\S]*feature\.addError\?\.message/);
|
||||||
assert.match(subscription, /aria-invalid=\{feature\.validationStatus === 'invalid'\}/);
|
assert.match(subscription, /aria-invalid=\{feature\.validationStatus === 'invalid'\}/);
|
||||||
assert.match(subscription, /disabled=\{feature\.addBlocked \|\| !feature\.label\.trim\(\) \|\| feature\.duplicateLabel \|\| feature\.validationStatus !== 'valid'\}/);
|
assert.match(subscription, /disabled=\{feature\.addBlocked \|\| feature\.validationStatus !== 'valid'\}/);
|
||||||
assert.match(subscription, /if \(!await onAdd\(normalizedLabel, normalizedUrl\)\) return;[\s\S]*resetAdd\(\)/);
|
assert.match(subscription, /const host = new URL\(normalizedUrl\)\.hostname;[\s\S]*if \(!await onAdd\(label, normalizedUrl\)\) return;[\s\S]*resetAdd\(\)/);
|
||||||
|
assert.doesNotMatch(subscription, />Название<|duplicateLabel/);
|
||||||
assert.match(subscription, /setUrl: \(value: string\) => \{[\s\S]*onDismissError\(\)/);
|
assert.match(subscription, /setUrl: \(value: string\) => \{[\s\S]*onDismissError\(\)/);
|
||||||
assert.match(component, /error\.retry[\s\S]*error\.correlationId/);
|
assert.match(component, /error\.retry[\s\S]*error\.correlationId/);
|
||||||
assert.match(rule('.client-inline-error.is-subscription small'), /flex-basis:\s*100%/);
|
assert.match(rule('.client-inline-error.is-subscription small'), /flex-basis:\s*100%/);
|
||||||
|
|||||||
@@ -94,8 +94,9 @@ test('manual ping uses plain language and keeps results beside server names', ()
|
|||||||
assert.match(picker, /<svg className="client-server-health-checking"/);
|
assert.match(picker, /<svg className="client-server-health-checking"/);
|
||||||
assert.doesNotMatch(picker, />Проверяем…<\/span>/);
|
assert.doesNotMatch(picker, />Проверяем…<\/span>/);
|
||||||
assert.match(picker, /function ServerCheckButton/);
|
assert.match(picker, /function ServerCheckButton/);
|
||||||
assert.match(picker, /client-server-check client-tooltip-anchor/);
|
assert.match(picker, /client-server-check\$\{checking \? ' is-checking' : ''\}/);
|
||||||
assert.match(picker, /<span className="client-tooltip" role="tooltip">/);
|
assert.match(picker, /<span className="client-server-check-label">/);
|
||||||
|
assert.doesNotMatch(picker, /client-tooltip/);
|
||||||
assert.doesNotMatch(picker, /TCP|Не проверен/);
|
assert.doesNotMatch(picker, /TCP|Не проверен/);
|
||||||
assert.match(picker, /<strong>\{server\.label\}<\/strong>[\s\S]*?<\/button>[\s\S]*?client-server-meta/);
|
assert.match(picker, /<strong>\{server\.label\}<\/strong>[\s\S]*?<\/button>[\s\S]*?client-server-meta/);
|
||||||
assert.match(picker, /\[server\.city, server\.country\]\.filter\(Boolean\)\.join\(' · '\)/);
|
assert.match(picker, /\[server\.city, server\.country\]\.filter\(Boolean\)\.join\(' · '\)/);
|
||||||
@@ -132,6 +133,7 @@ test('server picker starts simple and reveals advanced controls on demand', () =
|
|||||||
assert.match(picker, /\.slice\(0, SIMPLE_SERVER_LIMIT\)/);
|
assert.match(picker, /\.slice\(0, SIMPLE_SERVER_LIMIT\)/);
|
||||||
assert.match(picker, /\{selected && <ServerRow/);
|
assert.match(picker, /\{selected && <ServerRow/);
|
||||||
assert.match(styles, /\.client-profile-body \.client-server-row \{[\s\S]*?grid-template-columns: minmax\(0, 1fr\) 64px;/);
|
assert.match(styles, /\.client-profile-body \.client-server-row \{[\s\S]*?grid-template-columns: minmax\(0, 1fr\) 64px;/);
|
||||||
assert.match(styles, /\.client-profile-body \.client-server-row\.is-selected \{[\s\S]*?box-shadow: inset 2px 0 var\(--client-accent\);/);
|
assert.match(styles, /\.client-profile-body \.client-server-row\.is-selected \{[\s\S]*?background: color-mix\(in oklch, var\(--client-accent\) 5%, transparent\);/);
|
||||||
assert.match(styles, /\.client-profile-body \.client-server-check::after \{[\s\S]*?content: 'ПРОВЕРИТЬ ПИНГ';/);
|
assert.match(styles, /\.client-profile-body \.client-server-check-label \{[\s\S]*?display: inline;/);
|
||||||
|
assert.doesNotMatch(styles, /\.client-profile-body \.client-server-check::after/);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -36,26 +36,26 @@ const expectedImports = [
|
|||||||
const sha256 = (value) => crypto.createHash('sha256').update(value).digest('hex');
|
const sha256 = (value) => crypto.createHash('sha256').update(value).digest('hex');
|
||||||
const acceptedLedger = {
|
const acceptedLedger = {
|
||||||
counts: {
|
counts: {
|
||||||
cascadeEdges: 795,
|
cascadeEdges: 792,
|
||||||
customProperties: 31,
|
customProperties: 31,
|
||||||
declarations: 2996,
|
declarations: 2977,
|
||||||
important: 0,
|
important: 0,
|
||||||
keyframes: 51,
|
keyframes: 51,
|
||||||
media: 13,
|
media: 13,
|
||||||
rules: 911,
|
rules: 910,
|
||||||
variableReferences: 347,
|
variableReferences: 338,
|
||||||
},
|
},
|
||||||
hashes: {
|
hashes: {
|
||||||
cascadeEdges: '5cf4f4dc60eabe1e8175fa7c514a4ebc95983b588c2190adea0f75ffbbb813ef',
|
cascadeEdges: 'f83d896540885ddaf09e55551a2f1ba5b587dcebfc285cc305efe80136154d93',
|
||||||
customProperties: 'c7dd331e4bad898c450568999d8c9c6837e275a79c365c7680e143026fde4545',
|
customProperties: 'c7dd331e4bad898c450568999d8c9c6837e275a79c365c7680e143026fde4545',
|
||||||
declarations: '47604ceab56972914f0c193ea10b3fc29b2532ca5b8a8057d1cfb437f5c8f86e',
|
declarations: '14728723e4a95d6031b60eb39c0864da0ccc061e24798091cd093b6354a5d906',
|
||||||
duplicateKeyframes: '4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945',
|
duplicateKeyframes: '4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945',
|
||||||
duplicateSelectors: 'e063b7ac4965baed089fbcbba37544d7545eec7a7e84c3a68589465af53ed017',
|
duplicateSelectors: 'c0ec5c96e48cd2ceed32aa5050383bcf29c3c0b8f1b440ed6883435e3e9b964d',
|
||||||
keyframes: '9e87e997d0b776462ac3e495c7ced584871a5d9512226e5008eb84b4d311530a',
|
keyframes: '9e87e997d0b776462ac3e495c7ced584871a5d9512226e5008eb84b4d311530a',
|
||||||
ruleDeclarationSequences: 'd7db36cc3209e48c75e6e1b0c2748d0bff6be5a078f4e61b97c039d9da9c12cb',
|
ruleDeclarationSequences: 'eac60ada2de39eff87713f71f5ca533cd831ee7d400d27eaa09562c29dfbfadd',
|
||||||
selectors: 'cd61e05ec477919f2c34d3944edc4fbb175b1572f8060a7696f0b73a687ddfad',
|
selectors: 'e79f61996bcd2a806828406c0a626e87d60112f1b24264d60b8670f9b8f831af',
|
||||||
variableReferences: 'b6f3567fc26a250c81f9214d1486a1cd3f7998012383aeb9d9e53fdeb32b17b1',
|
variableReferences: '5c56197ab72968fc007f333c0cc58f90b6ec90465d6273bc757b7c77503dd26e',
|
||||||
witnesses: '1ba29e4efc61874a5c329d7d48d0fc399dae8b1c9dccfea0e682ceba3cb1630f',
|
witnesses: '7066589e7b69a5b3c728ff70430a8d6d3c6c6d562cea91377244c0754e18b8da',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -110,7 +110,7 @@ test('tokens, shared primitives, and feature styles have one explicit owner', ()
|
|||||||
|
|
||||||
test('accepted stylesheet has pinned declaration, selector, keyframe, variable, and cascade ledgers', () => {
|
test('accepted stylesheet has pinned declaration, selector, keyframe, variable, and cascade ledgers', () => {
|
||||||
const witnesses = readStyleWitnesses(root);
|
const witnesses = readStyleWitnesses(root);
|
||||||
assert.equal(witnesses.length, 745);
|
assert.equal(witnesses.length, 735);
|
||||||
assert.equal(witnesses.filter((witness) => witness.unknown || witness.ancestorUnknown).length, 0);
|
assert.equal(witnesses.filter((witness) => witness.unknown || witness.ancestorUnknown).length, 0);
|
||||||
const ledger = createStyleLedger(readStyleSource(root), { witnesses });
|
const ledger = createStyleLedger(readStyleSource(root), { witnesses });
|
||||||
assert.deepEqual(ledger.counts, acceptedLedger.counts);
|
assert.deepEqual(ledger.counts, acceptedLedger.counts);
|
||||||
@@ -306,8 +306,8 @@ test('main owns one public stylesheet and the regrouped production CSS is determ
|
|||||||
assert.equal((main.match(/import ['"][^'"]+\.css['"]/g) || []).length, 1);
|
assert.equal((main.match(/import ['"][^'"]+\.css['"]/g) || []).length, 1);
|
||||||
|
|
||||||
const assets = fs.readdirSync(path.join(root, 'dist/assets')).filter((file) => file.endsWith('.css'));
|
const assets = fs.readdirSync(path.join(root, 'dist/assets')).filter((file) => file.endsWith('.css'));
|
||||||
assert.deepEqual(assets, ['index-BJwATLJx.css']);
|
assert.deepEqual(assets, ['index-pEpt1ReD.css']);
|
||||||
const built = fs.readFileSync(path.join(root, 'dist/assets', assets[0]));
|
const built = fs.readFileSync(path.join(root, 'dist/assets', assets[0]));
|
||||||
assert.equal(built.byteLength, 112406);
|
assert.equal(built.byteLength, 111837);
|
||||||
assert.equal(sha256(built), '4b117a70cf431bbe7aacd368df3cb30ab50c5ab58498190b3822b92607aa9045');
|
assert.equal(sha256(built), 'f797af224ecae5e0435c7fb165cc158a237d11654c48a8cb287b32b2230e6b0c');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -33,10 +33,10 @@ test('App owns profile mutations and always uses the latest canonical revision',
|
|||||||
assert.equal((app.match(/revisionRef\.current = snapshot\.revision/g) || []).length, 2);
|
assert.equal((app.match(/revisionRef\.current = snapshot\.revision/g) || []).length, 2);
|
||||||
assert.doesNotMatch(app, /if \(state\) revisionRef\.current = state\.revision/);
|
assert.doesNotMatch(app, /if \(state\) revisionRef\.current = state\.revision/);
|
||||||
assert.match(app, /api\.profiles\.add\(label, url, revisionRef\.current\)/);
|
assert.match(app, /api\.profiles\.add\(label, url, revisionRef\.current\)/);
|
||||||
assert.match(app, /api\.profiles\.rename\(profileId, label, revisionRef\.current\)/);
|
|
||||||
assert.match(app, /api\.profiles\.refresh\(profileId, revisionRef\.current\)/);
|
assert.match(app, /api\.profiles\.refresh\(profileId, revisionRef\.current\)/);
|
||||||
assert.match(app, /api\.profiles\.forget\(profileId, mode, revisionRef\.current\)/);
|
assert.match(app, /api\.profiles\.forget\(profileId, mode, revisionRef\.current\)/);
|
||||||
assert.match(page, /profiles,[\s\S]*onAdd: onAddProfile,[\s\S]*onRename: onRenameProfile,[\s\S]*onRefresh: onRefreshProfile,[\s\S]*onForget: onForgetProfile/);
|
assert.match(page, /profiles,[\s\S]*onAdd: onAddProfile,[\s\S]*onRefresh: onRefreshProfile,[\s\S]*onForget: onForgetProfile/);
|
||||||
|
assert.doesNotMatch(page, /onRenameProfile/);
|
||||||
assert.doesNotMatch(feature, /from ['"][^'"]*\/api\/|\bapi\./);
|
assert.doesNotMatch(feature, /from ['"][^'"]*\/api\/|\bapi\./);
|
||||||
assert.doesNotMatch(feature, /<ServerPicker|<InlineError|<InlineProgress|<ConnectionPanel|<DevicesPanel|<DiagnosticsPanel/);
|
assert.doesNotMatch(feature, /<ServerPicker|<InlineError|<InlineProgress|<ConnectionPanel|<DevicesPanel|<DiagnosticsPanel/);
|
||||||
});
|
});
|
||||||
@@ -52,8 +52,8 @@ test('local validation, scoped refresh and drawer focus remain feature-owned', (
|
|||||||
assert.match(feature, /if \(deleteIdRef\.current\) return;[\s\S]*toggleRef\.current\?\.focus\(\)/);
|
assert.match(feature, /if \(deleteIdRef\.current\) return;[\s\S]*toggleRef\.current\?\.focus\(\)/);
|
||||||
assert.match(feature, /setAdding\(false\)[\s\S]*client-profile-add-trigger'\)\?\.focus\(\)/);
|
assert.match(feature, /setAdding\(false\)[\s\S]*client-profile-add-trigger'\)\?\.focus\(\)/);
|
||||||
assert.doesNotMatch(feature, /addInvokerRef/);
|
assert.doesNotMatch(feature, /addInvokerRef/);
|
||||||
assert.match(feature, /function cancelRename\(\)[\s\S]*client-profile-menu-\$\{profileId\}[\s\S]*\.focus\(\)/);
|
assert.match(feature, /className="client-profile-delete"[\s\S]*client-profile-delete-lid/);
|
||||||
assert.match(feature, /id=\{`client-profile-menu-\$\{profile\.id\}`\}/);
|
assert.doesNotMatch(feature, /cancelRename|client-profile-menu|onRename/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('validation rejection parser preserves structured errors and normalizes non-objects', () => {
|
test('validation rejection parser preserves structured errors and normalizes non-objects', () => {
|
||||||
@@ -85,7 +85,7 @@ test('validation rejection parser preserves structured errors and normalizes non
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('feature keeps exact slots, truthy closes and subscription DOM order', () => {
|
test('feature keeps exact slots, truthy closes and subscription DOM order', () => {
|
||||||
assert.match(feature, /if \(!await onAdd\(normalizedLabel, normalizedUrl\)\) return;[\s\S]*resetAdd\(\)/);
|
assert.match(feature, /const host = new URL\(normalizedUrl\)\.hostname;[\s\S]*if \(!await onAdd\(label, normalizedUrl\)\) return;[\s\S]*resetAdd\(\)/);
|
||||||
assert.match(feature, /if \(!await onForget\(deleteProfile\.id, deleteStopsVpn \? 'stop-and-delete' : 'delete'\)\) return;[\s\S]*setDeleteId\(''\)/);
|
assert.match(feature, /if \(!await onForget\(deleteProfile\.id, deleteStopsVpn \? 'stop-and-delete' : 'delete'\)\) return;[\s\S]*setDeleteId\(''\)/);
|
||||||
assert.match(feature, /client-profiles-operation[\s\S]*\{statusSlot\}[\s\S]*client-profile-list[\s\S]*feature\.adding[\s\S]*AddProfileForm[\s\S]*client-profile-add-trigger/);
|
assert.match(feature, /client-profiles-operation[\s\S]*\{statusSlot\}[\s\S]*client-profile-list[\s\S]*feature\.adding[\s\S]*AddProfileForm[\s\S]*client-profile-add-trigger/);
|
||||||
assert.doesNotMatch(feature, /client-profiles-current|aria-label="Добавить подписку"/);
|
assert.doesNotMatch(feature, /client-profiles-current|aria-label="Добавить подписку"/);
|
||||||
@@ -106,6 +106,9 @@ test('profiles render as flat accordion groups with scoped controls', () => {
|
|||||||
assert.match(feature, /!expanded && visibleServer && <button[\s\S]*client-profile-selected-server/);
|
assert.match(feature, /!expanded && visibleServer && <button[\s\S]*client-profile-selected-server/);
|
||||||
assert.match(feature, /const visibleServerId = applied \? feature\.selection\.appliedServerId : profile\.desiredServerId/);
|
assert.match(feature, /const visibleServerId = applied \? feature\.selection\.appliedServerId : profile\.desiredServerId/);
|
||||||
assert.match(feature, /const desired = !feature\.connected[\s\S]*&& !feature\.gatewayDirect/);
|
assert.match(feature, /const desired = !feature\.connected[\s\S]*&& !feature\.gatewayDirect/);
|
||||||
|
assert.match(feature, /const profileDomain = subscriptionDomain\(profile\.subscription\.host\)/);
|
||||||
|
assert.match(feature, /const profileDetails = `Серверов: \$\{profile\.servers\.length\}/);
|
||||||
|
assert.doesNotMatch(feature, /<strong>\{profile\.label\}<\/strong>|Переименовать|client-profile-menu/);
|
||||||
assert.match(styles, /\.client-profile-group/);
|
assert.match(styles, /\.client-profile-group/);
|
||||||
assert.match(styles, /\.client-profile-body/);
|
assert.match(styles, /\.client-profile-body/);
|
||||||
assert.match(styles, /\.client-profile-refresh\.is-refreshing/);
|
assert.match(styles, /\.client-profile-refresh\.is-refreshing/);
|
||||||
|
|||||||
Reference in New Issue
Block a user